Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-05-19 10:33:21 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-05-19 10:33:21 +0300
commit36a59d088eca61b834191dacea009677a96c052f (patch)
treee4f33972dab5d8ef79e3944a9f403035fceea43f /spec
parenta1761f15ec2cae7c7f7bbda39a75494add0dfd6f (diff)
Add latest changes from gitlab-org/gitlab@15-0-stable-eev15.0.0-rc42
Diffstat (limited to 'spec')
-rw-r--r--spec/commands/metrics_server/metrics_server_spec.rb89
-rw-r--r--spec/commands/sidekiq_cluster/cli_spec.rb102
-rw-r--r--spec/components/pajamas/alert_component_spec.rb100
-rw-r--r--spec/config/object_store_settings_spec.rb87
-rw-r--r--spec/config/settings_spec.rb2
-rw-r--r--spec/controllers/admin/application_settings_controller_spec.rb64
-rw-r--r--spec/controllers/admin/clusters_controller_spec.rb239
-rw-r--r--spec/controllers/admin/groups_controller_spec.rb6
-rw-r--r--spec/controllers/admin/requests_profiles_controller_spec.rb72
-rw-r--r--spec/controllers/admin/runners_controller_spec.rb64
-rw-r--r--spec/controllers/admin/topics_controller_spec.rb17
-rw-r--r--spec/controllers/dashboard/todos_controller_spec.rb13
-rw-r--r--spec/controllers/graphql_controller_spec.rb24
-rw-r--r--spec/controllers/groups/clusters_controller_spec.rb188
-rw-r--r--spec/controllers/groups/dependency_proxy_auth_controller_spec.rb12
-rw-r--r--spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb26
-rw-r--r--spec/controllers/groups/releases_controller_spec.rb26
-rw-r--r--spec/controllers/groups/runners_controller_spec.rb201
-rw-r--r--spec/controllers/groups/settings/ci_cd_controller_spec.rb75
-rw-r--r--spec/controllers/groups/shared_projects_controller_spec.rb3
-rw-r--r--spec/controllers/groups/uploads_controller_spec.rb163
-rw-r--r--spec/controllers/groups_controller_spec.rb68
-rw-r--r--spec/controllers/jira_connect/events_controller_spec.rb4
-rw-r--r--spec/controllers/oauth/authorizations_controller_spec.rb20
-rw-r--r--spec/controllers/profiles/emails_controller_spec.rb6
-rw-r--r--spec/controllers/projects/artifacts_controller_spec.rb1
-rw-r--r--spec/controllers/projects/branches_controller_spec.rb35
-rw-r--r--spec/controllers/projects/ci/secure_files_controller_spec.rb34
-rw-r--r--spec/controllers/projects/clusters_controller_spec.rb198
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb11
-rw-r--r--spec/controllers/projects/error_tracking/projects_controller_spec.rb31
-rw-r--r--spec/controllers/projects/error_tracking/stack_traces_controller_spec.rb37
-rw-r--r--spec/controllers/projects/error_tracking_controller_spec.rb69
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb159
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb13
-rw-r--r--spec/controllers/projects/logs_controller_spec.rb22
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb76
-rw-r--r--spec/controllers/projects/prometheus/alerts_controller_spec.rb131
-rw-r--r--spec/controllers/projects/releases_controller_spec.rb25
-rw-r--r--spec/controllers/projects/serverless/functions_controller_spec.rb341
-rw-r--r--spec/controllers/projects/service_ping_controller_spec.rb12
-rw-r--r--spec/controllers/projects/services_controller_spec.rb82
-rw-r--r--spec/controllers/projects/settings/operations_controller_spec.rb31
-rw-r--r--spec/controllers/projects/tracings_controller_spec.rb10
-rw-r--r--spec/controllers/projects/uploads_controller_spec.rb235
-rw-r--r--spec/controllers/projects_controller_spec.rb68
-rw-r--r--spec/controllers/repositories/lfs_storage_controller_spec.rb13
-rw-r--r--spec/controllers/uploads_controller_spec.rb13
-rw-r--r--spec/db/docs_spec.rb131
-rw-r--r--spec/db/schema_spec.rb5
-rw-r--r--spec/experiments/application_experiment_spec.rb8
-rw-r--r--spec/experiments/concerns/project_commit_count_spec.rb2
-rw-r--r--spec/factories/alert_management/alerts.rb14
-rw-r--r--spec/factories/ci/pipelines.rb4
-rw-r--r--spec/factories/ci/secure_files.rb2
-rw-r--r--spec/factories/clusters/agent_tokens.rb1
-rw-r--r--spec/factories/deploy_tokens.rb1
-rw-r--r--spec/factories/incident_management/timeline_events.rb14
-rw-r--r--spec/factories/keys.rb10
-rw-r--r--spec/factories/namespace_ci_cd_settings.rb7
-rw-r--r--spec/factories/packages/cleanup/policies.rb16
-rw-r--r--spec/factories/projects.rb6
-rw-r--r--spec/factories/topics.rb1
-rw-r--r--spec/factories/users/in_product_marketing_email.rb6
-rw-r--r--spec/features/admin/admin_groups_spec.rb2
-rw-r--r--spec/features/admin/admin_projects_spec.rb4
-rw-r--r--spec/features/admin/admin_requests_profiles_spec.rb136
-rw-r--r--spec/features/admin/admin_runners_spec.rb59
-rw-r--r--spec/features/admin/admin_sees_background_migrations_spec.rb142
-rw-r--r--spec/features/admin/admin_settings_spec.rb55
-rw-r--r--spec/features/admin/clusters/eks_spec.rb32
-rw-r--r--spec/features/admin/users/users_spec.rb2
-rw-r--r--spec/features/dashboard/issuables_counter_spec.rb5
-rw-r--r--spec/features/dashboard/issues_filter_spec.rb4
-rw-r--r--spec/features/dashboard/merge_requests_spec.rb15
-rw-r--r--spec/features/dashboard/todos/todos_sorting_spec.rb22
-rw-r--r--spec/features/dashboard/todos/todos_spec.rb4
-rw-r--r--spec/features/explore/topics_spec.rb4
-rw-r--r--spec/features/frequently_visited_projects_and_groups_spec.rb2
-rw-r--r--spec/features/groups/clusters/eks_spec.rb37
-rw-r--r--spec/features/groups/crm/contacts/create_spec.rb28
-rw-r--r--spec/features/groups/dependency_proxy_spec.rb16
-rw-r--r--spec/features/groups/empty_states_spec.rb18
-rw-r--r--spec/features/groups/group_settings_spec.rb2
-rw-r--r--spec/features/groups/issues_spec.rb65
-rw-r--r--spec/features/groups/members/manage_groups_spec.rb138
-rw-r--r--spec/features/groups/settings/ci_cd_spec.rb65
-rw-r--r--spec/features/groups_spec.rb18
-rw-r--r--spec/features/ide/user_opens_merge_request_spec.rb1
-rw-r--r--spec/features/issuables/issuable_list_spec.rb12
-rw-r--r--spec/features/issuables/sorting_list_spec.rb4
-rw-r--r--spec/features/issue_rebalancing_spec.rb12
-rw-r--r--spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb8
-rw-r--r--spec/features/issues/filtered_search/dropdown_assignee_spec.rb30
-rw-r--r--spec/features/issues/filtered_search/dropdown_author_spec.rb24
-rw-r--r--spec/features/issues/filtered_search/dropdown_base_spec.rb22
-rw-r--r--spec/features/issues/filtered_search/dropdown_emoji_spec.rb32
-rw-r--r--spec/features/issues/filtered_search/dropdown_hint_spec.rb62
-rw-r--r--spec/features/issues/filtered_search/dropdown_label_spec.rb10
-rw-r--r--spec/features/issues/filtered_search/dropdown_milestone_spec.rb14
-rw-r--r--spec/features/issues/filtered_search/dropdown_release_spec.rb16
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb344
-rw-r--r--spec/features/issues/filtered_search/recent_searches_spec.rb97
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb86
-rw-r--r--spec/features/issues/filtered_search/visual_tokens_spec.rb128
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb2
-rw-r--r--spec/features/issues/rss_spec.rb16
-rw-r--r--spec/features/issues/user_bulk_edits_issues_labels_spec.rb8
-rw-r--r--spec/features/issues/user_creates_branch_and_merge_request_spec.rb14
-rw-r--r--spec/features/issues/user_creates_issue_spec.rb6
-rw-r--r--spec/features/issues/user_filters_issues_spec.rb4
-rw-r--r--spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb1
-rw-r--r--spec/features/issues/user_sees_breadcrumb_links_spec.rb6
-rw-r--r--spec/features/issues/user_sorts_issues_spec.rb17
-rw-r--r--spec/features/labels_hierarchy_spec.rb64
-rw-r--r--spec/features/markdown/mermaid_spec.rb45
-rw-r--r--spec/features/merge_request/batch_comments_spec.rb13
-rw-r--r--spec/features/merge_request/close_reopen_report_toggle_spec.rb20
-rw-r--r--spec/features/merge_request/merge_request_discussion_lock_spec.rb93
-rw-r--r--spec/features/merge_request/user_accepts_merge_request_spec.rb18
-rw-r--r--spec/features/merge_request/user_assigns_themselves_spec.rb6
-rw-r--r--spec/features/merge_request/user_awards_emoji_spec.rb4
-rw-r--r--spec/features/merge_request/user_comments_on_diff_spec.rb13
-rw-r--r--spec/features/merge_request/user_creates_image_diff_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_creates_merge_request_spec.rb42
-rw-r--r--spec/features/merge_request/user_customizes_merge_commit_message_spec.rb8
-rw-r--r--spec/features/merge_request/user_edits_merge_request_spec.rb2
-rw-r--r--spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb1
-rw-r--r--spec/features/merge_request/user_manages_subscription_spec.rb45
-rw-r--r--spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb3
-rw-r--r--spec/features/merge_request/user_merges_immediately_spec.rb10
-rw-r--r--spec/features/merge_request/user_merges_merge_request_spec.rb5
-rw-r--r--spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb2
-rw-r--r--spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb13
-rw-r--r--spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb39
-rw-r--r--spec/features/merge_request/user_posts_diff_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb86
-rw-r--r--spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb9
-rw-r--r--spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb43
-rw-r--r--spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb1
-rw-r--r--spec/features/merge_request/user_squashes_merge_request_spec.rb2
-rw-r--r--spec/features/merge_request/user_views_diffs_spec.rb2
-rw-r--r--spec/features/merge_request/user_views_open_merge_request_spec.rb16
-rw-r--r--spec/features/merge_request/user_views_user_status_on_merge_request_spec.rb4
-rw-r--r--spec/features/merge_requests/user_lists_merge_requests_spec.rb14
-rw-r--r--spec/features/merge_requests/user_mass_updates_spec.rb6
-rw-r--r--spec/features/merge_requests/user_sorts_merge_requests_spec.rb33
-rw-r--r--spec/features/monitor_sidebar_link_spec.rb5
-rw-r--r--spec/features/oauth_login_spec.rb55
-rw-r--r--spec/features/profiles/keys_spec.rb2
-rw-r--r--spec/features/profiles/oauth_applications_spec.rb2
-rw-r--r--spec/features/projects/active_tabs_spec.rb2
-rw-r--r--spec/features/projects/blobs/blame_spec.rb67
-rw-r--r--spec/features/projects/ci/editor_spec.rb34
-rw-r--r--spec/features/projects/ci/secure_files_spec.rb44
-rw-r--r--spec/features/projects/clusters/eks_spec.rb36
-rw-r--r--spec/features/projects/clusters/gcp_spec.rb95
-rw-r--r--spec/features/projects/clusters_spec.rb97
-rw-r--r--spec/features/projects/commit/mini_pipeline_graph_spec.rb2
-rw-r--r--spec/features/projects/graph_spec.rb17
-rw-r--r--spec/features/projects/integrations/prometheus_external_alerts_spec.rb34
-rw-r--r--spec/features/projects/integrations/user_activates_prometheus_spec.rb5
-rw-r--r--spec/features/projects/issues/design_management/user_uploads_designs_spec.rb3
-rw-r--r--spec/features/projects/jobs/user_browses_jobs_spec.rb2
-rw-r--r--spec/features/projects/jobs_spec.rb2
-rw-r--r--spec/features/projects/members/groups_with_access_list_spec.rb2
-rw-r--r--spec/features/projects/members/manage_groups_spec.rb (renamed from spec/features/projects/members/invite_group_spec.rb)83
-rw-r--r--spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb2
-rw-r--r--spec/features/projects/members/master_manages_access_requests_spec.rb2
-rw-r--r--spec/features/projects/members/member_leaves_project_spec.rb2
-rw-r--r--spec/features/projects/navbar_spec.rb6
-rw-r--r--spec/features/projects/packages_spec.rb3
-rw-r--r--spec/features/projects/pipelines/legacy_pipeline_spec.rb1073
-rw-r--r--spec/features/projects/pipelines/legacy_pipelines_spec.rb847
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb288
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb26
-rw-r--r--spec/features/projects/serverless/functions_spec.rb88
-rw-r--r--spec/features/projects/settings/pipelines_settings_spec.rb18
-rw-r--r--spec/features/projects/settings/secure_files_settings_spec.rb46
-rw-r--r--spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb12
-rw-r--r--spec/features/projects/settings/user_manages_project_members_spec.rb2
-rw-r--r--spec/features/projects/show/user_uploads_files_spec.rb12
-rw-r--r--spec/features/projects/snippets/create_snippet_spec.rb9
-rw-r--r--spec/features/projects/tags/user_views_tags_spec.rb30
-rw-r--r--spec/features/projects/tree/tree_show_spec.rb16
-rw-r--r--spec/features/runners_spec.rb202
-rw-r--r--spec/features/security/project/internal_access_spec.rb2
-rw-r--r--spec/features/security/project/private_access_spec.rb4
-rw-r--r--spec/features/security/project/public_access_spec.rb4
-rw-r--r--spec/features/snippets/user_creates_snippet_spec.rb17
-rw-r--r--spec/features/topic_show_spec.rb7
-rw-r--r--spec/features/user_sorts_things_spec.rb15
-rw-r--r--spec/features/users/login_spec.rb2
-rw-r--r--spec/finders/error_tracking/errors_finder_spec.rb53
-rw-r--r--spec/finders/group_members_finder_spec.rb33
-rw-r--r--spec/finders/groups/projects_requiring_authorizations_refresh/on_direct_membership_finder_spec.rb73
-rw-r--r--spec/finders/groups/projects_requiring_authorizations_refresh/on_transfer_finder_spec.rb59
-rw-r--r--spec/finders/incident_management/timeline_events_finder_spec.rb56
-rw-r--r--spec/finders/issues_finder/params_spec.rb49
-rw-r--r--spec/finders/issues_finder_spec.rb234
-rw-r--r--spec/finders/merge_requests_finder_spec.rb3
-rw-r--r--spec/finders/packages/build_infos_finder_spec.rb120
-rw-r--r--spec/finders/packages/build_infos_for_many_packages_finder_spec.rb136
-rw-r--r--spec/finders/personal_access_tokens_finder_spec.rb18
-rw-r--r--spec/finders/projects/serverless/functions_finder_spec.rb185
-rw-r--r--spec/finders/releases_finder_spec.rb126
-rw-r--r--spec/fixtures/api/schemas/graphql/container_repository.json5
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/agent_token.json24
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/agent_token_basic.json22
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/agent_token_with_token.json26
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/agent_tokens.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/environment.json2
-rw-r--r--spec/fixtures/glfm/example_snapshots/examples_index.yml2020
-rw-r--r--spec/fixtures/glfm/example_snapshots/html.yml6097
-rw-r--r--spec/fixtures/glfm/example_snapshots/markdown.yml2203
-rw-r--r--spec/fixtures/glfm/example_snapshots/prosemirror_json.yml16739
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/project.json17
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson2
-rw-r--r--spec/fixtures/lib/gitlab/import_export/designs/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/project.json1
-rw-r--r--spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json2
-rw-r--r--spec/fixtures/logo_sample.svg40
-rw-r--r--spec/fixtures/markdown/markdown_golden_master_examples.yml18
-rw-r--r--spec/fixtures/security_reports/master/gl-common-scanning-report.json700
-rw-r--r--spec/frontend/.eslintrc.yml5
-rw-r--r--spec/frontend/__helpers__/dom_shims/clipboard.js5
-rw-r--r--spec/frontend/__helpers__/dom_shims/index.js1
-rw-r--r--spec/frontend/__helpers__/fixtures.js15
-rw-r--r--spec/frontend/__helpers__/flush_promises.js4
-rw-r--r--spec/frontend/__helpers__/shared_test_setup.js7
-rw-r--r--spec/frontend/__helpers__/user_mock_data_helper.js2
-rw-r--r--spec/frontend/__helpers__/vuex_action_helper.js35
-rw-r--r--spec/frontend/__helpers__/vuex_action_helper_spec.js61
-rw-r--r--spec/frontend/__helpers__/wait_for_promises.js5
-rw-r--r--spec/frontend/activities_spec.js7
-rw-r--r--spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap1
-rw-r--r--spec/frontend/admin/applications/components/delete_application_spec.js4
-rw-r--r--spec/frontend/admin/background_migrations/components/database_listbox_spec.js57
-rw-r--r--spec/frontend/admin/background_migrations/mock_data.js6
-rw-r--r--spec/frontend/admin/users/new_spec.js7
-rw-r--r--spec/frontend/alert_handler_spec.js18
-rw-r--r--spec/frontend/analytics/shared/components/metric_popover_spec.js2
-rw-r--r--spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js15
-rw-r--r--spec/frontend/api/tags_api_spec.js37
-rw-r--r--spec/frontend/api/user_api_spec.js50
-rw-r--r--spec/frontend/api_spec.js32
-rw-r--r--spec/frontend/attention_requests/components/navigation_popover_spec.js4
-rw-r--r--spec/frontend/authentication/u2f/authenticate_spec.js7
-rw-r--r--spec/frontend/authentication/u2f/register_spec.js7
-rw-r--r--spec/frontend/authentication/webauthn/authenticate_spec.js7
-rw-r--r--spec/frontend/authentication/webauthn/register_spec.js7
-rw-r--r--spec/frontend/awards_handler_spec.js7
-rw-r--r--spec/frontend/badges/components/badge_form_spec.js4
-rw-r--r--spec/frontend/badges/components/badge_list_row_spec.js4
-rw-r--r--spec/frontend/badges/components/badge_list_spec.js4
-rw-r--r--spec/frontend/badges/components/badge_spec.js7
-rw-r--r--spec/frontend/badges/store/actions_spec.js6
-rw-r--r--spec/frontend/behaviors/autosize_spec.js20
-rw-r--r--spec/frontend/behaviors/copy_as_gfm_spec.js3
-rw-r--r--spec/frontend/behaviors/date_picker_spec.js7
-rw-r--r--spec/frontend/behaviors/load_startup_css_spec.js6
-rw-r--r--spec/frontend/behaviors/markdown/highlight_current_user_spec.js7
-rw-r--r--spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js34
-rw-r--r--spec/frontend/behaviors/quick_submit_spec.js7
-rw-r--r--spec/frontend/behaviors/requires_input_spec.js7
-rw-r--r--spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js57
-rw-r--r--spec/frontend/blob/blob_file_dropzone_spec.js7
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap2
-rw-r--r--spec/frontend/blob/components/table_contents_spec.js4
-rw-r--r--spec/frontend/blob/file_template_mediator_spec.js7
-rw-r--r--spec/frontend/blob/file_template_selector_spec.js29
-rw-r--r--spec/frontend/blob/line_highlighter_spec.js8
-rw-r--r--spec/frontend/blob/openapi/index_spec.js28
-rw-r--r--spec/frontend/blob/pipeline_tour_success_modal_spec.js2
-rw-r--r--spec/frontend/blob/sketch/index_spec.js50
-rw-r--r--spec/frontend/blob/viewer/index_spec.js5
-rw-r--r--spec/frontend/blob_edit/blob_bundle_spec.js14
-rw-r--r--spec/frontend/blob_edit/edit_blob_spec.js41
-rw-r--r--spec/frontend/boards/board_card_inner_spec.js88
-rw-r--r--spec/frontend/boards/components/board_list_header_spec.js4
-rw-r--r--spec/frontend/boards/mock_data.js5
-rw-r--r--spec/frontend/boards/project_select_spec.js21
-rw-r--r--spec/frontend/boards/stores/getters_spec.js29
-rw-r--r--spec/frontend/bootstrap_jquery_spec.js13
-rw-r--r--spec/frontend/bootstrap_linked_tabs_spec.js7
-rw-r--r--spec/frontend/branches/components/delete_branch_modal_spec.js4
-rw-r--r--spec/frontend/broadcast_notification_spec.js9
-rw-r--r--spec/frontend/ci_secure_files/components/secure_files_list_spec.js56
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js19
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js7
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js22
-rw-r--r--spec/frontend/clusters/agents/components/revoke_token_button_spec.js239
-rw-r--r--spec/frontend/clusters/clusters_bundle_spec.js8
-rw-r--r--spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap2
-rw-r--r--spec/frontend/clusters/components/new_cluster_spec.js10
-rw-r--r--spec/frontend/clusters/forms/components/integration_form_spec.js2
-rw-r--r--spec/frontend/clusters/gke_cluster_namespace/gke_cluster_namespace_spec.js (renamed from spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js)9
-rw-r--r--spec/frontend/clusters/mock_data.js12
-rw-r--r--spec/frontend/clusters_list/components/agent_table_spec.js3
-rw-r--r--spec/frontend/clusters_list/components/clusters_actions_spec.js86
-rw-r--r--spec/frontend/code_navigation/components/app_spec.js6
-rw-r--r--spec/frontend/code_navigation/store/actions_spec.js7
-rw-r--r--spec/frontend/code_navigation/utils/index_spec.js9
-rw-r--r--spec/frontend/commits_spec.js7
-rw-r--r--spec/frontend/commons/nav/user_merge_requests_spec.js7
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap4
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/code_block_spec.js (renamed from spec/frontend/content_editor/components/code_block_bubble_menu_spec.js)36
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/formatting_spec.js (renamed from spec/frontend/content_editor/components/formatting_bubble_menu_spec.js)19
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/link_spec.js227
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/media_spec.js234
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js2
-rw-r--r--spec/frontend/content_editor/components/toolbar_button_spec.js2
-rw-r--r--spec/frontend/content_editor/components/wrappers/code_block_spec.js (renamed from spec/frontend/content_editor/components/wrappers/frontmatter_spec.js)33
-rw-r--r--spec/frontend/content_editor/components/wrappers/media_spec.js69
-rw-r--r--spec/frontend/content_editor/extensions/attachment_spec.js32
-rw-r--r--spec/frontend/content_editor/extensions/code_block_highlight_spec.js13
-rw-r--r--spec/frontend/content_editor/extensions/diagram_spec.js16
-rw-r--r--spec/frontend/content_editor/extensions/frontmatter_spec.js12
-rw-r--r--spec/frontend/content_editor/extensions/paste_markdown_spec.js51
-rw-r--r--spec/frontend/content_editor/remark_markdown_processing_spec.js248
-rw-r--r--spec/frontend/content_editor/services/asset_resolver_spec.js23
-rw-r--r--spec/frontend/content_editor/services/code_block_language_loader_spec.js36
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js15
-rw-r--r--spec/frontend/content_editor/services/create_content_editor_spec.js48
-rw-r--r--spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js (renamed from spec/frontend/content_editor/services/markdown_deserializer_spec.js)16
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js44
-rw-r--r--spec/frontend/content_editor/services/markdown_sourcemap_spec.js2
-rw-r--r--spec/frontend/content_editor/test_constants.js25
-rw-r--r--spec/frontend/content_editor/test_utils.js2
-rw-r--r--spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js214
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js98
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js562
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js124
-rw-r--r--spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js178
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/actions_spec.js366
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/getters_spec.js13
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js161
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js129
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js137
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js137
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_submit_button_spec.js51
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js111
-rw-r--r--spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js103
-rw-r--r--spec/frontend/create_cluster/gke_cluster/gapi_loader_spec.js47
-rw-r--r--spec/frontend/create_cluster/gke_cluster/helpers.js64
-rw-r--r--spec/frontend/create_cluster/gke_cluster/mock_data.js75
-rw-r--r--spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js141
-rw-r--r--spec/frontend/create_cluster/gke_cluster/stores/getters_spec.js103
-rw-r--r--spec/frontend/create_cluster/gke_cluster/stores/mutations_spec.js32
-rw-r--r--spec/frontend/create_cluster/init_create_cluster_spec.js77
-rw-r--r--spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js95
-rw-r--r--spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js36
-rw-r--r--spec/frontend/create_item_dropdown_spec.js4
-rw-r--r--spec/frontend/crm/contact_form_wrapper_spec.js112
-rw-r--r--spec/frontend/crm/contacts_root_spec.js2
-rw-r--r--spec/frontend/crm/form_spec.js57
-rw-r--r--spec/frontend/crm/organizations_root_spec.js4
-rw-r--r--spec/frontend/cycle_analytics/base_spec.js3
-rw-r--r--spec/frontend/cycle_analytics/mock_data.js4
-rw-r--r--spec/frontend/cycle_analytics/stage_table_spec.js2
-rw-r--r--spec/frontend/cycle_analytics/value_stream_filters_spec.js54
-rw-r--r--spec/frontend/cycle_analytics/value_stream_metrics_spec.js46
-rw-r--r--spec/frontend/deprecated_jquery_dropdown_spec.js5
-rw-r--r--spec/frontend/design_management/components/design_notes/design_reply_form_spec.js4
-rw-r--r--spec/frontend/design_management/components/design_sidebar_spec.js2
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap1
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap1
-rw-r--r--spec/frontend/diffs/components/diff_expansion_cell_spec.js21
-rw-r--r--spec/frontend/diffs/store/actions_spec.js2
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js9
-rw-r--r--spec/frontend/diffs/store/utils_spec.js24
-rw-r--r--spec/frontend/diffs/utils/diff_file_spec.js71
-rw-r--r--spec/frontend/diffs/utils/queue_events_spec.js37
-rw-r--r--spec/frontend/dropzone_input_spec.js5
-rw-r--r--spec/frontend/editor/components/helpers.js18
-rw-r--r--spec/frontend/editor/components/source_editor_toolbar_button_spec.js116
-rw-r--r--spec/frontend/editor/components/source_editor_toolbar_graphql_spec.js112
-rw-r--r--spec/frontend/editor/extensions/source_editor_toolbar_ext_spec.js156
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json10
-rw-r--r--spec/frontend/editor/source_editor_ci_schema_ext_spec.js5
-rw-r--r--spec/frontend/editor/source_editor_extension_base_spec.js6
-rw-r--r--spec/frontend/editor/source_editor_markdown_ext_spec.js5
-rw-r--r--spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js115
-rw-r--r--spec/frontend/editor/source_editor_spec.js8
-rw-r--r--spec/frontend/editor/source_editor_yaml_ext_spec.js9
-rw-r--r--spec/frontend/editor/utils_spec.js13
-rw-r--r--spec/frontend/emoji/components/utils_spec.js4
-rw-r--r--spec/frontend/environments/environment_folder_spec.js19
-rw-r--r--spec/frontend/filterable_list_spec.js6
-rw-r--r--spec/frontend/filtered_search/dropdown_user_spec.js7
-rw-r--r--spec/frontend/filtered_search/dropdown_utils_spec.js11
-rw-r--r--spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js7
-rw-r--r--spec/frontend/filtered_search/filtered_search_manager_spec.js8
-rw-r--r--spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js9
-rw-r--r--spec/frontend/filtered_search/visual_token_value_spec.js7
-rw-r--r--spec/frontend/fixtures/api_merge_requests.rb6
-rw-r--r--spec/frontend/fixtures/runner.rb22
-rw-r--r--spec/frontend/flash_spec.js6
-rw-r--r--spec/frontend/frequent_items/components/app_spec.js12
-rw-r--r--spec/frontend/frequent_items/utils_spec.js14
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js4
-rw-r--r--spec/frontend/gl_field_errors_spec.js7
-rw-r--r--spec/frontend/google_tag_manager/index_spec.js10
-rw-r--r--spec/frontend/gpg_badges_spec.js10
-rw-r--r--spec/frontend/group_settings/components/shared_runners_form_spec.js167
-rw-r--r--spec/frontend/groups/landing_spec.js2
-rw-r--r--spec/frontend/header_spec.js10
-rw-r--r--spec/frontend/helpers/startup_css_helper_spec.js9
-rw-r--r--spec/frontend/ide/components/commit_sidebar/message_field_spec.js5
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js4
-rw-r--r--spec/frontend/image_diff/image_diff_spec.js7
-rw-r--r--spec/frontend/image_diff/init_discussion_tab_spec.js7
-rw-r--r--spec/frontend/image_diff/replaced_image_diff_spec.js7
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_spec.js31
-rw-r--r--spec/frontend/import_entities/import_groups/utils_spec.js56
-rw-r--r--spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js4
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap1
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js24
-rw-r--r--spec/frontend/integrations/edit/components/jira_issues_fields_spec.js49
-rw-r--r--spec/frontend/integrations/edit/mock_data.js8
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js80
-rw-r--r--spec/frontend/invite_members/components/invite_modal_base_spec.js120
-rw-r--r--spec/frontend/invite_members/components/user_limit_notification_spec.js59
-rw-r--r--spec/frontend/invite_members/mock_data/member_modal.js2
-rw-r--r--spec/frontend/invite_members/mock_data/modal_base.js3
-rw-r--r--spec/frontend/issuable/components/issuable_header_warnings_spec.js10
-rw-r--r--spec/frontend/issuable/components/status_box_spec.js76
-rw-r--r--spec/frontend/issuable/issuable_form_spec.js10
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_root_spec.js27
-rw-r--r--spec/frontend/issues/issue_spec.js13
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js175
-rw-r--r--spec/frontend/issues/list/mock_data.js14
-rw-r--r--spec/frontend/issues/list/utils_spec.js29
-rw-r--r--spec/frontend/issues/show/components/app_spec.js53
-rw-r--r--spec/frontend/issues/show/components/description_spec.js91
-rw-r--r--spec/frontend/issues/show/components/fields/description_spec.js1
-rw-r--r--spec/frontend/issues/show/components/title_spec.js7
-rw-r--r--spec/frontend/issues/show/mock_data/mock_data.js17
-rw-r--r--spec/frontend/issues/show/utils_spec.js40
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js116
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/app_spec.js67
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js35
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js7
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js (renamed from spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js)8
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js83
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js69
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js82
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js71
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/subscriptions_spec.js56
-rw-r--r--spec/frontend/jira_connect/subscriptions/store/actions_spec.js172
-rw-r--r--spec/frontend/jira_connect/subscriptions/store/mutations_spec.js67
-rw-r--r--spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js1
-rw-r--r--spec/frontend/jobs/components/job_app_spec.js4
-rw-r--r--spec/frontend/jobs/components/stuck_block_spec.js6
-rw-r--r--spec/frontend/jobs/components/table/cells/actions_cell_spec.js105
-rw-r--r--spec/frontend/jobs/mock_data.js72
-rw-r--r--spec/frontend/jobs/store/getters_spec.js20
-rw-r--r--spec/frontend/lib/dompurify_spec.js10
-rw-r--r--spec/frontend/lib/gfm/index_spec.js6
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js69
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js4
-rw-r--r--spec/frontend/lib/utils/dom_utils_spec.js29
-rw-r--r--spec/frontend/lib/utils/file_upload_spec.js7
-rw-r--r--spec/frontend/lib/utils/mock_data.js42
-rw-r--r--spec/frontend/lib/utils/navigation_utility_spec.js5
-rw-r--r--spec/frontend/lib/utils/resize_observer_spec.js4
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js33
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js53
-rw-r--r--spec/frontend/lib/utils/users_cache_spec.js25
-rw-r--r--spec/frontend/listbox/index_spec.js6
-rw-r--r--spec/frontend/logs/components/tokens/token_with_loading_state_spec.js5
-rw-r--r--spec/frontend/members/components/table/role_dropdown_spec.js27
-rw-r--r--spec/frontend/merge_conflicts/store/actions_spec.js4
-rw-r--r--spec/frontend/merge_request_spec.js6
-rw-r--r--spec/frontend/merge_request_tabs_spec.js7
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap1
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js26
-rw-r--r--spec/frontend/new_branch_spec.js7
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js34
-rw-r--r--spec/frontend/notes/components/comment_type_dropdown_spec.js32
-rw-r--r--spec/frontend/notes/components/discussion_counter_spec.js42
-rw-r--r--spec/frontend/notes/components/note_body_spec.js90
-rw-r--r--spec/frontend/notes/components/note_form_spec.js21
-rw-r--r--spec/frontend/notes/components/note_header_spec.js4
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js32
-rw-r--r--spec/frontend/notes/deprecated_notes_spec.js11
-rw-r--r--spec/frontend/notes/mixins/discussion_navigation_spec.js1
-rw-r--r--spec/frontend/notes/mock_data.js14
-rw-r--r--spec/frontend/notes/stores/actions_spec.js8
-rw-r--r--spec/frontend/notes/stores/getters_spec.js86
-rw-r--r--spec/frontend/oauth_remember_me_spec.js7
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js27
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js38
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js21
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/app_spec.js83
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js66
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/mock_data.js14
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap12
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js9
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap39
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js29
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js41
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js5
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js23
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js12
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js19
-rw-r--r--spec/frontend/pager_spec.js31
-rw-r--r--spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js8
-rw-r--r--spec/frontend/pages/admin/application_settings/account_and_limits_spec.js7
-rw-r--r--spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js7
-rw-r--r--spec/frontend/pages/admin/projects/components/namespace_select_spec.js7
-rw-r--r--spec/frontend/pages/dashboard/todos/index/todos_spec.js7
-rw-r--r--spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js17
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap9
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js47
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js2
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/mock_data.js9
-rw-r--r--spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js5
-rw-r--r--spec/frontend/pages/projects/pages_domains/form_spec.js7
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js2
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js9
-rw-r--r--spec/frontend/pages/search/show/refresh_counts_spec.js7
-rw-r--r--spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js7
-rw-r--r--spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js7
-rw-r--r--spec/frontend/pdf/index_spec.js18
-rw-r--r--spec/frontend/performance_bar/index_spec.js4
-rw-r--r--spec/frontend/pipeline_editor/components/commit/commit_form_spec.js4
-rw-r--r--spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js133
-rw-r--r--spec/frontend/pipeline_editor/components/file-tree/container_spec.js138
-rw-r--r--spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js52
-rw-r--r--spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js2
-rw-r--r--spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js56
-rw-r--r--spec/frontend/pipeline_editor/components/popovers/walkthrough_popover_spec.js (renamed from spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js)2
-rw-r--r--spec/frontend/pipeline_editor/mock_data.js35
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_home_spec.js97
-rw-r--r--spec/frontend/pipeline_wizard/components/commit_spec.js18
-rw-r--r--spec/frontend/pipelines/__snapshots__/utils_spec.js.snap11
-rw-r--r--spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js87
-rw-r--r--spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js117
-rw-r--r--spec/frontend/pipelines/components/jobs/utils_spec.js14
-rw-r--r--spec/frontend/pipelines/components/pipeline_tabs_spec.js9
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js (renamed from spec/frontend/pipelines/empty_state/ci_templates_spec.js)47
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js138
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js (renamed from spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js)5
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js6
-rw-r--r--spec/frontend/pipelines/empty_state_spec.js60
-rw-r--r--spec/frontend/pipelines/graph/graph_component_wrapper_spec.js27
-rw-r--r--spec/frontend/pipelines/graph/job_item_spec.js184
-rw-r--r--spec/frontend/pipelines/graph/linked_pipeline_spec.js336
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_column_spec.js1
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_mock_data.js5
-rw-r--r--spec/frontend/pipelines/graph/mock_data.js122
-rw-r--r--spec/frontend/pipelines/graph_shared/links_inner_spec.js15
-rw-r--r--spec/frontend/pipelines/header_component_spec.js51
-rw-r--r--spec/frontend/pipelines/mock_data.js215
-rw-r--r--spec/frontend/pipelines/pipeline_graph/utils_spec.js20
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js2
-rw-r--r--spec/frontend/pipelines/test_reports/stores/actions_spec.js7
-rw-r--r--spec/frontend/pipelines/test_reports/stores/mutations_spec.js21
-rw-r--r--spec/frontend/pipelines/test_reports/test_suite_table_spec.js57
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js1
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_source_token_spec.js1
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_status_token_spec.js1
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js1
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js1
-rw-r--r--spec/frontend/project_select_combo_button_spec.js31
-rw-r--r--spec/frontend/projects/commits/components/author_select_spec.js4
-rw-r--r--spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap2
-rw-r--r--spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap1
-rw-r--r--spec/frontend/projects/new/components/deployment_target_select_spec.js4
-rw-r--r--spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js4
-rw-r--r--spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js6
-rw-r--r--spec/frontend/projects/project_import_gitlab_project_spec.js4
-rw-r--r--spec/frontend/projects/project_new_spec.js7
-rw-r--r--spec/frontend/projects/projects_filterable_list_spec.js6
-rw-r--r--spec/frontend/projects/settings/access_dropdown_spec.js7
-rw-r--r--spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js15
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js6
-rw-r--r--spec/frontend/prometheus_alerts/components/reset_key_spec.js99
-rw-r--r--spec/frontend/prometheus_metrics/custom_metrics_spec.js4
-rw-r--r--spec/frontend/prometheus_metrics/prometheus_metrics_spec.js7
-rw-r--r--spec/frontend/protected_branches/protected_branch_create_spec.js7
-rw-r--r--spec/frontend/protected_branches/protected_branch_edit_spec.js10
-rw-r--r--spec/frontend/read_more_spec.js7
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js12
-rw-r--r--spec/frontend/releases/components/tag_field_new_spec.js28
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js46
-rw-r--r--spec/frontend/releases/stores/modules/detail/getters_spec.js32
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js37
-rw-r--r--spec/frontend/reports/codequality_report/store/getters_spec.js4
-rw-r--r--spec/frontend/reports/components/report_link_spec.js81
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap14
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js9
-rw-r--r--spec/frontend/repository/components/breadcrumbs_spec.js3
-rw-r--r--spec/frontend/right_sidebar_spec.js6
-rw-r--r--spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js35
-rw-r--r--spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js52
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js82
-rw-r--r--spec/frontend/runner/components/registration/registration_dropdown_spec.js89
-rw-r--r--spec/frontend/runner/components/runner_delete_button_spec.js6
-rw-r--r--spec/frontend/runner/components/runner_details_spec.js3
-rw-r--r--spec/frontend/runner/components/runner_jobs_spec.js2
-rw-r--r--spec/frontend/runner/components/runner_projects_spec.js2
-rw-r--r--spec/frontend/runner/components/runner_update_form_spec.js61
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js82
-rw-r--r--spec/frontend/runner/local_storage_alert/save_alert_to_local_storage_spec.js24
-rw-r--r--spec/frontend/runner/local_storage_alert/show_alert_from_local_storage_spec.js40
-rw-r--r--spec/frontend/runner/mock_data.js24
-rw-r--r--spec/frontend/runner/runner_search_utils_spec.js12
-rw-r--r--spec/frontend/search/highlight_blob_search_result_spec.js7
-rw-r--r--spec/frontend/search_autocomplete_spec.js5
-rw-r--r--spec/frontend/security_configuration/components/app_spec.js75
-rw-r--r--spec/frontend/security_configuration/mock_data.js9
-rw-r--r--spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap1
-rw-r--r--spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap22
-rw-r--r--spec/frontend/serverless/components/area_spec.js121
-rw-r--r--spec/frontend/serverless/components/empty_state_spec.js25
-rw-r--r--spec/frontend/serverless/components/environment_row_spec.js68
-rw-r--r--spec/frontend/serverless/components/function_details_spec.js100
-rw-r--r--spec/frontend/serverless/components/function_row_spec.js34
-rw-r--r--spec/frontend/serverless/components/functions_spec.js86
-rw-r--r--spec/frontend/serverless/components/missing_prometheus_spec.js38
-rw-r--r--spec/frontend/serverless/components/pod_box_spec.js22
-rw-r--r--spec/frontend/serverless/components/url_spec.js26
-rw-r--r--spec/frontend/serverless/mock_data.js145
-rw-r--r--spec/frontend/serverless/store/actions_spec.js80
-rw-r--r--spec/frontend/serverless/store/getters_spec.js43
-rw-r--r--spec/frontend/serverless/store/mutations_spec.js86
-rw-r--r--spec/frontend/serverless/utils.js17
-rw-r--r--spec/frontend/settings_panels_spec.js7
-rw-r--r--spec/frontend/shortcuts_spec.js7
-rw-r--r--spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js2
-rw-r--r--spec/frontend/sidebar/components/attention_requested_toggle_spec.js7
-rw-r--r--spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js18
-rw-r--r--spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js2
-rw-r--r--spec/frontend/sidebar/components/crm_contacts_spec.js11
-rw-r--r--spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js7
-rw-r--r--spec/frontend/sidebar/components/time_tracking/mock_data.js6
-rw-r--r--spec/frontend/sidebar/lock/issuable_lock_form_spec.js3
-rw-r--r--spec/frontend/sidebar/reviewers_spec.js1
-rw-r--r--spec/frontend/sidebar/sidebar_mediator_spec.js62
-rw-r--r--spec/frontend/single_file_diff_spec.js3
-rw-r--r--spec/frontend/smart_interval_spec.js7
-rw-r--r--spec/frontend/snippet/collapsible_input_spec.js6
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap3
-rw-r--r--spec/frontend/snippets/components/edit_spec.js209
-rw-r--r--spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js36
-rw-r--r--spec/frontend/syntax_highlight_spec.js16
-rw-r--r--spec/frontend/tabs/index_spec.js8
-rw-r--r--spec/frontend/task_list_spec.js7
-rw-r--r--spec/frontend/tracking/tracking_spec.js12
-rw-r--r--spec/frontend/user_lists/components/user_lists_table_spec.js3
-rw-r--r--spec/frontend/user_popovers_spec.js45
-rw-r--r--spec/frontend/vue_alerts_spec.js6
-rw-r--r--spec/frontend/vue_mr_widget/components/added_commit_message_spec.js31
-rw-r--r--spec/frontend/vue_mr_widget/components/extensions/utils_spec.js22
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js144
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js48
-rw-r--r--spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/color_picker/color_picker_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/confidentiality_badge_spec.js52
-rw-r--r--spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js25
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js36
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js51
-rw-r--r--spec/frontend/vue_shared/components/file_finder/index_spec.js27
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js51
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/gitlab_version_check_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/markdown/header_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/registry/list_item_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/segmented_control_button_group_spec.js104
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js62
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js140
-rw-r--r--spec/frontend/vue_shared/directives/autofocusonshow_spec.js7
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js4
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js1
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js77
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js7
-rw-r--r--spec/frontend/vue_shared/issuable/show/mock_data.js5
-rw-r--r--spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js6
-rw-r--r--spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js (renamed from spec/frontend/security_configuration/components/section_layout_spec.js)11
-rw-r--r--spec/frontend/vue_shared/security_reports/mock_data.js6
-rw-r--r--spec/frontend/whats_new/components/feature_spec.js25
-rw-r--r--spec/frontend/whats_new/utils/notification_spec.js4
-rw-r--r--spec/frontend/wikis_spec.js6
-rw-r--r--spec/frontend/work_items/components/item_state_spec.js54
-rw-r--r--spec/frontend/work_items/components/work_item_actions_spec.js55
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js100
-rw-r--r--spec/frontend/work_items/components/work_item_state_spec.js117
-rw-r--r--spec/frontend/work_items/components/work_item_title_spec.js42
-rw-r--r--spec/frontend/work_items/mock_data.js23
-rw-r--r--spec/frontend/work_items/pages/create_work_item_spec.js7
-rw-r--r--spec/frontend/work_items/pages/work_item_detail_spec.js32
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js60
-rw-r--r--spec/frontend/work_items/router_spec.js1
-rw-r--r--spec/frontend/zen_mode_spec.js7
-rw-r--r--spec/frontend_integration/fly_out_nav_browser_spec.js21
-rw-r--r--spec/frontend_integration/ide/helpers/ide_helper.js12
-rw-r--r--spec/frontend_integration/ide/ide_integration_spec.js4
-rw-r--r--spec/frontend_integration/ide/user_opens_file_spec.js4
-rw-r--r--spec/frontend_integration/ide/user_opens_ide_spec.js4
-rw-r--r--spec/frontend_integration/ide/user_opens_mr_spec.js4
-rw-r--r--spec/frontend_integration/lib/utils/browser_spec.js9
-rw-r--r--spec/graphql/mutations/base_mutation_spec.rb2
-rw-r--r--spec/graphql/mutations/boards/update_spec.rb8
-rw-r--r--spec/graphql/mutations/ci/runner/delete_spec.rb8
-rw-r--r--spec/graphql/mutations/ci/runner/update_spec.rb8
-rw-r--r--spec/graphql/mutations/clusters/agent_tokens/create_spec.rb8
-rw-r--r--spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb52
-rw-r--r--spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb10
-rw-r--r--spec/graphql/mutations/clusters/agents/delete_spec.rb9
-rw-r--r--spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb2
-rw-r--r--spec/graphql/mutations/container_expiration_policies/update_spec.rb6
-rw-r--r--spec/graphql/mutations/container_repositories/destroy_spec.rb8
-rw-r--r--spec/graphql/mutations/container_repositories/destroy_tags_spec.rb8
-rw-r--r--spec/graphql/mutations/customer_relations/contacts/create_spec.rb6
-rw-r--r--spec/graphql/mutations/dependency_proxy/group_settings/update_spec.rb2
-rw-r--r--spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb4
-rw-r--r--spec/graphql/mutations/discussions/toggle_resolve_spec.rb17
-rw-r--r--spec/graphql/mutations/environments/canary_ingress/update_spec.rb10
-rw-r--r--spec/graphql/mutations/incident_management/timeline_event/create_spec.rb51
-rw-r--r--spec/graphql/mutations/incident_management/timeline_event/destroy_spec.rb66
-rw-r--r--spec/graphql/mutations/incident_management/timeline_event/promote_from_note_spec.rb85
-rw-r--r--spec/graphql/mutations/incident_management/timeline_event/update_spec.rb100
-rw-r--r--spec/graphql/mutations/issues/set_due_date_spec.rb2
-rw-r--r--spec/graphql/mutations/merge_requests/accept_spec.rb3
-rw-r--r--spec/graphql/mutations/merge_requests/create_spec.rb4
-rw-r--r--spec/graphql/mutations/namespace/package_settings/update_spec.rb6
-rw-r--r--spec/graphql/mutations/release_asset_links/delete_spec.rb12
-rw-r--r--spec/graphql/mutations/release_asset_links/update_spec.rb12
-rw-r--r--spec/graphql/mutations/timelogs/delete_spec.rb93
-rw-r--r--spec/graphql/mutations/todos/create_spec.rb15
-rw-r--r--spec/graphql/mutations/todos/mark_done_spec.rb9
-rw-r--r--spec/graphql/mutations/todos/restore_many_spec.rb7
-rw-r--r--spec/graphql/mutations/todos/restore_spec.rb9
-rw-r--r--spec/graphql/resolvers/alert_management/alert_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/ci/config_resolver_spec.rb7
-rw-r--r--spec/graphql/resolvers/concerns/resolves_ids_spec.rb19
-rw-r--r--spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/design_management/design_resolver_spec.rb13
-rw-r--r--spec/graphql/resolvers/design_management/designs_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb9
-rw-r--r--spec/graphql/resolvers/design_management/versions_resolver_spec.rb11
-rw-r--r--spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb50
-rw-r--r--spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb17
-rw-r--r--spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb31
-rw-r--r--spec/graphql/resolvers/incident_management/timeline_events_resolver_spec.rb70
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb34
-rw-r--r--spec/graphql/resolvers/package_pipelines_resolver_spec.rb147
-rw-r--r--spec/graphql/resolvers/projects/snippets_resolver_spec.rb6
-rw-r--r--spec/graphql/resolvers/snippets_resolver_spec.rb22
-rw-r--r--spec/graphql/resolvers/timelog_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/users/snippets_resolver_spec.rb10
-rw-r--r--spec/graphql/resolvers/work_item_resolver_spec.rb2
-rw-r--r--spec/graphql/subscriptions/issuable_updated_spec.rb8
-rw-r--r--spec/graphql/types/alert_management/domain_filter_enum_spec.rb2
-rw-r--r--spec/graphql/types/ci/config/config_type_spec.rb1
-rw-r--r--spec/graphql/types/ci/config/include_type_enum_spec.rb11
-rw-r--r--spec/graphql/types/ci/config/include_type_spec.rb21
-rw-r--r--spec/graphql/types/ci/runner_type_spec.rb4
-rw-r--r--spec/graphql/types/ci/runner_upgrade_status_type_enum_spec.rb13
-rw-r--r--spec/graphql/types/color_type_spec.rb38
-rw-r--r--spec/graphql/types/container_expiration_policy_type_spec.rb2
-rw-r--r--spec/graphql/types/container_repository_details_type_spec.rb2
-rw-r--r--spec/graphql/types/container_repository_type_spec.rb2
-rw-r--r--spec/graphql/types/current_user_todos_type_spec.rb207
-rw-r--r--spec/graphql/types/customer_relations/contact_type_spec.rb15
-rw-r--r--spec/graphql/types/customer_relations/organization_type_spec.rb2
-rw-r--r--spec/graphql/types/dependency_proxy/group_setting_type_spec.rb6
-rw-r--r--spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb2
-rw-r--r--spec/graphql/types/duration_type_spec.rb5
-rw-r--r--spec/graphql/types/global_id_type_spec.rb125
-rw-r--r--spec/graphql/types/incident_management/timeline_event_type_spec.rb28
-rw-r--r--spec/graphql/types/merge_request_type_spec.rb2
-rw-r--r--spec/graphql/types/mutation_type_spec.rb8
-rw-r--r--spec/graphql/types/namespace/package_settings_type_spec.rb2
-rw-r--r--spec/graphql/types/packages/package_base_type_spec.rb21
-rw-r--r--spec/graphql/types/packages/package_details_type_spec.rb13
-rw-r--r--spec/graphql/types/packages/package_type_spec.rb8
-rw-r--r--spec/graphql/types/permission_types/work_item_spec.rb15
-rw-r--r--spec/graphql/types/project_statistics_type_spec.rb3
-rw-r--r--spec/graphql/types/project_type_spec.rb1
-rw-r--r--spec/graphql/types/projects/topic_type_spec.rb1
-rw-r--r--spec/graphql/types/range_input_type_spec.rb4
-rw-r--r--spec/graphql/types/root_storage_statistics_type_spec.rb3
-rw-r--r--spec/graphql/types/terraform/state_version_type_spec.rb4
-rw-r--r--spec/graphql/types/timeframe_type_spec.rb4
-rw-r--r--spec/graphql/types/timelog_type_spec.rb3
-rw-r--r--spec/graphql/types/user_merge_request_interaction_type_spec.rb1
-rw-r--r--spec/graphql/types/work_item_type_spec.rb4
-rw-r--r--spec/haml_lint/linter/documentation_links_spec.rb5
-rw-r--r--spec/haml_lint/linter/inline_javascript_spec.rb31
-rw-r--r--spec/haml_lint/linter/no_plain_nodes_spec.rb5
-rw-r--r--spec/helpers/appearances_helper_spec.rb44
-rw-r--r--spec/helpers/application_settings_helper_spec.rb2
-rw-r--r--spec/helpers/auth_helper_spec.rb10
-rw-r--r--spec/helpers/badges_helper_spec.rb6
-rw-r--r--spec/helpers/ci/builds_helper_spec.rb14
-rw-r--r--spec/helpers/ci/pipeline_editor_helper_spec.rb2
-rw-r--r--spec/helpers/ci/pipelines_helper_spec.rb52
-rw-r--r--spec/helpers/ci/runners_helper_spec.rb14
-rw-r--r--spec/helpers/ci/secure_files_helper_spec.rb76
-rw-r--r--spec/helpers/clusters_helper_spec.rb16
-rw-r--r--spec/helpers/container_registry_helper_spec.rb14
-rw-r--r--spec/helpers/cookies_helper_spec.rb6
-rw-r--r--spec/helpers/emails_helper_spec.rb4
-rw-r--r--spec/helpers/instance_configuration_helper_spec.rb10
-rw-r--r--spec/helpers/integrations_helper_spec.rb1
-rw-r--r--spec/helpers/invite_members_helper_spec.rb46
-rw-r--r--spec/helpers/issuables_helper_spec.rb36
-rw-r--r--spec/helpers/issues_helper_spec.rb1
-rw-r--r--spec/helpers/jira_connect_helper_spec.rb1
-rw-r--r--spec/helpers/lazy_image_tag_helper_spec.rb109
-rw-r--r--spec/helpers/merge_requests_helper_spec.rb31
-rw-r--r--spec/helpers/namespaces_helper_spec.rb5
-rw-r--r--spec/helpers/page_layout_helper_spec.rb6
-rw-r--r--spec/helpers/profiles_helper_spec.rb9
-rw-r--r--spec/helpers/projects/pipeline_helper_spec.rb1
-rw-r--r--spec/helpers/projects_helper_spec.rb4
-rw-r--r--spec/helpers/releases_helper_spec.rb10
-rw-r--r--spec/helpers/search_helper_spec.rb3
-rw-r--r--spec/helpers/sidebars_helper_spec.rb2
-rw-r--r--spec/helpers/storage_helper_spec.rb28
-rw-r--r--spec/helpers/tracking_helper_spec.rb31
-rw-r--r--spec/helpers/users_helper_spec.rb2
-rw-r--r--spec/initializers/00_connection_logger_spec.rb39
-rw-r--r--spec/initializers/validate_database_config_spec.rb40
-rw-r--r--spec/lib/api/ci/helpers/runner_helpers_spec.rb12
-rw-r--r--spec/lib/api/entities/ci/job_request/dependency_spec.rb7
-rw-r--r--spec/lib/api/entities/plan_limit_spec.rb11
-rw-r--r--spec/lib/api/entities/projects/topic_spec.rb1
-rw-r--r--spec/lib/api/entities/user_spec.rb45
-rw-r--r--spec/lib/api/helpers_spec.rb249
-rw-r--r--spec/lib/atlassian/jira_connect/jwt/asymmetric_spec.rb (renamed from spec/lib/atlassian/jira_connect/asymmetric_jwt_spec.rb)17
-rw-r--r--spec/lib/atlassian/jira_connect/jwt/symmetric_spec.rb97
-rw-r--r--spec/lib/backup/manager_spec.rb794
-rw-r--r--spec/lib/backup/repositories_spec.rb76
-rw-r--r--spec/lib/banzai/filter/image_lazy_load_filter_spec.rb5
-rw-r--r--spec/lib/banzai/filter/references/issue_reference_filter_spec.rb22
-rw-r--r--spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb86
-rw-r--r--spec/lib/bulk_imports/common/extractors/json_extractor_spec.rb58
-rw-r--r--spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb29
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/group_attributes_pipeline_spec.rb80
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb2
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/namespace_settings_pipeline_spec.rb75
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb12
-rw-r--r--spec/lib/bulk_imports/groups/stage_spec.rb2
-rw-r--r--spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb26
-rw-r--r--spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb8
-rw-r--r--spec/lib/bulk_imports/ndjson_pipeline_spec.rb2
-rw-r--r--spec/lib/bulk_imports/pipeline_spec.rb4
-rw-r--r--spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb5
-rw-r--r--spec/lib/bulk_imports/projects/pipelines/releases_pipeline_spec.rb122
-rw-r--r--spec/lib/bulk_imports/projects/stage_spec.rb3
-rw-r--r--spec/lib/constraints/feature_constrainer_spec.rb16
-rw-r--r--spec/lib/container_registry/client_spec.rb53
-rw-r--r--spec/lib/container_registry/migration_spec.rb74
-rw-r--r--spec/lib/error_tracking/collector/dsn_spec.rb26
-rw-r--r--spec/lib/error_tracking/collector/sentry_auth_parser_spec.rb6
-rw-r--r--spec/lib/error_tracking/collector/sentry_request_parser_spec.rb6
-rw-r--r--spec/lib/feature/definition_spec.rb93
-rw-r--r--spec/lib/feature_spec.rb182
-rw-r--r--spec/lib/gitlab/application_rate_limiter_spec.rb14
-rw-r--r--spec/lib/gitlab/asciidoc_spec.rb12
-rw-r--r--spec/lib/gitlab/audit/deploy_token_author_spec.rb17
-rw-r--r--spec/lib/gitlab/audit/null_author_spec.rb9
-rw-r--r--spec/lib/gitlab/auth/ldap/adapter_spec.rb10
-rw-r--r--spec/lib/gitlab/auth/otp/strategies/forti_authenticator/manual_otp_spec.rb (renamed from spec/lib/gitlab/auth/otp/strategies/forti_authenticator_spec.rb)2
-rw-r--r--spec/lib/gitlab/auth/otp/strategies/forti_authenticator/push_otp_spec.rb65
-rw-r--r--spec/lib/gitlab/auth/saml/config_spec.rb19
-rw-r--r--spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb82
-rw-r--r--spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_with_corrected_regex_spec.rb75
-rw-r--r--spec/lib/gitlab/background_migration/backfill_group_features_spec.rb12
-rw-r--r--spec/lib/gitlab/background_migration/backfill_integrations_enable_ssl_verification_spec.rb73
-rw-r--r--spec/lib/gitlab/background_migration/backfill_integrations_type_new_spec.rb13
-rw-r--r--spec/lib/gitlab/background_migration/backfill_note_discussion_id_spec.rb29
-rw-r--r--spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb41
-rw-r--r--spec/lib/gitlab/background_migration/backfill_topics_title_spec.rb21
-rw-r--r--spec/lib/gitlab/background_migration/batched_migration_job_spec.rb96
-rw-r--r--spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb169
-rw-r--r--spec/lib/gitlab/background_migration/expire_o_auth_tokens_spec.rb35
-rw-r--r--spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/job_coordinator_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb25
-rw-r--r--spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb8
-rw-r--r--spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb39
-rw-r--r--spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb39
-rw-r--r--spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb85
-rw-r--r--spec/lib/gitlab/backtrace_cleaner_spec.rb1
-rw-r--r--spec/lib/gitlab/checks/branch_check_spec.rb6
-rw-r--r--spec/lib/gitlab/checks/changes_access_spec.rb39
-rw-r--r--spec/lib/gitlab/checks/single_change_access_spec.rb15
-rw-r--r--spec/lib/gitlab/ci/ansi2json_spec.rb11
-rw-r--r--spec/lib/gitlab/ci/build/rules/rule/clause/if_spec.rb74
-rw-r--r--spec/lib/gitlab/ci/build/rules_spec.rb13
-rw-r--r--spec/lib/gitlab/ci/config/entry/environment_spec.rb26
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/reports_spec.rb14
-rw-r--r--spec/lib/gitlab/ci/config/entry/root_spec.rb37
-rw-r--r--spec/lib/gitlab/ci/config/external/file/local_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/file/project_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/config/external/file/remote_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/file/template_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/external/processor_spec.rb72
-rw-r--r--spec/lib/gitlab/ci/config_spec.rb12
-rw-r--r--spec/lib/gitlab/ci/lint_spec.rb20
-rw-r--r--spec/lib/gitlab/ci/parsers/security/common_spec.rb29
-rw-r--r--spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb278
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexeme/matches_spec.rb28
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexeme/not_matches_spec.rb28
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb49
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb45
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/reports/security/scanner_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/runner_upgrade_check_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb65
-rw-r--r--spec/lib/gitlab/ci/templates/MATLAB_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb1
-rw-r--r--spec/lib/gitlab/ci/templates/managed_cluster_applications_gitlab_ci_yaml_spec.rb40
-rw-r--r--spec/lib/gitlab/ci/templates/templates_spec.rb3
-rw-r--r--spec/lib/gitlab/ci/templates/terraform_gitlab_ci_yaml_spec.rb5
-rw-r--r--spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/variables/builder_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/yaml_processor/result_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb10
-rw-r--r--spec/lib/gitlab/color_spec.rb42
-rw-r--r--spec/lib/gitlab/content_security_policy/config_loader_spec.rb20
-rw-r--r--spec/lib/gitlab/data_builder/issuable_spec.rb (renamed from spec/lib/gitlab/hook_data/issuable_builder_spec.rb)2
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_spec.rb9
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb96
-rw-r--r--spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb47
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb133
-rw-r--r--spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb141
-rw-r--r--spec/lib/gitlab/database/migrations/base_background_runner_spec.rb23
-rw-r--r--spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb41
-rw-r--r--spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb1
-rw-r--r--spec/lib/gitlab/database/migrations/reestablished_connection_stack_spec.rb57
-rw-r--r--spec/lib/gitlab/database/migrations/runner_spec.rb33
-rw-r--r--spec/lib/gitlab/database/migrations/test_background_runner_spec.rb37
-rw-r--r--spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb87
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb10
-rw-r--r--spec/lib/gitlab/database/query_analyzer_spec.rb34
-rw-r--r--spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb2
-rw-r--r--spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb70
-rw-r--r--spec/lib/gitlab/database/query_analyzers/restrict_allowed_schemas_spec.rb2
-rw-r--r--spec/lib/gitlab/database/shared_model_spec.rb2
-rw-r--r--spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/database_spec.rb34
-rw-r--r--spec/lib/gitlab/diff/file_spec.rb16
-rw-r--r--spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb22
-rw-r--r--spec/lib/gitlab/doctor/secrets_spec.rb34
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb4
-rw-r--r--spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb23
-rw-r--r--spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb2
-rw-r--r--spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb79
-rw-r--r--spec/lib/gitlab/experiment/rollout/feature_spec.rb3
-rw-r--r--spec/lib/gitlab/experimentation/controller_concern_spec.rb4
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb8
-rw-r--r--spec/lib/gitlab/git_access_spec.rb9
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_service_spec.rb129
-rw-r--r--spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb12
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/milestone_finder_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/parallel_scheduling_spec.rb71
-rw-r--r--spec/lib/gitlab/gon_helper_spec.rb1
-rw-r--r--spec/lib/gitlab/graphql/find_argument_in_parent_spec.rb45
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb4
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb4
-rw-r--r--spec/lib/gitlab/graphql/queries_spec.rb10
-rw-r--r--spec/lib/gitlab/health_checks/middleware_spec.rb (renamed from spec/lib/gitlab/metrics/exporter/health_checks_middleware_spec.rb)8
-rw-r--r--spec/lib/gitlab/health_checks/server_spec.rb64
-rw-r--r--spec/lib/gitlab/http_spec.rb4
-rw-r--r--spec/lib/gitlab/import/import_failure_service_spec.rb40
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/lib/gitlab/import_export/group/relation_factory_spec.rb15
-rw-r--r--spec/lib/gitlab/import_export/project/tree_restorer_spec.rb4
-rw-r--r--spec/lib/gitlab/inactive_projects_deletion_warning_tracker_spec.rb67
-rw-r--r--spec/lib/gitlab/instrumentation/rate_limiting_gates_spec.rb39
-rw-r--r--spec/lib/gitlab/instrumentation_helper_spec.rb21
-rw-r--r--spec/lib/gitlab/jira/middleware_spec.rb4
-rw-r--r--spec/lib/gitlab/json_cache_spec.rb4
-rw-r--r--spec/lib/gitlab/json_spec.rb2
-rw-r--r--spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb274
-rw-r--r--spec/lib/gitlab/kubernetes/kube_client_spec.rb64
-rw-r--r--spec/lib/gitlab/kubernetes/network_policy_spec.rb235
-rw-r--r--spec/lib/gitlab/legacy_github_import/importer_spec.rb3
-rw-r--r--spec/lib/gitlab/lograge/custom_options_spec.rb12
-rw-r--r--spec/lib/gitlab/markdown_cache/active_record/extension_spec.rb4
-rw-r--r--spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb44
-rw-r--r--spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb2
-rw-r--r--spec/lib/gitlab/metrics/methods_spec.rb2
-rw-r--r--spec/lib/gitlab/metrics/rails_slis_spec.rb14
-rw-r--r--spec/lib/gitlab/metrics/sli_spec.rb183
-rw-r--r--spec/lib/gitlab/metrics/subscribers/active_record_spec.rb122
-rw-r--r--spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb55
-rw-r--r--spec/lib/gitlab/patch/database_config_spec.rb59
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb2
-rw-r--r--spec/lib/gitlab/popen_spec.rb2
-rw-r--r--spec/lib/gitlab/process_supervisor_spec.rb76
-rw-r--r--spec/lib/gitlab/query_limiting/transaction_spec.rb15
-rw-r--r--spec/lib/gitlab/request_profiler/profile_spec.rb61
-rw-r--r--spec/lib/gitlab/request_profiler_spec.rb56
-rw-r--r--spec/lib/gitlab/saas_spec.rb6
-rw-r--r--spec/lib/gitlab/safe_request_purger_spec.rb73
-rw-r--r--spec/lib/gitlab/setup_helper/praefect_spec.rb79
-rw-r--r--spec/lib/gitlab/sidekiq_config_spec.rb40
-rw-r--r--spec/lib/gitlab/sidekiq_death_handler_spec.rb8
-rw-r--r--spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb3
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb16
-rw-r--r--spec/lib/gitlab/subscription_portal_spec.rb1
-rw-r--r--spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb49
-rw-r--r--spec/lib/gitlab/tracking/event_definition_spec.rb2
-rw-r--r--spec/lib/gitlab/url_builder_spec.rb16
-rw-r--r--spec/lib/gitlab/usage/metric_definition_spec.rb22
-rw-r--r--spec/lib/gitlab/usage/metric_spec.rb27
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/collected_data_categories_metric_spec.rb2
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb106
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_metric_spec.rb70
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb40
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/redis_hll_metric_spec.rb24
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/redis_metric_spec.rb24
-rw-r--r--spec/lib/gitlab/usage/metrics/query_spec.rb32
-rw-r--r--spec/lib/gitlab/usage/service_ping/legacy_metric_timing_decorator_spec.rb35
-rw-r--r--spec/lib/gitlab/usage/service_ping_report_spec.rb64
-rw-r--r--spec/lib/gitlab/usage_counters/pod_logs_spec.rb7
-rw-r--r--spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb3
-rw-r--r--spec/lib/gitlab/usage_data_counters/ipynb_diff_activity_counter_spec.rb107
-rw-r--r--spec/lib/gitlab/usage_data_queries_spec.rb6
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb29
-rw-r--r--spec/lib/gitlab/user_access_spec.rb11
-rw-r--r--spec/lib/gitlab/utils/usage_data_spec.rb65
-rw-r--r--spec/lib/gitlab/utils_spec.rb8
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb52
-rw-r--r--spec/lib/gitlab/zentao/client_spec.rb32
-rw-r--r--spec/lib/service_ping/build_payload_spec.rb (renamed from spec/services/service_ping/build_payload_service_spec.rb)7
-rw-r--r--spec/lib/service_ping/devops_report_spec.rb35
-rw-r--r--spec/lib/service_ping/permit_data_categories_spec.rb (renamed from spec/services/service_ping/permit_data_categories_service_spec.rb)11
-rw-r--r--spec/lib/service_ping/service_ping_settings_spec.rb (renamed from spec/services/service_ping/service_ping_settings_spec.rb)3
-rw-r--r--spec/lib/sidebars/groups/menus/ci_cd_menu_spec.rb8
-rw-r--r--spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb2
-rw-r--r--spec/lib/sidebars/groups/menus/settings_menu_spec.rb12
-rw-r--r--spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb36
-rw-r--r--spec/lib/sidebars/projects/menus/monitor_menu_spec.rb16
-rw-r--r--spec/lib/tasks/gitlab/metrics_exporter_task_spec.rb81
-rw-r--r--spec/mailers/emails/in_product_marketing_spec.rb24
-rw-r--r--spec/mailers/emails/merge_requests_spec.rb32
-rw-r--r--spec/mailers/emails/projects_spec.rb28
-rw-r--r--spec/metrics_server/metrics_server_spec.rb175
-rw-r--r--spec/migrations/20210413132500_reschedule_artifact_expiry_backfill_again_spec.rb38
-rw-r--r--spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb3
-rw-r--r--spec/migrations/20220124130028_dedup_runner_projects_spec.rb2
-rw-r--r--spec/migrations/20220213103859_remove_integrations_type_spec.rb31
-rw-r--r--spec/migrations/20220324165436_schedule_backfill_project_settings_spec.rb22
-rw-r--r--spec/migrations/20220331133802_schedule_backfill_topics_title_spec.rb26
-rw-r--r--spec/migrations/20220420135946_update_batched_background_migration_arguments_spec.rb44
-rw-r--r--spec/migrations/20220426185933_backfill_deployments_finished_at_spec.rb73
-rw-r--r--spec/migrations/20220502015011_clean_up_fix_merge_request_diff_commit_users_spec.rb35
-rw-r--r--spec/migrations/20220502173045_reset_too_many_tags_skipped_registry_imports_spec.rb68
-rw-r--r--spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb64
-rw-r--r--spec/migrations/20220505174658_update_index_on_alerts_to_exclude_null_fingerprints_spec.rb24
-rw-r--r--spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb31
-rw-r--r--spec/migrations/associate_existing_dast_builds_with_variables_spec.rb2
-rw-r--r--spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb1
-rw-r--r--spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb32
-rw-r--r--spec/migrations/backfill_work_item_type_id_on_issues_spec.rb52
-rw-r--r--spec/migrations/cleanup_after_fixing_regression_with_new_users_emails_spec.rb42
-rw-r--r--spec/migrations/finalize_project_namespaces_backfill_spec.rb12
-rw-r--r--spec/migrations/insert_ci_daily_pipeline_schedule_triggers_plan_limits_spec.rb2
-rw-r--r--spec/migrations/migrate_protected_attribute_to_pending_builds_spec.rb2
-rw-r--r--spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb55
-rw-r--r--spec/migrations/retry_backfill_traversal_ids_spec.rb2
-rw-r--r--spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb68
-rw-r--r--spec/migrations/toggle_vsa_aggregations_enable_spec.rb25
-rw-r--r--spec/models/abuse_report_spec.rb2
-rw-r--r--spec/models/alert_management/alert_spec.rb11
-rw-r--r--spec/models/alert_management/metric_image_spec.rb8
-rw-r--r--spec/models/analytics/cycle_analytics/aggregation_spec.rb31
-rw-r--r--spec/models/application_setting_spec.rb30
-rw-r--r--spec/models/ci/bridge_spec.rb30
-rw-r--r--spec/models/ci/build_spec.rb124
-rw-r--r--spec/models/ci/job_artifact_spec.rb13
-rw-r--r--spec/models/ci/pipeline_spec.rb87
-rw-r--r--spec/models/ci/processable_spec.rb223
-rw-r--r--spec/models/ci/runner_spec.rb32
-rw-r--r--spec/models/ci/secure_file_spec.rb7
-rw-r--r--spec/models/clusters/platforms/kubernetes_spec.rb52
-rw-r--r--spec/models/commit_status_spec.rb50
-rw-r--r--spec/models/concerns/cache_markdown_field_spec.rb2
-rw-r--r--spec/models/concerns/integrations/reset_secret_fields_spec.rb19
-rw-r--r--spec/models/concerns/issuable_spec.rb28
-rw-r--r--spec/models/concerns/pg_full_text_searchable_spec.rb13
-rw-r--r--spec/models/concerns/reactive_caching_spec.rb2
-rw-r--r--spec/models/concerns/schedulable_spec.rb10
-rw-r--r--spec/models/concerns/sha256_attribute_spec.rb91
-rw-r--r--spec/models/concerns/sha_attribute_spec.rb135
-rw-r--r--spec/models/container_registry/event_spec.rb60
-rw-r--r--spec/models/container_repository_spec.rb68
-rw-r--r--spec/models/deploy_token_spec.rb10
-rw-r--r--spec/models/deployment_spec.rb38
-rw-r--r--spec/models/design_management/action_spec.rb9
-rw-r--r--spec/models/environment_spec.rb35
-rw-r--r--spec/models/event_collection_spec.rb248
-rw-r--r--spec/models/event_spec.rb49
-rw-r--r--spec/models/incident_management/timeline_event_spec.rb84
-rw-r--r--spec/models/instance_configuration_spec.rb64
-rw-r--r--spec/models/integration_spec.rb72
-rw-r--r--spec/models/integrations/bamboo_spec.rb2
-rw-r--r--spec/models/integrations/base_chat_notification_spec.rb19
-rw-r--r--spec/models/integrations/buildkite_spec.rb2
-rw-r--r--spec/models/integrations/drone_ci_spec.rb2
-rw-r--r--spec/models/integrations/jenkins_spec.rb15
-rw-r--r--spec/models/integrations/jira_spec.rb192
-rw-r--r--spec/models/integrations/microsoft_teams_spec.rb2
-rw-r--r--spec/models/integrations/prometheus_spec.rb28
-rw-r--r--spec/models/integrations/teamcity_spec.rb4
-rw-r--r--spec/models/issue_spec.rb19
-rw-r--r--spec/models/key_spec.rb40
-rw-r--r--spec/models/member_spec.rb78
-rw-r--r--spec/models/merge_request/metrics_spec.rb39
-rw-r--r--spec/models/merge_request_assignee_spec.rb13
-rw-r--r--spec/models/merge_request_reviewer_spec.rb13
-rw-r--r--spec/models/merge_request_spec.rb46
-rw-r--r--spec/models/namespace_ci_cd_setting_spec.rb9
-rw-r--r--spec/models/namespace_spec.rb49
-rw-r--r--spec/models/packages/cleanup/policy_spec.rb28
-rw-r--r--spec/models/packages/package_spec.rb2
-rw-r--r--spec/models/pages_domain_spec.rb2
-rw-r--r--spec/models/performance_monitoring/prometheus_dashboard_spec.rb2
-rw-r--r--spec/models/personal_access_token_spec.rb8
-rw-r--r--spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb42
-rw-r--r--spec/models/project_import_state_spec.rb39
-rw-r--r--spec/models/project_setting_spec.rb18
-rw-r--r--spec/models/project_spec.rb61
-rw-r--r--spec/models/project_statistics_spec.rb61
-rw-r--r--spec/models/project_team_spec.rb16
-rw-r--r--spec/models/projects/topic_spec.rb14
-rw-r--r--spec/models/protected_branch/push_access_level_spec.rb2
-rw-r--r--spec/models/protected_branch_spec.rb6
-rw-r--r--spec/models/raw_usage_data_spec.rb25
-rw-r--r--spec/models/release_spec.rb5
-rw-r--r--spec/models/shard_spec.rb4
-rw-r--r--spec/models/system_note_metadata_spec.rb12
-rw-r--r--spec/models/user_custom_attribute_spec.rb57
-rw-r--r--spec/models/user_spec.rb35
-rw-r--r--spec/models/users/in_product_marketing_email_spec.rb53
-rw-r--r--spec/models/users/merge_request_interaction_spec.rb1
-rw-r--r--spec/policies/container_expiration_policy_policy_spec.rb33
-rw-r--r--spec/policies/group_policy_spec.rb45
-rw-r--r--spec/policies/issuable_policy_spec.rb50
-rw-r--r--spec/policies/issue_policy_spec.rb27
-rw-r--r--spec/policies/namespaces/project_namespace_policy_spec.rb4
-rw-r--r--spec/policies/namespaces/user_namespace_policy_spec.rb2
-rw-r--r--spec/policies/timelog_policy_spec.rb57
-rw-r--r--spec/policies/work_item_policy_spec.rb40
-rw-r--r--spec/presenters/clusters/cluster_presenter_spec.rb2
-rw-r--r--spec/presenters/group_clusterable_presenter_spec.rb18
-rw-r--r--spec/presenters/instance_clusterable_presenter_spec.rb12
-rw-r--r--spec/presenters/project_clusterable_presenter_spec.rb18
-rw-r--r--spec/presenters/projects/security/configuration_presenter_spec.rb2
-rw-r--r--spec/requests/admin/background_migrations_controller_spec.rb84
-rw-r--r--spec/requests/admin/batched_jobs_controller_spec.rb74
-rw-r--r--spec/requests/api/admin/plan_limits_spec.rb60
-rw-r--r--spec/requests/api/ci/job_artifacts_spec.rb15
-rw-r--r--spec/requests/api/ci/jobs_spec.rb24
-rw-r--r--spec/requests/api/ci/resource_groups_spec.rb30
-rw-r--r--spec/requests/api/ci/runner/jobs_artifacts_spec.rb160
-rw-r--r--spec/requests/api/ci/runner/jobs_request_post_spec.rb14
-rw-r--r--spec/requests/api/ci/runner/jobs_trace_spec.rb2
-rw-r--r--spec/requests/api/ci/runners_spec.rb11
-rw-r--r--spec/requests/api/ci/secure_files_spec.rb22
-rw-r--r--spec/requests/api/clusters/agent_tokens_spec.rb179
-rw-r--r--spec/requests/api/container_registry_event_spec.rb36
-rw-r--r--spec/requests/api/environments_spec.rb20
-rw-r--r--spec/requests/api/error_tracking/client_keys_spec.rb4
-rw-r--r--spec/requests/api/error_tracking/collector_spec.rb12
-rw-r--r--spec/requests/api/features_spec.rb50
-rw-r--r--spec/requests/api/files_spec.rb60
-rw-r--r--spec/requests/api/graphql/boards/board_lists_query_spec.rb6
-rw-r--r--spec/requests/api/graphql/ci/config_spec.rb144
-rw-r--r--spec/requests/api/graphql/ci/job_spec.rb15
-rw-r--r--spec/requests/api/graphql/ci/runner_spec.rb18
-rw-r--r--spec/requests/api/graphql/ci/runners_spec.rb4
-rw-r--r--spec/requests/api/graphql/container_repository/container_repository_details_spec.rb8
-rw-r--r--spec/requests/api/graphql/current_user_todos_spec.rb8
-rw-r--r--spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb12
-rw-r--r--spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb12
-rw-r--r--spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb4
-rw-r--r--spec/requests/api/graphql/group/group_members_spec.rb50
-rw-r--r--spec/requests/api/graphql/group/merge_requests_spec.rb2
-rw-r--r--spec/requests/api/graphql/group/milestones_spec.rb8
-rw-r--r--spec/requests/api/graphql/issue/issue_spec.rb5
-rw-r--r--spec/requests/api/graphql/merge_request/merge_request_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb (renamed from spec/requests/api/graphql/mutations/ci/ci_cd_settings_update_spec.rb)2
-rw-r--r--spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb14
-rw-r--r--spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/dependency_proxy/group_settings/update_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/incident_management/timeline_event/create_spec.rb60
-rw-r--r--spec/requests/api/graphql/mutations/incident_management/timeline_event/destroy_spec.rb67
-rw-r--r--spec/requests/api/graphql/mutations/incident_management/timeline_event/promote_from_note_spec.rb62
-rw-r--r--spec/requests/api/graphql/mutations/incident_management/timeline_event/update_spec.rb80
-rw-r--r--spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb22
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/request_attention_spec.rb79
-rw-r--r--spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/notes/create/note_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/remove_attention_request_spec.rb79
-rw-r--r--spec/requests/api/graphql/mutations/timelogs/delete_spec.rb38
-rw-r--r--spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb8
-rw-r--r--spec/requests/api/graphql/mutations/todos/restore_many_spec.rb8
-rw-r--r--spec/requests/api/graphql/mutations/work_items/delete_task_spec.rb93
-rw-r--r--spec/requests/api/graphql/packages/conan_spec.rb21
-rw-r--r--spec/requests/api/graphql/packages/maven_spec.rb8
-rw-r--r--spec/requests/api/graphql/packages/nuget_spec.rb17
-rw-r--r--spec/requests/api/graphql/packages/package_spec.rb26
-rw-r--r--spec/requests/api/graphql/packages/pypi_spec.rb5
-rw-r--r--spec/requests/api/graphql/project/alert_management/integrations_spec.rb57
-rw-r--r--spec/requests/api/graphql/project/cluster_agents_spec.rb8
-rw-r--r--spec/requests/api/graphql/project/incident_management/timeline_events_spec.rb127
-rw-r--r--spec/requests/api/graphql/project/issue/design_collection/version_spec.rb29
-rw-r--r--spec/requests/api/graphql/project/issue/designs/designs_spec.rb22
-rw-r--r--spec/requests/api/graphql/project/issue/designs/notes_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/issue_spec.rb7
-rw-r--r--spec/requests/api/graphql/project/merge_request_spec.rb6
-rw-r--r--spec/requests/api/graphql/project/merge_requests_spec.rb41
-rw-r--r--spec/requests/api/graphql/project/milestones_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/pipeline_spec.rb19
-rw-r--r--spec/requests/api/graphql/project/project_members_spec.rb9
-rw-r--r--spec/requests/api/graphql/project/release_spec.rb40
-rw-r--r--spec/requests/api/graphql/project/terraform/state_spec.rb18
-rw-r--r--spec/requests/api/graphql/project/terraform/states_spec.rb17
-rw-r--r--spec/requests/api/graphql/query_spec.rb16
-rw-r--r--spec/requests/api/graphql/user/starred_projects_query_spec.rb18
-rw-r--r--spec/requests/api/graphql/user_query_spec.rb72
-rw-r--r--spec/requests/api/graphql/users_spec.rb26
-rw-r--r--spec/requests/api/graphql/work_item_spec.rb3
-rw-r--r--spec/requests/api/group_container_repositories_spec.rb5
-rw-r--r--spec/requests/api/group_milestones_spec.rb2
-rw-r--r--spec/requests/api/import_bitbucket_server_spec.rb12
-rw-r--r--spec/requests/api/import_github_spec.rb6
-rw-r--r--spec/requests/api/integrations/jira_connect/subscriptions_spec.rb86
-rw-r--r--spec/requests/api/internal/base_spec.rb115
-rw-r--r--spec/requests/api/internal/container_registry/migration_spec.rb19
-rw-r--r--spec/requests/api/lint_spec.rb41
-rw-r--r--spec/requests/api/members_spec.rb4
-rw-r--r--spec/requests/api/merge_requests_spec.rb63
-rw-r--r--spec/requests/api/personal_access_tokens_spec.rb48
-rw-r--r--spec/requests/api/project_attributes.yml1
-rw-r--r--spec/requests/api/project_container_repositories_spec.rb10
-rw-r--r--spec/requests/api/project_export_spec.rb21
-rw-r--r--spec/requests/api/project_milestones_spec.rb4
-rw-r--r--spec/requests/api/projects_spec.rb86
-rw-r--r--spec/requests/api/releases_spec.rb8
-rw-r--r--spec/requests/api/settings_spec.rb38
-rw-r--r--spec/requests/api/sidekiq_metrics_spec.rb13
-rw-r--r--spec/requests/api/topics_spec.rb19
-rw-r--r--spec/requests/api/usage_data_spec.rb24
-rw-r--r--spec/requests/api/user_counts_spec.rb2
-rw-r--r--spec/requests/api/users_spec.rb8
-rw-r--r--spec/requests/lfs_http_spec.rb63
-rw-r--r--spec/requests/oauth_tokens_spec.rb25
-rw-r--r--spec/requests/projects/issue_links_controller_spec.rb11
-rw-r--r--spec/requests/pwa_controller_spec.rb14
-rw-r--r--spec/requests/request_profiler_spec.rb56
-rw-r--r--spec/rubocop/cop/database/multiple_databases_spec.rb7
-rw-r--r--spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/namespaced_class_spec.rb143
-rw-r--r--spec/rubocop/cop/migration/background_migration_base_class_spec.rb104
-rw-r--r--spec/rubocop/cop/migration/background_migration_record_spec.rb59
-rw-r--r--spec/rubocop/cop/migration/hash_index_spec.rb47
-rw-r--r--spec/rubocop/cop/migration/migration_record_spec.rb59
-rw-r--r--spec/scripts/changed-feature-flags_spec.rb118
-rw-r--r--spec/scripts/lib/glfm/shared_spec.rb36
-rw-r--r--spec/scripts/lib/glfm/update_example_snapshots_spec.rb316
-rw-r--r--spec/scripts/lib/glfm/update_specification_spec.rb196
-rw-r--r--spec/scripts/trigger-build_spec.rb970
-rw-r--r--spec/serializers/build_details_entity_spec.rb47
-rw-r--r--spec/serializers/ci/job_entity_spec.rb16
-rw-r--r--spec/serializers/cluster_entity_spec.rb16
-rw-r--r--spec/serializers/discussion_entity_spec.rb18
-rw-r--r--spec/serializers/environment_entity_spec.rb12
-rw-r--r--spec/serializers/issue_board_entity_spec.rb16
-rw-r--r--spec/serializers/issue_entity_spec.rb13
-rw-r--r--spec/serializers/issue_sidebar_basic_entity_spec.rb32
-rw-r--r--spec/serializers/linked_project_issue_entity_spec.rb22
-rw-r--r--spec/serializers/merge_request_user_entity_spec.rb4
-rw-r--r--spec/serializers/release_serializer_spec.rb4
-rw-r--r--spec/services/alert_management/alerts/update_service_spec.rb45
-rw-r--r--spec/services/audit_event_service_spec.rb12
-rw-r--r--spec/services/authorized_project_update/project_create_service_spec.rb185
-rw-r--r--spec/services/authorized_project_update/project_group_link_create_service_spec.rb222
-rw-r--r--spec/services/ci/create_downstream_pipeline_service_spec.rb118
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/generate_kubeconfig_service_spec.rb5
-rw-r--r--spec/services/ci/job_artifacts/create_service_spec.rb11
-rw-r--r--spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb2
-rw-r--r--spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb6
-rw-r--r--spec/services/ci/pipeline_trigger_service_spec.rb8
-rw-r--r--spec/services/ci/retry_job_service_spec.rb442
-rw-r--r--spec/services/clusters/agents/delete_service_spec.rb2
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb2
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb43
-rw-r--r--spec/services/container_expiration_policies/cleanup_service_spec.rb6
-rw-r--r--spec/services/container_expiration_policies/update_service_spec.rb4
-rw-r--r--spec/services/container_expiration_policy_service_spec.rb32
-rw-r--r--spec/services/customer_relations/contacts/create_service_spec.rb2
-rw-r--r--spec/services/customer_relations/contacts/update_service_spec.rb27
-rw-r--r--spec/services/customer_relations/organizations/update_service_spec.rb25
-rw-r--r--spec/services/database/consistency_fix_service_spec.rb153
-rw-r--r--spec/services/dependency_proxy/group_settings/update_service_spec.rb2
-rw-r--r--spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb6
-rw-r--r--spec/services/environments/stop_service_spec.rb29
-rw-r--r--spec/services/error_tracking/base_service_spec.rb11
-rw-r--r--spec/services/error_tracking/collect_error_service_spec.rb15
-rw-r--r--spec/services/error_tracking/issue_details_service_spec.rb16
-rw-r--r--spec/services/error_tracking/issue_latest_event_service_spec.rb16
-rw-r--r--spec/services/error_tracking/issue_update_service_spec.rb21
-rw-r--r--spec/services/groups/group_links/create_service_spec.rb210
-rw-r--r--spec/services/groups/group_links/destroy_service_spec.rb85
-rw-r--r--spec/services/groups/open_issues_count_service_spec.rb64
-rw-r--r--spec/services/groups/transfer_service_spec.rb2
-rw-r--r--spec/services/import/bitbucket_server_service_spec.rb17
-rw-r--r--spec/services/import/github_service_spec.rb27
-rw-r--r--spec/services/incident_management/timeline_events/create_service_spec.rb117
-rw-r--r--spec/services/incident_management/timeline_events/destroy_service_spec.rb80
-rw-r--r--spec/services/incident_management/timeline_events/update_service_spec.rb148
-rw-r--r--spec/services/issues/close_service_spec.rb2
-rw-r--r--spec/services/issues/create_service_spec.rb8
-rw-r--r--spec/services/issues/set_crm_contacts_service_spec.rb16
-rw-r--r--spec/services/jira_connect/sync_service_spec.rb14
-rw-r--r--spec/services/keys/expiry_notification_service_spec.rb2
-rw-r--r--spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb10
-rw-r--r--spec/services/members/create_service_spec.rb32
-rw-r--r--spec/services/members/groups/creator_service_spec.rb24
-rw-r--r--spec/services/members/invite_service_spec.rb70
-rw-r--r--spec/services/members/projects/creator_service_spec.rb24
-rw-r--r--spec/services/merge_requests/approval_service_spec.rb15
-rw-r--r--spec/services/merge_requests/close_service_spec.rb2
-rw-r--r--spec/services/merge_requests/handle_assignees_change_service_spec.rb8
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb2
-rw-r--r--spec/services/merge_requests/post_merge_service_spec.rb78
-rw-r--r--spec/services/merge_requests/push_options_handler_service_spec.rb98
-rw-r--r--spec/services/merge_requests/rebase_service_spec.rb12
-rw-r--r--spec/services/merge_requests/remove_approval_service_spec.rb8
-rw-r--r--spec/services/merge_requests/remove_attention_requested_service_spec.rb135
-rw-r--r--spec/services/merge_requests/request_attention_service_spec.rb220
-rw-r--r--spec/services/merge_requests/squash_service_spec.rb12
-rw-r--r--spec/services/merge_requests/toggle_attention_requested_service_spec.rb4
-rw-r--r--spec/services/merge_requests/update_assignees_service_spec.rb43
-rw-r--r--spec/services/merge_requests/update_service_spec.rb90
-rw-r--r--spec/services/namespaces/package_settings/update_service_spec.rb4
-rw-r--r--spec/services/notes/create_service_spec.rb34
-rw-r--r--spec/services/notification_service_spec.rb75
-rw-r--r--spec/services/projects/android_target_platform_detector_service_spec.rb30
-rw-r--r--spec/services/projects/batch_open_issues_count_service_spec.rb34
-rw-r--r--spec/services/projects/blame_service_spec.rb129
-rw-r--r--spec/services/projects/container_repository/cleanup_tags_service_spec.rb31
-rw-r--r--spec/services/projects/container_repository/delete_tags_service_spec.rb51
-rw-r--r--spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb77
-rw-r--r--spec/services/projects/create_service_spec.rb53
-rw-r--r--spec/services/projects/group_links/create_service_spec.rb121
-rw-r--r--spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb130
-rw-r--r--spec/services/projects/open_issues_count_service_spec.rb109
-rw-r--r--spec/services/projects/prometheus/alerts/create_service_spec.rb52
-rw-r--r--spec/services/projects/prometheus/alerts/destroy_service_spec.rb21
-rw-r--r--spec/services/projects/prometheus/alerts/update_service_spec.rb53
-rw-r--r--spec/services/projects/prometheus/metrics/destroy_service_spec.rb13
-rw-r--r--spec/services/projects/prometheus/metrics/update_service_spec.rb44
-rw-r--r--spec/services/projects/record_target_platforms_service_spec.rb104
-rw-r--r--spec/services/projects/update_pages_service_spec.rb12
-rw-r--r--spec/services/prometheus/create_default_alerts_service_spec.rb92
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb15
-rw-r--r--spec/services/service_ping/submit_service_ping_service_spec.rb110
-rw-r--r--spec/services/system_note_service_spec.rb47
-rw-r--r--spec/services/system_notes/incidents_service_spec.rb88
-rw-r--r--spec/services/system_notes/time_tracking_service_spec.rb24
-rw-r--r--spec/services/timelogs/delete_service_spec.rb65
-rw-r--r--spec/services/users/destroy_service_spec.rb41
-rw-r--r--spec/services/users/in_product_marketing_email_records_spec.rb (renamed from spec/services/namespaces/in_product_marketing_email_records_spec.rb)25
-rw-r--r--spec/services/users/validate_manual_otp_service_spec.rb (renamed from spec/services/users/validate_otp_service_spec.rb)27
-rw-r--r--spec/services/users/validate_push_otp_service_spec.rb45
-rw-r--r--spec/services/work_items/delete_task_service_spec.rb88
-rw-r--r--spec/services/work_items/task_list_reference_removal_service_spec.rb151
-rw-r--r--spec/spec_helper.rb4
-rw-r--r--spec/support/database/query_analyzer.rb8
-rw-r--r--spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb4
-rw-r--r--spec/support/graphql/arguments.rb4
-rw-r--r--spec/support/helpers/database/migration_testing_helpers.rb43
-rw-r--r--spec/support/helpers/features/snippet_helpers.rb9
-rw-r--r--spec/support/helpers/features/sorting_helpers.rb8
-rw-r--r--spec/support/helpers/filtered_search_helpers.rb112
-rw-r--r--spec/support/helpers/gitaly_setup.rb66
-rw-r--r--spec/support/helpers/graphql_helpers.rb119
-rw-r--r--spec/support/helpers/migrations_helpers.rb21
-rw-r--r--spec/support/helpers/namespaces_test_helper.rb13
-rw-r--r--spec/support/helpers/navbar_structure_helper.rb8
-rw-r--r--spec/support/helpers/next_instance_of.rb8
-rw-r--r--spec/support/helpers/project_helpers.rb16
-rw-r--r--spec/support/helpers/query_recorder.rb10
-rw-r--r--spec/support/helpers/rendered_helpers.rb12
-rw-r--r--spec/support/helpers/saas_test_helper.rb9
-rw-r--r--spec/support/helpers/stub_feature_flags.rb14
-rw-r--r--spec/support/helpers/stub_object_storage.rb5
-rw-r--r--spec/support/helpers/test_env.rb2
-rw-r--r--spec/support/helpers/trial_status_widget_test_helper.rb9
-rw-r--r--spec/support/helpers/workhorse_helpers.rb6
-rw-r--r--spec/support/helpers/workhorse_lfs_helpers.rb45
-rw-r--r--spec/support/import_export/common_util.rb2
-rw-r--r--spec/support/matchers/background_migrations_matchers.rb19
-rw-r--r--spec/support/matchers/graphql_matchers.rb9
-rw-r--r--spec/support/matchers/make_queries.rb31
-rw-r--r--spec/support/rspec.rb2
-rw-r--r--spec/support/shared_contexts/email_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/graphql/requests/packages_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb3
-rw-r--r--spec/support/shared_contexts/models/concerns/integrations/enable_ssl_verification_shared_context.rb4
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb24
-rw-r--r--spec/support/shared_contexts/policies/group_policy_shared_context.rb5
-rw-r--r--spec/support/shared_contexts/policies/project_policy_shared_context.rb4
-rw-r--r--spec/support/shared_contexts/sentry_error_tracking_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb4
-rw-r--r--spec/support/shared_examples/ci/log_downstream_pipeline_shared_examples.rb34
-rw-r--r--spec/support/shared_examples/controllers/environments_controller_shared_examples.rb17
-rw-r--r--spec/support/shared_examples/controllers/repository_lfs_file_load_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/features/content_editor_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/features/dependency_proxy_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/editable_merge_request_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/inviting_groups_shared_examples.rb144
-rw-r--r--spec/support/shared_examples/features/manage_applications_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/multiple_reviewers_mr_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/packages_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/features/sidebar_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb17
-rw-r--r--spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb48
-rw-r--r--spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb4
-rw-r--r--spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb32
-rw-r--r--spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb162
-rw-r--r--spec/support/shared_examples/merge_request_author_auto_assign_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/models/chat_integration_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/concerns/integrations/reset_secret_fields_shared_examples.rb110
-rw-r--r--spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb27
-rw-r--r--spec/support/shared_examples/models/member_shared_examples.rb403
-rw-r--r--spec/support/shared_examples/models/reviewer_state_shared_examples.rb15
-rw-r--r--spec/support/shared_examples/models/wiki_shared_examples.rb233
-rw-r--r--spec/support/shared_examples/nav_sidebar_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb27
-rw-r--r--spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/graphql/noteable_shared_examples.rb18
-rw-r--r--spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb89
-rw-r--r--spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/requests/api/graphql/projects/alert_management/integrations_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/requests/api/milestones_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/requests/rack_attack_shared_examples.rb19
-rw-r--r--spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/services/issuable/destroy_service_shared_examples.rb26
-rw-r--r--spec/support/shared_examples/services/jira/requests/base_shared_examples.rb16
-rw-r--r--spec/support/shared_examples/work_item_base_types_importer.rb42
-rw-r--r--spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb119
-rw-r--r--spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb8
-rw-r--r--spec/support_specs/helpers/active_record/query_recorder_spec.rb10
-rw-r--r--spec/support_specs/helpers/graphql_helpers_spec.rb75
-rw-r--r--spec/support_specs/helpers/migrations_helpers_spec.rb74
-rw-r--r--spec/support_specs/helpers/stub_feature_flags_spec.rb12
-rw-r--r--spec/tasks/dev_rake_spec.rb6
-rw-r--r--spec/tasks/gitlab/artifacts/migrate_rake_spec.rb14
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb77
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb28
-rw-r--r--spec/tooling/danger/project_helper_spec.rb9
-rw-r--r--spec/tooling/danger/specs_spec.rb5
-rw-r--r--spec/tooling/fixtures/find_codeowners/dir0/dir1/dir2/file20
-rw-r--r--spec/tooling/fixtures/find_codeowners/dir0/dir1/file10
-rw-r--r--spec/tooling/fixtures/find_codeowners/dir0/file00
-rw-r--r--spec/tooling/fixtures/find_codeowners/file0
-rw-r--r--spec/tooling/lib/tooling/find_codeowners_spec.rb199
-rw-r--r--spec/tooling/quality/test_level_spec.rb14
-rw-r--r--spec/views/admin/application_settings/general.html.haml_spec.rb22
-rw-r--r--spec/views/devise/shared/_signin_box.html.haml_spec.rb14
-rw-r--r--spec/views/devise/shared/_signup_box.html.haml_spec.rb37
-rw-r--r--spec/views/groups/runners/_group_runners.html.haml_spec.rb42
-rw-r--r--spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb26
-rw-r--r--spec/views/help/instance_configuration.html.haml_spec.rb2
-rw-r--r--spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb2
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb18
-rw-r--r--spec/views/profiles/keys/_form.html.haml_spec.rb4
-rw-r--r--spec/views/profiles/keys/_key.html.haml_spec.rb8
-rw-r--r--spec/views/projects/clusters/clusters/gcp/_form.html.haml_spec.rb26
-rw-r--r--spec/views/projects/issues/show.html.haml_spec.rb16
-rw-r--r--spec/views/projects/merge_requests/show.html.haml_spec.rb5
-rw-r--r--spec/views/projects/project_members/index.html.haml_spec.rb3
-rw-r--r--spec/views/projects/settings/operations/show.html.haml_spec.rb24
-rw-r--r--spec/views/shared/access_tokens/_table.html.haml_spec.rb41
-rw-r--r--spec/views/shared/notes/_form.html.haml_spec.rb2
-rw-r--r--spec/workers/authorized_project_update/project_create_worker_spec.rb50
-rw-r--r--spec/workers/authorized_project_update/project_group_link_create_worker_spec.rb52
-rw-r--r--spec/workers/build_finished_worker_spec.rb2
-rw-r--r--spec/workers/bulk_imports/pipeline_worker_spec.rb14
-rw-r--r--spec/workers/ci/build_finished_worker_spec.rb2
-rw-r--r--spec/workers/cleanup_container_repository_worker_spec.rb39
-rw-r--r--spec/workers/clusters/applications/activate_service_worker_spec.rb2
-rw-r--r--spec/workers/concerns/gitlab/github_import/object_importer_spec.rb30
-rw-r--r--spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb42
-rw-r--r--spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb8
-rw-r--r--spec/workers/container_expiration_policy_worker_spec.rb84
-rw-r--r--spec/workers/container_registry/migration/enqueuer_worker_spec.rb738
-rw-r--r--spec/workers/container_registry/migration/guard_worker_spec.rb114
-rw-r--r--spec/workers/create_commit_signature_worker_spec.rb4
-rw-r--r--spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb9
-rw-r--r--spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb11
-rw-r--r--spec/workers/delete_diff_files_worker_spec.rb6
-rw-r--r--spec/workers/delete_user_worker_spec.rb4
-rw-r--r--spec/workers/deployments/hooks_worker_spec.rb10
-rw-r--r--spec/workers/every_sidekiq_worker_spec.rb10
-rw-r--r--spec/workers/expire_build_instance_artifacts_worker_spec.rb75
-rw-r--r--spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb18
-rw-r--r--spec/workers/merge_requests/close_issue_worker_spec.rb63
-rw-r--r--spec/workers/post_receive_spec.rb2
-rw-r--r--spec/workers/project_service_worker_spec.rb32
-rw-r--r--spec/workers/projects/after_import_worker_spec.rb (renamed from spec/services/projects/after_import_service_spec.rb)40
-rw-r--r--spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb139
-rw-r--r--spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb41
-rw-r--r--spec/workers/projects/record_target_platforms_worker_spec.rb79
-rw-r--r--spec/workers/prometheus/create_default_alerts_worker_spec.rb58
-rw-r--r--spec/workers/ssh_keys/expired_notification_worker_spec.rb10
-rw-r--r--spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb2
1557 files changed, 65122 insertions, 20995 deletions
diff --git a/spec/commands/metrics_server/metrics_server_spec.rb b/spec/commands/metrics_server/metrics_server_spec.rb
index 217aa185767..f93be1d9f88 100644
--- a/spec/commands/metrics_server/metrics_server_spec.rb
+++ b/spec/commands/metrics_server/metrics_server_spec.rb
@@ -1,44 +1,83 @@
# frozen_string_literal: true
require 'spec_helper'
+require 'rake_helper'
require_relative '../../../metrics_server/metrics_server'
# End-to-end tests for the metrics server process we use to serve metrics
# from forking applications (Sidekiq, Puma) to the Prometheus scraper.
-RSpec.describe 'bin/metrics-server', :aggregate_failures do
+RSpec.describe 'GitLab metrics server', :aggregate_failures do
let(:config_file) { Tempfile.new('gitlab.yml') }
+ let(:address) { '127.0.0.1' }
+ let(:port) { 3807 }
let(:config) do
{
'test' => {
'monitoring' => {
'web_exporter' => {
- 'address' => 'localhost',
+ 'address' => address,
'enabled' => true,
- 'port' => 3807
+ 'port' => port
},
'sidekiq_exporter' => {
- 'address' => 'localhost',
+ 'address' => address,
'enabled' => true,
- 'port' => 3807
+ 'port' => port
}
}
}
}
end
- %w(puma sidekiq).each do |target|
- context "with a running server targeting #{target}" do
+ before(:all) do
+ Rake.application.rake_require 'tasks/gitlab/metrics_exporter'
+
+ @exporter_path = Rails.root.join('tmp', 'test', 'gme')
+
+ run_rake_task('gitlab:metrics_exporter:install', @exporter_path)
+ end
+
+ after(:all) do
+ FileUtils.rm_rf(@exporter_path)
+ end
+
+ shared_examples 'serves metrics endpoint' do
+ it 'serves /metrics endpoint' do
+ start_server!
+
+ expect do
+ Timeout.timeout(10) do
+ http_ok = false
+ until http_ok
+ sleep 1
+ response = Gitlab::HTTP.try_get("http://#{address}:#{port}/metrics", allow_local_requests: true)
+ http_ok = response&.success?
+ end
+ end
+ end.not_to raise_error
+ end
+ end
+
+ shared_examples 'spawns a server' do |target, use_golang_server|
+ context "targeting #{target} when using Golang server is #{use_golang_server}" do
let(:metrics_dir) { Dir.mktmpdir }
+ subject(:start_server!) do
+ @pid = MetricsServer.spawn(target, metrics_dir: metrics_dir, path: @exporter_path.join('bin'))
+ end
+
before do
+ if use_golang_server
+ stub_env('GITLAB_GOLANG_METRICS_SERVER', '1')
+ allow(Settings).to receive(:monitoring).and_return(config.dig('test', 'monitoring'))
+ else
+ config_file.write(YAML.dump(config))
+ config_file.close
+ stub_env('GITLAB_CONFIG', config_file.path)
+ end
# We need to send a request to localhost
WebMock.allow_net_connect!
-
- config_file.write(YAML.dump(config))
- config_file.close
-
- @pid = MetricsServer.spawn(target, metrics_dir: metrics_dir, gitlab_config: config_file.path, wipe_metrics_dir: true)
end
after do
@@ -54,25 +93,25 @@ RSpec.describe 'bin/metrics-server', :aggregate_failures do
expect(Gitlab::ProcessManagement.process_alive?(@pid)).to be(false)
end
- rescue Errno::ESRCH => _
- # 'No such process' means the process died before
+ rescue Errno::ESRCH, Errno::ECHILD => _
+ # 'No such process' or 'No child processes' means the process died before
ensure
config_file.unlink
FileUtils.rm_rf(metrics_dir, secure: true)
end
- it 'serves /metrics endpoint' do
- expect do
- Timeout.timeout(10) do
- http_ok = false
- until http_ok
- sleep 1
- response = Gitlab::HTTP.try_get("http://localhost:3807/metrics", allow_local_requests: true)
- http_ok = response&.success?
- end
- end
- end.not_to raise_error
+ it_behaves_like 'serves metrics endpoint'
+
+ context 'when using Pathname instance as target directory' do
+ let(:metrics_dir) { Pathname.new(Dir.mktmpdir) }
+
+ it_behaves_like 'serves metrics endpoint'
end
end
end
+
+ it_behaves_like 'spawns a server', 'puma', true
+ it_behaves_like 'spawns a server', 'puma', false
+ it_behaves_like 'spawns a server', 'sidekiq', true
+ it_behaves_like 'spawns a server', 'sidekiq', false
end
diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb
index bbf5f2bc4d9..223d0c3b0ec 100644
--- a/spec/commands/sidekiq_cluster/cli_spec.rb
+++ b/spec/commands/sidekiq_cluster/cli_spec.rb
@@ -18,7 +18,6 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
let(:sidekiq_exporter_enabled) { false }
let(:sidekiq_exporter_port) { '3807' }
- let(:sidekiq_health_checks_port) { '3807' }
let(:config_file) { Tempfile.new('gitlab.yml') }
let(:config) do
@@ -29,11 +28,6 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
'address' => 'localhost',
'enabled' => sidekiq_exporter_enabled,
'port' => sidekiq_exporter_port
- },
- 'sidekiq_health_checks' => {
- 'address' => 'localhost',
- 'enabled' => sidekiq_exporter_enabled,
- 'port' => sidekiq_health_checks_port
}
}
}
@@ -310,63 +304,37 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
cli.run(%w(foo))
end
- context 'when there are no sidekiq_health_checks settings set' do
- let(:sidekiq_exporter_enabled) { true }
-
- it 'does not start a sidekiq metrics server' do
- expect(MetricsServer).not_to receive(:fork)
-
- cli.run(%w(foo))
- end
- end
-
- context 'when the sidekiq_exporter.port setting is not set' do
- let(:sidekiq_exporter_enabled) { true }
-
- it 'does not start a sidekiq metrics server' do
- expect(MetricsServer).not_to receive(:fork)
-
- cli.run(%w(foo))
- end
- end
-
- context 'when sidekiq_exporter.enabled setting is not set' do
+ context 'when sidekiq_exporter is not set up' do
let(:config) do
{
'test' => {
'monitoring' => {
- 'sidekiq_exporter' => {},
- 'sidekiq_health_checks' => {
- 'address' => 'localhost',
- 'enabled' => sidekiq_exporter_enabled,
- 'port' => sidekiq_health_checks_port
- }
+ 'sidekiq_exporter' => {}
}
}
}
end
it 'does not start a sidekiq metrics server' do
- expect(MetricsServer).not_to receive(:fork)
+ expect(MetricsServer).not_to receive(:start_for_sidekiq)
cli.run(%w(foo))
end
end
- context 'with a blank sidekiq_exporter setting' do
+ context 'with missing sidekiq_exporter setting' do
let(:config) do
{
'test' => {
'monitoring' => {
- 'sidekiq_exporter' => nil,
- 'sidekiq_health_checks' => nil
+ 'sidekiq_exporter' => nil
}
}
}
end
it 'does not start a sidekiq metrics server' do
- expect(MetricsServer).not_to receive(:fork)
+ expect(MetricsServer).not_to receive(:start_for_sidekiq)
cli.run(%w(foo))
end
@@ -376,26 +344,21 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
end
end
- context 'with valid settings' do
- using RSpec::Parameterized::TableSyntax
+ context 'when sidekiq_exporter is disabled' do
+ it 'does not start a sidekiq metrics server' do
+ expect(MetricsServer).not_to receive(:start_for_sidekiq)
- where(:sidekiq_exporter_enabled, :sidekiq_exporter_port, :sidekiq_health_checks_port, :start_metrics_server) do
- true | '3807' | '3907' | true
- true | '3807' | '3807' | false
- false | '3807' | '3907' | false
- false | '3807' | '3907' | false
+ cli.run(%w(foo))
end
+ end
+
+ context 'when sidekiq_exporter is enabled' do
+ let(:sidekiq_exporter_enabled) { true }
- with_them do
- specify do
- if start_metrics_server
- expect(MetricsServer).to receive(:fork).with('sidekiq', metrics_dir: metrics_dir, reset_signals: trapped_signals)
- else
- expect(MetricsServer).not_to receive(:fork)
- end
+ it 'starts the metrics server' do
+ expect(MetricsServer).to receive(:start_for_sidekiq).with(metrics_dir: metrics_dir, reset_signals: trapped_signals)
- cli.run(%w(foo))
- end
+ cli.run(%w(foo))
end
end
@@ -421,7 +384,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
let(:sidekiq_exporter_enabled) { true }
it 'does not start the server' do
- expect(MetricsServer).not_to receive(:fork)
+ expect(MetricsServer).not_to receive(:start_for_sidekiq)
cli.run(%w(foo --dryrun))
end
@@ -431,7 +394,6 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
context 'supervising the cluster' do
let(:sidekiq_exporter_enabled) { true }
- let(:sidekiq_health_checks_port) { '3907' }
let(:metrics_server_pid) { 99 }
let(:sidekiq_worker_pids) { [2, 42] }
@@ -440,32 +402,18 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
end
it 'stops the entire process cluster if one of the workers has been terminated' do
- expect(supervisor).to receive(:alive).and_return(true)
- expect(supervisor).to receive(:supervise).and_yield([2])
- expect(MetricsServer).to receive(:fork).once.and_return(metrics_server_pid)
- expect(Gitlab::ProcessManagement).to receive(:signal_processes).with([42, 99], :TERM)
+ expect(MetricsServer).to receive(:start_for_sidekiq).once.and_return(metrics_server_pid)
+ expect(supervisor).to receive(:supervise).and_yield([2, 99])
+ expect(supervisor).to receive(:shutdown)
cli.run(%w(foo))
end
- context 'when the supervisor is alive' do
- it 'restarts the metrics server when it is down' do
- expect(supervisor).to receive(:alive).and_return(true)
- expect(supervisor).to receive(:supervise).and_yield([metrics_server_pid])
- expect(MetricsServer).to receive(:fork).twice.and_return(metrics_server_pid)
+ it 'restarts the metrics server when it is down' do
+ expect(supervisor).to receive(:supervise).and_yield([metrics_server_pid])
+ expect(MetricsServer).to receive(:start_for_sidekiq).twice.and_return(metrics_server_pid)
- cli.run(%w(foo))
- end
- end
-
- context 'when the supervisor is shutting down' do
- it 'does not restart the metrics server' do
- expect(supervisor).to receive(:alive).and_return(false)
- expect(supervisor).to receive(:supervise).and_yield([metrics_server_pid])
- expect(MetricsServer).to receive(:fork).once.and_return(metrics_server_pid)
-
- cli.run(%w(foo))
- end
+ cli.run(%w(foo))
end
end
end
diff --git a/spec/components/pajamas/alert_component_spec.rb b/spec/components/pajamas/alert_component_spec.rb
index 628d715ff64..e596f07a15a 100644
--- a/spec/components/pajamas/alert_component_spec.rb
+++ b/spec/components/pajamas/alert_component_spec.rb
@@ -2,13 +2,23 @@
require "spec_helper"
RSpec.describe Pajamas::AlertComponent, :aggregate_failures, type: :component do
- context 'with content' do
+ context 'slots' do
+ let_it_be(:body) { 'Alert body' }
+ let_it_be(:actions) { 'Alert actions' }
+
before do
- render_inline(described_class.new) { '_content_' }
+ render_inline described_class.new do |c|
+ c.body { body }
+ c.actions { actions }
+ end
end
- it 'has content' do
- expect(rendered_component).to have_text('_content_')
+ it 'renders alert body' do
+ expect(rendered_component).to have_content(body)
+ end
+
+ it 'renders actions' do
+ expect(rendered_component).to have_content(actions)
end
end
@@ -25,47 +35,71 @@ RSpec.describe Pajamas::AlertComponent, :aggregate_failures, type: :component do
it 'renders the default variant' do
expect(rendered_component).to have_selector('.gl-alert-info')
expect(rendered_component).to have_selector("[data-testid='information-o-icon']")
+ expect(rendered_component).not_to have_selector('.gl-alert-no-icon')
end
it 'renders a dismiss button' do
expect(rendered_component).to have_selector('.gl-dismiss-btn.js-close')
expect(rendered_component).to have_selector("[data-testid='close-icon']")
+ expect(rendered_component).not_to have_selector('.gl-alert-not-dismissible')
end
end
context 'with custom options' do
context 'with simple options' do
- context 'without dismissible content' do
- before do
- render_inline described_class.new(
- title: '_title_',
- dismissible: false,
- alert_class: '_alert_class_',
- alert_data: {
- feature_id: '_feature_id_',
- dismiss_endpoint: '_dismiss_endpoint_'
- }
- )
- end
+ before do
+ render_inline described_class.new(
+ title: '_title_',
+ alert_class: '_alert_class_',
+ alert_data: {
+ feature_id: '_feature_id_',
+ dismiss_endpoint: '_dismiss_endpoint_'
+ }
+ )
+ end
- it 'sets the title' do
- expect(rendered_component).to have_selector('.gl-alert-title')
- expect(rendered_component).to have_content('_title_')
- expect(rendered_component).not_to have_selector('.gl-alert-icon-no-title')
- end
+ it 'sets the title' do
+ expect(rendered_component).to have_selector('.gl-alert-title')
+ expect(rendered_component).to have_content('_title_')
+ expect(rendered_component).not_to have_selector('.gl-alert-icon-no-title')
+ end
- it 'sets to not be dismissible' do
- expect(rendered_component).not_to have_selector('.gl-dismiss-btn.js-close')
- expect(rendered_component).not_to have_selector("[data-testid='close-icon']")
- end
+ it 'sets the alert_class' do
+ expect(rendered_component).to have_selector('._alert_class_')
+ end
- it 'sets the alert_class' do
- expect(rendered_component).to have_selector('._alert_class_')
- end
+ it 'sets the alert_data' do
+ expect(rendered_component).to have_selector('[data-feature-id="_feature_id_"][data-dismiss-endpoint="_dismiss_endpoint_"]')
+ end
+ end
- it 'sets the alert_data' do
- expect(rendered_component).to have_selector('[data-feature-id="_feature_id_"][data-dismiss-endpoint="_dismiss_endpoint_"]')
- end
+ context 'with dismissible disabled' do
+ before do
+ render_inline described_class.new(dismissible: false)
+ end
+
+ it 'has the "not dismissible" class' do
+ expect(rendered_component).to have_selector('.gl-alert-not-dismissible')
+ end
+
+ it 'does not render the dismiss button' do
+ expect(rendered_component).not_to have_selector('.gl-dismiss-btn.js-close')
+ expect(rendered_component).not_to have_selector("[data-testid='close-icon']")
+ end
+ end
+
+ context 'with the icon hidden' do
+ before do
+ render_inline described_class.new(show_icon: false)
+ end
+
+ it 'has the hidden icon class' do
+ expect(rendered_component).to have_selector('.gl-alert-no-icon')
+ end
+
+ it 'does not render the icon' do
+ expect(rendered_component).not_to have_selector('.gl-alert-icon')
+ expect(rendered_component).not_to have_selector("[data-testid='information-o-icon']")
end
end
@@ -79,6 +113,10 @@ RSpec.describe Pajamas::AlertComponent, :aggregate_failures, type: :component do
)
end
+ it 'does not have "not dismissible" class' do
+ expect(rendered_component).not_to have_selector('.gl-alert-not-dismissible')
+ end
+
it 'renders a dismiss button and data' do
expect(rendered_component).to have_selector('.gl-dismiss-btn.js-close._close_button_class_')
expect(rendered_component).to have_selector("[data-testid='close-icon']")
diff --git a/spec/config/object_store_settings_spec.rb b/spec/config/object_store_settings_spec.rb
index 33443509e4a..56ad0943377 100644
--- a/spec/config/object_store_settings_spec.rb
+++ b/spec/config/object_store_settings_spec.rb
@@ -162,14 +162,14 @@ RSpec.describe ObjectStoreSettings do
{
'enabled' => true,
'remote_directory' => 'some-bucket',
- 'direct_upload' => true,
- 'background_upload' => false,
+ 'direct_upload' => false,
+ 'background_upload' => true,
'proxy_download' => false
}
end
before do
- settings.lfs['object_store'] = described_class.legacy_parse(legacy_settings)
+ settings.lfs['object_store'] = described_class.legacy_parse(legacy_settings, 'lfs')
end
it 'does not alter config if legacy settings are specified' do
@@ -177,6 +177,35 @@ RSpec.describe ObjectStoreSettings do
expect(settings.artifacts['object_store']).to be_nil
expect(settings.lfs['object_store']['remote_directory']).to eq('some-bucket')
+ # Disable background_upload, regardless of the input config
+ expect(settings.lfs['object_store']['direct_upload']).to eq(true)
+ expect(settings.lfs['object_store']['background_upload']).to eq(false)
+ expect(settings.external_diffs['object_store']).to be_nil
+ end
+ end
+
+ context 'with legacy config and legacy background upload is enabled' do
+ let(:legacy_settings) do
+ {
+ 'enabled' => true,
+ 'remote_directory' => 'some-bucket',
+ 'proxy_download' => false
+ }
+ end
+
+ before do
+ stub_env(ObjectStoreSettings::LEGACY_BACKGROUND_UPLOADS_ENV, 'lfs')
+ settings.lfs['object_store'] = described_class.legacy_parse(legacy_settings, 'lfs')
+ end
+
+ it 'enables background_upload and disables direct_upload' do
+ subject
+
+ expect(settings.artifacts['object_store']).to be_nil
+ expect(settings.lfs['object_store']['remote_directory']).to eq('some-bucket')
+ # Enable background_upload if the environment variable is available
+ expect(settings.lfs['object_store']['direct_upload']).to eq(false)
+ expect(settings.lfs['object_store']['background_upload']).to eq(true)
expect(settings.external_diffs['object_store']).to be_nil
end
end
@@ -185,11 +214,11 @@ RSpec.describe ObjectStoreSettings do
describe '.legacy_parse' do
it 'sets correct default values' do
- settings = described_class.legacy_parse(nil)
+ settings = described_class.legacy_parse(nil, 'artifacts')
expect(settings['enabled']).to be false
- expect(settings['direct_upload']).to be false
- expect(settings['background_upload']).to be true
+ expect(settings['direct_upload']).to be true
+ expect(settings['background_upload']).to be false
expect(settings['remote_directory']).to be nil
end
@@ -199,12 +228,52 @@ RSpec.describe ObjectStoreSettings do
'remote_directory' => 'artifacts'
})
- settings = described_class.legacy_parse(original_settings)
+ settings = described_class.legacy_parse(original_settings, 'artifacts')
expect(settings['enabled']).to be true
- expect(settings['direct_upload']).to be false
- expect(settings['background_upload']).to be true
+ expect(settings['direct_upload']).to be true
+ expect(settings['background_upload']).to be false
expect(settings['remote_directory']).to eq 'artifacts'
end
+
+ context 'legacy background upload environment variable is enabled' do
+ before do
+ stub_env(ObjectStoreSettings::LEGACY_BACKGROUND_UPLOADS_ENV, 'artifacts,lfs')
+ end
+
+ it 'enables background_upload and disables direct_upload' do
+ original_settings = Settingslogic.new({
+ 'enabled' => true,
+ 'remote_directory' => 'artifacts'
+ })
+
+ settings = described_class.legacy_parse(original_settings, 'artifacts')
+
+ expect(settings['enabled']).to be true
+ expect(settings['direct_upload']).to be false
+ expect(settings['background_upload']).to be true
+ expect(settings['remote_directory']).to eq 'artifacts'
+ end
+ end
+
+ context 'legacy background upload environment variable is enabled for other types' do
+ before do
+ stub_env(ObjectStoreSettings::LEGACY_BACKGROUND_UPLOADS_ENV, 'uploads,lfs')
+ end
+
+ it 'enables direct_upload and disables background_upload' do
+ original_settings = Settingslogic.new({
+ 'enabled' => true,
+ 'remote_directory' => 'artifacts'
+ })
+
+ settings = described_class.legacy_parse(original_settings, 'artifacts')
+
+ expect(settings['enabled']).to be true
+ expect(settings['direct_upload']).to be true
+ expect(settings['background_upload']).to be false
+ expect(settings['remote_directory']).to eq 'artifacts'
+ end
+ end
end
end
diff --git a/spec/config/settings_spec.rb b/spec/config/settings_spec.rb
index 0c2465678f9..1de0e7e6c26 100644
--- a/spec/config/settings_spec.rb
+++ b/spec/config/settings_spec.rb
@@ -118,7 +118,7 @@ RSpec.describe Settings do
allow(Gitlab::CurrentSettings)
.to receive(:uuid) { 'd9e2f4e8-db1f-4e51-b03d-f427e1965c4a'}
- expect(described_class.send(:cron_for_service_ping)).to eq('21 18 * * 4')
+ expect(described_class.send(:cron_for_service_ping)).to eq('44 10 * * 4')
end
it 'returns min, hour, day in the valid range' do
diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb
index a18ebe9c9a0..4a92911f914 100644
--- a/spec/controllers/admin/application_settings_controller_spec.rb
+++ b/spec/controllers/admin/application_settings_controller_spec.rb
@@ -66,6 +66,26 @@ RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_set
sign_in(admin)
end
+ context 'when there are recent ServicePing reports' do
+ it 'attempts to use prerecorded data' do
+ create(:raw_usage_data)
+
+ expect(Gitlab::Usage::ServicePingReport).not_to receive(:for)
+
+ get :usage_data, format: :json
+ end
+ end
+
+ context 'when there are NO recent ServicePing reports' do
+ it 'calculates data on the fly' do
+ allow(Gitlab::Usage::ServicePingReport).to receive(:for).and_call_original
+
+ get :usage_data, format: :json
+
+ expect(Gitlab::Usage::ServicePingReport).to have_received(:for)
+ end
+ end
+
it 'returns HTML data' do
get :usage_data, format: :html
@@ -331,6 +351,17 @@ RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_set
end
end
end
+
+ context 'pipeline creation rate limiting' do
+ let(:application_settings) { ApplicationSetting.current }
+
+ it 'updates pipeline_limit_per_project_user_sha setting' do
+ put :update, params: { application_setting: { pipeline_limit_per_project_user_sha: 25 } }
+
+ expect(response).to redirect_to(general_admin_application_settings_path)
+ expect(application_settings.reload.pipeline_limit_per_project_user_sha).to eq(25)
+ end
+ end
end
describe 'PUT #reset_registration_token' do
@@ -368,4 +399,37 @@ RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_set
expect(response).to redirect_to("https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf")
end
end
+
+ describe 'GET #service_usage_data' do
+ before do
+ stub_usage_data_connections
+ stub_database_flavor_check
+ sign_in(admin)
+ end
+
+ it 'assigns truthy value if there are recent ServicePing reports in database' do
+ create(:raw_usage_data)
+
+ get :service_usage_data, format: :html
+
+ expect(assigns(:service_ping_data_present)).to be_truthy
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'assigns truthy value if there are recent ServicePing reports in cache', :use_clean_rails_memory_store_caching do
+ Rails.cache.write('usage_data', true)
+
+ get :service_usage_data, format: :html
+
+ expect(assigns(:service_ping_data_present)).to be_truthy
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'assigns falsey value if there are NO recent ServicePing reports' do
+ get :service_usage_data, format: :html
+
+ expect(assigns(:service_ping_data_present)).to be_falsey
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
diff --git a/spec/controllers/admin/clusters_controller_spec.rb b/spec/controllers/admin/clusters_controller_spec.rb
index fed9d2e8588..ca2b50b529c 100644
--- a/spec/controllers/admin/clusters_controller_spec.rb
+++ b/spec/controllers/admin/clusters_controller_spec.rb
@@ -102,87 +102,6 @@ RSpec.describe Admin::ClustersController do
end
end
- describe 'GET #new' do
- let(:user) { admin }
-
- def go(provider: 'gcp')
- get :new, params: { provider: provider }
- end
-
- include_examples ':certificate_based_clusters feature flag controller responses' do
- let(:subject) { go }
- end
-
- describe 'functionality for new cluster' do
- context 'when omniauth has been configured' do
- let(:key) { 'secret-key' }
- let(:session_key_for_redirect_uri) do
- GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(key)
- end
-
- context 'when selected provider is gke and no valid gcp token exists' do
- it 'redirects to gcp authorize_url' do
- go
-
- expect(response).to redirect_to(assigns(:authorize_url))
- end
- end
- end
-
- context 'when omniauth has not configured' do
- before do
- stub_omniauth_setting(providers: [])
- end
-
- it 'does not have authorize_url' do
- go
-
- expect(assigns(:authorize_url)).to be_nil
- end
- end
-
- context 'when access token is valid' do
- before do
- stub_google_api_validate_token
- end
-
- it 'has new object' do
- go
-
- expect(assigns(:gcp_cluster)).to be_an_instance_of(Clusters::ClusterPresenter)
- end
- end
-
- context 'when access token is expired' do
- before do
- stub_google_api_expired_token
- end
-
- it { expect(@valid_gcp_token).to be_falsey }
- end
-
- context 'when access token is not stored in session' do
- it { expect(@valid_gcp_token).to be_falsey }
- end
- end
-
- describe 'functionality for existing cluster' do
- it 'has new object' do
- go
-
- expect(assigns(:user_cluster)).to be_an_instance_of(Clusters::ClusterPresenter)
- end
- end
-
- include_examples 'GET new cluster shared examples'
-
- describe 'security' do
- it { expect { go }.to be_allowed_for(:admin) }
- it { expect { go }.to be_denied_for(:user) }
- it { expect { go }.to be_denied_for(:external) }
- end
- end
-
it_behaves_like 'GET #metrics_dashboard for dashboard', 'Cluster health' do
let(:cluster) { create(:cluster, :instance, :provided_by_gcp) }
@@ -216,164 +135,6 @@ RSpec.describe Admin::ClustersController do
end
end
- describe 'POST #create_gcp' do
- let(:legacy_abac_param) { 'true' }
- let(:params) do
- {
- cluster: {
- name: 'new-cluster',
- provider_gcp_attributes: {
- gcp_project_id: 'gcp-project-12345',
- legacy_abac: legacy_abac_param
- }
- }
- }
- end
-
- def post_create_gcp
- post :create_gcp, params: params
- end
-
- include_examples ':certificate_based_clusters feature flag controller responses' do
- let(:subject) { post_create_gcp }
- end
-
- describe 'functionality' do
- context 'when access token is valid' do
- before do
- stub_google_api_validate_token
- end
-
- it 'creates a new cluster' do
- expect(ClusterProvisionWorker).to receive(:perform_async)
- expect { post_create_gcp }.to change { Clusters::Cluster.count }
- .and change { Clusters::Providers::Gcp.count }
-
- cluster = Clusters::Cluster.instance_type.first
-
- expect(response).to redirect_to(admin_cluster_path(cluster))
- expect(cluster).to be_gcp
- expect(cluster).to be_kubernetes
- expect(cluster.provider_gcp).to be_legacy_abac
- end
-
- context 'when legacy_abac param is false' do
- let(:legacy_abac_param) { 'false' }
-
- it 'creates a new cluster with legacy_abac_disabled' do
- expect(ClusterProvisionWorker).to receive(:perform_async)
- expect { post_create_gcp }.to change { Clusters::Cluster.count }
- .and change { Clusters::Providers::Gcp.count }
- expect(Clusters::Cluster.instance_type.first.provider_gcp).not_to be_legacy_abac
- end
- end
- end
-
- context 'when access token is expired' do
- before do
- stub_google_api_expired_token
- end
-
- it { expect(@valid_gcp_token).to be_falsey }
- end
-
- context 'when access token is not stored in session' do
- it { expect(@valid_gcp_token).to be_falsey }
- end
- end
-
- describe 'security' do
- before do
- allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:token_in_session).and_return('token')
- allow(instance).to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s)
- end
- allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance|
- allow(instance).to receive(:projects_zones_clusters_create) do
- double(
- 'instance',
- self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123',
- status: 'RUNNING'
- )
- end
- end
-
- allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
- end
-
- it { expect { post_create_gcp }.to be_allowed_for(:admin) }
- it { expect { post_create_gcp }.to be_denied_for(:user) }
- it { expect { post_create_gcp }.to be_denied_for(:external) }
- end
- end
-
- describe 'POST #create_aws' do
- let(:params) do
- {
- cluster: {
- name: 'new-cluster',
- provider_aws_attributes: {
- key_name: 'key',
- role_arn: 'arn:role',
- region: 'region',
- vpc_id: 'vpc',
- instance_type: 'instance type',
- num_nodes: 3,
- security_group_id: 'security group',
- subnet_ids: %w(subnet1 subnet2)
- }
- }
- }
- end
-
- def post_create_aws
- post :create_aws, params: params
- end
-
- include_examples ':certificate_based_clusters feature flag controller responses' do
- let(:subject) { post_create_aws }
- end
-
- it 'creates a new cluster' do
- expect(ClusterProvisionWorker).to receive(:perform_async)
- expect { post_create_aws }.to change { Clusters::Cluster.count }
- .and change { Clusters::Providers::Aws.count }
-
- cluster = Clusters::Cluster.instance_type.first
-
- expect(response).to have_gitlab_http_status(:created)
- expect(response.location).to eq(admin_cluster_path(cluster))
- expect(cluster).to be_aws
- expect(cluster).to be_kubernetes
- end
-
- context 'params are invalid' do
- let(:params) do
- {
- cluster: { name: '' }
- }
- end
-
- it 'does not create a cluster' do
- expect { post_create_aws }.not_to change { Clusters::Cluster.count }
-
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- expect(response.media_type).to eq('application/json')
- expect(response.body).to include('is invalid')
- end
- end
-
- describe 'security' do
- before do
- allow(WaitForClusterCreationWorker).to receive(:perform_in)
- end
-
- it { expect { post_create_aws }.to be_allowed_for(:admin) }
- it { expect { post_create_aws }.to be_denied_for(:user) }
- it { expect { post_create_aws }.to be_denied_for(:external) }
- end
- end
-
describe 'POST #create_user' do
let(:params) do
{
diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb
index d9b7e00fd75..fb843ac6a7a 100644
--- a/spec/controllers/admin/groups_controller_spec.rb
+++ b/spec/controllers/admin/groups_controller_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe Admin::GroupsController do
it 'adds user to members', :aggregate_failures, :snowplow do
put :members_update, params: {
id: group,
- user_ids: group_user.id,
+ user_id: group_user.id,
access_level: Gitlab::Access::GUEST
}
@@ -70,7 +70,7 @@ RSpec.describe Admin::GroupsController do
it 'can add unlimited members', :aggregate_failures do
put :members_update, params: {
id: group,
- user_ids: 1.upto(1000).to_a.join(','),
+ user_id: 1.upto(1000).to_a.join(','),
access_level: Gitlab::Access::GUEST
}
@@ -81,7 +81,7 @@ RSpec.describe Admin::GroupsController do
it 'adds no user to members', :aggregate_failures do
put :members_update, params: {
id: group,
- user_ids: '',
+ user_id: '',
access_level: Gitlab::Access::GUEST
}
diff --git a/spec/controllers/admin/requests_profiles_controller_spec.rb b/spec/controllers/admin/requests_profiles_controller_spec.rb
deleted file mode 100644
index 7ee46b5b28a..00000000000
--- a/spec/controllers/admin/requests_profiles_controller_spec.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Admin::RequestsProfilesController do
- let_it_be(:admin) { create(:admin) }
-
- before do
- sign_in(admin)
- end
-
- describe '#show' do
- let(:tmpdir) { Dir.mktmpdir('profiler-test') }
- let(:test_file) { File.join(tmpdir, basename) }
-
- subject do
- get :show, params: { name: basename }
- end
-
- before do
- stub_const('Gitlab::RequestProfiler::PROFILES_DIR', tmpdir)
- File.write(test_file, sample_data)
- end
-
- after do
- FileUtils.rm_rf(tmpdir)
- end
-
- context 'when loading HTML profile' do
- let(:basename) { "profile_#{Time.current.to_i}_execution.html" }
-
- let(:sample_data) do
- '<html> <body> <h1>Heading</h1> <p>paragraph.</p> </body> </html>'
- end
-
- it 'renders the data' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to eq(sample_data)
- end
- end
-
- context 'when loading TXT profile' do
- let(:basename) { "profile_#{Time.current.to_i}_memory.txt" }
-
- let(:sample_data) do
- <<~TXT
- Total allocated: 112096396 bytes (1080431 objects)
- Total retained: 10312598 bytes (53567 objects)
- TXT
- end
-
- it 'renders the data' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to eq(sample_data)
- end
- end
-
- context 'when loading PDF profile' do
- let(:basename) { "profile_#{Time.current.to_i}_anything.pdf" }
-
- let(:sample_data) { 'mocked pdf content' }
-
- it 'fails to render the data' do
- expect { subject }.to raise_error(ActionController::UrlGenerationError, /No route matches.*unmatched constraints:/)
- end
- end
- end
-end
diff --git a/spec/controllers/admin/runners_controller_spec.rb b/spec/controllers/admin/runners_controller_spec.rb
index 8f70cb32d3e..fea59969400 100644
--- a/spec/controllers/admin/runners_controller_spec.rb
+++ b/spec/controllers/admin/runners_controller_spec.rb
@@ -26,27 +26,12 @@ RSpec.describe Admin::RunnersController do
describe '#show' do
render_views
- let_it_be(:project) { create(:project) }
-
- before_all do
- create(:ci_build, runner: runner, project: project)
- end
-
it 'shows a runner show page' do
get :show, params: { id: runner.id }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
end
-
- it 'when runner_read_only_admin_view is off, redirects to the runner edit page' do
- stub_feature_flags(runner_read_only_admin_view: false)
-
- get :show, params: { id: runner.id }
-
- expect(response).to have_gitlab_http_status(:redirect)
- expect(response).to redirect_to edit_admin_runner_path(runner)
- end
end
describe '#edit' do
@@ -55,11 +40,6 @@ RSpec.describe Admin::RunnersController do
let_it_be(:project) { create(:project) }
let_it_be(:project_two) { create(:project) }
- before_all do
- create(:ci_build, runner: runner, project: project)
- create(:ci_build, runner: runner, project: project_two)
- end
-
it 'shows a runner edit page' do
get :edit, params: { id: runner.id }
@@ -77,9 +57,6 @@ RSpec.describe Admin::RunnersController do
control_count = ActiveRecord::QueryRecorder.new { get :edit, params: { id: runner.id } }.count
- new_project = create(:project)
- create(:ci_build, runner: runner, project: new_project)
-
# There is one additional query looking up subject.group in ProjectPolicy for the
# needs_new_sso_session permission
expect { get :edit, params: { id: runner.id } }.not_to exceed_query_limit(control_count + 1)
@@ -89,17 +66,42 @@ RSpec.describe Admin::RunnersController do
end
describe '#update' do
- it 'updates the runner and ticks the queue' do
- new_desc = runner.description.swapcase
+ let(:new_desc) { runner.description.swapcase }
+ let(:runner_params) { { id: runner.id, runner: { description: new_desc } } }
- expect do
- post :update, params: { id: runner.id, runner: { description: new_desc } }
- end.to change { runner.ensure_runner_queue_value }
+ subject(:request) { post :update, params: runner_params }
- runner.reload
+ context 'with update succeeding' do
+ before do
+ expect_next_instance_of(Ci::Runners::UpdateRunnerService, runner) do |service|
+ expect(service).to receive(:update).with(anything).and_call_original
+ end
+ end
- expect(response).to have_gitlab_http_status(:found)
- expect(runner.description).to eq(new_desc)
+ it 'updates the runner and ticks the queue' do
+ expect { request }.to change { runner.ensure_runner_queue_value }
+
+ runner.reload
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(runner.description).to eq(new_desc)
+ end
+ end
+
+ context 'with update failing' do
+ before do
+ expect_next_instance_of(Ci::Runners::UpdateRunnerService, runner) do |service|
+ expect(service).to receive(:update).with(anything).and_return(false)
+ end
+ end
+
+ it 'does not update runner or tick the queue' do
+ expect { request }.not_to change { runner.ensure_runner_queue_value }
+ expect { request }.not_to change { runner.reload.description }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:show)
+ end
end
end
diff --git a/spec/controllers/admin/topics_controller_spec.rb b/spec/controllers/admin/topics_controller_spec.rb
index ea510f916da..67943525687 100644
--- a/spec/controllers/admin/topics_controller_spec.rb
+++ b/spec/controllers/admin/topics_controller_spec.rb
@@ -77,24 +77,31 @@ RSpec.describe Admin::TopicsController do
describe 'POST #create' do
it 'creates topic' do
expect do
- post :create, params: { projects_topic: { name: 'test' } }
+ post :create, params: { projects_topic: { name: 'test', title: 'Test' } }
end.to change { Projects::Topic.count }.by(1)
end
- it 'shows error message for invalid topic' do
- post :create, params: { projects_topic: { name: nil } }
+ it 'shows error message for invalid topic name' do
+ post :create, params: { projects_topic: { name: nil, title: 'Test' } }
errors = assigns[:topic].errors
expect(errors).to contain_exactly(errors.full_message(:name, I18n.t('errors.messages.blank')))
end
- it 'shows error message if topic not unique (case insensitive)' do
- post :create, params: { projects_topic: { name: topic.name.upcase } }
+ it 'shows error message if topic name not unique (case insensitive)' do
+ post :create, params: { projects_topic: { name: topic.name.upcase, title: topic.title } }
errors = assigns[:topic].errors
expect(errors).to contain_exactly(errors.full_message(:name, I18n.t('errors.messages.taken')))
end
+ it 'shows error message for invalid topic title' do
+ post :create, params: { projects_topic: { name: 'test', title: nil } }
+
+ errors = assigns[:topic].errors
+ expect(errors).to contain_exactly(errors.full_message(:title, I18n.t('errors.messages.blank')))
+ end
+
context 'as a normal user' do
before do
sign_in(user)
diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb
index abada97fb10..efa00877142 100644
--- a/spec/controllers/dashboard/todos_controller_spec.rb
+++ b/spec/controllers/dashboard/todos_controller_spec.rb
@@ -113,6 +113,19 @@ RSpec.describe Dashboard::TodosController do
expect(response).to redirect_to(dashboard_todos_path(page: last_page, project_id: project.id))
end
+
+ it 'returns directly addressed if filtering by mentioned action_id' do
+ allow(controller).to receive(:current_user).and_return(user)
+
+ mentioned_todos = [
+ create(:todo, :directly_addressed, project: project, user: user, target: issues.first),
+ create(:todo, :mentioned, project: project, user: user, target: issues.first)
+ ]
+
+ get :index, params: { action_id: ::Todo::MENTIONED, project_id: project.id }
+
+ expect(assigns(:todos)).to match_array(mentioned_todos)
+ end
end
end
diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb
index 4de31e2e135..0818dce776d 100644
--- a/spec/controllers/graphql_controller_spec.rb
+++ b/spec/controllers/graphql_controller_spec.rb
@@ -9,15 +9,10 @@ RSpec.describe GraphqlController do
stub_feature_flags(graphql: true)
end
- describe 'ArgumentError' do
- let(:user) { create(:user) }
- let(:message) { 'green ideas sleep furiously' }
+ describe 'rescue_from' do
+ let_it_be(:message) { 'green ideas sleep furiously' }
- before do
- sign_in(user)
- end
-
- it 'handles argument errors' do
+ it 'handles ArgumentError' do
allow(subject).to receive(:execute) do
raise Gitlab::Graphql::Errors::ArgumentError, message
end
@@ -28,6 +23,19 @@ RSpec.describe GraphqlController do
'errors' => include(a_hash_including('message' => message))
)
end
+
+ it 'handles StandardError' do
+ allow(subject).to receive(:execute) do
+ raise StandardError, message
+ end
+
+ post :execute
+
+ expect(json_response).to include(
+ 'errors' => include(a_hash_including('message' => /Internal server error/,
+ 'raisedAt' => /graphql_controller_spec.rb/))
+ )
+ end
end
describe 'POST #execute' do
diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb
index 4eeae64b760..4b82c5ceb1c 100644
--- a/spec/controllers/groups/clusters_controller_spec.rb
+++ b/spec/controllers/groups/clusters_controller_spec.rb
@@ -115,95 +115,6 @@ RSpec.describe Groups::ClustersController do
end
end
- describe 'GET new' do
- def go(provider: 'gcp')
- get :new, params: { group_id: group, provider: provider }
- end
-
- include_examples ':certificate_based_clusters feature flag controller responses' do
- let(:subject) { go }
- end
-
- describe 'functionality for new cluster' do
- context 'when omniauth has been configured' do
- let(:key) { 'secret-key' }
- let(:session_key_for_redirect_uri) do
- GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(key)
- end
-
- before do
- allow(SecureRandom).to receive(:hex).and_return(key)
- end
-
- it 'redirects to gcp authorize_url' do
- go
-
- expect(assigns(:authorize_url)).to include(key)
- expect(session[session_key_for_redirect_uri]).to eq(new_group_cluster_path(group, provider: :gcp))
- expect(response).to redirect_to(assigns(:authorize_url))
- end
- end
-
- context 'when omniauth has not configured' do
- before do
- stub_omniauth_setting(providers: [])
- end
-
- it 'does not have authorize_url' do
- go
-
- expect(assigns(:authorize_url)).to be_nil
- end
- end
-
- context 'when access token is valid' do
- before do
- stub_google_api_validate_token
- end
-
- it 'has new object' do
- go
-
- expect(assigns(:gcp_cluster)).to be_an_instance_of(Clusters::ClusterPresenter)
- end
- end
-
- context 'when access token is expired' do
- before do
- stub_google_api_expired_token
- end
-
- it { expect(@valid_gcp_token).to be_falsey }
- end
-
- context 'when access token is not stored in session' do
- it { expect(@valid_gcp_token).to be_falsey }
- end
- end
-
- describe 'functionality for existing cluster' do
- it 'has new object' do
- go
-
- expect(assigns(:user_cluster)).to be_an_instance_of(Clusters::ClusterPresenter)
- end
- end
-
- include_examples 'GET new cluster shared examples'
-
- describe 'security' do
- it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
- it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
- it { expect { go }.to be_allowed_for(:owner).of(group) }
- it { expect { go }.to be_allowed_for(:maintainer).of(group) }
- it { expect { go }.to be_denied_for(:developer).of(group) }
- it { expect { go }.to be_denied_for(:reporter).of(group) }
- it { expect { go }.to be_denied_for(:guest).of(group) }
- it { expect { go }.to be_denied_for(:user) }
- it { expect { go }.to be_denied_for(:external) }
- end
- end
-
it_behaves_like 'GET #metrics_dashboard for dashboard', 'Cluster health' do
let(:cluster) { create(:cluster, :provided_by_gcp, cluster_type: :group_type, groups: [group]) }
@@ -244,105 +155,6 @@ RSpec.describe Groups::ClustersController do
end
end
- describe 'POST create for new cluster' do
- let(:legacy_abac_param) { 'true' }
- let(:params) do
- {
- cluster: {
- name: 'new-cluster',
- managed: '1',
- provider_gcp_attributes: {
- gcp_project_id: 'gcp-project-12345',
- legacy_abac: legacy_abac_param
- }
- }
- }
- end
-
- def go
- post :create_gcp, params: params.merge(group_id: group)
- end
-
- include_examples ':certificate_based_clusters feature flag controller responses' do
- let(:subject) { go }
- end
-
- describe 'functionality' do
- context 'when access token is valid' do
- before do
- stub_google_api_validate_token
- end
-
- it 'creates a new cluster' do
- expect(ClusterProvisionWorker).to receive(:perform_async)
- expect { go }.to change { Clusters::Cluster.count }
- .and change { Clusters::Providers::Gcp.count }
-
- cluster = group.clusters.first
-
- expect(response).to redirect_to(group_cluster_path(group, cluster))
- expect(cluster).to be_gcp
- expect(cluster).to be_kubernetes
- expect(cluster.provider_gcp).to be_legacy_abac
- expect(cluster).to be_managed
- expect(cluster).to be_namespace_per_environment
- end
-
- context 'when legacy_abac param is false' do
- let(:legacy_abac_param) { 'false' }
-
- it 'creates a new cluster with legacy_abac_disabled' do
- expect(ClusterProvisionWorker).to receive(:perform_async)
- expect { go }.to change { Clusters::Cluster.count }
- .and change { Clusters::Providers::Gcp.count }
- expect(group.clusters.first.provider_gcp).not_to be_legacy_abac
- end
- end
- end
-
- context 'when access token is expired' do
- before do
- stub_google_api_expired_token
- end
-
- it { expect(@valid_gcp_token).to be_falsey }
- end
-
- context 'when access token is not stored in session' do
- it { expect(@valid_gcp_token).to be_falsey }
- end
- end
-
- describe 'security' do
- before do
- allow_any_instance_of(described_class)
- .to receive(:token_in_session).and_return('token')
- allow_any_instance_of(described_class)
- .to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s)
- allow_any_instance_of(GoogleApi::CloudPlatform::Client)
- .to receive(:projects_zones_clusters_create) do
- double(
- 'instance',
- self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123',
- status: 'RUNNING'
- )
- end
-
- allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
- end
-
- it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
- it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
- it { expect { go }.to be_allowed_for(:owner).of(group) }
- it { expect { go }.to be_allowed_for(:maintainer).of(group) }
- it { expect { go }.to be_denied_for(:developer).of(group) }
- it { expect { go }.to be_denied_for(:reporter).of(group) }
- it { expect { go }.to be_denied_for(:guest).of(group) }
- it { expect { go }.to be_denied_for(:user) }
- it { expect { go }.to be_denied_for(:external) }
- end
- end
-
describe 'POST create for existing cluster' do
let(:params) do
{
diff --git a/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb b/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb
index 50e19d5b482..ed79712f828 100644
--- a/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb
+++ b/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb
@@ -8,18 +8,6 @@ RSpec.describe Groups::DependencyProxyAuthController do
describe 'GET #authenticate' do
subject { get :authenticate }
- context 'feature flag disabled' do
- before do
- stub_feature_flags(dependency_proxy_for_private_groups: false)
- end
-
- it 'returns successfully', :aggregate_failures do
- subject
-
- expect(response).to have_gitlab_http_status(:success)
- end
- end
-
context 'without JWT' do
it 'returns unauthorized with oauth realm', :aggregate_failures do
subject
diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
index 61445603a2d..5b4b00106cb 100644
--- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
+++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
@@ -20,33 +20,9 @@ RSpec.describe Groups::DependencyProxyForContainersController do
request.headers['HTTP_AUTHORIZATION'] = nil
end
- context 'feature flag disabled' do
- let_it_be(:group) { create(:group) }
-
- before do
- stub_feature_flags(dependency_proxy_for_private_groups: false)
- end
-
- it { is_expected.to have_gitlab_http_status(:ok) }
- end
-
it { is_expected.to have_gitlab_http_status(:unauthorized) }
end
- shared_examples 'feature flag disabled with private group' do
- before do
- stub_feature_flags(dependency_proxy_for_private_groups: false)
- end
-
- it 'returns not found' do
- group.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
-
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
shared_examples 'with invalid path' do
context 'with invalid image' do
let(:image) { '../path_traversal' }
@@ -208,7 +184,6 @@ RSpec.describe Groups::DependencyProxyForContainersController do
context 'feature enabled' do
it_behaves_like 'without a token'
it_behaves_like 'without permission'
- it_behaves_like 'feature flag disabled with private group'
context 'remote token request fails' do
let(:token_response) do
@@ -321,7 +296,6 @@ RSpec.describe Groups::DependencyProxyForContainersController do
context 'feature enabled' do
it_behaves_like 'without a token'
it_behaves_like 'without permission'
- it_behaves_like 'feature flag disabled with private group'
context 'a valid user' do
before do
diff --git a/spec/controllers/groups/releases_controller_spec.rb b/spec/controllers/groups/releases_controller_spec.rb
index 8b08f913e10..9d372114d62 100644
--- a/spec/controllers/groups/releases_controller_spec.rb
+++ b/spec/controllers/groups/releases_controller_spec.rb
@@ -60,32 +60,6 @@ RSpec.describe Groups::ReleasesController do
end
end
- context 'group_releases_finder_inoperator feature flag' do
- before do
- sign_in(guest)
- end
-
- it 'calls old code when disabled' do
- stub_feature_flags(group_releases_finder_inoperator: false)
-
- allow(ReleasesFinder).to receive(:new).and_call_original
-
- index
-
- expect(ReleasesFinder).to have_received(:new)
- end
-
- it 'calls new code when enabled' do
- stub_feature_flags(group_releases_finder_inoperator: true)
-
- allow(Releases::GroupReleasesFinder).to receive(:new).and_call_original
-
- index
-
- expect(Releases::GroupReleasesFinder).to have_received(:new)
- end
- end
-
context 'N+1 queries' do
it 'avoids N+1 database queries' do
control_count = ActiveRecord::QueryRecorder.new { subject }.count
diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb
index a53f09e2afc..77c62c0d930 100644
--- a/spec/controllers/groups/runners_controller_spec.rb
+++ b/spec/controllers/groups/runners_controller_spec.rb
@@ -194,205 +194,4 @@ RSpec.describe Groups::RunnersController do
end
end
end
-
- describe '#destroy' do
- context 'when user is an owner' do
- before do
- group.add_owner(user)
- end
-
- it 'destroys the runner and redirects' do
- expect_next_instance_of(Ci::Runners::UnregisterRunnerService, runner, user) do |service|
- expect(service).to receive(:execute).once.and_call_original
- end
-
- delete :destroy, params: params
-
- expect(response).to have_gitlab_http_status(:found)
- expect(Ci::Runner.find_by(id: runner.id)).to be_nil
- end
-
- it 'destroys the project runner and redirects' do
- delete :destroy, params: params_runner_project
-
- expect(response).to have_gitlab_http_status(:found)
- expect(Ci::Runner.find_by(id: runner_project.id)).to be_nil
- end
- end
-
- context 'with runner associated with multiple projects' do
- let_it_be(:project_2) { create(:project, group: group) }
-
- let(:runner_project_2) { create(:ci_runner, :project, projects: [project, project_2]) }
- let(:params_runner_project_2) { { group_id: group, id: runner_project_2 } }
-
- context 'when user is an admin', :enable_admin_mode do
- let(:user) { create(:user, :admin) }
-
- before do
- sign_in(user)
- end
-
- it 'destroys the project runner and redirects' do
- delete :destroy, params: params_runner_project_2
-
- expect(response).to have_gitlab_http_status(:found)
- expect(Ci::Runner.find_by(id: runner_project_2.id)).to be_nil
- end
- end
-
- context 'when user is an owner' do
- before do
- group.add_owner(user)
- end
-
- it 'does not destroy the project runner' do
- delete :destroy, params: params_runner_project_2
-
- expect(response).to have_gitlab_http_status(:found)
- expect(flash[:alert]).to eq('Runner cannot be deleted, please contact your administrator.')
- expect(Ci::Runner.find_by(id: runner_project_2.id)).to be_present
- end
- end
- end
-
- context 'when user is not an owner' do
- before do
- group.add_maintainer(user)
- end
-
- it 'responds 404 and does not destroy the runner' do
- delete :destroy, params: params
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(Ci::Runner.find_by(id: runner.id)).to be_present
- end
-
- it 'responds 404 and does not destroy the project runner' do
- delete :destroy, params: params_runner_project
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(Ci::Runner.find_by(id: runner_project.id)).to be_present
- end
- end
- end
-
- describe '#resume' do
- context 'when user is an owner' do
- before do
- group.add_owner(user)
- end
-
- it 'marks the runner as active, ticks the queue, and redirects' do
- runner.update!(active: false)
-
- expect do
- post :resume, params: params
- end.to change { runner.ensure_runner_queue_value }
-
- expect(response).to have_gitlab_http_status(:found)
- expect(runner.reload.active).to eq(true)
- end
-
- it 'marks the project runner as active, ticks the queue, and redirects' do
- runner_project.update!(active: false)
-
- expect do
- post :resume, params: params_runner_project
- end.to change { runner_project.ensure_runner_queue_value }
-
- expect(response).to have_gitlab_http_status(:found)
- expect(runner_project.reload.active).to eq(true)
- end
- end
-
- context 'when user is not an owner' do
- before do
- group.add_maintainer(user)
- end
-
- it 'responds 404 and does not activate the runner' do
- runner.update!(active: false)
-
- expect do
- post :resume, params: params
- end.not_to change { runner.ensure_runner_queue_value }
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(runner.reload.active).to eq(false)
- end
-
- it 'responds 404 and does not activate the project runner' do
- runner_project.update!(active: false)
-
- expect do
- post :resume, params: params_runner_project
- end.not_to change { runner_project.ensure_runner_queue_value }
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(runner_project.reload.active).to eq(false)
- end
- end
- end
-
- describe '#pause' do
- context 'when user is an owner' do
- before do
- group.add_owner(user)
- end
-
- it 'marks the runner as inactive, ticks the queue, and redirects' do
- runner.update!(active: true)
-
- expect do
- post :pause, params: params
- end.to change { runner.ensure_runner_queue_value }
-
- expect(response).to have_gitlab_http_status(:found)
- expect(runner.reload.active).to eq(false)
- end
-
- it 'marks the project runner as inactive, ticks the queue, and redirects' do
- runner_project.update!(active: true)
-
- expect do
- post :pause, params: params_runner_project
- end.to change { runner_project.ensure_runner_queue_value }
-
- expect(response).to have_gitlab_http_status(:found)
- expect(runner_project.reload.active).to eq(false)
- end
- end
-
- context 'when user is not an owner' do
- before do
- # Disable limit checking
- allow(runner).to receive(:runner_scope).and_return(nil)
-
- group.add_maintainer(user)
- end
-
- it 'responds 404 and does not update the runner or queue' do
- runner.update!(active: true)
-
- expect do
- post :pause, params: params
- end.not_to change { runner.ensure_runner_queue_value }
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(runner.reload.active).to eq(true)
- end
-
- it 'responds 404 and does not update the project runner or queue' do
- runner_project.update!(active: true)
-
- expect do
- post :pause, params: params
- end.not_to change { runner_project.ensure_runner_queue_value }
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(runner_project.reload.active).to eq(true)
- end
- end
- end
end
diff --git a/spec/controllers/groups/settings/ci_cd_controller_spec.rb b/spec/controllers/groups/settings/ci_cd_controller_spec.rb
index f225d798886..9aa97c37add 100644
--- a/spec/controllers/groups/settings/ci_cd_controller_spec.rb
+++ b/spec/controllers/groups/settings/ci_cd_controller_spec.rb
@@ -6,14 +6,7 @@ RSpec.describe Groups::Settings::CiCdController do
include ExternalAuthorizationServiceHelpers
let_it_be(:group) { create(:group) }
- let_it_be(:sub_group) { create(:group, parent: group) }
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, group: group) }
- let_it_be(:project_2) { create(:project, group: sub_group) }
- let_it_be(:runner_group) { create(:ci_runner, :group, groups: [group]) }
- let_it_be(:runner_project_1) { create(:ci_runner, :project, projects: [project])}
- let_it_be(:runner_project_2) { create(:ci_runner, :project, projects: [project_2])}
- let_it_be(:runner_project_3) { create(:ci_runner, :project, projects: [project, project_2])}
before do
sign_in(user)
@@ -25,23 +18,11 @@ RSpec.describe Groups::Settings::CiCdController do
group.add_owner(user)
end
- it 'renders show with 200 status code and correct runners' do
+ it 'renders show with 200 status code' do
get :show, params: { group_id: group }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
- expect(assigns(:group_runners)).to match_array([runner_group, runner_project_1, runner_project_2, runner_project_3])
- end
-
- it 'paginates runners' do
- stub_const("Groups::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE", 1)
-
- create(:ci_runner)
-
- get :show, params: { group_id: group }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:group_runners).count).to be(1)
end
end
@@ -54,7 +35,6 @@ RSpec.describe Groups::Settings::CiCdController do
get :show, params: { group_id: group }
expect(response).to have_gitlab_http_status(:not_found)
- expect(assigns(:group_runners)).to be_nil
end
end
@@ -72,38 +52,6 @@ RSpec.describe Groups::Settings::CiCdController do
end
end
- describe 'PUT #reset_registration_token' do
- subject { put :reset_registration_token, params: { group_id: group } }
-
- context 'when user is owner' do
- before do
- group.add_owner(user)
- end
-
- it 'resets runner registration token' do
- expect { subject }.to change { group.reload.runners_token }
- end
-
- it 'redirects the user to admin runners page' do
- subject
-
- expect(response).to redirect_to(group_settings_ci_cd_path)
- end
- end
-
- context 'when user is not owner' do
- before do
- group.add_maintainer(user)
- end
-
- it 'renders a 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-
describe 'PATCH #update_auto_devops' do
let(:auto_devops_param) { '1' }
@@ -236,25 +184,4 @@ RSpec.describe Groups::Settings::CiCdController do
end
end
end
-
- describe 'GET #runner_setup_scripts' do
- before do
- group.add_owner(user)
- end
-
- it 'renders the setup scripts' do
- get :runner_setup_scripts, params: { os: 'linux', arch: 'amd64', group_id: group }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key("install")
- expect(json_response).to have_key("register")
- end
-
- it 'renders errors if they occur' do
- get :runner_setup_scripts, params: { os: 'foo', arch: 'bar', group_id: group }
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response).to have_key("errors")
- end
- end
end
diff --git a/spec/controllers/groups/shared_projects_controller_spec.rb b/spec/controllers/groups/shared_projects_controller_spec.rb
index 528d5c073b7..0c5a3b9df08 100644
--- a/spec/controllers/groups/shared_projects_controller_spec.rb
+++ b/spec/controllers/groups/shared_projects_controller_spec.rb
@@ -12,9 +12,10 @@ RSpec.describe Groups::SharedProjectsController do
Projects::GroupLinks::CreateService.new(
project,
+ group,
user,
link_group_access: Gitlab::Access::DEVELOPER
- ).execute(group)
+ ).execute
end
let!(:group) { create(:group) }
diff --git a/spec/controllers/groups/uploads_controller_spec.rb b/spec/controllers/groups/uploads_controller_spec.rb
index 7dafb813545..8fcc3a7fccf 100644
--- a/spec/controllers/groups/uploads_controller_spec.rb
+++ b/spec/controllers/groups/uploads_controller_spec.rb
@@ -35,6 +35,169 @@ RSpec.describe Groups::UploadsController do
end
end
+ describe "GET #show" do
+ let(:filename) { "rails_sample.jpg" }
+ let(:user) { create(:user) }
+ let(:jpg) { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') }
+ let(:txt) { fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain') }
+ let(:secret) { FileUploader.generate_secret }
+ let(:uploader_class) { FileUploader }
+
+ let(:upload_service) do
+ UploadService.new(model, jpg, uploader_class).execute
+ end
+
+ let(:show_upload) do
+ get :show, params: params.merge(secret: secret, filename: filename)
+ end
+
+ before do
+ allow(FileUploader).to receive(:generate_secret).and_return(secret)
+
+ allow_next_instance_of(FileUploader) do |instance|
+ allow(instance).to receive(:image?).and_return(true)
+ end
+
+ upload_service
+ end
+
+ context 'when the group is public' do
+ before do
+ model.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
+ end
+
+ context "when not signed in" do
+ context "enforce_auth_checks_on_uploads feature flag" do
+ context "with flag enabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: true)
+ end
+
+ it "responds with appropriate status" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context "with flag disabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context "when signed in" do
+ before do
+ sign_in(user)
+ end
+
+ context "when the user doesn't have access to the model" do
+ context "enforce_auth_checks_on_uploads feature flag" do
+ context "with flag enabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: true)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context "with flag disabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when the group is private' do
+ before do
+ model.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ context "when not signed in" do
+ context "enforce_auth_checks_on_uploads feature flag" do
+ context "with flag enabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: true)
+ end
+
+ it "responds with appropriate status" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context "with flag disabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context "when signed in" do
+ before do
+ sign_in(user)
+ end
+
+ context "when the user doesn't have access to the model" do
+ context "enforce_auth_checks_on_uploads feature flag" do
+ context "with flag enabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: true)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context "with flag disabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+ end
+ end
+
def post_authorize(verified: true)
request.headers.merge!(workhorse_internal_api_request_header) if verified
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index be30011905c..4a74eff90dc 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -18,6 +18,7 @@ RSpec.describe GroupsController, factory_default: :keep do
let_it_be(:guest) { group.add_guest(create(:user)).user }
before do
+ stub_feature_flags(vue_issues_list: true)
enable_admin_mode!(admin_with_admin_mode)
end
@@ -71,6 +72,17 @@ RSpec.describe GroupsController, factory_default: :keep do
context 'when the group is not importing' do
it_behaves_like 'details view'
+
+ it 'tracks page views', :snowplow do
+ subject
+
+ expect_snowplow_event(
+ category: 'group_overview',
+ action: 'render',
+ user: user,
+ namespace: group
+ )
+ end
end
context 'when the group is importing' do
@@ -81,6 +93,17 @@ RSpec.describe GroupsController, factory_default: :keep do
it 'redirects to the import status page' do
expect(subject).to redirect_to group_import_path(group)
end
+
+ it 'does not track page views', :snowplow do
+ subject
+
+ expect_no_snowplow_event(
+ category: 'group_overview',
+ action: 'render',
+ user: user,
+ namespace: group
+ )
+ end
end
end
@@ -466,53 +489,12 @@ RSpec.describe GroupsController, factory_default: :keep do
end
end
- describe 'GET #issues', :sidekiq_might_not_need_inline do
- let_it_be(:issue_1) { create(:issue, project: project, title: 'foo') }
- let_it_be(:issue_2) { create(:issue, project: project, title: 'bar') }
-
+ describe 'GET #issues' do
before do
- create_list(:award_emoji, 3, awardable: issue_2)
- create_list(:award_emoji, 2, awardable: issue_1)
- create_list(:award_emoji, 2, :downvote, awardable: issue_2)
-
sign_in(user)
end
- context 'sorting by votes' do
- it 'sorts most popular issues' do
- get :issues, params: { id: group.to_param, sort: 'upvotes_desc' }
- expect(assigns(:issues)).to eq [issue_2, issue_1]
- end
-
- it 'sorts least popular issues' do
- get :issues, params: { id: group.to_param, sort: 'downvotes_desc' }
- expect(assigns(:issues)).to eq [issue_2, issue_1]
- end
- end
-
- context 'searching' do
- it 'works with popularity sort' do
- get :issues, params: { id: group.to_param, search: 'foo', sort: 'popularity' }
-
- expect(assigns(:issues)).to eq([issue_1])
- end
-
- it 'works with priority sort' do
- get :issues, params: { id: group.to_param, search: 'foo', sort: 'priority' }
-
- expect(assigns(:issues)).to eq([issue_1])
- end
-
- it 'works with label priority sort' do
- get :issues, params: { id: group.to_param, search: 'foo', sort: 'label_priority' }
-
- expect(assigns(:issues)).to eq([issue_1])
- end
- end
-
it 'saves the sort order to user preferences' do
- stub_feature_flags(vue_issues_list: true)
-
get :issues, params: { id: group.to_param, sort: 'priority' }
expect(user.reload.user_preference.issues_sort).to eq('priority')
@@ -765,7 +747,7 @@ RSpec.describe GroupsController, factory_default: :keep do
end
it 'does not update the attribute' do
- expect { subject }.not_to change { group.namespace_settings.reload.prevent_sharing_groups_outside_hierarchy }
+ expect { subject }.not_to change { group.reload.prevent_sharing_groups_outside_hierarchy }
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb
index 5e90ceb0f9c..80375a02b33 100644
--- a/spec/controllers/jira_connect/events_controller_spec.rb
+++ b/spec/controllers/jira_connect/events_controller_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe JiraConnect::EventsController do
shared_context 'valid JWT token' do
before do
- allow_next_instance_of(Atlassian::JiraConnect::AsymmetricJwt) do |asymmetric_jwt|
+ allow_next_instance_of(Atlassian::JiraConnect::Jwt::Asymmetric) do |asymmetric_jwt|
allow(asymmetric_jwt).to receive(:valid?).and_return(true)
allow(asymmetric_jwt).to receive(:iss_claim).and_return(client_key)
end
@@ -36,7 +36,7 @@ RSpec.describe JiraConnect::EventsController do
shared_context 'invalid JWT token' do
before do
- allow_next_instance_of(Atlassian::JiraConnect::AsymmetricJwt) do |asymmetric_jwt|
+ allow_next_instance_of(Atlassian::JiraConnect::Jwt::Asymmetric) do |asymmetric_jwt|
allow(asymmetric_jwt).to receive(:valid?).and_return(false)
end
end
diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb
index e6553c027d6..7489f506674 100644
--- a/spec/controllers/oauth/authorizations_controller_spec.rb
+++ b/spec/controllers/oauth/authorizations_controller_spec.rb
@@ -56,27 +56,9 @@ RSpec.describe Oauth::AuthorizationsController do
end
end
- shared_examples "Implicit grant can't be used in confidential application" do
- context 'when application is confidential' do
- before do
- application.update!(confidential: true)
- params[:response_type] = 'token'
- end
-
- it 'does not allow the implicit flow' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template('doorkeeper/authorizations/error')
- end
- end
- end
-
describe 'GET #new' do
subject { get :new, params: params }
- include_examples "Implicit grant can't be used in confidential application"
-
context 'when the user is confirmed' do
context 'when there is already an access token for the application with a matching scope' do
before do
@@ -219,14 +201,12 @@ RSpec.describe Oauth::AuthorizationsController do
subject { post :create, params: params }
include_examples 'OAuth Authorizations require confirmed user'
- include_examples "Implicit grant can't be used in confidential application"
end
describe 'DELETE #destroy' do
subject { delete :destroy, params: params }
include_examples 'OAuth Authorizations require confirmed user'
- include_examples "Implicit grant can't be used in confidential application"
end
it 'includes Two-factor enforcement concern' do
diff --git a/spec/controllers/profiles/emails_controller_spec.rb b/spec/controllers/profiles/emails_controller_spec.rb
index e41ae406d13..b63db831462 100644
--- a/spec/controllers/profiles/emails_controller_spec.rb
+++ b/spec/controllers/profiles/emails_controller_spec.rb
@@ -62,7 +62,11 @@ RSpec.describe Profiles::EmailsController do
end
describe '#resend_confirmation_instructions' do
- let_it_be(:email) { create(:email, user: user) }
+ let_it_be(:email) do
+ travel_to(5.minutes.ago) do
+ create(:email, user: user)
+ end
+ end
let(:params) { { id: email.id } }
diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb
index 9410fe08d0b..958fcd4360c 100644
--- a/spec/controllers/projects/artifacts_controller_spec.rb
+++ b/spec/controllers/projects/artifacts_controller_spec.rb
@@ -361,6 +361,7 @@ RSpec.describe Projects::ArtifactsController do
subject
expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Gitlab-Workhorse-Detect-Content-Type']).to eq('true')
expect(send_data).to start_with('artifacts-entry:')
expect(params.keys).to eq(%w(Archive Entry))
diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb
index 1580ad9361d..ed11d5936b0 100644
--- a/spec/controllers/projects/branches_controller_spec.rb
+++ b/spec/controllers/projects/branches_controller_spec.rb
@@ -307,17 +307,36 @@ RSpec.describe Projects::BranchesController do
sign_in(developer)
end
- it 'returns 303' do
- post :destroy,
- format: :html,
- params: {
- id: 'foo/bar/baz',
- namespace_id: project.namespace,
- project_id: project
- }
+ subject(:post_request) do
+ post :destroy, format: :html, params: {
+ id: 'foo/bar/baz',
+ namespace_id: project.namespace,
+ project_id: project
+ }
+ end
+ it "returns response code 303" do
+ post_request
expect(response).to have_gitlab_http_status(:see_other)
end
+
+ context 'with http referer' do
+ before do
+ request.env['HTTP_REFERER'] = '/'
+ end
+
+ it "redirects to the referer path" do
+ post_request
+ expect(response).to redirect_to('/')
+ end
+ end
+
+ context 'without http referer' do
+ it "redirects to the project branches path" do
+ post_request
+ expect(response).to redirect_to(project_branches_path(project))
+ end
+ end
end
describe "POST destroy" do
diff --git a/spec/controllers/projects/ci/secure_files_controller_spec.rb b/spec/controllers/projects/ci/secure_files_controller_spec.rb
index 1138897bcc6..200997e31b9 100644
--- a/spec/controllers/projects/ci/secure_files_controller_spec.rb
+++ b/spec/controllers/projects/ci/secure_files_controller_spec.rb
@@ -9,17 +9,35 @@ RSpec.describe Projects::Ci::SecureFilesController do
subject(:show_request) { get :show, params: { namespace_id: project.namespace, project_id: project } }
describe 'GET #show' do
- context 'with enough privileges' do
- before do
- sign_in(user)
- project.add_developer(user)
- show_request
+ context 'when the :ci_secure_files feature flag is enabled' do
+ context 'with enough privileges' do
+ before do
+ stub_feature_flags(ci_secure_files: true)
+ sign_in(user)
+ project.add_developer(user)
+ show_request
+ end
+
+ it { expect(response).to have_gitlab_http_status(:ok) }
+
+ it 'renders show page' do
+ expect(response).to render_template :show
+ end
end
+ end
- it { expect(response).to have_gitlab_http_status(:ok) }
+ context 'when the :ci_secure_files feature flag is disabled' do
+ context 'with enough privileges' do
+ before do
+ stub_feature_flags(ci_secure_files: false)
+ sign_in(user)
+ project.add_developer(user)
+ show_request
+ end
- it 'renders show page' do
- expect(response).to render_template :show
+ it 'responds with 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
end
end
diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb
index 44bdc958805..01420e30d24 100644
--- a/spec/controllers/projects/clusters_controller_spec.rb
+++ b/spec/controllers/projects/clusters_controller_spec.rb
@@ -113,103 +113,6 @@ RSpec.describe Projects::ClustersController do
end
end
- describe 'GET new' do
- def go(provider: 'gcp')
- get :new, params: {
- namespace_id: project.namespace,
- project_id: project,
- provider: provider
- }
- end
-
- include_examples ':certificate_based_clusters feature flag controller responses' do
- let(:subject) { go }
- end
-
- describe 'functionality for new cluster' do
- context 'when omniauth has been configured' do
- let(:key) { 'secret-key' }
- let(:session_key_for_redirect_uri) do
- GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(key)
- end
-
- before do
- allow(SecureRandom).to receive(:hex).and_return(key)
- end
-
- it 'redirects to gcp authorize_url' do
- go
-
- expect(assigns(:authorize_url)).to include(key)
- expect(session[session_key_for_redirect_uri]).to eq(new_project_cluster_path(project, provider: :gcp))
- expect(response).to redirect_to(assigns(:authorize_url))
- end
- end
-
- context 'when omniauth has not configured' do
- before do
- stub_omniauth_setting(providers: [])
- end
-
- it 'does not have authorize_url' do
- go
-
- expect(assigns(:authorize_url)).to be_nil
- end
- end
-
- context 'when access token is valid' do
- before do
- stub_google_api_validate_token
- end
-
- it 'has new object' do
- go
-
- expect(assigns(:gcp_cluster)).to be_an_instance_of(Clusters::ClusterPresenter)
- end
- end
-
- context 'when access token is expired' do
- before do
- stub_google_api_expired_token
- end
-
- it { expect(@valid_gcp_token).to be_falsey }
- end
-
- context 'when access token is not stored in session' do
- it { expect(@valid_gcp_token).to be_falsey }
- end
- end
-
- describe 'functionality for existing cluster' do
- it 'has new object' do
- go
-
- expect(assigns(:user_cluster)).to be_an_instance_of(Clusters::ClusterPresenter)
- end
- end
-
- include_examples 'GET new cluster shared examples'
-
- describe 'security' do
- it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
- expect { go }.to be_allowed_for(:admin)
- end
- it 'is disabled for admin when admin mode disabled' do
- expect { go }.to be_denied_for(:admin)
- end
- it { expect { go }.to be_allowed_for(:owner).of(project) }
- it { expect { go }.to be_allowed_for(:maintainer).of(project) }
- it { expect { go }.to be_denied_for(:developer).of(project) }
- it { expect { go }.to be_denied_for(:reporter).of(project) }
- it { expect { go }.to be_denied_for(:guest).of(project) }
- it { expect { go }.to be_denied_for(:user) }
- it { expect { go }.to be_denied_for(:external) }
- end
- end
-
describe 'GET #prometheus_proxy' do
let(:proxyable) do
create(:cluster, :provided_by_gcp, projects: [project])
@@ -252,107 +155,6 @@ RSpec.describe Projects::ClustersController do
end
end
- describe 'POST create for new cluster' do
- let(:legacy_abac_param) { 'true' }
- let(:params) do
- {
- cluster: {
- name: 'new-cluster',
- managed: '1',
- namespace_per_environment: '0',
- provider_gcp_attributes: {
- gcp_project_id: 'gcp-project-12345',
- legacy_abac: legacy_abac_param
- }
- }
- }
- end
-
- def go
- post :create_gcp, params: params.merge(namespace_id: project.namespace, project_id: project)
- end
-
- include_examples ':certificate_based_clusters feature flag controller responses' do
- let(:subject) { go }
- end
-
- describe 'functionality' do
- context 'when access token is valid' do
- before do
- stub_google_api_validate_token
- end
-
- it 'creates a new cluster' do
- expect(ClusterProvisionWorker).to receive(:perform_async)
- expect { go }.to change { Clusters::Cluster.count }
- .and change { Clusters::Providers::Gcp.count }
- expect(response).to redirect_to(project_cluster_path(project, project.clusters.first))
- expect(project.clusters.first).to be_gcp
- expect(project.clusters.first).to be_kubernetes
- expect(project.clusters.first.provider_gcp).to be_legacy_abac
- expect(project.clusters.first.managed?).to be_truthy
- expect(project.clusters.first.namespace_per_environment?).to be_falsy
- end
-
- context 'when legacy_abac param is false' do
- let(:legacy_abac_param) { 'false' }
-
- it 'creates a new cluster with legacy_abac_disabled' do
- expect(ClusterProvisionWorker).to receive(:perform_async)
- expect { go }.to change { Clusters::Cluster.count }
- .and change { Clusters::Providers::Gcp.count }
- expect(project.clusters.first.provider_gcp).not_to be_legacy_abac
- end
- end
- end
-
- context 'when access token is expired' do
- before do
- stub_google_api_expired_token
- end
-
- it { expect(@valid_gcp_token).to be_falsey }
- end
-
- context 'when access token is not stored in session' do
- it { expect(@valid_gcp_token).to be_falsey }
- end
- end
-
- describe 'security' do
- before do
- allow_any_instance_of(described_class)
- .to receive(:token_in_session).and_return('token')
- allow_any_instance_of(described_class)
- .to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s)
- allow_any_instance_of(GoogleApi::CloudPlatform::Client)
- .to receive(:projects_zones_clusters_create) do
- double(
- 'secure',
- self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123',
- status: 'RUNNING'
- )
- end
-
- allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
- end
-
- it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
- expect { go }.to be_allowed_for(:admin)
- end
- it 'is disabled for admin when admin mode disabled' do
- expect { go }.to be_denied_for(:admin)
- end
- it { expect { go }.to be_allowed_for(:owner).of(project) }
- it { expect { go }.to be_allowed_for(:maintainer).of(project) }
- it { expect { go }.to be_denied_for(:developer).of(project) }
- it { expect { go }.to be_denied_for(:reporter).of(project) }
- it { expect { go }.to be_denied_for(:guest).of(project) }
- it { expect { go }.to be_denied_for(:user) }
- it { expect { go }.to be_denied_for(:external) }
- end
- end
-
describe 'POST create for existing cluster' do
let(:params) do
{
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index f4cad5790a3..f63e0cea04c 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -208,6 +208,17 @@ RSpec.describe Projects::EnvironmentsController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
+
+ it_behaves_like 'avoids N+1 queries on environment detail page'
+
+ def create_deployment_with_associations(sequence:)
+ commit = project.commit("HEAD~#{sequence}")
+ create(:user, email: commit.author_email)
+
+ deployer = create(:user)
+ build = create(:ci_build, environment: environment.name, pipeline: create(:ci_pipeline, project: environment.project), user: deployer)
+ create(:deployment, :success, environment: environment, deployable: build, user: deployer, project: project, sha: commit.sha)
+ end
end
describe 'GET edit' do
diff --git a/spec/controllers/projects/error_tracking/projects_controller_spec.rb b/spec/controllers/projects/error_tracking/projects_controller_spec.rb
index 67947d1c9d9..7529c701b2b 100644
--- a/spec/controllers/projects/error_tracking/projects_controller_spec.rb
+++ b/spec/controllers/projects/error_tracking/projects_controller_spec.rb
@@ -6,18 +6,21 @@ RSpec.describe Projects::ErrorTracking::ProjectsController do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
+ before_all do
+ project.add_maintainer(user)
+ end
+
before do
sign_in(user)
- project.add_maintainer(user)
end
describe 'GET #index' do
context 'with insufficient permissions' do
- before do
- project.add_guest(user)
- end
+ let(:user) { create(:user) }
it 'returns 404' do
+ project.add_guest(user)
+
get :index, params: list_projects_params
expect(response).to have_gitlab_http_status(:not_found)
@@ -37,8 +40,8 @@ RSpec.describe Projects::ErrorTracking::ProjectsController do
end
context 'with authorized user' do
- let(:list_projects_service) { spy(:list_projects_service) }
- let(:sentry_project) { build(:error_tracking_project) }
+ let(:list_projects_service) { instance_double('ErrorTracking::ListProjectsService') }
+ let(:sentry_project) { build_stubbed(:error_tracking_project) }
let(:query_params) do
list_projects_params.slice(:api_host, :token)
@@ -50,9 +53,9 @@ RSpec.describe Projects::ErrorTracking::ProjectsController do
.and_return(list_projects_service)
end
- context 'service result is successful' do
+ context 'when service result is successful' do
before do
- expect(list_projects_service).to receive(:execute)
+ allow(list_projects_service).to receive(:execute)
.and_return(status: :success, projects: [sentry_project])
end
@@ -65,12 +68,12 @@ RSpec.describe Projects::ErrorTracking::ProjectsController do
end
end
- context 'service result is erroneous' do
+ context 'with service result is erroneous' do
let(:error_message) { 'error message' }
context 'without http_status' do
before do
- expect(list_projects_service).to receive(:execute)
+ allow(list_projects_service).to receive(:execute)
.and_return(status: :error, message: error_message)
end
@@ -86,7 +89,7 @@ RSpec.describe Projects::ErrorTracking::ProjectsController do
let(:http_status) { :no_content }
before do
- expect(list_projects_service).to receive(:execute).and_return(
+ allow(list_projects_service).to receive(:execute).and_return(
status: :error,
message: error_message,
http_status: http_status
@@ -106,11 +109,7 @@ RSpec.describe Projects::ErrorTracking::ProjectsController do
private
def list_projects_params(opts = {})
- project_params(
- format: :json,
- api_host: 'gitlab.com',
- token: 'token'
- )
+ project_params(format: :json, api_host: 'gitlab.com', token: 'token')
end
end
diff --git a/spec/controllers/projects/error_tracking/stack_traces_controller_spec.rb b/spec/controllers/projects/error_tracking/stack_traces_controller_spec.rb
index 19b6b597a84..e011428adde 100644
--- a/spec/controllers/projects/error_tracking/stack_traces_controller_spec.rb
+++ b/spec/controllers/projects/error_tracking/stack_traces_controller_spec.rb
@@ -6,30 +6,34 @@ RSpec.describe Projects::ErrorTracking::StackTracesController do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
+ before_all do
+ project.add_maintainer(user)
+ end
+
before do
sign_in(user)
- project.add_maintainer(user)
end
describe 'GET #index' do
let(:issue_id) { non_existing_record_id }
- let(:issue_stack_trace_service) { spy(:issue_stack_trace_service) }
+ let(:issue_latest_event_service) { instance_double('ErrorTracking::IssueLatestEventService') }
subject(:get_stack_trace) do
get :index, params: { namespace_id: project.namespace, project_id: project, issue_id: issue_id, format: :json }
end
before do
- expect(ErrorTracking::IssueLatestEventService)
+ allow(ErrorTracking::IssueLatestEventService)
.to receive(:new).with(project, user, issue_id: issue_id.to_s)
- .and_return(issue_stack_trace_service)
- expect(issue_stack_trace_service).to receive(:execute).and_return(service_response)
+ .and_return(issue_latest_event_service)
+
+ allow(issue_latest_event_service).to receive(:execute).and_return(service_response)
get_stack_trace
end
- context 'awaiting data' do
- let(:service_response) { { status: :error, http_status: :no_content }}
+ context 'when awaiting data' do
+ let(:service_response) { { status: :error, http_status: :no_content } }
it 'responds with no data' do
expect(response).to have_gitlab_http_status(:no_content)
@@ -38,19 +42,14 @@ RSpec.describe Projects::ErrorTracking::StackTracesController do
it_behaves_like 'sets the polling header'
end
- context 'service result is successful' do
+ context 'when service result is successful' do
let(:service_response) { { status: :success, latest_event: error_event } }
- let(:error_event) { build(:error_tracking_sentry_error_event) }
+ let(:error_event) { build_stubbed(:error_tracking_sentry_error_event) }
- it 'responds with success' do
+ it 'highlights stack trace source code' do
expect(response).to have_gitlab_http_status(:ok)
- end
-
- it 'responds with error' do
expect(response).to match_response_schema('error_tracking/issue_stack_trace')
- end
- it 'highlights stack trace source code' do
expect(json_response['error']).to eq(
Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(error_event).as_json
)
@@ -59,7 +58,7 @@ RSpec.describe Projects::ErrorTracking::StackTracesController do
it_behaves_like 'sets the polling header'
end
- context 'service result is erroneous' do
+ context 'when service result is erroneous' do
let(:error_message) { 'error message' }
context 'without http_status' do
@@ -67,9 +66,6 @@ RSpec.describe Projects::ErrorTracking::StackTracesController do
it 'responds with bad request' do
expect(response).to have_gitlab_http_status(:bad_request)
- end
-
- it 'responds with error message' do
expect(json_response['message']).to eq(error_message)
end
end
@@ -80,9 +76,6 @@ RSpec.describe Projects::ErrorTracking::StackTracesController do
it 'responds with custom http status' do
expect(response).to have_gitlab_http_status(http_status)
- end
-
- it 'responds with error message' do
expect(json_response['message']).to eq(error_message)
end
end
diff --git a/spec/controllers/projects/error_tracking_controller_spec.rb b/spec/controllers/projects/error_tracking_controller_spec.rb
index b4f21e070c6..cf0e481495c 100644
--- a/spec/controllers/projects/error_tracking_controller_spec.rb
+++ b/spec/controllers/projects/error_tracking_controller_spec.rb
@@ -6,9 +6,12 @@ RSpec.describe Projects::ErrorTrackingController do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
+ before_all do
+ project.add_maintainer(user)
+ end
+
before do
sign_in(user)
- project.add_maintainer(user)
end
describe 'GET #index' do
@@ -46,18 +49,18 @@ RSpec.describe Projects::ErrorTrackingController do
end
describe 'format json' do
- let(:list_issues_service) { spy(:list_issues_service) }
+ let(:list_issues_service) { instance_double('ErrorTracking::ListIssuesService') }
let(:external_url) { 'http://example.com' }
- context 'no data' do
+ context 'with no data' do
let(:permitted_params) { permit_index_parameters!({}) }
before do
- expect(ErrorTracking::ListIssuesService)
+ allow(ErrorTracking::ListIssuesService)
.to receive(:new).with(project, user, permitted_params)
.and_return(list_issues_service)
- expect(list_issues_service).to receive(:execute)
+ allow(list_issues_service).to receive(:execute)
.and_return(status: :error, http_status: :no_content)
end
@@ -76,22 +79,22 @@ RSpec.describe Projects::ErrorTrackingController do
let(:permitted_params) { permit_index_parameters!(search_term: search_term, sort: sort, cursor: cursor) }
before do
- expect(ErrorTracking::ListIssuesService)
+ allow(ErrorTracking::ListIssuesService)
.to receive(:new).with(project, user, permitted_params)
.and_return(list_issues_service)
end
- context 'service result is successful' do
+ context 'when service result is successful' do
before do
- expect(list_issues_service).to receive(:execute)
+ allow(list_issues_service).to receive(:execute)
.and_return(status: :success, issues: [error], pagination: {})
- expect(list_issues_service).to receive(:external_url)
+ allow(list_issues_service).to receive(:external_url)
.and_return(external_url)
get :index, params: params
end
- let(:error) { build(:error_tracking_sentry_error) }
+ let(:error) { build_stubbed(:error_tracking_sentry_error) }
it 'returns a list of errors' do
expect(response).to have_gitlab_http_status(:ok)
@@ -109,16 +112,16 @@ RSpec.describe Projects::ErrorTrackingController do
context 'without extra params' do
before do
- expect(ErrorTracking::ListIssuesService)
+ allow(ErrorTracking::ListIssuesService)
.to receive(:new).with(project, user, permit_index_parameters!({}))
.and_return(list_issues_service)
end
- context 'service result is successful' do
+ context 'when service result is successful' do
before do
- expect(list_issues_service).to receive(:execute)
+ allow(list_issues_service).to receive(:execute)
.and_return(status: :success, issues: [error], pagination: {})
- expect(list_issues_service).to receive(:external_url)
+ allow(list_issues_service).to receive(:external_url)
.and_return(external_url)
end
@@ -137,12 +140,12 @@ RSpec.describe Projects::ErrorTrackingController do
end
end
- context 'service result is erroneous' do
+ context 'when service result is erroneous' do
let(:error_message) { 'error message' }
context 'without http_status' do
before do
- expect(list_issues_service).to receive(:execute)
+ allow(list_issues_service).to receive(:execute)
.and_return(status: :error, message: error_message)
end
@@ -158,7 +161,7 @@ RSpec.describe Projects::ErrorTrackingController do
let(:http_status) { :no_content }
before do
- expect(list_issues_service).to receive(:execute).and_return(
+ allow(list_issues_service).to receive(:execute).and_return(
status: :error,
message: error_message,
http_status: http_status
@@ -189,7 +192,7 @@ RSpec.describe Projects::ErrorTrackingController do
describe 'GET #issue_details' do
let_it_be(:issue_id) { non_existing_record_id }
- let(:issue_details_service) { spy(:issue_details_service) }
+ let(:issue_details_service) { instance_double('ErrorTracking::IssueDetailsService') }
let(:permitted_params) do
ActionController::Parameters.new(
@@ -199,15 +202,15 @@ RSpec.describe Projects::ErrorTrackingController do
end
before do
- expect(ErrorTracking::IssueDetailsService)
+ allow(ErrorTracking::IssueDetailsService)
.to receive(:new).with(project, user, permitted_params)
.and_return(issue_details_service)
end
describe 'format json' do
- context 'no data' do
+ context 'with no data' do
before do
- expect(issue_details_service).to receive(:execute)
+ allow(issue_details_service).to receive(:execute)
.and_return(status: :error, http_status: :no_content)
get :details, params: issue_params(issue_id: issue_id, format: :json)
end
@@ -219,15 +222,15 @@ RSpec.describe Projects::ErrorTrackingController do
it_behaves_like 'sets the polling header'
end
- context 'service result is successful' do
+ context 'when service result is successful' do
before do
- expect(issue_details_service).to receive(:execute)
+ allow(issue_details_service).to receive(:execute)
.and_return(status: :success, issue: error)
get :details, params: issue_params(issue_id: issue_id, format: :json)
end
- let(:error) { build(:error_tracking_sentry_detailed_error) }
+ let(:error) { build_stubbed(:error_tracking_sentry_detailed_error) }
it 'returns an error' do
expected_error = error.as_json.except('first_release_version').merge(
@@ -245,12 +248,12 @@ RSpec.describe Projects::ErrorTrackingController do
it_behaves_like 'sets the polling header'
end
- context 'service result is erroneous' do
+ context 'when service result is erroneous' do
let(:error_message) { 'error message' }
context 'without http_status' do
before do
- expect(issue_details_service).to receive(:execute)
+ allow(issue_details_service).to receive(:execute)
.and_return(status: :error, message: error_message)
end
@@ -266,7 +269,7 @@ RSpec.describe Projects::ErrorTrackingController do
let(:http_status) { :no_content }
before do
- expect(issue_details_service).to receive(:execute).and_return(
+ allow(issue_details_service).to receive(:execute).and_return(
status: :error,
message: error_message,
http_status: http_status
@@ -286,7 +289,7 @@ RSpec.describe Projects::ErrorTrackingController do
describe 'PUT #update' do
let(:issue_id) { non_existing_record_id }
- let(:issue_update_service) { spy(:issue_update_service) }
+ let(:issue_update_service) { instance_double('ErrorTracking::IssueUpdateService') }
let(:permitted_params) do
ActionController::Parameters.new(
{ issue_id: issue_id.to_s, status: 'resolved' }
@@ -298,15 +301,15 @@ RSpec.describe Projects::ErrorTrackingController do
end
before do
- expect(ErrorTracking::IssueUpdateService)
+ allow(ErrorTracking::IssueUpdateService)
.to receive(:new).with(project, user, permitted_params)
.and_return(issue_update_service)
end
describe 'format json' do
- context 'update result is successful' do
+ context 'when update result is successful' do
before do
- expect(issue_update_service).to receive(:execute)
+ allow(issue_update_service).to receive(:execute)
.and_return(status: :success, updated: true, closed_issue_iid: non_existing_record_iid)
update_issue
@@ -318,11 +321,11 @@ RSpec.describe Projects::ErrorTrackingController do
end
end
- context 'update result is erroneous' do
+ context 'when update result is erroneous' do
let(:error_message) { 'error message' }
before do
- expect(issue_update_service).to receive(:execute)
+ allow(issue_update_service).to receive(:execute)
.and_return(status: :error, message: error_message)
update_issue
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index ce0af784cdf..8a03c1e709b 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -12,6 +12,10 @@ RSpec.describe Projects::IssuesController do
let(:issue) { create(:issue, project: project) }
let(:spam_action_response_fields) { { 'stub_spam_action_response_fields' => true } }
+ before do
+ stub_feature_flags(vue_issues_list: true)
+ end
+
describe "GET #index" do
context 'external issue tracker' do
before do
@@ -72,22 +76,6 @@ RSpec.describe Projects::IssuesController do
project.add_developer(user)
end
- context 'when issues_full_text_search is disabled' do
- before do
- stub_feature_flags(issues_full_text_search: false)
- end
-
- it_behaves_like 'issuables list meta-data', :issue
- end
-
- context 'when issues_full_text_search is enabled' do
- before do
- stub_feature_flags(issues_full_text_search: true)
- end
-
- it_behaves_like 'issuables list meta-data', :issue
- end
-
it_behaves_like 'set sort order from user preference' do
let(:sorting_param) { 'updated_asc' }
end
@@ -98,16 +86,6 @@ RSpec.describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(:ok)
end
- it 'returns only list type issues' do
- issue = create(:issue, project: project)
- incident = create(:issue, project: project, issue_type: 'incident')
- create(:issue, project: project, issue_type: 'test_case')
-
- get :index, params: { namespace_id: project.namespace, project_id: project }
-
- expect(assigns(:issues)).to contain_exactly(issue, incident)
- end
-
it "returns 301 if request path doesn't match project path" do
get :index, params: { namespace_id: project.namespace, project_id: project.path.upcase }
@@ -123,17 +101,10 @@ RSpec.describe Projects::IssuesController do
end
end
- it_behaves_like 'issuable list with anonymous search disabled' do
- let(:params) { { namespace_id: project.namespace, project_id: project } }
-
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- end
- end
-
- it_behaves_like 'paginated collection' do
+ describe 'pagination' do
let!(:issue_list) { create_list(:issue, 2, project: project) }
let(:collection) { project.issues }
+ let(:last_page) { collection.page.total_pages }
let(:params) do
{
namespace_id: project.namespace.to_param,
@@ -154,46 +125,6 @@ RSpec.describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(:redirect)
expect(response).to redirect_to(action: 'index', format: 'atom', page: last_page, state: 'opened')
end
-
- it 'does not use pagination if disabled' do
- allow(controller).to receive(:pagination_disabled?).and_return(true)
-
- get :index, params: params.merge(page: last_page + 1)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:issues).size).to eq(2)
- end
- end
-
- context 'with relative_position sorting' do
- let!(:issue_list) { create_list(:issue, 2, project: project) }
-
- before do
- sign_in(user)
- project.add_developer(user)
- allow(Kaminari.config).to receive(:default_per_page).and_return(1)
- end
-
- it 'overrides the number allowed on the page' do
- get :index,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- sort: 'relative_position'
- }
-
- expect(assigns(:issues).count).to eq 2
- end
-
- it 'allows the default number on the page' do
- get :index,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project
- }
-
- expect(assigns(:issues).count).to eq 1
- end
end
context 'external authorization' do
@@ -746,84 +677,6 @@ RSpec.describe Projects::IssuesController do
let_it_be(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) }
let_it_be(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignees: [assignee]) }
- describe 'GET #index' do
- it 'does not list confidential issues for guests' do
- sign_out(:user)
- get_issues
-
- expect(assigns(:issues)).to eq [issue]
- end
-
- it 'does not list confidential issues for non project members' do
- sign_in(non_member)
- get_issues
-
- expect(assigns(:issues)).to eq [issue]
- end
-
- it 'does not list confidential issues for project members with guest role' do
- sign_in(member)
- project.add_guest(member)
-
- get_issues
-
- expect(assigns(:issues)).to eq [issue]
- end
-
- it 'lists confidential issues for author' do
- sign_in(author)
- get_issues
-
- expect(assigns(:issues)).to include unescaped_parameter_value
- expect(assigns(:issues)).not_to include request_forgery_timing_attack
- end
-
- it 'lists confidential issues for assignee' do
- sign_in(assignee)
- get_issues
-
- expect(assigns(:issues)).not_to include unescaped_parameter_value
- expect(assigns(:issues)).to include request_forgery_timing_attack
- end
-
- it 'lists confidential issues for project members' do
- sign_in(member)
- project.add_developer(member)
-
- get_issues
-
- expect(assigns(:issues)).to include unescaped_parameter_value
- expect(assigns(:issues)).to include request_forgery_timing_attack
- end
-
- context 'when admin mode is enabled', :enable_admin_mode do
- it 'lists confidential issues for admin' do
- sign_in(admin)
- get_issues
-
- expect(assigns(:issues)).to include unescaped_parameter_value
- expect(assigns(:issues)).to include request_forgery_timing_attack
- end
- end
-
- context 'when admin mode is disabled' do
- it 'does not list confidential issues for admin' do
- sign_in(admin)
- get_issues
-
- expect(assigns(:issues)).to eq [issue]
- end
- end
-
- def get_issues
- get :index,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project
- }
- end
- end
-
shared_examples_for 'restricted action' do |http_status|
it 'returns 404 for guests' do
sign_out(:user)
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index e9f1232b5e7..162c36f5069 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -929,13 +929,13 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
context 'when continue url is present' do
let(:job) { create(:ci_build, :cancelable, pipeline: pipeline) }
+ before do
+ post_cancel(continue: { to: url })
+ end
+
context 'when continue to is a safe url' do
let(:url) { '/test' }
- before do
- post_cancel(continue: { to: url })
- end
-
it 'redirects to the continue url' do
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to(url)
@@ -949,8 +949,9 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
context 'when continue to is not a safe url' do
let(:url) { 'http://example.com' }
- it 'raises an error' do
- expect { cancel_with_redirect(url) }.to raise_error
+ it 'redirects to the builds page' do
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(builds_namespace_project_pipeline_path(id: pipeline.id))
end
end
end
diff --git a/spec/controllers/projects/logs_controller_spec.rb b/spec/controllers/projects/logs_controller_spec.rb
index d5c602df41d..1c81ae93b42 100644
--- a/spec/controllers/projects/logs_controller_spec.rb
+++ b/spec/controllers/projects/logs_controller_spec.rb
@@ -47,6 +47,20 @@ RSpec.describe Projects::LogsController do
expect(response).to be_ok
expect(response).to render_template 'index'
end
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(monitor_logging: false)
+ end
+
+ it 'returns 404 with reporter access' do
+ project.add_developer(user)
+
+ get :index, params: environment_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
shared_examples 'pod logs service' do |endpoint, service|
@@ -103,14 +117,6 @@ RSpec.describe Projects::LogsController do
expect(json_response).to eq(service_result_json)
end
- it 'registers a usage of the endpoint' do
- expect(::Gitlab::UsageCounters::PodLogs).to receive(:increment).with(project.id)
-
- get endpoint, params: environment_params(pod_name: pod_name, format: :json)
-
- expect(response).to have_gitlab_http_status(:success)
- end
-
it 'sets the polling header' do
get endpoint, params: environment_params(pod_name: pod_name, format: :json)
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 8fae82d54a2..1be4177acd1 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -19,6 +19,27 @@ RSpec.describe Projects::PipelinesController do
sign_in(user)
end
+ shared_examples 'the show page' do |param|
+ it 'redirects to pipeline path with param' do
+ get param, params: { namespace_id: project.namespace, project_id: project, id: pipeline }
+
+ expect(response).to redirect_to(pipeline_path(pipeline, tab: param))
+ end
+
+ context 'when the FF pipeline_tabs_vue is disabled' do
+ before do
+ stub_feature_flags(pipeline_tabs_vue: false)
+ end
+
+ it 'renders the show template' do
+ get param, params: { namespace_id: project.namespace, project_id: project, id: pipeline }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template :show
+ end
+ end
+ end
+
describe 'GET index.json' do
before do
create_all_pipeline_types
@@ -625,6 +646,12 @@ RSpec.describe Projects::PipelinesController do
end
end
+ describe 'GET dag' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ it_behaves_like 'the show page', 'dag'
+ end
+
describe 'GET dag.json' do
let(:pipeline) { create(:ci_pipeline, project: project) }
@@ -658,6 +685,49 @@ RSpec.describe Projects::PipelinesController do
end
end
+ describe 'GET builds' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ it_behaves_like 'the show page', 'builds'
+ end
+
+ describe 'GET failures' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ context 'with ff `pipeline_tabs_vue` disabled' do
+ before do
+ stub_feature_flags(pipeline_tabs_vue: false)
+ end
+
+ context 'with failed jobs' do
+ before do
+ create(:ci_build, :failed, pipeline: pipeline, name: 'hello')
+ end
+
+ it 'shows the page' do
+ get :failures, params: { namespace_id: project.namespace, project_id: project, id: pipeline }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template :show
+ end
+ end
+
+ context 'without failed jobs' do
+ it 'redirects to the main pipeline page' do
+ get :failures, params: { namespace_id: project.namespace, project_id: project, id: pipeline }
+
+ expect(response).to redirect_to(pipeline_path(pipeline))
+ end
+ end
+ end
+
+ it 'redirects to the pipeline page with `failures` query param' do
+ get :failures, params: { namespace_id: project.namespace, project_id: project, id: pipeline }
+
+ expect(response).to redirect_to(pipeline_path(pipeline, tab: 'failures'))
+ end
+ end
+
describe 'GET stages.json' do
let(:pipeline) { create(:ci_pipeline, project: project) }
@@ -988,6 +1058,12 @@ RSpec.describe Projects::PipelinesController do
end
end
+ describe 'GET test_report' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ it_behaves_like 'the show page', 'test_report'
+ end
+
describe 'GET test_report.json' do
let(:pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/controllers/projects/prometheus/alerts_controller_spec.rb b/spec/controllers/projects/prometheus/alerts_controller_spec.rb
index d66ad445c32..f42119e7811 100644
--- a/spec/controllers/projects/prometheus/alerts_controller_spec.rb
+++ b/spec/controllers/projects/prometheus/alerts_controller_spec.rb
@@ -226,137 +226,6 @@ RSpec.describe Projects::Prometheus::AlertsController do
end
end
- describe 'POST #create' do
- let(:schedule_update_service) { spy }
-
- let(:alert_params) do
- {
- 'title' => metric.title,
- 'query' => metric.query,
- 'operator' => '>',
- 'threshold' => 1.0,
- 'runbook_url' => 'https://sample.runbook.com'
- }
- end
-
- def make_request(opts = {})
- post :create, params: request_params(
- opts,
- operator: '>',
- threshold: '1',
- runbook_url: 'https://sample.runbook.com',
- environment_id: environment,
- prometheus_metric_id: metric
- )
- end
-
- it 'creates a new prometheus alert' do
- allow(::Clusters::Applications::ScheduleUpdateService)
- .to receive(:new).and_return(schedule_update_service)
-
- make_request
-
- expect(schedule_update_service).to have_received(:execute)
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to include(alert_params)
- end
-
- it 'returns bad_request for an invalid metric' do
- make_request(prometheus_metric_id: 'invalid')
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
-
- it_behaves_like 'unprivileged'
- it_behaves_like 'project non-specific environment', :bad_request
- end
-
- describe 'PUT #update' do
- let(:schedule_update_service) { spy }
-
- let(:alert) do
- create(:prometheus_alert,
- project: project,
- environment: environment,
- prometheus_metric: metric)
- end
-
- let(:alert_params) do
- {
- 'id' => alert.id,
- 'title' => alert.title,
- 'query' => alert.query,
- 'operator' => '<',
- 'threshold' => alert.threshold,
- 'alert_path' => alert_path(alert)
- }
- end
-
- before do
- allow(::Clusters::Applications::ScheduleUpdateService)
- .to receive(:new).and_return(schedule_update_service)
- end
-
- def make_request(opts = {})
- put :update, params: request_params(
- opts,
- id: alert.prometheus_metric_id,
- operator: '<',
- environment_id: alert.environment
- )
- end
-
- it 'updates an already existing prometheus alert' do
- expect { make_request(operator: '<') }
- .to change { alert.reload.operator }.to('lt')
-
- expect(schedule_update_service).to have_received(:execute)
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to include(alert_params)
- end
-
- it 'returns bad_request for an invalid alert data' do
- make_request(runbook_url: 'bad-url')
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
-
- it_behaves_like 'unprivileged'
- it_behaves_like 'project non-specific environment', :not_found
- it_behaves_like 'project non-specific metric', :not_found
- end
-
- describe 'DELETE #destroy' do
- let(:schedule_update_service) { spy }
-
- let!(:alert) do
- create(:prometheus_alert, project: project, prometheus_metric: metric)
- end
-
- before do
- allow(::Clusters::Applications::ScheduleUpdateService)
- .to receive(:new).and_return(schedule_update_service)
- end
-
- def make_request(opts = {})
- delete :destroy, params: request_params(
- opts,
- id: alert.prometheus_metric_id,
- environment_id: alert.environment
- )
- end
-
- it 'destroys the specified prometheus alert' do
- expect { make_request }.to change { PrometheusAlert.count }.by(-1)
-
- expect(schedule_update_service).to have_received(:execute)
- end
-
- it_behaves_like 'unprivileged'
- it_behaves_like 'project non-specific environment', :not_found
- it_behaves_like 'project non-specific metric', :not_found
- end
-
describe 'GET #metrics_dashboard' do
let!(:alert) do
create(:prometheus_alert,
diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb
index 9dd18e58109..0dba7dab643 100644
--- a/spec/controllers/projects/releases_controller_spec.rb
+++ b/spec/controllers/projects/releases_controller_spec.rb
@@ -78,14 +78,12 @@ RSpec.describe Projects::ReleasesController do
end
describe 'GET #index' do
- before do
- get_index
- end
-
context 'as html' do
let(:format) { :html }
it 'returns a text/html content_type' do
+ get_index
+
expect(response.media_type).to eq 'text/html'
end
@@ -95,6 +93,8 @@ RSpec.describe Projects::ReleasesController do
let(:project) { private_project }
it 'returns a redirect' do
+ get_index
+
expect(response).to have_gitlab_http_status(:redirect)
end
end
@@ -104,11 +104,24 @@ RSpec.describe Projects::ReleasesController do
let(:format) { :json }
it 'returns an application/json content_type' do
+ get_index
+
expect(response.media_type).to eq 'application/json'
end
it "returns the project's releases as JSON, ordered by released_at" do
- expect(response.body).to eq([release_2, release_1].to_json)
+ get_index
+
+ expect(json_response.map { |release| release["id"] } ).to eq([release_2.id, release_1.id])
+ end
+
+ # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/360903
+ it "returns release sha when remove_sha_from_releases_json is disabled" do
+ stub_feature_flags(remove_sha_from_releases_json: false)
+
+ get_index
+
+ expect(json_response).to eq([release_2, release_1].as_json)
end
it_behaves_like 'common access controls'
@@ -117,6 +130,8 @@ RSpec.describe Projects::ReleasesController do
let(:project) { private_project }
it 'returns a redirect' do
+ get_index
+
expect(response).to have_gitlab_http_status(:redirect)
end
end
diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb
deleted file mode 100644
index f8cee09006c..00000000000
--- a/spec/controllers/projects/serverless/functions_controller_spec.rb
+++ /dev/null
@@ -1,341 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Serverless::FunctionsController do
- include KubernetesHelpers
- include ReactiveCachingHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
- let(:service) { cluster.platform_kubernetes }
- let(:environment) { create(:environment, project: project) }
- let!(:deployment) { create(:deployment, :success, environment: environment, cluster: cluster) }
- let(:knative_services_finder) { environment.knative_services_finder }
- let(:function_description) { 'A serverless function' }
- let(:function_name) { 'some-function-name' }
- let(:knative_stub_options) do
- { namespace: namespace.namespace, name: function_name, description: function_description }
- end
-
- let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
-
- let(:namespace) do
- create(:cluster_kubernetes_namespace,
- cluster: cluster,
- cluster_project: cluster.cluster_project,
- project: cluster.cluster_project.project,
- environment: environment)
- end
-
- before do
- project.add_maintainer(user)
- sign_in(user)
- end
-
- def params(opts = {})
- opts.reverse_merge(namespace_id: project.namespace.to_param,
- project_id: project.to_param)
- end
-
- shared_examples_for 'behind :deprecated_serverless feature flag' do
- before do
- stub_feature_flags(deprecated_serverless: false)
- end
-
- it 'returns 404' do
- action
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- describe 'GET #index' do
- let(:expected_json) { { 'knative_installed' => knative_state, 'functions' => functions } }
-
- it_behaves_like 'behind :deprecated_serverless feature flag' do
- let(:action) { get :index, params: params({ format: :json }) }
- end
-
- context 'when cache is being read' do
- let(:knative_state) { 'checking' }
- let(:functions) { [] }
-
- before do
- get :index, params: params({ format: :json })
- end
-
- it 'returns checking' do
- expect(json_response).to eq expected_json
- end
-
- it { expect(response).to have_gitlab_http_status(:ok) }
- end
-
- context 'when cache is ready' do
- let(:knative_state) { true }
-
- before do
- allow(Clusters::KnativeServicesFinder)
- .to receive(:new)
- .and_return(knative_services_finder)
- synchronous_reactive_cache(knative_services_finder)
- stub_kubeclient_service_pods(
- kube_response({ "kind" => "PodList", "items" => [] }),
- namespace: namespace.namespace
- )
- end
-
- context 'when no functions were found' do
- let(:functions) { [] }
-
- before do
- stub_kubeclient_knative_services(
- namespace: namespace.namespace,
- response: kube_response({ "kind" => "ServiceList", "items" => [] })
- )
- get :index, params: params({ format: :json })
- end
-
- it 'returns checking' do
- expect(json_response).to eq expected_json
- end
-
- it { expect(response).to have_gitlab_http_status(:ok) }
- end
-
- context 'when functions were found' do
- let(:functions) { [{}, {}] }
-
- before do
- stub_kubeclient_knative_services(namespace: namespace.namespace, cluster_id: cluster.id, name: function_name)
- end
-
- it 'returns functions' do
- get :index, params: params({ format: :json })
- expect(json_response["functions"]).not_to be_empty
- end
-
- it 'filters out the functions whose cluster the user does not have permission to read' do
- allow(controller).to receive(:can?).and_return(true)
- expect(controller).to receive(:can?).with(user, :read_cluster, cluster).and_return(false)
-
- get :index, params: params({ format: :json })
-
- expect(json_response["functions"]).to be_empty
- end
-
- it 'returns a successful response status' do
- get :index, params: params({ format: :json })
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- context 'when there is serverless domain for a cluster' do
- let!(:serverless_domain_cluster) do
- create(:serverless_domain_cluster, clusters_applications_knative_id: knative.id)
- end
-
- it 'returns JSON with function details with serverless domain URL' do
- get :index, params: params({ format: :json })
- expect(response).to have_gitlab_http_status(:ok)
-
- expect(json_response["functions"]).not_to be_empty
-
- expect(json_response["functions"]).to all(
- include(
- 'url' => "https://#{function_name}-#{serverless_domain_cluster.uuid[0..1]}a1#{serverless_domain_cluster.uuid[2..-3]}f2#{serverless_domain_cluster.uuid[-2..]}#{"%x" % environment.id}-#{environment.slug}.#{serverless_domain_cluster.domain}"
- )
- )
- end
- end
-
- context 'when there is no serverless domain for a cluster' do
- it 'keeps function URL as it was' do
- expect(::Serverless::Domain).not_to receive(:new)
-
- get :index, params: params({ format: :json })
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
- end
- end
-
- describe 'GET #show' do
- it_behaves_like 'behind :deprecated_serverless feature flag' do
- let(:action) { get :show, params: params({ format: :json, environment_id: "*", id: "foo" }) }
- end
-
- context 'with function that does not exist' do
- it 'returns 404' do
- get :show, params: params({ format: :json, environment_id: "*", id: "foo" })
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'with valid data', :use_clean_rails_memory_store_caching do
- shared_examples 'GET #show with valid data' do
- context 'when there is serverless domain for a cluster' do
- let!(:serverless_domain_cluster) do
- create(:serverless_domain_cluster, clusters_applications_knative_id: knative.id)
- end
-
- it 'returns JSON with function details with serverless domain URL' do
- get :show, params: params({ format: :json, environment_id: "*", id: function_name })
- expect(response).to have_gitlab_http_status(:ok)
-
- expect(json_response).to include(
- 'url' => "https://#{function_name}-#{serverless_domain_cluster.uuid[0..1]}a1#{serverless_domain_cluster.uuid[2..-3]}f2#{serverless_domain_cluster.uuid[-2..]}#{"%x" % environment.id}-#{environment.slug}.#{serverless_domain_cluster.domain}"
- )
- end
-
- it 'returns 404 when user does not have permission to read the cluster' do
- allow(controller).to receive(:can?).and_return(true)
- expect(controller).to receive(:can?).with(user, :read_cluster, cluster).and_return(false)
-
- get :show, params: params({ format: :json, environment_id: "*", id: function_name })
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'when there is no serverless domain for a cluster' do
- it 'keeps function URL as it was' do
- get :show, params: params({ format: :json, environment_id: "*", id: function_name })
- expect(response).to have_gitlab_http_status(:ok)
-
- expect(json_response).to include(
- 'url' => "http://#{function_name}.#{namespace.namespace}.example.com"
- )
- end
- end
-
- it 'return json with function details' do
- get :show, params: params({ format: :json, environment_id: "*", id: function_name })
- expect(response).to have_gitlab_http_status(:ok)
-
- expect(json_response).to include(
- 'name' => function_name,
- 'url' => "http://#{function_name}.#{namespace.namespace}.example.com",
- 'description' => function_description,
- 'podcount' => 0
- )
- end
- end
-
- context 'on Knative 0.5.0' do
- before do
- prepare_knative_stubs(knative_05_service(**knative_stub_options))
- end
-
- include_examples 'GET #show with valid data'
- end
-
- context 'on Knative 0.6.0' do
- before do
- prepare_knative_stubs(knative_06_service(**knative_stub_options))
- end
-
- include_examples 'GET #show with valid data'
- end
-
- context 'on Knative 0.7.0' do
- before do
- prepare_knative_stubs(knative_07_service(**knative_stub_options))
- end
-
- include_examples 'GET #show with valid data'
- end
-
- context 'on Knative 0.9.0' do
- before do
- prepare_knative_stubs(knative_09_service(**knative_stub_options))
- end
-
- include_examples 'GET #show with valid data'
- end
- end
- end
-
- describe 'GET #metrics' do
- it_behaves_like 'behind :deprecated_serverless feature flag' do
- let(:action) { get :metrics, params: params({ format: :json, environment_id: "*", id: "foo" }) }
- end
-
- context 'invalid data' do
- it 'has a bad function name' do
- get :metrics, params: params({ format: :json, environment_id: "*", id: "foo" })
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
- end
-
- describe 'GET #index with data', :use_clean_rails_memory_store_caching do
- shared_examples 'GET #index with data' do
- it 'has data' do
- get :index, params: params({ format: :json })
-
- expect(response).to have_gitlab_http_status(:ok)
-
- expect(json_response).to match({
- 'knative_installed' => 'checking',
- 'functions' => [
- a_hash_including(
- 'name' => function_name,
- 'url' => "http://#{function_name}.#{namespace.namespace}.example.com",
- 'description' => function_description
- )
- ]
- })
- end
-
- it 'has data in html' do
- get :index, params: params
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
- context 'on Knative 0.5.0' do
- before do
- prepare_knative_stubs(knative_05_service(**knative_stub_options))
- end
-
- include_examples 'GET #index with data'
- end
-
- context 'on Knative 0.6.0' do
- before do
- prepare_knative_stubs(knative_06_service(**knative_stub_options))
- end
-
- include_examples 'GET #index with data'
- end
-
- context 'on Knative 0.7.0' do
- before do
- prepare_knative_stubs(knative_07_service(**knative_stub_options))
- end
-
- include_examples 'GET #index with data'
- end
-
- context 'on Knative 0.9.0' do
- before do
- prepare_knative_stubs(knative_09_service(**knative_stub_options))
- end
-
- include_examples 'GET #index with data'
- end
- end
-
- def prepare_knative_stubs(knative_service)
- stub_kubeclient_service_pods
- stub_reactive_cache(knative_services_finder,
- {
- services: [knative_service],
- pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
- },
- *knative_services_finder.cache_args)
- end
-end
diff --git a/spec/controllers/projects/service_ping_controller_spec.rb b/spec/controllers/projects/service_ping_controller_spec.rb
index 13b34290962..fa92efee079 100644
--- a/spec/controllers/projects/service_ping_controller_spec.rb
+++ b/spec/controllers/projects/service_ping_controller_spec.rb
@@ -79,6 +79,18 @@ RSpec.describe Projects::ServicePingController do
it_behaves_like 'counter is not increased'
it_behaves_like 'counter is increased', 'WEB_IDE_PREVIEWS_SUCCESS_COUNT'
+
+ context 'when the user has access to the project' do
+ let(:user) { project.owner }
+
+ it 'increases the live preview view counter' do
+ expect(Gitlab::UsageDataCounters::EditorUniqueCounter).to receive(:track_live_preview_edit_action).with(author: user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
context 'when web ide clientside preview is not enabled' do
diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb
index 7e96c59fbb1..6802ebeb63e 100644
--- a/spec/controllers/projects/services_controller_spec.rb
+++ b/spec/controllers/projects/services_controller_spec.rb
@@ -326,56 +326,6 @@ RSpec.describe Projects::ServicesController do
end
end
end
-
- context 'with Prometheus integration' do
- let_it_be(:prometheus_integration) { create(:prometheus_integration, project: project) }
-
- let(:integration) { prometheus_integration }
- let(:integration_params) { { manual_configuration: '1', api_url: 'http://example.com' } }
-
- context 'when feature flag :settings_operations_prometheus_service is enabled' do
- before do
- stub_feature_flags(settings_operations_prometheus_service: true)
- end
-
- it 'redirects user back to edit page with alert' do
- put :update, params: project_params.merge(service: integration_params)
-
- expect(response).to redirect_to(edit_project_integration_path(project, integration))
- expected_alert = [
- "You can now manage your Prometheus settings on the",
- %(<a href="#{project_settings_operations_path(project)}">Operations</a> page.),
- "Fields on this page have been deprecated."
- ].join(' ')
-
- expect(controller).to set_flash.now[:alert].to(expected_alert)
- end
-
- it 'does not modify integration' do
- expect { put :update, params: project_params.merge(service: integration_params) }
- .not_to change { prometheus_integration_as_data }
- end
-
- def prometheus_integration_as_data
- pi = project.prometheus_integration.reload
- attrs = pi.attributes.except('encrypted_properties',
- 'encrypted_properties_iv')
-
- [attrs, pi.properties]
- end
- end
-
- context 'when feature flag :settings_operations_prometheus_service is disabled' do
- before do
- stub_feature_flags(settings_operations_prometheus_service: false)
- end
-
- it 'modifies integration' do
- expect { put :update, params: project_params.merge(service: integration_params) }
- .to change { project.prometheus_integration.reload.attributes }
- end
- end
- end
end
describe 'GET #edit' do
@@ -392,38 +342,6 @@ RSpec.describe Projects::ServicesController do
end
end
end
-
- context 'with Prometheus service' do
- let(:integration_param) { 'prometheus' }
-
- context 'when feature flag :settings_operations_prometheus_service is enabled' do
- before do
- stub_feature_flags(settings_operations_prometheus_service: true)
- get :edit, params: project_params(id: integration_param)
- end
-
- it 'renders deprecation warning notice' do
- expected_alert = [
- "You can now manage your Prometheus settings on the",
- %(<a href="#{project_settings_operations_path(project)}">Operations</a> page.),
- "Fields on this page have been deprecated."
- ].join(' ')
-
- expect(controller).to set_flash.now[:alert].to(expected_alert)
- end
- end
-
- context 'when feature flag :settings_operations_prometheus_service is disabled' do
- before do
- stub_feature_flags(settings_operations_prometheus_service: false)
- get :edit, params: project_params(id: integration_param)
- end
-
- it 'does not render deprecation warning notice' do
- expect(controller).not_to set_flash.now[:alert]
- end
- end
- end
end
private
diff --git a/spec/controllers/projects/settings/operations_controller_spec.rb b/spec/controllers/projects/settings/operations_controller_spec.rb
index 7ef5371f2b5..c1fa91e9f8b 100644
--- a/spec/controllers/projects/settings/operations_controller_spec.rb
+++ b/spec/controllers/projects/settings/operations_controller_spec.rb
@@ -354,37 +354,6 @@ RSpec.describe Projects::Settings::OperationsController do
end
context 'prometheus integration' do
- describe 'PATCH #update' do
- let(:params) do
- {
- prometheus_integration_attributes: {
- manual_configuration: '0',
- api_url: 'https://gitlab.prometheus.rocks'
- }
- }
- end
-
- context 'feature flag :settings_operations_prometheus_service is enabled' do
- before do
- stub_feature_flags(settings_operations_prometheus_service: true)
- end
-
- it_behaves_like 'PATCHable'
- end
-
- context 'feature flag :settings_operations_prometheus_service is disabled' do
- before do
- stub_feature_flags(settings_operations_prometheus_service: false)
- end
-
- it_behaves_like 'PATCHable' do
- let(:permitted_params) do
- ActionController::Parameters.new(params.except(:prometheus_integration_attributes)).permit!
- end
- end
- end
- end
-
describe 'POST #reset_alerting_token' do
context 'with existing alerting setting' do
let!(:alerting_setting) do
diff --git a/spec/controllers/projects/tracings_controller_spec.rb b/spec/controllers/projects/tracings_controller_spec.rb
index 1f8a68cc861..80e21349e20 100644
--- a/spec/controllers/projects/tracings_controller_spec.rb
+++ b/spec/controllers/projects/tracings_controller_spec.rb
@@ -51,6 +51,16 @@ RSpec.describe Projects::TracingsController do
it_behaves_like 'user with read access', :public
it_behaves_like 'user with read access', :internal
it_behaves_like 'user with read access', :private
+
+ context 'feature flag disabled' do
+ before do
+ stub_feature_flags(monitor_tracing: false)
+ end
+
+ it_behaves_like 'user without read access', :public
+ it_behaves_like 'user without read access', :internal
+ it_behaves_like 'user without read access', :private
+ end
end
context 'without maintainer role' do
diff --git a/spec/controllers/projects/uploads_controller_spec.rb b/spec/controllers/projects/uploads_controller_spec.rb
index c008c7253d8..6d2db25ade2 100644
--- a/spec/controllers/projects/uploads_controller_spec.rb
+++ b/spec/controllers/projects/uploads_controller_spec.rb
@@ -54,6 +54,241 @@ RSpec.describe Projects::UploadsController do
end
end
+ describe "GET #show" do
+ let(:filename) { "rails_sample.jpg" }
+ let(:user) { create(:user) }
+ let(:jpg) { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') }
+ let(:txt) { fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain') }
+ let(:secret) { FileUploader.generate_secret }
+ let(:uploader_class) { FileUploader }
+
+ let(:upload_service) do
+ UploadService.new(model, jpg, uploader_class).execute
+ end
+
+ let(:show_upload) do
+ get :show, params: params.merge(secret: secret, filename: filename)
+ end
+
+ before do
+ allow(FileUploader).to receive(:generate_secret).and_return(secret)
+
+ allow_next_instance_of(FileUploader) do |instance|
+ allow(instance).to receive(:image?).and_return(true)
+ end
+
+ upload_service
+ end
+
+ context 'when project is private do' do
+ before do
+ model.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ context "when not signed in" do
+ context "enforce_auth_checks_on_uploads feature flag" do
+ context "with flag enabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: true)
+ end
+
+ context 'when the project has setting enforce_auth_checks_on_uploads true' do
+ before do
+ model.update!(enforce_auth_checks_on_uploads: true)
+ end
+
+ it "responds with status 302" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:redirect)
+ end
+ end
+
+ context 'when the project has setting enforce_auth_checks_on_uploads false' do
+ before do
+ model.update!(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context "with flag disabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context "when signed in" do
+ before do
+ sign_in(user)
+ end
+
+ context "when the user doesn't have access to the model" do
+ context "enforce_auth_checks_on_uploads feature flag" do
+ context "with flag enabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: true)
+ end
+
+ context 'when the project has setting enforce_auth_checks_on_uploads true' do
+ before do
+ model.update!(enforce_auth_checks_on_uploads: true)
+ end
+
+ it "responds with status 404" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when the project has setting enforce_auth_checks_on_uploads false' do
+ before do
+ model.update!(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context "with flag disabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+ end
+
+ context 'when project is public' do
+ before do
+ model.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
+ end
+
+ context "when not signed in" do
+ context "enforce_auth_checks_on_uploads feature flag" do
+ context "with flag enabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: true)
+ end
+
+ context 'when the project has setting enforce_auth_checks_on_uploads true' do
+ before do
+ model.update!(enforce_auth_checks_on_uploads: true)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when the project has setting enforce_auth_checks_on_uploads false' do
+ before do
+ model.update!(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context "with flag disabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context "when signed in" do
+ before do
+ sign_in(user)
+ end
+
+ context "when the user doesn't have access to the model" do
+ context "enforce_auth_checks_on_uploads feature flag" do
+ context "with flag enabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: true)
+ end
+
+ context 'when the project has setting enforce_auth_checks_on_uploads true' do
+ before do
+ model.update!(enforce_auth_checks_on_uploads: true)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when the project has setting enforce_auth_checks_on_uploads false' do
+ before do
+ model.update!(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context "with flag disabled" do
+ before do
+ stub_feature_flags(enforce_auth_checks_on_uploads: false)
+ end
+
+ it "responds with status 200" do
+ show_upload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+ end
+ end
+
def post_authorize(verified: true)
request.headers.merge!(workhorse_internal_api_request_header) if verified
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index 07bd198137a..537f7aa5fee 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -309,6 +309,35 @@ RSpec.describe ProjectsController do
expect(response.body).to have_content('LICENSE') # would be 'MIT license' if stub not works
end
+ describe 'tracking events', :snowplow do
+ before do
+ allow(controller).to receive(:current_user).and_return(user)
+ get_show
+ end
+
+ it 'tracks page views' do
+ expect_snowplow_event(
+ category: 'project_overview',
+ action: 'render',
+ user: user,
+ project: public_project
+ )
+ end
+
+ context 'when the project is importing' do
+ let_it_be(:public_project) { create(:project, :public, :import_scheduled) }
+
+ it 'does not track page views' do
+ expect_no_snowplow_event(
+ category: 'project_overview',
+ action: 'render',
+ user: user,
+ project: public_project
+ )
+ end
+ end
+ end
+
describe "PUC highlighting" do
render_views
@@ -834,7 +863,8 @@ RSpec.describe ProjectsController do
id: project.path,
project: {
project_setting_attributes: {
- show_default_award_emojis: boolean_value
+ show_default_award_emojis: boolean_value,
+ enforce_auth_checks_on_uploads: boolean_value
}
}
}
@@ -842,6 +872,7 @@ RSpec.describe ProjectsController do
project.reload
expect(project.show_default_award_emojis?).to eq(result)
+ expect(project.enforce_auth_checks_on_uploads?).to eq(result)
end
end
end
@@ -1423,6 +1454,41 @@ RSpec.describe ProjectsController do
expect(response).to have_gitlab_http_status(:found)
end
+
+ context 'when the project storage_size exceeds the application setting max_export_size' do
+ it 'returns 302 with alert' do
+ stub_application_setting(max_export_size: 1)
+ project.statistics.update!(lfs_objects_size: 2.megabytes, repository_size: 2.megabytes)
+
+ post action, params: { namespace_id: project.namespace, id: project }
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(flash[:alert]).to include('The project size exceeds the export limit.')
+ end
+ end
+
+ context 'when the project storage_size does not exceed the application setting max_export_size' do
+ it 'returns 302 without alert' do
+ stub_application_setting(max_export_size: 1)
+ project.statistics.update!(lfs_objects_size: 0.megabytes, repository_size: 0.megabytes)
+
+ post action, params: { namespace_id: project.namespace, id: project }
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(flash[:alert]).to be_nil
+ end
+ end
+
+ context 'when application setting max_export_size is not set' do
+ it 'returns 302 without alert' do
+ project.statistics.update!(lfs_objects_size: 2.megabytes, repository_size: 2.megabytes)
+
+ post action, params: { namespace_id: project.namespace, id: project }
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(flash[:alert]).to be_nil
+ end
+ end
end
context 'when project export is disabled' do
diff --git a/spec/controllers/repositories/lfs_storage_controller_spec.rb b/spec/controllers/repositories/lfs_storage_controller_spec.rb
index 7ddc5723e2e..672e6f1e85b 100644
--- a/spec/controllers/repositories/lfs_storage_controller_spec.rb
+++ b/spec/controllers/repositories/lfs_storage_controller_spec.rb
@@ -155,7 +155,7 @@ RSpec.describe Repositories::LfsStorageController do
context 'with an invalid file' do
let(:uploaded_file) { 'test' }
- it_behaves_like 'returning response status', :unprocessable_entity
+ it_behaves_like 'returning response status', :bad_request
end
context 'when an expected error' do
@@ -179,12 +179,10 @@ RSpec.describe Repositories::LfsStorageController do
end
context 'when existing file has been deleted' do
- let(:lfs_object) { create(:lfs_object, :with_file) }
+ let(:lfs_object) { create(:lfs_object, :with_file, size: params[:size], oid: params[:oid]) }
before do
FileUtils.rm(lfs_object.file.path)
- params[:oid] = lfs_object.oid
- params[:size] = lfs_object.size
end
it 'replaces the file' do
@@ -204,10 +202,10 @@ RSpec.describe Repositories::LfsStorageController do
end
end
- it 'renders LFS forbidden' do
+ it 'renders bad request' do
subject
- expect(response).to have_gitlab_http_status(:forbidden)
+ expect(response).to have_gitlab_http_status(:bad_request)
expect(lfs_object.reload.file).not_to exist
end
end
@@ -239,8 +237,9 @@ RSpec.describe Repositories::LfsStorageController do
FileUtils.mkdir_p(upload_path)
File.write(file_path, 'test')
+ File.truncate(file_path, params[:size].to_i)
- UploadedFile.new(file_path, filename: File.basename(file_path))
+ UploadedFile.new(file_path, filename: File.basename(file_path), sha256: params[:oid])
end
end
end
diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb
index ffcd759435c..c27e58634f6 100644
--- a/spec/controllers/uploads_controller_spec.rb
+++ b/spec/controllers/uploads_controller_spec.rb
@@ -703,13 +703,16 @@ RSpec.describe UploadsController do
end
context 'when viewing alert metric images' do
- let!(:user) { create(:user) }
- let!(:project) { create(:project) }
- let(:alert) { create(:alert_management_alert, project: project) }
- let(:metric_image) { create(:alert_metric_image, alert: alert) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project) }
+ let_it_be(:metric_image) { create(:alert_metric_image, alert: alert) }
- before do
+ before_all do
project.add_developer(user)
+ end
+
+ before do
sign_in(user)
end
diff --git a/spec/db/docs_spec.rb b/spec/db/docs_spec.rb
new file mode 100644
index 00000000000..20746e107fb
--- /dev/null
+++ b/spec/db/docs_spec.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Database Documentation' do
+ context 'for each table' do
+ let(:all_tables) do
+ Gitlab::Database.database_base_models.flat_map { |_, m| m.connection.tables }.sort.uniq
+ end
+
+ let(:metadata_required_fields) do
+ %i(
+ feature_categories
+ table_name
+ )
+ end
+
+ let(:metadata_allowed_fields) do
+ metadata_required_fields + %i(
+ classes
+ description
+ introduced_by_url
+ milestone
+ )
+ end
+
+ let(:metadata) do
+ all_tables.each_with_object({}) do |table_name, hash|
+ next unless File.exist?(table_metadata_file_path(table_name))
+
+ hash[table_name] ||= load_table_metadata(table_name)
+ end
+ end
+
+ let(:tables_without_metadata) do
+ all_tables.reject { |t| metadata.has_key?(t) }
+ end
+
+ let(:tables_without_valid_metadata) do
+ metadata.select { |_, t| t.has_key?(:error) }.keys
+ end
+
+ let(:tables_with_disallowed_fields) do
+ metadata.select { |_, t| t.has_key?(:disallowed_fields) }.keys
+ end
+
+ let(:tables_with_missing_required_fields) do
+ metadata.select { |_, t| t.has_key?(:missing_required_fields) }.keys
+ end
+
+ it 'has a metadata file' do
+ expect(tables_without_metadata).to be_empty, multiline_error(
+ 'Missing metadata files',
+ tables_without_metadata.map { |t| " #{table_metadata_file(t)}" }
+ )
+ end
+
+ it 'has a valid metadata file' do
+ expect(tables_without_valid_metadata).to be_empty, table_metadata_errors(
+ 'Table metadata files with errors',
+ :error,
+ tables_without_valid_metadata
+ )
+ end
+
+ it 'has a valid metadata file with allowed fields' do
+ expect(tables_with_disallowed_fields).to be_empty, table_metadata_errors(
+ 'Table metadata files with disallowed fields',
+ :disallowed_fields,
+ tables_with_disallowed_fields
+ )
+ end
+
+ it 'has a valid metadata file without missing fields' do
+ expect(tables_with_missing_required_fields).to be_empty, table_metadata_errors(
+ 'Table metadata files with missing fields',
+ :missing_required_fields,
+ tables_with_missing_required_fields
+ )
+ end
+ end
+
+ private
+
+ def table_metadata_file(table_name)
+ File.join('db', 'docs', "#{table_name}.yml")
+ end
+
+ def table_metadata_file_path(table_name)
+ Rails.root.join(table_metadata_file(table_name))
+ end
+
+ def load_table_metadata(table_name)
+ result = {}
+ begin
+ result[:metadata] = YAML.safe_load(File.read(table_metadata_file_path(table_name))).deep_symbolize_keys
+
+ disallowed_fields = (result[:metadata].keys - metadata_allowed_fields)
+ unless disallowed_fields.empty?
+ result[:disallowed_fields] = "fields not allowed: #{disallowed_fields.join(', ')}"
+ end
+
+ missing_required_fields = (metadata_required_fields - result[:metadata].reject { |_, v| v.blank? }.keys)
+ unless missing_required_fields.empty?
+ result[:missing_required_fields] = "missing required fields: #{missing_required_fields.join(', ')}"
+ end
+ rescue Psych::SyntaxError => ex
+ result[:error] = ex.message
+ end
+ result
+ end
+
+ def table_metadata_errors(title, field, tables)
+ lines = tables.map do |table_name|
+ <<~EOM
+ #{table_metadata_file(table_name)}
+ #{metadata[table_name][field]}
+ EOM
+ end
+
+ multiline_error(title, lines)
+ end
+
+ def multiline_error(title, lines)
+ <<~EOM
+ #{title}:
+
+ #{lines.join("\n")}
+ EOM
+ end
+end
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index 04f73050ea5..e21c73976a8 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe 'Database schema' do
approvals: %w[user_id],
approver_groups: %w[target_id],
approvers: %w[target_id user_id],
- analytics_cycle_analytics_aggregations: %w[last_full_issues_id last_full_merge_requests_id last_incremental_issues_id last_full_run_issues_id last_full_run_merge_requests_id last_incremental_merge_requests_id],
+ analytics_cycle_analytics_aggregations: %w[last_full_issues_id last_full_merge_requests_id last_incremental_issues_id last_full_run_issues_id last_full_run_merge_requests_id last_incremental_merge_requests_id last_consistency_check_issues_stage_event_hash_id last_consistency_check_issues_issuable_id last_consistency_check_merge_requests_stage_event_hash_id last_consistency_check_merge_requests_issuable_id],
analytics_cycle_analytics_merge_request_stage_events: %w[author_id group_id merge_request_id milestone_id project_id stage_event_hash_id state_id],
analytics_cycle_analytics_issue_stage_events: %w[author_id group_id issue_id milestone_id project_id stage_event_hash_id state_id],
audit_events: %w[author_id entity_id target_id],
@@ -47,7 +47,6 @@ RSpec.describe 'Database schema' do
events: %w[target_id],
forked_project_links: %w[forked_from_project_id],
geo_event_log: %w[hashed_storage_attachments_event_id],
- geo_job_artifact_deleted_events: %w[job_artifact_id],
geo_lfs_object_deleted_events: %w[lfs_object_id],
geo_node_statuses: %w[last_event_id cursor_last_event_id],
geo_nodes: %w[oauth_application_id],
@@ -79,7 +78,7 @@ RSpec.describe 'Database schema' do
repository_languages: %w[programming_language_id],
routes: %w[source_id],
sent_notifications: %w[project_id noteable_id recipient_id commit_id in_reply_to_discussion_id],
- slack_integrations: %w[team_id user_id],
+ slack_integrations: %w[team_id user_id bot_user_id], # these are external Slack IDs
snippets: %w[author_id],
spam_logs: %w[user_id],
status_check_responses: %w[external_approval_rule_id],
diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb
index 15b45099a06..13c12afc15d 100644
--- a/spec/experiments/application_experiment_spec.rb
+++ b/spec/experiments/application_experiment_spec.rb
@@ -8,13 +8,9 @@ RSpec.describe ApplicationExperiment, :experiment do
let(:context) { {} }
let(:feature_definition) { { name: 'namespaced_stub', type: 'experiment', default_enabled: false } }
- around do |example|
- Feature::Definition.definitions[:namespaced_stub] = Feature::Definition.new('namespaced_stub.yml', feature_definition)
- example.run
- Feature::Definition.definitions.delete(:namespaced_stub)
- end
-
before do
+ stub_feature_flag_definition(:namespaced_stub, feature_definition)
+
allow(application_experiment).to receive(:enabled?).and_return(true)
end
diff --git a/spec/experiments/concerns/project_commit_count_spec.rb b/spec/experiments/concerns/project_commit_count_spec.rb
index 5616f167cb4..f5969ad6241 100644
--- a/spec/experiments/concerns/project_commit_count_spec.rb
+++ b/spec/experiments/concerns/project_commit_count_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe ProjectCommitCount do
allow(Gitlab::GitalyClient).to receive(:call).and_call_original
allow(Gitlab::GitalyClient).to receive(:call).with(anything, :commit_service, :count_commits, anything, anything).and_raise(e = StandardError.new('_message_'))
- expect(Gitlab::ErrorTracking).to receive(:track_exception).with(e, caller_info: :identifiable)
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(e, { caller_info: :identifiable })
expect(subject).to eq(42)
end
diff --git a/spec/factories/alert_management/alerts.rb b/spec/factories/alert_management/alerts.rb
index 589a62a68bb..7e9e58edc1e 100644
--- a/spec/factories/alert_management/alerts.rb
+++ b/spec/factories/alert_management/alerts.rb
@@ -113,20 +113,6 @@ FactoryBot.define do
end
end
- trait :cilium do
- monitoring_tool { Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:cilium] }
- payload do
- {
- annotations: {
- title: 'This is a cilium alert',
- summary: 'Summary of the alert',
- description: 'Description of the alert'
- },
- startsAt: started_at
- }.with_indifferent_access
- end
- end
-
trait :all_fields do
with_incident
with_assignee
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index 122af139985..d6b1da1d5c2 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -79,6 +79,10 @@ FactoryBot.define do
status { :running }
end
+ trait :canceled do
+ status { :canceled }
+ end
+
trait :failed do
status { :failed }
end
diff --git a/spec/factories/ci/secure_files.rb b/spec/factories/ci/secure_files.rb
index 9198ea61d14..9afec5db858 100644
--- a/spec/factories/ci/secure_files.rb
+++ b/spec/factories/ci/secure_files.rb
@@ -2,7 +2,7 @@
FactoryBot.define do
factory :ci_secure_file, class: 'Ci::SecureFile' do
- name { 'filename' }
+ sequence(:name) { |n| "file#{n}" }
file { fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks', 'application/octet-stream') }
checksum { 'foo1234' }
project
diff --git a/spec/factories/clusters/agent_tokens.rb b/spec/factories/clusters/agent_tokens.rb
index 03f765123db..3ca6c95d0df 100644
--- a/spec/factories/clusters/agent_tokens.rb
+++ b/spec/factories/clusters/agent_tokens.rb
@@ -3,6 +3,7 @@
FactoryBot.define do
factory :cluster_agent_token, class: 'Clusters::AgentToken' do
association :agent, factory: :cluster_agent
+ association :created_by_user, factory: :user
token_encrypted { Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50)) }
diff --git a/spec/factories/deploy_tokens.rb b/spec/factories/deploy_tokens.rb
index b2c478fd3fe..a2116b738fd 100644
--- a/spec/factories/deploy_tokens.rb
+++ b/spec/factories/deploy_tokens.rb
@@ -2,7 +2,6 @@
FactoryBot.define do
factory :deploy_token do
- token { nil }
token_encrypted { Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50)) }
sequence(:name) { |n| "PDT #{n}" }
read_repository { true }
diff --git a/spec/factories/incident_management/timeline_events.rb b/spec/factories/incident_management/timeline_events.rb
new file mode 100644
index 00000000000..e2e216d24b8
--- /dev/null
+++ b/spec/factories/incident_management/timeline_events.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :incident_management_timeline_event, class: 'IncidentManagement::TimelineEvent' do
+ association :project
+ association :author, factory: :user
+ association :incident
+ association :promoted_from_note, factory: :note
+ occurred_at { Time.current }
+ note { 'timeline created' }
+ note_html { '<strong>timeline created</strong>' }
+ action { 'comment' }
+ end
+end
diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb
index 6b800e3d790..a7478ce2657 100644
--- a/spec/factories/keys.rb
+++ b/spec/factories/keys.rb
@@ -5,6 +5,16 @@ FactoryBot.define do
title
key { SSHData::PrivateKey::RSA.generate(1024, unsafe_allow_small_key: true).public_key.openssh(comment: 'dummy@gitlab.com') }
+ trait :expired do
+ to_create { |key| key.save!(validate: false) }
+ expires_at { 2.days.ago }
+ end
+
+ trait :expired_today do
+ to_create { |key| key.save!(validate: false) }
+ expires_at { Date.today.beginning_of_day + 3.hours }
+ end
+
factory :key_without_comment do
key { SSHData::PrivateKey::RSA.generate(1024, unsafe_allow_small_key: true).public_key.openssh }
end
diff --git a/spec/factories/namespace_ci_cd_settings.rb b/spec/factories/namespace_ci_cd_settings.rb
new file mode 100644
index 00000000000..0e58a19ee8d
--- /dev/null
+++ b/spec/factories/namespace_ci_cd_settings.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :namespace_ci_cd_settings, class: 'NamespaceCiCdSetting' do
+ namespace
+ end
+end
diff --git a/spec/factories/packages/cleanup/policies.rb b/spec/factories/packages/cleanup/policies.rb
new file mode 100644
index 00000000000..80baa2f78bd
--- /dev/null
+++ b/spec/factories/packages/cleanup/policies.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :packages_cleanup_policy, class: 'Packages::Cleanup::Policy' do
+ project
+
+ keep_n_duplicated_package_files { '10' }
+
+ trait :runnable do
+ after(:create) do |policy|
+ # next_run_at will be set before_save to Time.now + cadence, so this ensures the policy is active
+ policy.update_column(:next_run_at, Time.zone.now - 1.day)
+ end
+ end
+ end
+end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index b3395758729..c3c02782578 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -183,6 +183,10 @@ FactoryBot.define do
request_access_enabled { false }
end
+ trait :with_namespace_settings do
+ namespace factory: [:namespace, :with_namespace_settings]
+ end
+
trait :with_avatar do
avatar { fixture_file_upload('spec/fixtures/dk.png') }
end
@@ -304,6 +308,8 @@ FactoryBot.define do
trait :wiki_repo do
after(:create) do |project|
+ stub_feature_flags(main_branch_over_master: false)
+
raise 'Failed to create wiki repository!' unless project.create_wiki
end
end
diff --git a/spec/factories/topics.rb b/spec/factories/topics.rb
index e77441d9eae..a6e614e0c66 100644
--- a/spec/factories/topics.rb
+++ b/spec/factories/topics.rb
@@ -3,5 +3,6 @@
FactoryBot.define do
factory :topic, class: 'Projects::Topic' do
name { generate(:name) }
+ title { generate(:title) }
end
end
diff --git a/spec/factories/users/in_product_marketing_email.rb b/spec/factories/users/in_product_marketing_email.rb
index c86c469ff31..42309319bf3 100644
--- a/spec/factories/users/in_product_marketing_email.rb
+++ b/spec/factories/users/in_product_marketing_email.rb
@@ -6,5 +6,11 @@ FactoryBot.define do
track { 'create' }
series { 0 }
+
+ trait :campaign do
+ track { nil }
+ series { nil }
+ campaign { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
+ end
end
end
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index 3b3289a8487..90dde7340d5 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -212,7 +212,7 @@ RSpec.describe 'Admin Groups' do
it do
visit admin_group_path(group)
- select2(user_selector, from: '#user_ids', multiple: true)
+ select2(user_selector, from: '#user_id', multiple: true)
page.within '#new_project_member' do
select2(Gitlab::Access::REPORTER, from: '#access_level')
end
diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb
index b0737377de0..2166edf65ff 100644
--- a/spec/features/admin/admin_projects_spec.rb
+++ b/spec/features/admin/admin_projects_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe "Admin::Projects" do
include Spec::Support::Helpers::ModalHelpers
let(:user) { create :user }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :with_namespace_settings) }
let(:current_user) { create(:admin) }
before do
@@ -82,7 +82,7 @@ RSpec.describe "Admin::Projects" do
describe 'transfer project' do
# The gitlab-shell transfer will fail for a project without a repository
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :with_namespace_settings) }
before do
create(:group, name: 'Web')
diff --git a/spec/features/admin/admin_requests_profiles_spec.rb b/spec/features/admin/admin_requests_profiles_spec.rb
deleted file mode 100644
index e92528d431d..00000000000
--- a/spec/features/admin/admin_requests_profiles_spec.rb
+++ /dev/null
@@ -1,136 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Admin::RequestsProfilesController' do
- let(:tmpdir) { Dir.mktmpdir('profiler-test') }
-
- before do
- stub_const('Gitlab::RequestProfiler::PROFILES_DIR', tmpdir)
- admin = create(:admin)
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- end
-
- after do
- FileUtils.rm_rf(tmpdir)
- end
-
- describe 'GET /admin/requests_profiles' do
- it 'shows the current profile token' do
- allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
-
- visit admin_requests_profiles_path
-
- expect(page).to have_content("X-Profile-Token: #{Gitlab::RequestProfiler.profile_token}")
- end
-
- context 'when having multiple profiles' do
- let(:time1) { 1.hour.ago }
- let(:time2) { 2.hours.ago }
-
- let(:profiles) do
- [
- {
- request_path: '/gitlab-org/gitlab-foss',
- name: "|gitlab-org|gitlab-foss_#{time1.to_i}_execution.html",
- created: time1,
- profile_mode: 'Execution'
- },
- {
- request_path: '/gitlab-org/gitlab-foss',
- name: "|gitlab-org|gitlab-foss_#{time2.to_i}_execution.html",
- created: time2,
- profile_mode: 'Execution'
- },
- {
- request_path: '/gitlab-org/gitlab-foss',
- name: "|gitlab-org|gitlab-foss_#{time1.to_i}_memory.html",
- created: time1,
- profile_mode: 'Memory'
- },
- {
- request_path: '/gitlab-org/gitlab-foss',
- name: "|gitlab-org|gitlab-foss_#{time2.to_i}_memory.html",
- created: time2,
- profile_mode: 'Memory'
- },
- {
- request_path: '/gitlab-org/infrastructure',
- name: "|gitlab-org|infrastructure_#{time1.to_i}_execution.html",
- created: time1,
- profile_mode: 'Execution'
- },
- {
- request_path: '/gitlab-org/infrastructure',
- name: "|gitlab-org|infrastructure_#{time2.to_i}_memory.html",
- created: time2,
- profile_mode: 'Memory'
- },
- {
- request_path: '/gitlab-org/infrastructure',
- name: "|gitlab-org|infrastructure_#{time2.to_i}.html",
- created: time2,
- profile_mode: 'Unknown'
- }
- ]
- end
-
- before do
- profiles.each do |profile|
- FileUtils.touch(File.join(Gitlab::RequestProfiler::PROFILES_DIR, profile[:name]))
- end
- end
-
- it 'lists all available profiles' do
- visit admin_requests_profiles_path
-
- profiles.each do |profile|
- within('.card', text: profile[:request_path]) do
- expect(page).to have_selector(
- "a[href='#{admin_requests_profile_path(profile[:name])}']",
- text: "#{profile[:created].to_s(:long)} #{profile[:profile_mode]}")
- end
- end
- end
- end
- end
-
- describe 'GET /admin/requests_profiles/:profile' do
- context 'when a profile exists' do
- before do
- File.write("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile}", content)
- end
-
- context 'when is valid call stack profile' do
- let(:content) { 'This is a call stack request profile' }
- let(:profile) { "|gitlab-org|gitlab-ce_#{Time.now.to_i}_execution.html" }
-
- it 'displays the content' do
- visit admin_requests_profile_path(profile)
-
- expect(page).to have_content(content)
- end
- end
-
- context 'when is valid memory profile' do
- let(:content) { 'This is a memory request profile' }
- let(:profile) { "|gitlab-org|gitlab-ce_#{Time.now.to_i}_memory.txt" }
-
- it 'displays the content' do
- visit admin_requests_profile_path(profile)
-
- expect(page).to have_content(content)
- end
- end
- end
-
- context 'when a profile does not exist' do
- it 'shows an error message' do
- visit admin_requests_profile_path('|non|existent_12345.html')
-
- expect(page).to have_content('Profile not found')
- end
- end
- end
-end
diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb
index 7fe49c2571c..e1a1e2bbb2d 100644
--- a/spec/features/admin/admin_runners_spec.rb
+++ b/spec/features/admin/admin_runners_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe "Admin Runners" do
include Spec::Support::Helpers::Features::RunnersHelpers
+ include Spec::Support::Helpers::ModalHelpers
let_it_be(:admin) { create(:admin) }
@@ -429,12 +430,6 @@ RSpec.describe "Admin Runners" do
end
context "when visiting outdated URLs" do
- it 'updates NOT_CONNECTED runner status to NEVER_CONNECTED' do
- visit admin_runners_path('status[]': 'NOT_CONNECTED')
-
- expect(page).to have_current_path(admin_runners_path('status[]': 'NEVER_CONTACTED') )
- end
-
it 'updates ACTIVE runner status to paused=false' do
visit admin_runners_path('status[]': 'ACTIVE')
@@ -467,7 +462,7 @@ RSpec.describe "Admin Runners" do
describe 'runner show page breadcrumbs' do
it 'contains the current runner id and token' do
page.within '[data-testid="breadcrumb-links"]' do
- expect(page.find('h2')).to have_link("##{runner.id} (#{runner.short_sha})")
+ expect(page.find('[data-testid="breadcrumb-current-link"]')).to have_link("##{runner.id} (#{runner.short_sha})")
end
end
end
@@ -483,10 +478,28 @@ RSpec.describe "Admin Runners" do
expect(page).to have_content 'Tags tag1'
end
end
+
+ describe 'when a runner is deleted' do
+ before do
+ click_on 'Delete runner'
+
+ within_modal do
+ click_on 'Delete runner'
+ end
+ end
+
+ it 'deletes runner' do
+ expect(page.find('[data-testid="alert-success"]')).to have_content('deleted')
+ end
+
+ it 'redirects to runner list' do
+ expect(current_url).to match(admin_runners_path)
+ end
+ end
end
describe "Runner edit page" do
- let(:runner) { create(:ci_runner) }
+ let(:runner) { create(:ci_runner, :project) }
before do
@project1 = create(:project)
@@ -500,14 +513,29 @@ RSpec.describe "Admin Runners" do
it 'contains the current runner id and token' do
page.within '[data-testid="breadcrumb-links"]' do
expect(page).to have_link("##{runner.id} (#{runner.short_sha})")
- expect(page.find('h2')).to have_content("Edit")
+ expect(page.find('[data-testid="breadcrumb-current-link"]')).to have_content("Edit")
end
end
end
describe 'runner header', :js do
it 'contains the runner status, type and id' do
- expect(page).to have_content("never contacted shared Runner ##{runner.id} created")
+ expect(page).to have_content("never contacted specific Runner ##{runner.id} created")
+ end
+ end
+
+ describe 'when a runner is updated', :js do
+ before do
+ click_on _('Save changes')
+ wait_for_requests
+ end
+
+ it 'show success alert' do
+ expect(page.find('[data-testid="alert-success"]')).to have_content('saved')
+ end
+
+ it 'redirects to runner page' do
+ expect(current_url).to match(admin_runner_path(runner))
end
end
@@ -564,17 +592,6 @@ RSpec.describe "Admin Runners" do
it_behaves_like 'assignable runner'
end
-
- context 'with shared runner' do
- let(:runner) { create(:ci_runner, :instance) }
-
- before do
- @project1.destroy!
- visit edit_admin_runner_path(runner)
- end
-
- it_behaves_like 'assignable runner'
- end
end
describe 'disable/destroy' do
diff --git a/spec/features/admin/admin_sees_background_migrations_spec.rb b/spec/features/admin/admin_sees_background_migrations_spec.rb
index 432721d63ad..8edddcf9a9b 100644
--- a/spec/features/admin/admin_sees_background_migrations_spec.rb
+++ b/spec/features/admin/admin_sees_background_migrations_spec.rb
@@ -31,6 +31,52 @@ RSpec.describe "Admin > Admin sees background migrations" do
end
end
+ it 'can click on a specific migration' do
+ visit admin_background_migrations_path
+
+ within '#content-body' do
+ tab = find_link active_migration.job_class_name
+ tab.click
+
+ expect(page).to have_current_path admin_background_migration_path(active_migration)
+ end
+ end
+
+ it 'can view failed jobs' do
+ visit admin_background_migration_path(failed_migration)
+
+ within '#content-body' do
+ expect(page).to have_content('Failed jobs')
+ expect(page).to have_content('Id')
+ expect(page).to have_content('Started at')
+ expect(page).to have_content('Finished at')
+ expect(page).to have_content('Batch size')
+ end
+ end
+
+ it 'can click on a specific job' do
+ job = create(:batched_background_migration_job, :failed, batched_migration: failed_migration)
+
+ visit admin_background_migration_path(failed_migration)
+
+ within '#content-body' do
+ tab = find_link job.id
+ tab.click
+
+ expect(page).to have_current_path admin_background_migration_batched_job_path(id: job.id, background_migration_id: failed_migration.id)
+ end
+ end
+
+ context 'when there are no failed jobs' do
+ it 'dos not display failed jobs' do
+ visit admin_background_migration_path(active_migration)
+
+ within '#content-body' do
+ expect(page).not_to have_content('Failed jobs')
+ end
+ end
+ end
+
it 'can view queued migrations and pause and resume them' do
visit admin_background_migrations_path
@@ -66,6 +112,17 @@ RSpec.describe "Admin > Admin sees background migrations" do
end
end
+ it 'can fire an action with a database param' do
+ visit admin_background_migrations_path(database: 'main')
+
+ within '#content-body' do
+ tab = find_link 'Failed'
+ tab.click
+
+ expect(page).to have_selector("[method='post'][action='/admin/background_migrations/#{failed_migration.id}/retry?database=main']")
+ end
+ end
+
it 'can view and retry them' do
visit admin_background_migrations_path
@@ -109,4 +166,89 @@ RSpec.describe "Admin > Admin sees background migrations" do
expect(page).to have_content(finished_migration.status_name.to_s)
end
end
+
+ it 'can change tabs and retain database param' do
+ skip_if_multiple_databases_not_setup
+
+ visit admin_background_migrations_path(database: 'ci')
+
+ within '#content-body' do
+ tab = find_link 'Finished'
+ expect(tab[:class]).not_to include('gl-tab-nav-item-active')
+
+ tab.click
+
+ expect(page).to have_current_path(admin_background_migrations_path(tab: 'finished', database: 'ci'))
+ expect(tab[:class]).to include('gl-tab-nav-item-active')
+ end
+ end
+
+ it 'can view documentation from Learn more link' do
+ visit admin_background_migrations_path
+
+ within '#content-body' do
+ expect(page).to have_link('Learn more', href: help_page_path('development/database/batched_background_migrations'))
+ end
+ end
+
+ describe 'selected database toggle', :js do
+ context 'when multi database is not enabled' do
+ before do
+ skip_if_multiple_databases_are_setup
+
+ allow(Gitlab::Database).to receive(:db_config_names).and_return(['main'])
+ end
+
+ it 'does not render the database listbox' do
+ visit admin_background_migrations_path
+
+ expect(page).not_to have_selector('[data-testid="database-listbox"]')
+ end
+ end
+
+ context 'when multi database is enabled' do
+ before do
+ skip_if_multiple_databases_not_setup
+
+ allow(Gitlab::Database).to receive(:db_config_names).and_return(%w[main ci])
+ end
+
+ it 'does render the database listbox' do
+ visit admin_background_migrations_path
+
+ expect(page).to have_selector('[data-testid="database-listbox"]')
+ end
+
+ it 'defaults to main when no parameter is passed' do
+ visit admin_background_migrations_path
+
+ listbox = page.find('[data-testid="database-listbox"]')
+
+ expect(listbox).to have_text('main')
+ end
+
+ it 'shows correct database when a parameter is passed' do
+ visit admin_background_migrations_path(database: 'ci')
+
+ listbox = page.find('[data-testid="database-listbox"]')
+
+ expect(listbox).to have_text('ci')
+ end
+
+ it 'updates the path to correct database when clicking on listbox option' do
+ visit admin_background_migrations_path
+
+ listbox = page.find('[data-testid="database-listbox"]')
+ expect(listbox).to have_text('main')
+
+ listbox.find('button').click
+ listbox.find('li', text: 'ci').click
+ wait_for_requests
+
+ expect(page).to have_current_path(admin_background_migrations_path(database: 'ci'))
+ listbox = page.find('[data-testid="database-listbox"]')
+ expect(listbox).to have_text('ci')
+ end
+ end
+ end
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 4cdc3df978d..79b3f049047 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe 'Admin updates settings' do
it 'change Visibility and Access Controls' do
page.within('.as-visibility-access') do
- uncheck 'Project export enabled'
+ uncheck 'Enabled'
click_button 'Save changes'
end
@@ -111,6 +111,16 @@ RSpec.describe 'Admin updates settings' do
expect(page).to have_content "Application settings saved successfully"
end
+ it 'change Maximum export size' do
+ page.within('.as-account-limit') do
+ fill_in 'Maximum export size (MB)', with: 25
+ click_button 'Save changes'
+ end
+
+ expect(current_settings.max_export_size).to eq 25
+ expect(page).to have_content "Application settings saved successfully"
+ end
+
it 'change Maximum import size' do
page.within('.as-account-limit') do
fill_in 'Maximum import size (MB)', with: 15
@@ -370,7 +380,7 @@ RSpec.describe 'Admin updates settings' do
expect(current_settings.valid_runner_registrars).to eq(ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES)
page.within('.as-runner') do
- find_all('.form-check-input').each(&:click)
+ find_all('input[type="checkbox"]').each(&:click)
click_button 'Save changes'
end
@@ -396,7 +406,6 @@ RSpec.describe 'Admin updates settings' do
end
context 'Container Registry' do
- let(:feature_flag_enabled) { true }
let(:client_support) { true }
let(:settings_titles) do
{
@@ -409,18 +418,9 @@ RSpec.describe 'Admin updates settings' do
before do
stub_container_registry_config(enabled: true)
- stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
end
- shared_examples 'not having container registry setting' do |registry_setting|
- it "lacks the container setting #{registry_setting}" do
- visit ci_cd_admin_application_settings_path
-
- expect(page).not_to have_content(settings_titles[registry_setting])
- end
- end
-
%i[container_registry_delete_tags_service_timeout container_registry_expiration_policies_worker_capacity container_registry_cleanup_tags_service_max_list_size].each do |setting|
context "for container registry setting #{setting}" do
it 'changes the setting' do
@@ -434,12 +434,6 @@ RSpec.describe 'Admin updates settings' do
expect(current_settings.public_send(setting)).to eq(400)
expect(page).to have_content "Application settings saved successfully"
end
-
- context 'with feature flag disabled' do
- let(:feature_flag_enabled) { false }
-
- it_behaves_like 'not having container registry setting', setting
- end
end
end
@@ -457,12 +451,6 @@ RSpec.describe 'Admin updates settings' do
expect(current_settings.container_registry_expiration_policies_caching).to eq(!old_value)
expect(page).to have_content "Application settings saved successfully"
end
-
- context 'with feature flag disabled' do
- let(:feature_flag_enabled) { false }
-
- it_behaves_like 'not having container registry setting', :container_registry_expiration_policies_caching
- end
end
end
end
@@ -665,7 +653,7 @@ RSpec.describe 'Admin updates settings' do
visit network_admin_application_settings_path
page.within('.as-issue-limits') do
- fill_in 'Max requests per minute per user', with: 0
+ fill_in 'Maximum number of requests per minute', with: 0
click_button 'Save changes'
end
@@ -673,6 +661,18 @@ RSpec.describe 'Admin updates settings' do
expect(current_settings.issues_create_limit).to eq(0)
end
+ it 'changes Pipelines rate limits settings' do
+ visit network_admin_application_settings_path
+
+ page.within('.as-pipeline-limits') do
+ fill_in 'Maximum number of requests per minute', with: 10
+ click_button 'Save changes'
+ end
+
+ expect(page).to have_content "Application settings saved successfully"
+ expect(current_settings.pipeline_limit_per_project_user_sha).to eq(10)
+ end
+
it 'changes Users API rate limits settings' do
visit network_admin_application_settings_path
@@ -856,10 +856,9 @@ RSpec.describe 'Admin updates settings' do
stub_database_flavor_check
end
- context 'when service data cached', :clean_gitlab_redis_cache do
+ context 'when service data cached', :use_clean_rails_memory_store_caching do
before do
- allow(Rails.cache).to receive(:exist?).with('usage_data').and_return(true)
-
+ visit usage_data_admin_application_settings_path
visit service_usage_data_admin_application_settings_path
end
diff --git a/spec/features/admin/clusters/eks_spec.rb b/spec/features/admin/clusters/eks_spec.rb
deleted file mode 100644
index 4667f9c20a1..00000000000
--- a/spec/features/admin/clusters/eks_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Instance-level AWS EKS Cluster', :js do
- let(:user) { create(:admin) }
-
- before do
- sign_in(user)
- gitlab_enable_admin_mode_sign_in(user)
- stub_application_setting(eks_integration_enabled: true)
- end
-
- context 'when user does not have a cluster and visits group clusters page' do
- before do
- visit admin_clusters_path
-
- click_button(class: 'dropdown-toggle-split')
- click_link 'Create a cluster (deprecated)'
- end
-
- context 'when user creates a cluster on AWS EKS' do
- before do
- click_link 'Amazon EKS'
- end
-
- it 'user sees a form to create an EKS cluster' do
- expect(page).to have_content('Authenticate with Amazon Web Services')
- end
- end
- end
-end
diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb
index 4d9a7f31911..a05e1531949 100644
--- a/spec/features/admin/users/users_spec.rb
+++ b/spec/features/admin/users/users_spec.rb
@@ -548,7 +548,7 @@ RSpec.describe 'Admin::Users' do
end
def check_breadcrumb(content)
- expect(find('.breadcrumbs-sub-title')).to have_content(content)
+ expect(find('[data-testid="breadcrumb-current-link"]')).to have_content(content)
end
end
diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb
index aa445265eec..f8b68be7f93 100644
--- a/spec/features/dashboard/issuables_counter_spec.rb
+++ b/spec/features/dashboard/issuables_counter_spec.rb
@@ -53,6 +53,11 @@ RSpec.describe 'Navigation bar counter', :use_clean_rails_memory_store_caching d
describe 'feature flag mr_attention_requests is enabled' do
before do
merge_request.update!(assignees: [user])
+
+ merge_request.find_assignee(user).update!(state: :attention_requested)
+
+ user.invalidate_attention_requested_count
+
sign_in(user)
end
diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb
index 4d59e1ded3d..3c774f8b269 100644
--- a/spec/features/dashboard/issues_filter_spec.rb
+++ b/spec/features/dashboard/issues_filter_spec.rb
@@ -78,14 +78,14 @@ RSpec.describe 'Dashboard Issues filtering', :js do
end
it 'remembers last sorting value' do
- sort_by('Created date')
+ pajamas_sort_by(s_('SortOptions|Created date'))
visit_issues(assignee_username: user.username)
expect(page).to have_button('Created date')
end
it 'keeps sorting issues after visiting Projects Issues page' do
- sort_by('Created date')
+ pajamas_sort_by(s_('SortOptions|Created date'))
visit project_issues_path(project)
expect(page).to have_button('Created date')
diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb
index 7507ef4e453..fd580b679ad 100644
--- a/spec/features/dashboard/merge_requests_spec.rb
+++ b/spec/features/dashboard/merge_requests_spec.rb
@@ -112,9 +112,12 @@ RSpec.describe 'Dashboard Merge Requests' do
end
it 'includes assigned and reviewers in badge' do
- within("span[aria-label='#{n_("%d merge request", "%d merge requests", 3) % 3}']") do
- expect(page).to have_content('3')
+ within("span[aria-label='#{n_("%d merge request", "%d merge requests", 0) % 0}']") do
+ expect(page).to have_content('0')
end
+
+ find('.dashboard-shortcuts-merge_requests').click
+
expect(find('.js-assigned-mr-count')).to have_content('2')
expect(find('.js-reviewer-mr-count')).to have_content('1')
end
@@ -165,16 +168,16 @@ RSpec.describe 'Dashboard Merge Requests' do
expect(page).to have_content('Please select at least one filter to see results')
end
- it 'shows sorted merge requests' do
- sort_by('Created date')
+ it 'shows sorted merge requests', :js do
+ pajamas_sort_by(s_('SortOptions|Created date'))
visit merge_requests_dashboard_path(assignee_username: current_user.username)
expect(find('.issues-filters')).to have_content('Created date')
end
- it 'keeps sorting merge requests after visiting Projects MR page' do
- sort_by('Created date')
+ it 'keeps sorting merge requests after visiting Projects MR page', :js do
+ pajamas_sort_by(s_('SortOptions|Created date'))
visit project_merge_requests_path(project)
diff --git a/spec/features/dashboard/todos/todos_sorting_spec.rb b/spec/features/dashboard/todos/todos_sorting_spec.rb
index d0f9a2b35f3..d593031590e 100644
--- a/spec/features/dashboard/todos/todos_sorting_spec.rb
+++ b/spec/features/dashboard/todos/todos_sorting_spec.rb
@@ -23,11 +23,12 @@ RSpec.describe 'Dashboard > User sorts todos' do
let!(:merge_request_1) { create(:merge_request, source_project: project, title: 'merge_request_1') }
before do
- create(:todo, user: user, project: project, target: issue_4, created_at: 5.hours.ago)
- create(:todo, user: user, project: project, target: issue_2, created_at: 4.hours.ago)
- create(:todo, user: user, project: project, target: issue_3, created_at: 3.hours.ago)
- create(:todo, user: user, project: project, target: issue_1, created_at: 2.hours.ago)
- create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago)
+ create(:todo, user: user, project: project, target: issue_4, created_at: 5.hours.ago, updated_at: 5.hours.ago)
+ create(:todo, user: user, project: project, target: issue_2, created_at: 4.hours.ago, updated_at: 4.hours.ago)
+ create(:todo, user: user, project: project, target: issue_3, created_at: 3.hours.ago, updated_at: 2.minutes.ago)
+ create(:todo, user: user, project: project, target: issue_1, created_at: 2.hours.ago, updated_at: 2.hours.ago)
+ create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago,
+ updated_at: 1.hour.ago)
merge_request_1.labels << label_1
issue_3.labels << label_1
@@ -70,6 +71,17 @@ RSpec.describe 'Dashboard > User sorts todos' do
expect(results_list.all('.todo-title')[3]).to have_content('issue_2')
expect(results_list.all('.todo-title')[4]).to have_content('issue_4')
end
+
+ it 'sorts by newest updated todos first' do
+ click_link 'Updated date'
+
+ results_list = page.find('.todos-list')
+ expect(results_list.all('.todo-title')[0]).to have_content('issue_3')
+ expect(results_list.all('.todo-title')[1]).to have_content('merge_request_1')
+ expect(results_list.all('.todo-title')[2]).to have_content('issue_1')
+ expect(results_list.all('.todo-title')[3]).to have_content('issue_2')
+ expect(results_list.all('.todo-title')[4]).to have_content('issue_4')
+ end
end
context 'issues and merge requests' do
diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb
index 68d979bb1cf..04e78b59ab4 100644
--- a/spec/features/dashboard/todos/todos_spec.rb
+++ b/spec/features/dashboard/todos/todos_spec.rb
@@ -211,9 +211,9 @@ RSpec.describe 'Dashboard Todos' do
visit dashboard_todos_path
end
- it 'shows you directly addressed yourself message' do
+ it 'shows you directly addressed yourself message being displayed as mentioned yourself' do
page.within('.js-todos-all') do
- expect(page).to have_content("You directly addressed yourself on issue #{issue.to_reference} \"Fix bug\" at #{project.namespace.owner_name} / #{project.name}")
+ expect(page).to have_content("You mentioned yourself on issue #{issue.to_reference} \"Fix bug\" at #{project.namespace.owner_name} / #{project.name}")
expect(page).not_to have_content('to yourself')
end
end
diff --git a/spec/features/explore/topics_spec.rb b/spec/features/explore/topics_spec.rb
index d6f3d6a123d..f0c57c2417a 100644
--- a/spec/features/explore/topics_spec.rb
+++ b/spec/features/explore/topics_spec.rb
@@ -13,13 +13,13 @@ RSpec.describe 'Explore Topics' do
end
context 'when topics exist' do
- let!(:topic) { create(:topic, name: 'topic1') }
+ let!(:topic) { create(:topic, name: 'topic1', title: 'Topic 1') }
it 'renders topic list' do
visit topics_explore_projects_path
expect(page).to have_current_path topics_explore_projects_path, ignore_query: true
- expect(page).to have_content('topic1')
+ expect(page).to have_content(topic.title)
end
end
end
diff --git a/spec/features/frequently_visited_projects_and_groups_spec.rb b/spec/features/frequently_visited_projects_and_groups_spec.rb
index 6bc3b745851..7fbbc4dfc85 100644
--- a/spec/features/frequently_visited_projects_and_groups_spec.rb
+++ b/spec/features/frequently_visited_projects_and_groups_spec.rb
@@ -16,7 +16,6 @@ RSpec.describe 'Frequently visited items', :js do
it 'increments localStorage counter when visiting the project' do
visit project_path(project)
- open_top_nav_projects
frequent_projects = nil
@@ -35,7 +34,6 @@ RSpec.describe 'Frequently visited items', :js do
it 'increments localStorage counter when visiting the group' do
visit group_path(group)
- open_top_nav_groups
frequent_groups = nil
diff --git a/spec/features/groups/clusters/eks_spec.rb b/spec/features/groups/clusters/eks_spec.rb
deleted file mode 100644
index 0e64a2faf3e..00000000000
--- a/spec/features/groups/clusters/eks_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Group AWS EKS Cluster', :js do
- let(:group) { create(:group) }
- let(:user) { create(:user) }
-
- before do
- group.add_maintainer(user)
- gitlab_sign_in(user)
-
- allow(Groups::ClustersController).to receive(:STATUS_POLLING_INTERVAL) { 100 }
- allow_any_instance_of(Clusters::Kubernetes::CreateOrUpdateNamespaceService).to receive(:execute)
- allow_any_instance_of(Clusters::Cluster).to receive(:retrieve_connection_status).and_return(:connected)
- stub_application_setting(eks_integration_enabled: true)
- end
-
- context 'when user does not have a cluster and visits group clusters page' do
- before do
- visit group_clusters_path(group)
-
- click_button(class: 'dropdown-toggle-split')
- click_link 'Create a cluster (deprecated)'
- end
-
- context 'when user creates a cluster on AWS EKS' do
- before do
- click_link 'Amazon EKS'
- end
-
- it 'user sees a form to create an EKS cluster' do
- expect(page).to have_content('Authenticate with Amazon Web Services')
- end
- end
- end
-end
diff --git a/spec/features/groups/crm/contacts/create_spec.rb b/spec/features/groups/crm/contacts/create_spec.rb
new file mode 100644
index 00000000000..d6c6e3f1745
--- /dev/null
+++ b/spec/features/groups/crm/contacts/create_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create a CRM contact', :js do
+ let(:user) { create(:user) }
+ let(:group) { create(:group, :crm_enabled) }
+ let!(:organization) { create(:organization, group: group, name: 'GitLab') }
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ visit new_group_crm_contact_path(group)
+ end
+
+ it 'creates a new contact' do
+ fill_in 'firstName', with: 'Forename'
+ fill_in 'lastName', with: 'Surname'
+ fill_in 'email', with: 'gitlab@example.com'
+ fill_in 'phone', with: '01234 555666'
+ select 'GitLab', from: 'organizationId'
+ fill_in 'description', with: 'VIP'
+ click_button 'Save changes'
+
+ expect(page).to have_content 'gitlab@example.com'
+ expect(page).to have_current_path("#{group_crm_contacts_path(group)}/", ignore_query: true)
+ end
+end
diff --git a/spec/features/groups/dependency_proxy_spec.rb b/spec/features/groups/dependency_proxy_spec.rb
index 623fb065bfc..af9c4a40729 100644
--- a/spec/features/groups/dependency_proxy_spec.rb
+++ b/spec/features/groups/dependency_proxy_spec.rb
@@ -88,22 +88,6 @@ RSpec.describe 'Group Dependency Proxy' do
sign_in(owner)
end
- context 'feature flag is disabled', :js do
- before do
- stub_feature_flags(dependency_proxy_for_private_groups: false)
- end
-
- context 'group is private' do
- let(:group) { create(:group, :private) }
-
- it 'informs user that feature is only available for public groups' do
- visit path
-
- expect(page).to have_content('Dependency Proxy feature is limited to public groups for now.')
- end
- end
- end
-
context 'feature is disabled globally' do
it 'renders 404 page' do
disable_feature
diff --git a/spec/features/groups/empty_states_spec.rb b/spec/features/groups/empty_states_spec.rb
index 0317f9162cc..71f38401fa1 100644
--- a/spec/features/groups/empty_states_spec.rb
+++ b/spec/features/groups/empty_states_spec.rb
@@ -7,6 +7,8 @@ RSpec.describe 'Group empty states' do
let(:user) { create(:group_member, :developer, user: create(:user), group: group ).user }
before do
+ stub_feature_flags(vue_issues_list: true)
+
sign_in(user)
end
@@ -100,21 +102,23 @@ RSpec.describe 'Group empty states' do
end
it "the new #{issuable_name} button opens a project dropdown" do
- within '.empty-state' do
- click_button 'Toggle project select'
- end
+ click_button 'Toggle project select'
- expect(page).to have_selector('.ajax-project-dropdown')
+ if issuable == :issue
+ expect(page).to have_button project.name
+ else
+ expect(page).to have_selector('.ajax-project-dropdown')
+ end
end
end
end
shared_examples "no projects" do
- it 'displays an empty state' do
+ it 'displays an empty state', :js do
expect(page).to have_selector('.empty-state')
end
- it "does not show a new #{issuable_name} button" do
+ it "does not show a new #{issuable_name} button", :js do
within '.empty-state' do
expect(page).not_to have_link("create #{issuable_name}")
end
@@ -143,7 +147,7 @@ RSpec.describe 'Group empty states' do
visit path
end
- it 'displays an empty state' do
+ it 'displays an empty state', :js do
expect(page).to have_selector('.empty-state')
end
end
diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb
index 50982cb1452..019b094ccb5 100644
--- a/spec/features/groups/group_settings_spec.rb
+++ b/spec/features/groups/group_settings_spec.rb
@@ -223,7 +223,7 @@ RSpec.describe 'Edit group settings' do
check 'group_prevent_sharing_groups_outside_hierarchy'
expect { save_permissions_group }.to change {
- group.reload.namespace_settings.prevent_sharing_groups_outside_hierarchy
+ group.reload.prevent_sharing_groups_outside_hierarchy
}.to(true)
end
diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb
index 6b663445124..ef3346b9763 100644
--- a/spec/features/groups/issues_spec.rb
+++ b/spec/features/groups/issues_spec.rb
@@ -11,6 +11,10 @@ RSpec.describe 'Group issues page' do
let(:project_with_issues_disabled) { create(:project, :issues_disabled, group: group) }
let(:path) { issues_group_path(group) }
+ before do
+ stub_feature_flags(vue_issues_list: true)
+ end
+
context 'with shared examples', :js do
let(:issuable) { create(:issue, project: project, title: "this is my created issuable")}
@@ -58,10 +62,10 @@ RSpec.describe 'Group issues page' do
let(:user2) { user_outside_group }
it 'filters by only group users' do
- filtered_search.set('assignee:=')
+ select_tokens 'Assignee', '='
- expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name)
- expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name)
+ expect_suggestion(user.name)
+ expect_no_suggestion(user2.name)
end
end
end
@@ -76,23 +80,9 @@ RSpec.describe 'Group issues page' do
it 'returns all group and subgroup issues' do
visit issues_group_path(group)
- page.within('.issuable-list') do
- expect(page).to have_selector('li.issue', count: 2)
- expect(page).to have_content('root group issue')
- expect(page).to have_content('subgroup issue')
- end
- end
-
- it 'truncates issue counts if over the threshold', :clean_gitlab_redis_cache do
- allow(Rails.cache).to receive(:read).and_call_original
- allow(Rails.cache).to receive(:read).with(
- ['group', group.id, 'issues'],
- { expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN }
- ).and_return({ opened: 1050, closed: 500, all: 1550 })
-
- visit issues_group_path(group)
-
- expect(page).to have_text('Open 1.1k Closed 500 All 1.6k')
+ expect(page).to have_selector('li.issue', count: 2)
+ expect(page).to have_content('root group issue')
+ expect(page).to have_content('subgroup issue')
end
context 'when project is archived' do
@@ -115,7 +105,6 @@ RSpec.describe 'Group issues page' do
let!(:subgroup_issue) { create(:issue, project: subgroup_project) }
before do
- stub_feature_flags(vue_issues_list: true)
visit issues_group_path(group_with_no_issues)
end
@@ -135,14 +124,10 @@ RSpec.describe 'Group issues page' do
end
it 'shows projects only with issues feature enabled', :js do
- within '.empty-state' do
- click_button 'Toggle project select'
- end
+ click_button 'Toggle project select'
- page.within('.select2-results') do
- expect(page).to have_content(project.full_name)
- expect(page).not_to have_content(project_with_issues_disabled.full_name)
- end
+ expect(page).to have_button project.full_name
+ expect(page).not_to have_button project_with_issues_disabled.full_name
end
end
end
@@ -155,15 +140,15 @@ RSpec.describe 'Group issues page' do
let!(:issue3) { create(:issue, project: project, title: 'Issue #3', relative_position: 3) }
before do
+ stub_feature_flags(vue_issues_list: false)
+
sign_in(user_in_group)
end
it 'displays all issues' do
visit issues_group_path(group, sort: 'relative_position')
- page.within('.issues-list') do
- expect(page).to have_selector('li.issue', count: 3)
- end
+ expect(page).to have_selector('li.issue', count: 3)
end
it 'has manual-ordering css applied' do
@@ -218,11 +203,9 @@ RSpec.describe 'Group issues page' do
end
def check_issue_order
- page.within('.manual-ordering') do
- expect(find('.issue:nth-child(1) .title')).to have_content('Issue #2')
- expect(find('.issue:nth-child(2) .title')).to have_content('Issue #3')
- expect(find('.issue:nth-child(3) .title')).to have_content('Issue #1')
- end
+ expect(page).to have_css('.issue:nth-child(1) .title', text: 'Issue #2')
+ expect(page).to have_css('.issue:nth-child(2) .title', text: 'Issue #3')
+ expect(page).to have_css('.issue:nth-child(3) .title', text: 'Issue #1')
end
end
@@ -239,14 +222,8 @@ RSpec.describe 'Group issues page' do
end
it 'shows the pagination' do
- expect(page).to have_link 'Prev'
- expect(page).to have_link 'Next'
- end
-
- it 'first pagination item is active' do
- page.within('.gl-pagination') do
- expect(find('li.active')).to have_content('1')
- end
+ expect(page).to have_button 'Prev', disabled: true
+ expect(page).to have_button 'Next'
end
end
end
diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb
index 5a9223d9ee8..e4252e2f3aa 100644
--- a/spec/features/groups/members/manage_groups_spec.rb
+++ b/spec/features/groups/members/manage_groups_spec.rb
@@ -119,141 +119,11 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
describe 'group search results' do
let_it_be(:group, refind: true) { create(:group) }
- context 'with instance admin considerations' do
- let_it_be(:group_to_share) { create(:group) }
-
- context 'when user is an admin' do
- let_it_be(:admin) { create(:admin) }
-
- before do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- end
-
- it 'shows groups where the admin has no direct membership' do
- visit group_group_members_path(group)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_to_share)
- expect_not_to_have_group(group)
- end
- end
-
- it 'shows groups where the admin has at least guest level membership' do
- group_to_share.add_guest(admin)
-
- visit group_group_members_path(group)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_to_share)
- expect_not_to_have_group(group)
- end
- end
- end
-
- context 'when user is not an admin' do
- before do
- group.add_owner(user)
- end
-
- it 'shows groups where the user has no direct membership' do
- visit group_group_members_path(group)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_not_to_have_group(group_to_share)
- expect_not_to_have_group(group)
- end
- end
-
- it 'shows groups where the user has at least guest level membership' do
- group_to_share.add_guest(user)
-
- visit group_group_members_path(group)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_to_share)
- expect_not_to_have_group(group)
- end
- end
- end
- end
-
- context 'when user is not an admin and there are hierarchy considerations' do
+ it_behaves_like 'inviting groups search results' do
+ let_it_be(:entity) { group }
let_it_be(:group_within_hierarchy) { create(:group, parent: group) }
- let_it_be(:group_outside_hierarchy) { create(:group) }
-
- before_all do
- group.add_owner(user)
- group_within_hierarchy.add_owner(user)
- group_outside_hierarchy.add_owner(user)
- end
-
- it 'does not show self or ancestors', :aggregate_failures do
- group_sibbling = create(:group, parent: group)
- group_sibbling.add_owner(user)
-
- visit group_group_members_path(group_within_hierarchy)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_outside_hierarchy)
- expect_to_have_group(group_sibbling)
- expect_not_to_have_group(group)
- expect_not_to_have_group(group_within_hierarchy)
- end
- end
-
- context 'when sharing with groups outside the hierarchy is enabled' do
- it 'shows groups within and outside the hierarchy in search results' do
- visit group_group_members_path(group)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_within_hierarchy)
- expect_to_have_group(group_outside_hierarchy)
- end
- end
- end
-
- context 'when sharing with groups outside the hierarchy is disabled' do
- before do
- group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true)
- end
-
- it 'shows only groups within the hierarchy in search results' do
- visit group_group_members_path(group)
-
- click_on 'Invite a group'
- click_on 'Select a group'
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_within_hierarchy)
- expect_not_to_have_group(group_outside_hierarchy)
- end
- end
- end
+ let_it_be(:members_page_path) { group_group_members_path(entity) }
+ let_it_be(:members_page_path_within_hierarchy) { group_group_members_path(group_within_hierarchy) }
end
end
end
diff --git a/spec/features/groups/settings/ci_cd_spec.rb b/spec/features/groups/settings/ci_cd_spec.rb
index c5ad524e647..50c481c115c 100644
--- a/spec/features/groups/settings/ci_cd_spec.rb
+++ b/spec/features/groups/settings/ci_cd_spec.rb
@@ -17,62 +17,29 @@ RSpec.describe 'Group CI/CD settings' do
end
describe 'Runners section' do
- let(:shared_runners_toggle) { page.find('[data-testid="enable-runners-toggle"]') }
+ let(:shared_runners_toggle) { page.find('[data-testid="shared-runners-toggle"]') }
- context 'with runner_list_group_view_vue_ui enabled' do
- before do
- visit group_settings_ci_cd_path(group)
- end
-
- it 'displays the new group runners view banner' do
- expect(page).to have_content(s_('Runners|New group runners view'))
- expect(page).to have_link(href: group_runners_path(group))
- end
-
- it 'has "Enable shared runners for this group" toggle', :js do
- expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group'))
- end
+ before do
+ visit group_settings_ci_cd_path(group)
end
- context 'with runner_list_group_view_vue_ui disabled' do
- before do
- stub_feature_flags(runner_list_group_view_vue_ui: false)
-
- visit group_settings_ci_cd_path(group)
- end
-
- it 'does not display the new group runners view banner' do
- expect(page).not_to have_content(s_('Runners|New group runners view'))
- expect(page).not_to have_link(href: group_runners_path(group))
- end
-
- it 'has "Enable shared runners for this group" toggle', :js do
- expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group'))
- end
-
- context 'with runners registration token' do
- let!(:token) { group.runners_token }
-
- before do
- visit group_settings_ci_cd_path(group)
- end
+ it 'displays the new group runners view banner' do
+ expect(page).to have_content(s_('Runners|New group runners view'))
+ expect(page).to have_link(href: group_runners_path(group))
+ end
- it 'displays the registration token' do
- expect(page.find('#registration_token')).to have_content(token)
- end
+ it 'has "Enable shared runners for this group" toggle', :js do
+ expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group'))
+ end
- describe 'reload registration token' do
- let(:page_token) { find('#registration_token').text }
+ it 'clicks on toggle to enable setting', :js do
+ expect(group.shared_runners_setting).to be(Namespace::SR_ENABLED)
- before do
- click_button 'Reset registration token'
- end
+ shared_runners_toggle.find('button').click
+ wait_for_requests
- it 'changes the registration token' do
- expect(page_token).not_to eq token
- end
- end
- end
+ group.reload
+ expect(group.shared_runners_setting).to be(Namespace::SR_DISABLED_AND_UNOVERRIDABLE)
end
end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 08183badda1..ceb4af03f89 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -105,6 +105,24 @@ RSpec.describe 'Group' do
expect(page).to have_content('Group path is available')
end
+
+ context 'when filling in the `Group name` field' do
+ let_it_be(:group1) { create(:group, :public, path: 'foo-bar') }
+ let_it_be(:group2) { create(:group, :public, path: 'bar-baz') }
+
+ it 'automatically populates the `Group URL` field' do
+ fill_in 'Group name', with: 'Foo bar'
+ # Wait for debounce in app/assets/javascripts/group.js#18
+ sleep(1)
+ fill_in 'Group name', with: 'Bar baz'
+ # Wait for debounce in app/assets/javascripts/group.js#18
+ sleep(1)
+
+ wait_for_requests
+
+ expect(page).to have_field('Group URL', with: 'bar-baz1')
+ end
+ end
end
describe 'Mattermost team creation' do
diff --git a/spec/features/ide/user_opens_merge_request_spec.rb b/spec/features/ide/user_opens_merge_request_spec.rb
index 72fe6eb6ca8..8f4668d49ee 100644
--- a/spec/features/ide/user_opens_merge_request_spec.rb
+++ b/spec/features/ide/user_opens_merge_request_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe 'IDE merge request', :js do
end
it 'user opens merge request' do
+ click_button 'Code'
click_link 'Open in Web IDE'
wait_for_requests
diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb
index a0786d36fdf..7edf5fdc5ff 100644
--- a/spec/features/issuables/issuable_list_spec.rb
+++ b/spec/features/issuables/issuable_list_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe 'issuable list', :js do
issuable_types = [:issue, :merge_request]
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_user(user, :developer)
sign_in(user)
issuable_types.each { |type| create_issuables(type) }
@@ -34,16 +36,16 @@ RSpec.describe 'issuable list', :js do
it 'sorts labels alphabetically' do
label1 = create(:label, project: project, title: 'a')
label2 = create(:label, project: project, title: 'z')
- label3 = create(:label, project: project, title: 'X')
- label4 = create(:label, project: project, title: 'B')
+ label3 = create(:label, project: project, title: 'x')
+ label4 = create(:label, project: project, title: 'b')
issuable = create_issuable(issuable_type)
issuable.labels << [label1, label2, label3, label4]
visit_issuable_list(issuable_type)
- expect(all('.gl-label-text')[0].text).to have_content('B')
- expect(all('.gl-label-text')[1].text).to have_content('X')
- expect(all('.gl-label-text')[2].text).to have_content('a')
+ expect(all('.gl-label-text')[0].text).to have_content('a')
+ expect(all('.gl-label-text')[1].text).to have_content('b')
+ expect(all('.gl-label-text')[2].text).to have_content('x')
expect(all('.gl-label-text')[3].text).to have_content('z')
end
end
diff --git a/spec/features/issuables/sorting_list_spec.rb b/spec/features/issuables/sorting_list_spec.rb
index bc40fb713ac..53723b39d5b 100644
--- a/spec/features/issuables/sorting_list_spec.rb
+++ b/spec/features/issuables/sorting_list_spec.rb
@@ -88,14 +88,14 @@ RSpec.describe 'Sort Issuable List' do
end
end
- context 'custom sorting' do
+ context 'custom sorting', :js do
let(:issuable_type) { :merge_request }
it 'supports sorting in asc and desc order' do
visit_merge_requests_with_state(project, 'open')
click_button('Created date')
- click_link('Updated date')
+ find('.dropdown-item', text: 'Updated date').click
expect(first_merge_request).to include(last_updated_issuable.title)
expect(last_merge_request).to include(first_updated_issuable.title)
diff --git a/spec/features/issue_rebalancing_spec.rb b/spec/features/issue_rebalancing_spec.rb
index 978768270ec..8a05aeec7ec 100644
--- a/spec/features/issue_rebalancing_spec.rb
+++ b/spec/features/issue_rebalancing_spec.rb
@@ -15,6 +15,10 @@ RSpec.describe 'Issue rebalancing' do
group.add_developer(user)
end
+ before do
+ stub_feature_flags(vue_issues_list: true)
+ end
+
context 'when issue rebalancing is in progress' do
before do
sign_in(user)
@@ -38,16 +42,16 @@ RSpec.describe 'Issue rebalancing' do
expect(page).to have_selector('.gl-alert-info', text: alert_message_regex, count: 1)
end
- it 'shows an alert in project issues list with manual sort' do
+ it 'shows an alert in project issues list with manual sort', :js do
visit project_issues_path(project, sort: 'relative_position')
- expect(page).to have_selector('.gl-alert-info', text: alert_message_regex, count: 1)
+ expect(page).to have_selector('.flash-notice', text: alert_message_regex, count: 1)
end
- it 'shows an alert in group issues list with manual sort' do
+ it 'shows an alert in group issues list with manual sort', :js do
visit issues_group_path(group, sort: 'relative_position')
- expect(page).to have_selector('.gl-alert-info', text: alert_message_regex, count: 1)
+ expect(page).to have_selector('.flash-notice', text: alert_message_regex, count: 1)
end
it 'does not show an alert in project issues list with other sorts' do
diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
index 507d427bf0b..1e8b9b6b60b 100644
--- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
+++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
end
it 'shows a button to resolve all threads by creating a new issue' do
- within('.line-resolve-all-container') do
+ within('.discussions-counter') do
expect(page).to have_selector resolve_all_discussions_link_selector( title: "Create issue to resolve all threads" )
end
end
@@ -76,14 +76,12 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
end
it 'has a link to resolve all threads by creating an issue' do
- page.within '.mr-widget-body' do
- expect(page).to have_link 'Create issue to resolve all threads', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
- end
+ expect(page).to have_link 'Create issue to resolve all threads', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
context 'creating an issue for threads' do
before do
- page.within '.mr-widget-body' do
+ page.within '.mr-state-widget' do
page.click_link 'Create issue to resolve all threads', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
wait_for_all_requests
diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
index 3ba2f7e788d..18b70c9622a 100644
--- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
@@ -6,11 +6,12 @@ RSpec.describe 'Dropdown assignee', :js do
include FilteredSearchHelpers
let_it_be(:project) { create(:project) }
- let_it_be(:user) { create(:user, name: 'administrator', username: 'root') }
+ let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
- let(:js_dropdown_assignee) { '#js-dropdown-assignee' }
- let(:filter_dropdown) { find("#{js_dropdown_assignee} .filter-dropdown") }
+ before do
+ stub_feature_flags(vue_issues_list: true)
+ end
describe 'behavior' do
before do
@@ -21,15 +22,17 @@ RSpec.describe 'Dropdown assignee', :js do
end
it 'loads all the assignees when opened' do
- input_filtered_search('assignee:=', submit: false, extra_space: false)
+ select_tokens 'Assignee', '='
- expect_filtered_search_dropdown_results(filter_dropdown, 2)
+ # Expect None, Any, administrator, John Doe2
+ expect_suggestion_count 4
end
it 'shows current user at top of dropdown' do
- input_filtered_search('assignee:=', submit: false, extra_space: false)
+ select_tokens 'Assignee', '='
- expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name)
+ # List items 1 to 3 are None, Any, divider
+ expect(page).to have_css('.gl-filtered-search-suggestion:nth-child(4)', text: user.name)
end
end
@@ -41,7 +44,7 @@ RSpec.describe 'Dropdown assignee', :js do
visit project_issues_path(project)
Gitlab::Testing::RequestBlockerMiddleware.block_requests!
- input_filtered_search('assignee:=', submit: false, extra_space: false)
+ select_tokens 'Assignee', '='
end
after do
@@ -49,11 +52,10 @@ RSpec.describe 'Dropdown assignee', :js do
end
it 'selects current user' do
- find("#{js_dropdown_assignee} .filter-dropdown-item", text: user.username).click
+ click_on user.username
- expect(page).to have_css(js_dropdown_assignee, visible: false)
- expect_tokens([assignee_token(user.username)])
- expect_filtered_search_input_empty
+ expect_assignee_token(user.username)
+ expect_empty_search_term
end
end
@@ -93,7 +95,7 @@ RSpec.describe 'Dropdown assignee', :js do
it 'shows inherited, direct, and invited group members but not descendent members', :aggregate_failures do
visit issues_group_path(subgroup)
- input_filtered_search('assignee:=', submit: false, extra_space: false)
+ select_tokens 'Assignee', '='
expect(page).to have_text group_user.name
expect(page).to have_text subgroup_user.name
@@ -103,7 +105,7 @@ RSpec.describe 'Dropdown assignee', :js do
visit project_issues_path(subgroup_project)
- input_filtered_search('assignee:=', submit: false, extra_space: false)
+ select_tokens 'Assignee', '='
expect(page).to have_text group_user.name
expect(page).to have_text subgroup_user.name
diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb
index 893ffc6575b..07e2bd3b7e4 100644
--- a/spec/features/issues/filtered_search/dropdown_author_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb
@@ -6,13 +6,12 @@ RSpec.describe 'Dropdown author', :js do
include FilteredSearchHelpers
let_it_be(:project) { create(:project) }
- let_it_be(:user) { create(:user, name: 'administrator', username: 'root') }
+ let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
- let(:js_dropdown_author) { '#js-dropdown-author' }
- let(:filter_dropdown) { find("#{js_dropdown_author} .filter-dropdown") }
-
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_maintainer(user)
sign_in(user)
@@ -21,22 +20,22 @@ RSpec.describe 'Dropdown author', :js do
describe 'behavior' do
it 'loads all the authors when opened' do
- input_filtered_search('author:=', submit: false, extra_space: false)
+ select_tokens 'Author', '='
- expect_filtered_search_dropdown_results(filter_dropdown, 2)
+ expect_suggestion_count 2
end
it 'shows current user at top of dropdown' do
- input_filtered_search('author:=', submit: false, extra_space: false)
+ select_tokens 'Author', '='
- expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name)
+ expect(page).to have_css('.gl-filtered-search-suggestion:first-child', text: user.name)
end
end
describe 'selecting from dropdown without Ajax call' do
before do
Gitlab::Testing::RequestBlockerMiddleware.block_requests!
- input_filtered_search('author:=', submit: false, extra_space: false)
+ select_tokens 'Author', '='
end
after do
@@ -44,11 +43,10 @@ RSpec.describe 'Dropdown author', :js do
end
it 'selects current user' do
- find("#{js_dropdown_author} .filter-dropdown-item", text: user.username).click
+ click_on user.username
- expect(page).to have_css(js_dropdown_author, visible: false)
- expect_tokens([author_token(user.username)])
- expect_filtered_search_input_empty
+ expect_author_token(user.username)
+ expect_empty_search_term
end
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_base_spec.rb b/spec/features/issues/filtered_search/dropdown_base_spec.rb
index b8fb807dd78..5fdab288b2d 100644
--- a/spec/features/issues/filtered_search/dropdown_base_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_base_spec.rb
@@ -6,18 +6,12 @@ RSpec.describe 'Dropdown base', :js do
include FilteredSearchHelpers
let_it_be(:project) { create(:project) }
- let_it_be(:user) { create(:user, name: 'administrator', username: 'root') }
+ let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
- let(:filtered_search) { find('.filtered-search') }
- let(:js_dropdown_assignee) { '#js-dropdown-assignee' }
- let(:filter_dropdown) { find("#{js_dropdown_assignee} .filter-dropdown") }
-
- def dropdown_assignee_size
- filter_dropdown.all('.filter-dropdown-item').size
- end
-
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_maintainer(user)
sign_in(user)
@@ -26,17 +20,17 @@ RSpec.describe 'Dropdown base', :js do
describe 'caching requests' do
it 'caches requests after the first load' do
- input_filtered_search('assignee:=', submit: false, extra_space: false)
- initial_size = dropdown_assignee_size
+ select_tokens 'Assignee', '='
+ initial_size = get_suggestion_count
expect(initial_size).to be > 0
new_user = create(:user)
project.add_maintainer(new_user)
- find('.filtered-search-box .clear-search').click
- input_filtered_search('assignee:=', submit: false, extra_space: false)
+ click_button 'Clear'
+ select_tokens 'Assignee', '='
- expect(dropdown_assignee_size).to eq(initial_size)
+ expect_suggestion_count(initial_size)
end
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb
index f5ab53d5052..d6d59b89a8c 100644
--- a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb
@@ -6,15 +6,13 @@ RSpec.describe 'Dropdown emoji', :js do
include FilteredSearchHelpers
let_it_be(:project) { create(:project, :public) }
- let_it_be(:user) { create(:user, name: 'administrator', username: 'root') }
+ let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:award_emoji_star) { create(:award_emoji, name: 'star', user: user, awardable: issue) }
- let(:filtered_search) { find('.filtered-search') }
- let(:js_dropdown_emoji) { '#js-dropdown-my-reaction' }
- let(:filter_dropdown) { find("#{js_dropdown_emoji} .filter-dropdown") }
-
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_maintainer(user)
create_list(:award_emoji, 2, user: user, name: 'thumbsup')
create_list(:award_emoji, 1, user: user, name: 'thumbsdown')
@@ -27,15 +25,15 @@ RSpec.describe 'Dropdown emoji', :js do
end
describe 'behavior' do
- it 'does not open when the search bar has my-reaction=' do
- filtered_search.set('my-reaction=')
+ it 'does not contain My-Reaction in the list of suggestions' do
+ click_filtered_search_bar
- expect(page).not_to have_css(js_dropdown_emoji)
+ expect(page).not_to have_link 'My-Reaction'
end
end
end
- context 'when user loggged in' do
+ context 'when user logged in' do
before do
sign_in(user)
@@ -43,22 +41,18 @@ RSpec.describe 'Dropdown emoji', :js do
end
describe 'behavior' do
- it 'opens when the search bar has my-reaction=' do
- filtered_search.set('my-reaction:=')
-
- expect(page).to have_css(js_dropdown_emoji, visible: true)
- end
-
it 'loads all the emojis when opened' do
- input_filtered_search('my-reaction:=', submit: false, extra_space: false)
+ select_tokens 'My-Reaction', '='
- expect_filtered_search_dropdown_results(filter_dropdown, 3)
+ # Expect None, Any, star, thumbsup, thumbsdown
+ expect_suggestion_count 5
end
it 'shows the most populated emoji at top of dropdown' do
- input_filtered_search('my-reaction:=', submit: false, extra_space: false)
+ select_tokens 'My-Reaction', '='
- expect(first("#{js_dropdown_emoji} .filter-dropdown li")).to have_content(award_emoji_star.name)
+ # List items 1-3 are None, Any, divider
+ expect(page).to have_css('.gl-filtered-search-suggestion-list li:nth-child(4)', text: award_emoji_star.name)
end
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
index 9cc58a33bb7..c64247b2b15 100644
--- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
@@ -9,19 +9,9 @@ RSpec.describe 'Dropdown hint', :js do
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
- let(:filtered_search) { find('.filtered-search') }
- let(:js_dropdown_hint) { '#js-dropdown-hint' }
- let(:js_dropdown_operator) { '#js-dropdown-operator' }
-
- def click_hint(text)
- find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click
- end
-
- def click_operator(op)
- find("#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value='#{op}']").click
- end
-
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_maintainer(user)
end
@@ -31,8 +21,9 @@ RSpec.describe 'Dropdown hint', :js do
end
it 'does not exist my-reaction dropdown item' do
- expect(page).to have_css(js_dropdown_hint, visible: false)
- expect(page).not_to have_content('My-reaction')
+ click_filtered_search_bar
+
+ expect(page).not_to have_link 'My-reaction'
end
end
@@ -45,57 +36,56 @@ RSpec.describe 'Dropdown hint', :js do
describe 'behavior' do
before do
- expect(page).to have_css(js_dropdown_hint, visible: false)
- filtered_search.click
+ click_filtered_search_bar
end
it 'opens when the search bar is first focused' do
- expect(page).to have_css(js_dropdown_hint, visible: true)
+ expect_visible_suggestions_list
find('body').click
- expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect_hidden_suggestions_list
end
end
describe 'filtering' do
it 'filters with text' do
- filtered_search.set('a')
+ click_filtered_search_bar
+ send_keys 'as'
- expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6)
+ # Expect Assignee and Release
+ expect_suggestion_count 2
end
end
describe 'selecting from dropdown with no input' do
before do
- filtered_search.click
+ click_filtered_search_bar
end
it 'opens the token dropdown when you click on it' do
- click_hint('Author')
+ click_link 'Author'
- expect(page).to have_css(js_dropdown_hint, visible: false)
- expect(page).to have_css(js_dropdown_operator, visible: true)
+ expect_visible_suggestions_list
+ expect_suggestion '='
- click_operator('=')
+ click_link '= is'
- expect(page).to have_css(js_dropdown_hint, visible: false)
- expect(page).to have_css(js_dropdown_operator, visible: false)
- expect(page).to have_css('#js-dropdown-author', visible: true)
- expect_tokens([{ name: 'Author', operator: '=' }])
- expect_filtered_search_input_empty
+ expect_visible_suggestions_list
+ expect_token_segment 'Author'
+ expect_token_segment '='
+ expect_empty_search_term
end
end
describe 'reselecting from dropdown' do
it 'reuses existing token text' do
- filtered_search.send_keys('author')
- filtered_search.send_keys(:backspace)
- filtered_search.send_keys(:backspace)
- click_hint('Author')
+ click_filtered_search_bar
+ send_keys 'author', :backspace, :backspace
+ click_link 'Author'
- expect_tokens([{ name: 'Author' }])
- expect_filtered_search_input_empty
+ expect_token_segment 'Author'
+ expect_empty_search_term
end
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb
index 1b48810f716..67e3792a04c 100644
--- a/spec/features/issues/filtered_search/dropdown_label_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb
@@ -10,10 +10,9 @@ RSpec.describe 'Dropdown label', :js do
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:label) { create(:label, project: project, title: 'bug-label') }
- let(:filtered_search) { find('.filtered-search') }
- let(:filter_dropdown) { find('#js-dropdown-label .filter-dropdown') }
-
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_maintainer(user)
sign_in(user)
@@ -22,9 +21,10 @@ RSpec.describe 'Dropdown label', :js do
describe 'behavior' do
it 'loads all the labels when opened' do
- filtered_search.set('label:=')
+ select_tokens 'Label', '='
- expect_filtered_search_dropdown_results(filter_dropdown, 1)
+ # Expect None, Any, bug-label
+ expect_suggestion_count 3
end
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
index 859d1e4a5e5..19a4c8853f1 100644
--- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
@@ -11,10 +11,9 @@ RSpec.describe 'Dropdown milestone', :js do
let_it_be(:uppercase_milestone) { create(:milestone, title: 'CAP_MILESTONE', project: project) }
let_it_be(:issue) { create(:issue, project: project) }
- let(:filtered_search) { find('.filtered-search') }
- let(:filter_dropdown) { find('#js-dropdown-milestone .filter-dropdown') }
-
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_maintainer(user)
sign_in(user)
@@ -22,12 +21,11 @@ RSpec.describe 'Dropdown milestone', :js do
end
describe 'behavior' do
- before do
- filtered_search.set('milestone:=')
- end
-
it 'loads all the milestones when opened' do
- expect_filtered_search_dropdown_results(filter_dropdown, 2)
+ select_tokens 'Milestone', '='
+
+ # Expect None, Any, Upcoming, Started, CAP_MILESTONE, v1.0
+ expect_suggestion_count 6
end
end
end
diff --git a/spec/features/issues/filtered_search/dropdown_release_spec.rb b/spec/features/issues/filtered_search/dropdown_release_spec.rb
index 2210a26c251..50ac9068b26 100644
--- a/spec/features/issues/filtered_search/dropdown_release_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_release_spec.rb
@@ -5,16 +5,15 @@ require 'spec_helper'
RSpec.describe 'Dropdown release', :js do
include FilteredSearchHelpers
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:release) { create(:release, tag: 'v1.0', project: project) }
let_it_be(:crazy_release) { create(:release, tag: '☺!/"#%&\'{}+,-.<>;=@]_`{|}🚀', project: project) }
let_it_be(:issue) { create(:issue, project: project) }
- let(:filtered_search) { find('.filtered-search') }
- let(:filter_dropdown) { find('#js-dropdown-release .filter-dropdown') }
-
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_maintainer(user)
sign_in(user)
@@ -22,12 +21,11 @@ RSpec.describe 'Dropdown release', :js do
end
describe 'behavior' do
- before do
- filtered_search.set('release:=')
- end
-
it 'loads all the releases when opened' do
- expect_filtered_search_dropdown_results(filter_dropdown, 2)
+ select_tokens 'Release', '='
+
+ # Expect None, Any, v1.0, !/\"#%&'{}+,-.<>;=@]_`{|}
+ expect_suggestion_count 4
end
end
end
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index 1375384d1aa..13bce49e6d1 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -6,13 +6,8 @@ RSpec.describe 'Filter issues', :js do
include FilteredSearchHelpers
let(:project) { create(:project) }
-
- # NOTE: The short name here is actually important
- #
- # When the name is longer, the filtered search input can end up scrolling
- # horizontally, and PhantomJS can't handle it.
- let(:user) { create(:user, name: 'Ann') }
- let(:user2) { create(:user, name: 'jane') }
+ let(:user) { create(:user) }
+ let(:user2) { create(:user) }
let!(:bug_label) { create(:label, project: project, title: 'bug') }
let!(:caps_sensitive_label) { create(:label, project: project, title: 'CaPs') }
@@ -24,6 +19,7 @@ RSpec.describe 'Filter issues', :js do
end
before do
+ stub_feature_flags(vue_issues_list: true)
project.add_maintainer(user)
create(:issue, project: project, author: user2, title: "Bug report 1")
@@ -64,31 +60,25 @@ RSpec.describe 'Filter issues', :js do
it 'filters by all available tokens' do
search_term = 'issue'
+ select_tokens 'Assignee', '=', user.username, 'Author', '=', user.username, 'Label', '=', caps_sensitive_label.title, 'Milestone', '=', milestone.title
+ send_keys search_term, :enter
- input_filtered_search("assignee:=@#{user.username} author:=@#{user.username} label:=~#{caps_sensitive_label.title} milestone:=%#{milestone.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- assignee_token(user.name),
- author_token(user.name),
- label_token(caps_sensitive_label.title),
- milestone_token(milestone.title)
- ])
+ expect_assignee_token(user.name)
+ expect_author_token(user.name)
+ expect_label_token(caps_sensitive_label.title)
+ expect_milestone_token(milestone.title)
expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
+ expect_search_term(search_term)
end
describe 'filter issues by author' do
context 'only author' do
it 'filters issues by searched author' do
- input_filtered_search("author:=@#{user.username}")
+ select_tokens 'Author', '=', user.username, submit: true
- wait_for_requests
-
- expect_tokens([author_token(user.name)])
+ expect_author_token(user.name)
expect_issues_list_count(5)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
end
end
@@ -96,46 +86,30 @@ RSpec.describe 'Filter issues', :js do
describe 'filter issues by assignee' do
context 'only assignee' do
it 'filters issues by searched assignee' do
- input_filtered_search("assignee:=@#{user.username}")
-
- wait_for_requests
+ select_tokens 'Assignee', '=', user.username, submit: true
- expect_tokens([assignee_token(user.name)])
+ expect_assignee_token(user.name)
expect_issues_list_count(5)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by no assignee' do
- input_filtered_search('assignee:=none')
+ select_tokens 'Assignee', '=', 'None', submit: true
- expect_tokens([assignee_token('None')])
+ expect_assignee_token 'None'
expect_issues_list_count(3)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by invalid assignee' do
skip('to be tested, issue #26546')
end
-
- it 'filters issues by multiple assignees' do
- create(:issue, project: project, author: user, assignees: [user2, user])
-
- input_filtered_search("assignee:=@#{user.username} assignee:=@#{user2.username}")
-
- expect_tokens([
- assignee_token(user.name),
- assignee_token(user2.name)
- ])
-
- expect_issues_list_count(1)
- expect_filtered_search_input_empty
- end
end
end
describe 'filter by reviewer' do
it 'does not allow filtering by reviewer' do
- find('.filtered-search').click
+ click_filtered_search_bar
expect(page).not_to have_button('Reviewer')
end
@@ -144,57 +118,53 @@ RSpec.describe 'Filter issues', :js do
describe 'filter issues by label' do
context 'only label' do
it 'filters issues by searched label' do
- input_filtered_search("label:=~#{bug_label.title}")
+ select_tokens 'Label', '=', bug_label.title, submit: true
- expect_tokens([label_token(bug_label.title)])
+ expect_label_token(bug_label.title)
expect_issues_list_count(2)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues not containing searched label' do
- input_filtered_search("label:!=~#{bug_label.title}")
+ select_tokens 'Label', '!=', bug_label.title, submit: true
- expect_tokens([label_token(bug_label.title)])
+ expect_negated_label_token(bug_label.title)
expect_issues_list_count(6)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by any label' do
- input_filtered_search('label:=any')
+ select_tokens 'Label', '=', 'Any', submit: true
- expect_tokens([label_token('Any', false)])
+ expect_label_token 'Any'
expect_issues_list_count(4)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by no label' do
- input_filtered_search('label:=none')
+ select_tokens 'Label', '=', 'None', submit: true
- expect_tokens([label_token('None', false)])
+ expect_label_token 'None'
expect_issues_list_count(4)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by multiple labels' do
- input_filtered_search("label:=~#{bug_label.title} label:=~#{caps_sensitive_label.title}")
+ select_tokens 'Label', '=', bug_label.title, 'Label', '=', caps_sensitive_label.title, submit: true
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title)
- ])
+ expect_label_token(bug_label.title)
+ expect_label_token(caps_sensitive_label.title)
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by multiple labels with not operator' do
- input_filtered_search("label:!=~#{bug_label.title} label:=~#{caps_sensitive_label.title}")
+ select_tokens 'Label', '!=', bug_label.title, 'Label', '=', caps_sensitive_label.title, submit: true
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title)
- ])
+ expect_negated_label_token(bug_label.title)
+ expect_label_token(caps_sensitive_label.title)
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by label containing special characters' do
@@ -202,11 +172,11 @@ RSpec.describe 'Filter issues', :js do
special_issue = create(:issue, title: "Issue with special character label", project: project)
special_issue.labels << special_label
- input_filtered_search("label:=~#{special_label.title}")
+ select_tokens 'Label', '=', special_label.title, submit: true
- expect_tokens([label_token(special_label.title)])
+ expect_label_token(special_label.title)
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by label not containing special characters' do
@@ -214,21 +184,21 @@ RSpec.describe 'Filter issues', :js do
special_issue = create(:issue, title: "Issue with special character label", project: project)
special_issue.labels << special_label
- input_filtered_search("label:!=~#{special_label.title}")
+ select_tokens 'Label', '!=', special_label.title, submit: true
- expect_tokens([label_token(special_label.title)])
+ expect_negated_label_token(special_label.title)
expect_issues_list_count(8)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'does not show issues for unused labels' do
new_label = create(:label, project: project, title: 'new_label')
- input_filtered_search("label:=~#{new_label.title}")
+ select_tokens 'Label', '=', new_label.title, submit: true
- expect_tokens([label_token(new_label.title)])
+ expect_label_token(new_label.title)
expect_no_issues_list
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
end
@@ -238,31 +208,28 @@ RSpec.describe 'Filter issues', :js do
special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project)
special_multiple_issue.labels << special_multiple_label
- input_filtered_search("label:=~'#{special_multiple_label.title}'")
+ select_tokens 'Label', '=', special_multiple_label.title, submit: true
# Check for search results (which makes sure that the page has changed)
expect_issues_list_count(1)
-
- # filtered search defaults quotations to double quotes
- expect_tokens([label_token("\"#{special_multiple_label.title}\"")])
-
- expect_filtered_search_input_empty
+ expect_label_token(special_multiple_label.title)
+ expect_empty_search_term
end
it 'single quotes' do
- input_filtered_search("label:=~'#{multiple_words_label.title}'")
+ select_tokens 'Label', '=', multiple_words_label.title, submit: true
expect_issues_list_count(1)
- expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
- expect_filtered_search_input_empty
+ expect_label_token(multiple_words_label.title)
+ expect_empty_search_term
end
it 'double quotes' do
- input_filtered_search("label:=~\"#{multiple_words_label.title}\"")
+ select_tokens 'Label', '=', multiple_words_label.title, submit: true
- expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
+ expect_label_token(multiple_words_label.title)
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'single quotes containing double quotes' do
@@ -270,11 +237,11 @@ RSpec.describe 'Filter issues', :js do
double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project)
double_quotes_label_issue.labels << double_quotes_label
- input_filtered_search("label:=~'#{double_quotes_label.title}'")
+ select_tokens 'Label', '=', double_quotes_label.title, submit: true
- expect_tokens([label_token("'#{double_quotes_label.title}'")])
+ expect_label_token(double_quotes_label.title)
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'double quotes containing single quotes' do
@@ -282,49 +249,41 @@ RSpec.describe 'Filter issues', :js do
single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project)
single_quotes_label_issue.labels << single_quotes_label
- input_filtered_search("label:=~\"#{single_quotes_label.title}\"")
+ select_tokens 'Label', '=', single_quotes_label.title, submit: true
- expect_tokens([label_token("\"#{single_quotes_label.title}\"")])
+ expect_label_token(single_quotes_label.title)
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
end
context 'multiple labels with other filters' do
it 'filters issues by searched label, label2, author, assignee, milestone and text' do
search_term = 'bug'
-
- input_filtered_search("label:=~#{bug_label.title} label:=~#{caps_sensitive_label.title} author:=@#{user.username} assignee:=@#{user.username} milestone:=%#{milestone.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title),
- author_token(user.name),
- assignee_token(user.name),
- milestone_token(milestone.title)
- ])
+ select_tokens 'Label', '=', bug_label.title, 'Label', '=', caps_sensitive_label.title, 'Author', '=', user.username, 'Assignee', '=', user.username, 'Milestone', '=', milestone.title
+ send_keys search_term, :enter
+
+ expect_label_token(bug_label.title)
+ expect_label_token(caps_sensitive_label.title)
+ expect_author_token(user.name)
+ expect_assignee_token(user.name)
+ expect_milestone_token(milestone.title)
expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
+ expect_search_term(search_term)
end
it 'filters issues by searched label, label2, author, assignee, not included in a milestone' do
search_term = 'bug'
-
- input_filtered_search("label:=~#{bug_label.title} label:=~#{caps_sensitive_label.title} author:=@#{user.username} assignee:=@#{user.username} milestone:!=%#{milestone.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title),
- author_token(user.name),
- assignee_token(user.name),
- milestone_token(milestone.title, false, '!=')
- ])
+ select_tokens 'Label', '=', bug_label.title, 'Label', '=', caps_sensitive_label.title, 'Author', '=', user.username, 'Assignee', '=', user.username, 'Milestone', '!=', milestone.title
+ send_keys search_term, :enter
+
+ expect_label_token(bug_label.title)
+ expect_label_token(caps_sensitive_label.title)
+ expect_author_token(user.name)
+ expect_assignee_token(user.name)
+ expect_negated_milestone_token(milestone.title)
expect_issues_list_count(0)
- expect_filtered_search_input(search_term)
+ expect_search_term(search_term)
end
end
@@ -333,8 +292,8 @@ RSpec.describe 'Filter issues', :js do
click_link multiple_words_label.title
expect_issues_list_count(1)
- expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
- expect_filtered_search_input_empty
+ expect_label_token(multiple_words_label.title)
+ expect_empty_search_term
end
end
end
@@ -342,19 +301,19 @@ RSpec.describe 'Filter issues', :js do
describe 'filter issues by milestone' do
context 'only milestone' do
it 'filters issues by searched milestone' do
- input_filtered_search("milestone:=%#{milestone.title}")
+ select_tokens 'Milestone', '=', milestone.title, submit: true
- expect_tokens([milestone_token(milestone.title)])
+ expect_milestone_token(milestone.title)
expect_issues_list_count(5)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by no milestone' do
- input_filtered_search("milestone:=none")
+ select_tokens 'Milestone', '=', 'None', submit: true
- expect_tokens([milestone_token('None', false)])
+ expect_milestone_token 'None'
expect_issues_list_count(3)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by upcoming milestones' do
@@ -362,11 +321,11 @@ RSpec.describe 'Filter issues', :js do
create(:issue, project: project, milestone: future_milestone, author: user)
end
- input_filtered_search("milestone:=upcoming")
+ select_tokens 'Milestone', '=', 'Upcoming', submit: true
- expect_tokens([milestone_token('Upcoming', false)])
+ expect_milestone_token 'Upcoming'
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by negation of upcoming milestones' do
@@ -378,72 +337,72 @@ RSpec.describe 'Filter issues', :js do
create(:issue, project: project, milestone: past_milestone, author: user)
end
- input_filtered_search("milestone:!=upcoming")
+ select_tokens 'Milestone', '!=', 'Upcoming', submit: true
- expect_tokens([milestone_token('Upcoming', false, '!=')])
+ expect_negated_milestone_token 'Upcoming'
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by started milestones' do
- input_filtered_search("milestone:=started")
+ select_tokens 'Milestone', '=', 'Started', submit: true
- expect_tokens([milestone_token('Started', false)])
+ expect_milestone_token 'Started'
expect_issues_list_count(5)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by negation of started milestones' do
milestone2 = create(:milestone, title: "9", project: project, start_date: 2.weeks.from_now)
create(:issue, project: project, author: user, title: "something else", milestone: milestone2)
- input_filtered_search("milestone:!=started")
+ select_tokens 'Milestone', '!=', 'Started', submit: true
- expect_tokens([milestone_token('Started', false, '!=')])
+ expect_negated_milestone_token 'Started'
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by milestone containing special characters' do
special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
create(:issue, project: project, milestone: special_milestone)
- input_filtered_search("milestone:=%#{special_milestone.title}")
+ select_tokens 'Milestone', '=', special_milestone.title, submit: true
- expect_tokens([milestone_token(special_milestone.title)])
+ expect_milestone_token(special_milestone.title)
expect_issues_list_count(1)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'filters issues by milestone not containing special characters' do
special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
create(:issue, project: project, milestone: special_milestone)
- input_filtered_search("milestone:!=%#{special_milestone.title}")
+ select_tokens 'Milestone', '!=', special_milestone.title, submit: true
- expect_tokens([milestone_token(special_milestone.title, false, '!=')])
+ expect_negated_milestone_token(special_milestone.title)
expect_issues_list_count(8)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'does not show issues for unused milestones' do
new_milestone = create(:milestone, title: 'new', project: project)
- input_filtered_search("milestone:=%#{new_milestone.title}")
+ select_tokens 'Milestone', '=', new_milestone.title, submit: true
- expect_tokens([milestone_token(new_milestone.title)])
+ expect_milestone_token(new_milestone.title)
expect_no_issues_list
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
it 'show issues for unused milestones' do
new_milestone = create(:milestone, title: 'new', project: project)
- input_filtered_search("milestone:!=%#{new_milestone.title}")
+ select_tokens 'Milestone', '!=', new_milestone.title, submit: true
- expect_tokens([milestone_token(new_milestone.title, false, '!=')])
+ expect_negated_milestone_token(new_milestone.title)
expect_issues_list_count(8)
- expect_filtered_search_input_empty
+ expect_empty_search_term
end
end
end
@@ -452,47 +411,47 @@ RSpec.describe 'Filter issues', :js do
context 'only text' do
it 'filters issues by searched text' do
search = 'Bug'
- input_filtered_search(search)
+ submit_search_term(search)
expect_issues_list_count(4)
- expect_filtered_search_input(search)
+ expect_search_term(search)
end
it 'filters issues by multiple searched text' do
search = 'Bug report'
- input_filtered_search(search)
+ submit_search_term(search)
expect_issues_list_count(3)
- expect_filtered_search_input(search)
+ expect_search_term(search)
end
it 'filters issues by case insensitive searched text' do
search = 'bug report'
- input_filtered_search(search)
+ submit_search_term(search)
expect_issues_list_count(3)
- expect_filtered_search_input(search)
+ expect_search_term(search)
end
it 'filters issues by searched text containing single quotes' do
issue = create(:issue, project: project, author: user, title: "issue with 'single quotes'")
- search = "'single quotes'"
- input_filtered_search(search)
+ search = 'single quotes'
+ submit_search_term "'#{search}'"
expect_issues_list_count(1)
- expect_filtered_search_input(search)
+ expect_search_term(search)
expect(page).to have_content(issue.title)
end
it 'filters issues by searched text containing double quotes' do
issue = create(:issue, project: project, author: user, title: "issue with \"double quotes\"")
- search = '"double quotes"'
- input_filtered_search(search)
+ search = 'double quotes'
+ submit_search_term "\"#{search}\""
expect_issues_list_count(1)
- expect_filtered_search_input(search)
+ expect_search_term(search)
expect(page).to have_content(issue.title)
end
@@ -502,36 +461,43 @@ RSpec.describe 'Filter issues', :js do
issue = create(:issue, project: project, author: user, title: "issue with !@\#{$%^&*()-+")
search = '!@#{$%^&*()-+'
- input_filtered_search(search)
+ submit_search_term(search)
expect_issues_list_count(1)
- expect_filtered_search_input(search)
+ expect_search_term(search)
expect(page).to have_content(issue.title)
end
it 'does not show any issues' do
search = 'testing'
- input_filtered_search(search)
+ submit_search_term(search)
expect_no_issues_list
- expect_filtered_search_input(search)
+ expect_search_term(search)
end
it 'filters issues by issue reference' do
search = '#1'
- input_filtered_search(search)
+ submit_search_term(search)
expect_issues_list_count(1)
- expect_filtered_search_input(search)
+ expect_search_term(search)
end
end
context 'searched text with other filters' do
it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do
- input_filtered_search("bug author:=@#{user.username} report label:=~#{bug_label.title} label:=~#{caps_sensitive_label.title} milestone:=%#{milestone.title} foo")
+ click_filtered_search_bar
+ send_keys 'bug '
+ select_tokens 'Author', '=', user.username
+ send_keys 'report '
+ select_tokens 'Label', '=', bug_label.title
+ select_tokens 'Label', '=', caps_sensitive_label.title
+ select_tokens 'Milestone', '=', milestone.title
+ send_keys 'foo', :enter
expect_issues_list_count(1)
- expect_filtered_search_input('bug report foo')
+ expect_search_term('bug report foo')
end
end
@@ -549,17 +515,11 @@ RSpec.describe 'Filter issues', :js do
author: user,
created_at: 5.days.ago)
- input_filtered_search('days ago')
+ submit_search_term 'days ago'
expect_issues_list_count(2)
-
- sort_toggle = find('.filter-dropdown-container .dropdown')
- sort_toggle.click
-
- find('.filter-dropdown-container .dropdown-menu li a', text: 'Created date').click
- wait_for_requests
-
- expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(new_issue.title)
+ expect(page).to have_button 'Created date'
+ expect(page).to have_css('.issue:first-of-type .issue-title', text: new_issue.title)
end
end
end
@@ -568,7 +528,7 @@ RSpec.describe 'Filter issues', :js do
let!(:closed_issue) { create(:issue, :closed, project: project, title: 'closed bug') }
before do
- input_filtered_search('bug')
+ submit_search_term 'bug'
# This ensures that the search is performed
expect_issues_list_count(4, 1)
@@ -599,19 +559,17 @@ RSpec.describe 'Filter issues', :js do
end
it 'milestone dropdown loads milestones' do
- input_filtered_search("milestone:=", submit: false)
+ select_tokens 'Milestone', '='
- within('#js-dropdown-milestone') do
- expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
- end
+ # Expect None, Any, Upcoming, Started, 8
+ expect_suggestion_count 5
end
it 'label dropdown load labels' do
- input_filtered_search("label:=", submit: false)
+ select_tokens 'Label', '='
- within('#js-dropdown-label') do
- expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3)
- end
+ # Dropdown shows None, Any, and 3 labels
+ expect_suggestion_count 5
end
end
end
diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb
index 3929d3694ff..bb5964258be 100644
--- a/spec/features/issues/filtered_search/recent_searches_spec.rb
+++ b/spec/features/issues/filtered_search/recent_searches_spec.rb
@@ -4,7 +4,6 @@ require 'spec_helper'
RSpec.describe 'Recent searches', :js do
include FilteredSearchHelpers
- include MobileHelpers
let_it_be(:project_1) { create(:project, :public) }
let_it_be(:project_2) { create(:project, :public) }
@@ -14,116 +13,96 @@ RSpec.describe 'Recent searches', :js do
let(:project_1_local_storage_key) { "#{project_1.full_path}-issue-recent-searches" }
before do
- Capybara.ignore_hidden_elements = false
+ stub_feature_flags(vue_issues_list: true)
# Visit any fast-loading page so we can clear local storage without a DOM exception
visit '/404'
remove_recent_searches
end
- after do
- Capybara.ignore_hidden_elements = true
- end
-
it 'searching adds to recent searches' do
visit project_issues_path(project_1)
- input_filtered_search('foo', submit: true)
- input_filtered_search('bar', submit: true)
-
- items = all('.filtered-search-history-dropdown-item', visible: false, count: 2)
+ submit_then_clear_search 'foo'
+ submit_then_clear_search 'bar'
+ click_button 'Toggle history'
- expect(items[0].text).to eq('bar')
- expect(items[1].text).to eq('foo')
+ expect_recent_searches_history_item 'bar'
+ expect_recent_searches_history_item 'foo'
end
it 'visiting URL with search params adds to recent searches' do
visit project_issues_path(project_1, label_name: 'foo', search: 'bar')
visit project_issues_path(project_1, label_name: 'qux', search: 'garply')
- items = all('.filtered-search-history-dropdown-item', visible: false, count: 2)
+ click_button 'Toggle history'
- expect(items[0].text).to eq('label: = ~qux garply')
- expect(items[1].text).to eq('label: = ~foo bar')
+ expect_recent_searches_history_item 'Label := qux garply'
+ expect_recent_searches_history_item 'Label := foo bar'
end
it 'saved recent searches are restored last on the list' do
- set_recent_searches(project_1_local_storage_key, '["saved1", "saved2"]')
+ set_recent_searches(project_1_local_storage_key, '[[{"type":"filtered-search-term","value":{"data":"saved1"}}],[{"type":"filtered-search-term","value":{"data":"saved2"}}]]')
visit project_issues_path(project_1, search: 'foo')
+ click_button 'Toggle history'
- items = all('.filtered-search-history-dropdown-item', visible: false, count: 3)
-
- expect(items[0].text).to eq('foo')
- expect(items[1].text).to eq('saved1')
- expect(items[2].text).to eq('saved2')
+ expect_recent_searches_history_item 'foo'
+ expect_recent_searches_history_item 'saved1'
+ expect_recent_searches_history_item 'saved2'
end
it 'searches are scoped to projects' do
visit project_issues_path(project_1)
- input_filtered_search('foo', submit: true)
- input_filtered_search('bar', submit: true)
+ submit_then_clear_search 'foo'
+ submit_then_clear_search 'bar'
visit project_issues_path(project_2)
- input_filtered_search('more', submit: true)
- input_filtered_search('things', submit: true)
-
- items = all('.filtered-search-history-dropdown-item', visible: false, count: 2)
+ submit_then_clear_search 'more'
+ submit_then_clear_search 'things'
+ click_button 'Toggle history'
- expect(items[0].text).to eq('things')
- expect(items[1].text).to eq('more')
+ expect_recent_searches_history_item 'things'
+ expect_recent_searches_history_item 'more'
end
it 'clicking item fills search input' do
- set_recent_searches(project_1_local_storage_key, '["foo", "bar"]')
+ set_recent_searches(project_1_local_storage_key, '[[{"type":"filtered-search-term","value":{"data":"foo"}}],[{"type":"filtered-search-term","value":{"data":"bar"}}]]')
visit project_issues_path(project_1)
- find('.filtered-search-history-dropdown-toggle-button').click
- all('.filtered-search-history-dropdown-item', count: 2)[0].click
- wait_for_filtered_search('foo')
+ click_button 'Toggle history'
+ click_button 'foo'
- expect(find('.filtered-search').value.strip).to eq('foo')
+ expect_search_term 'foo'
end
it 'clear recent searches button, clears recent searches' do
- set_recent_searches(project_1_local_storage_key, '["foo"]')
+ set_recent_searches(project_1_local_storage_key, '[[{"type":"filtered-search-term","value":{"data":"foo"}}]]')
visit project_issues_path(project_1)
- find('.filtered-search-history-dropdown-toggle-button').click
- all('.filtered-search-history-dropdown-item', count: 1)
+ click_button 'Toggle history'
- find('.filtered-search-history-clear-button').click
- items_after = all('.filtered-search-history-dropdown-item', count: 0)
+ expect_recent_searches_history_item_count 1
- expect(items_after.count).to eq(0)
+ click_button 'Clear recent searches'
+ click_button 'Toggle history'
+
+ expect(page).to have_text "You don't have any recent searches"
+ expect_recent_searches_history_item_count 0
end
it 'shows flash error when failed to parse saved history' do
set_recent_searches(project_1_local_storage_key, 'fail')
visit project_issues_path(project_1)
- expect(find('[data-testid="alert-danger"]')).to have_text('An error occurred while parsing recent searches')
+ expect(page).to have_text 'An error occurred while parsing recent searches'
end
- context 'on tablet/mobile screen' do
- it 'shows only the history icon in the dropdown' do
- resize_screen_sm
- visit project_issues_path(project_1)
-
- expect(find('.filtered-search-history-dropdown-wrapper')).to have_selector('svg', visible: true)
- expect(find('.filtered-search-history-dropdown-wrapper')).to have_selector('span', text: 'Recent searches', visible: false)
- end
- end
-
- context 'on PC screen' do
- it 'shows only the Recent searches text in the dropdown' do
- restore_window_size
- visit project_issues_path(project_1)
-
- expect(find('.filtered-search-history-dropdown-wrapper')).to have_selector('svg', visible: false)
- expect(find('.filtered-search-history-dropdown-wrapper')).to have_selector('span', text: 'Recent searches', visible: true)
- end
+ def submit_then_clear_search(search)
+ click_filtered_search_bar
+ send_keys(search, :enter)
+ click_button 'Clear'
end
end
diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb
index 60963d95ae5..8639ec2a227 100644
--- a/spec/features/issues/filtered_search/search_bar_spec.rb
+++ b/spec/features/issues/filtered_search/search_bar_spec.rb
@@ -9,102 +9,74 @@ RSpec.describe 'Search bar', :js do
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
- let(:filtered_search) { find('.filtered-search') }
-
before do
+ stub_feature_flags(vue_issues_list: true)
project.add_maintainer(user)
sign_in(user)
visit project_issues_path(project)
end
- def get_left_style(style)
- left_style = /left:\s\d*[.]\d*px/.match(style)
- left_style.to_s.gsub('left: ', '').to_f
- end
-
describe 'keyboard navigation' do
- it 'makes item active' do
- filtered_search.native.send_keys(:down)
-
- page.within '#js-dropdown-hint' do
- expect(page).to have_selector('.droplab-item-active')
- end
- end
-
it 'selects item' do
- filtered_search.native.send_keys(:down, :down, :enter)
+ click_filtered_search_bar
+ send_keys :down, :enter
- expect_tokens([{ name: 'Assignee' }])
- expect_filtered_search_input_empty
+ expect_token_segment 'Assignee'
end
end
describe 'clear search button' do
it 'clears text' do
search_text = 'search_text'
- filtered_search.set(search_text)
+ click_filtered_search_bar
+ send_keys search_text
+
+ expect(page).to have_field 'Search', with: search_text
- expect(filtered_search.value).to eq(search_text)
- find('.filtered-search-box .clear-search').click
+ click_button 'Clear'
- expect(filtered_search.value).to eq('')
+ expect(page).to have_field 'Search', with: ''
end
it 'hides by default' do
- expect(page).to have_css('.clear-search', visible: false)
+ expect(page).not_to have_button 'Clear'
end
it 'hides after clicked' do
- filtered_search.set('a')
- find('.filtered-search-box .clear-search').click
+ click_filtered_search_bar
+ send_keys 'a'
- expect(page).to have_css('.clear-search', visible: false)
+ click_button 'Clear'
+
+ expect(page).not_to have_button 'Clear'
end
it 'hides when there is no text' do
- filtered_search.set('a')
- filtered_search.set('')
+ click_filtered_search_bar
+ send_keys 'a', :backspace, :backspace
- expect(page).to have_css('.clear-search', visible: false)
+ expect(page).not_to have_button 'Clear'
end
it 'shows when there is text' do
- filtered_search.set('a')
+ click_filtered_search_bar
+ send_keys 'a'
- expect(page).to have_css('.clear-search', visible: true)
+ expect(page).to have_button 'Clear'
end
it 'resets the dropdown hint filter' do
- filtered_search.click
- original_size = page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size
-
- filtered_search.set('autho')
-
- expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
-
- find('.filtered-search-box .clear-search').click
- filtered_search.click
-
- expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: original_size)
- end
-
- it 'resets the dropdown filters' do
- filtered_search.click
-
- hint_offset = get_left_style(find('#js-dropdown-hint')['style'])
-
- filtered_search.set('a')
-
- filtered_search.set('author:')
+ click_filtered_search_bar
+ original_size = get_suggestion_count
+ send_keys 'autho'
- find('#js-dropdown-hint', visible: false)
+ expect_suggestion_count 1
- find('.filtered-search-box .clear-search').click
- filtered_search.click
+ click_button 'Clear'
+ click_filtered_search_bar
- expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', minimum: 6)
- expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset)
+ expect_suggestion_count(original_size)
end
end
end
diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb
index 2d8587d886f..9fb6a4cc2af 100644
--- a/spec/features/issues/filtered_search/visual_tokens_spec.rb
+++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb
@@ -14,178 +14,160 @@ RSpec.describe 'Visual tokens', :js do
let_it_be(:cc_label) { create(:label, project: project, title: 'Community Contribution') }
let_it_be(:issue) { create(:issue, project: project) }
- let(:filtered_search) { find('.filtered-search') }
- let(:filter_author_dropdown) { find("#js-dropdown-author .filter-dropdown") }
-
- def is_input_focused
- page.evaluate_script("document.activeElement.classList.contains('filtered-search')")
- end
-
before do
+ stub_feature_flags(vue_issues_list: true)
project.add_user(user, :maintainer)
project.add_user(user_rock, :maintainer)
sign_in(user)
- set_cookie('sidebar_collapsed', 'true')
-
visit project_issues_path(project)
end
describe 'editing a single token' do
before do
- input_filtered_search('author:=@root assignee:=none', submit: false)
- first('.tokens-container .filtered-search-token').click
- wait_for_requests
+ select_tokens 'Author', '=', user.username, 'Assignee', '=', 'None'
+ click_token_segment(user.name)
end
it 'opens author dropdown' do
- expect(page).to have_css('#js-dropdown-author', visible: true)
- expect_filtered_search_input('@root')
+ expect_visible_suggestions_list
+ expect(page).to have_field('Search', with: 'root')
end
it 'filters value' do
- filtered_search.send_keys(:backspace)
+ send_keys :backspace
- expect(page).to have_css('#js-dropdown-author .filter-dropdown .filter-dropdown-item', count: 1)
+ expect_suggestion_count 1
end
it 'ends editing mode when document is clicked' do
find('.js-navbar').click
- expect_filtered_search_input_empty
- expect(page).to have_css('#js-dropdown-author', visible: false)
+ expect_empty_search_term
+ expect_hidden_suggestions_list
end
describe 'selecting different author from dropdown' do
before do
- filter_author_dropdown.find('.filter-dropdown-item .dropdown-light-content', text: "@#{user_rock.username}").click
+ send_keys :backspace, :backspace, :backspace, :backspace
+ click_on user_rock.name
end
it 'changes value in visual token' do
- wait_for_requests
- expect(first('.tokens-container .filtered-search-token .value').text).to eq("#{user_rock.name}")
- end
-
- it 'moves input to the right' do
- expect(is_input_focused).to eq(true)
+ expect_author_token(user_rock.name)
end
end
end
describe 'editing multiple tokens' do
before do
- input_filtered_search('author:=@root assignee:=none', submit: false)
- first('.tokens-container .filtered-search-token').click
+ select_tokens 'Author', '=', user.username, 'Assignee', '=', 'None'
+ click_token_segment(user.name)
end
it 'opens author dropdown' do
- expect(page).to have_css('#js-dropdown-author', visible: true)
+ expect_visible_suggestions_list
end
it 'opens assignee dropdown' do
- find('.tokens-container .filtered-search-token', text: 'Assignee').click
- expect(page).to have_css('#js-dropdown-assignee', visible: true)
+ click_token_segment 'Assignee'
+
+ expect_visible_suggestions_list
end
end
describe 'editing a search term while editing another filter token' do
before do
- input_filtered_search('foo assignee:=', submit: false)
- first('.tokens-container .filtered-search-term').click
+ click_filtered_search_bar
+ send_keys 'foo '
+ select_tokens 'Assignee', '='
+ click_token_segment 'foo'
+ send_keys ' '
end
it 'opens author dropdown' do
- find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'Author').click
+ click_on 'Author'
- expect(page).to have_css('#js-dropdown-operator', visible: true)
- expect(page).to have_css('#js-dropdown-author', visible: false)
+ expect_suggestion '='
+ expect_suggestion '!='
- find('#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value="="]').click
+ click_on '= is'
- expect(page).to have_css('#js-dropdown-operator', visible: false)
- expect(page).to have_css('#js-dropdown-author', visible: true)
+ expect_suggestion(user.name)
+ expect_suggestion(user_rock.name)
end
end
describe 'add new token after editing existing token' do
before do
- input_filtered_search('author:=@root assignee:=none', submit: false)
- first('.tokens-container .filtered-search-token').click
- filtered_search.send_keys(' ')
+ select_tokens 'Assignee', '=', user.username, 'Label', '=', 'None'
+ click_token_segment(user.name)
+ send_keys ' '
end
describe 'opens dropdowns' do
it 'opens hint dropdown' do
- expect(page).to have_css('#js-dropdown-hint', visible: true)
+ expect_visible_suggestions_list
end
it 'opens token dropdown' do
- filtered_search.send_keys('author:=')
+ click_on 'Author'
- expect(page).to have_css('#js-dropdown-author', visible: true)
+ expect_visible_suggestions_list
end
end
describe 'visual tokens' do
it 'creates visual token' do
- filtered_search.send_keys('author:=@thomas ')
- token = page.all('.tokens-container .filtered-search-token')[1]
+ click_on 'Author'
+ click_on '= is'
+ click_on 'The Rock'
- expect(token.find('.name').text).to eq('Author')
- expect(token.find('.value').text).to eq('@thomas')
+ expect_author_token 'The Rock'
end
end
it 'does not tokenize incomplete token' do
- filtered_search.send_keys('author:=')
-
+ click_on 'Author'
find('.js-navbar').click
- token = page.all('.tokens-container .js-visual-token')[1]
- expect_filtered_search_input_empty
- expect(token.find('.name').text).to eq('Author')
+ expect_empty_search_term
+ expect_token_segment 'Assignee'
end
end
describe 'search using incomplete visual tokens' do
before do
- input_filtered_search('author:=@root assignee:=none', extra_space: false)
+ select_tokens 'Author', '=', user.username, 'Assignee', '=', 'None'
end
it 'tokenizes the search term to complete visual token' do
- expect_tokens([
- author_token(user.name),
- assignee_token('None')
- ])
+ expect_author_token(user.name)
+ expect_assignee_token 'None'
end
end
it 'does retain hint token when mix of typing and clicks are performed' do
- input_filtered_search('label:', extra_space: false, submit: false)
-
- expect(page).to have_css('#js-dropdown-operator', visible: true)
-
- find('#js-dropdown-operator li[data-value="="]').click
-
- token = page.all('.tokens-container .js-visual-token')[0]
+ select_tokens 'Label'
+ click_on '= is'
- expect(token.find('.name').text).to eq('Label')
- expect(token.find('.operator').text).to eq('=')
+ expect_token_segment 'Label'
+ expect_token_segment '='
end
describe 'Any/None option' do
it 'hidden when NOT operator is selected' do
- input_filtered_search('milestone:!=', extra_space: false, submit: false)
+ select_tokens 'Milestone', '!='
- expect(page).not_to have_selector("#js-dropdown-milestone", text: 'Any')
- expect(page).not_to have_selector("#js-dropdown-milestone", text: 'None')
+ expect_no_suggestion 'Any'
+ expect_no_suggestion 'None'
end
it 'shown when EQUAL operator is selected' do
- input_filtered_search('milestone:=', extra_space: false, submit: false)
+ select_tokens 'Milestone', '='
- expect(page).to have_selector("#js-dropdown-milestone", text: 'Any')
- expect(page).to have_selector("#js-dropdown-milestone", text: 'None')
+ expect_suggestion 'Any'
+ expect_suggestion 'None'
end
end
end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 6f4a13c5fad..8732e2ecff2 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -445,7 +445,7 @@ RSpec.describe 'GFM autocomplete', :js do
click_button('Cancel')
page.within('.modal') do
- click_button('OK', match: :first)
+ click_button('Discard changes', match: :first)
end
wait_for_requests
diff --git a/spec/features/issues/rss_spec.rb b/spec/features/issues/rss_spec.rb
index b20502ecc25..bdc5f282875 100644
--- a/spec/features/issues/rss_spec.rb
+++ b/spec/features/issues/rss_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Project Issues RSS' do
+RSpec.describe 'Project Issues RSS', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
@@ -13,6 +13,10 @@ RSpec.describe 'Project Issues RSS' do
group.add_developer(user)
end
+ before do
+ stub_feature_flags(vue_issues_list: true)
+ end
+
context 'when signed in' do
let_it_be(:user) { create(:user) }
@@ -25,7 +29,10 @@ RSpec.describe 'Project Issues RSS' do
visit path
end
- it_behaves_like "it has an RSS button with current_user's feed token"
+ it "shows the RSS button with current_user's feed token" do
+ expect(page).to have_link 'Subscribe to RSS feed', href: /feed_token=#{user.feed_token}/
+ end
+
it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
end
@@ -34,7 +41,10 @@ RSpec.describe 'Project Issues RSS' do
visit path
end
- it_behaves_like "it has an RSS button without a feed token"
+ it "shows the RSS button without a feed token" do
+ expect(page).not_to have_link 'Subscribe to RSS feed', href: /feed_token/
+ end
+
it_behaves_like "an autodiscoverable RSS feed without a feed token"
end
diff --git a/spec/features/issues/user_bulk_edits_issues_labels_spec.rb b/spec/features/issues/user_bulk_edits_issues_labels_spec.rb
index 71213fb661f..27377f6e1fd 100644
--- a/spec/features/issues/user_bulk_edits_issues_labels_spec.rb
+++ b/spec/features/issues/user_bulk_edits_issues_labels_spec.rb
@@ -12,8 +12,12 @@ RSpec.describe 'Issues > Labels bulk assignment' do
let!(:issue1) { create(:issue, project: project, title: "Issue 1", labels: [frontend]) }
let!(:issue2) { create(:issue, project: project, title: "Issue 2") }
- let(:issue_1_selector) { "#issue_#{issue1.id}" }
- let(:issue_2_selector) { "#issue_#{issue2.id}" }
+ let(:issue_1_selector) { "#issuable_#{issue1.id}" }
+ let(:issue_2_selector) { "#issuable_#{issue2.id}" }
+
+ before do
+ stub_feature_flags(vue_issues_list: true)
+ end
context 'as an allowed user', :js do
before do
diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
index ae1bce7ea4c..1c707466b51 100644
--- a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
+++ b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
@@ -25,6 +25,20 @@ RSpec.describe 'User creates branch and merge request on issue page', :js do
sign_in(user)
end
+ context 'when ’Create merge request’ button is clicked' do
+ before do
+ visit project_issue_path(project, issue)
+
+ wait_for_requests
+
+ click_button('Create merge request')
+
+ wait_for_requests
+ end
+
+ it_behaves_like 'merge request author auto assign'
+ end
+
context 'when interacting with the dropdown' do
before do
visit project_issue_path(project, issue)
diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb
index 8a5e33ba18c..3bba041dab7 100644
--- a/spec/features/issues/user_creates_issue_spec.rb
+++ b/spec/features/issues/user_creates_issue_spec.rb
@@ -8,12 +8,16 @@ RSpec.describe "User creates issue" do
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:user) { create(:user) }
+ before do
+ stub_feature_flags(vue_issues_list: true)
+ end
+
context "when unauthenticated" do
before do
sign_out(:user)
end
- it "redirects to signin then back to new issue after signin" do
+ it "redirects to signin then back to new issue after signin", :js do
create(:issue, project: project)
visit project_issues_path(project)
diff --git a/spec/features/issues/user_filters_issues_spec.rb b/spec/features/issues/user_filters_issues_spec.rb
index 5d05df6aaf0..42c2b5d32c1 100644
--- a/spec/features/issues/user_filters_issues_spec.rb
+++ b/spec/features/issues/user_filters_issues_spec.rb
@@ -7,6 +7,8 @@ RSpec.describe 'User filters issues', :js do
let_it_be(:project) { create(:project_empty_repo, :public) }
before do
+ stub_feature_flags(vue_issues_list: true)
+
%w[foobar barbaz].each do |title|
create(:issue,
author: user,
@@ -24,7 +26,7 @@ RSpec.describe 'User filters issues', :js do
let(:issue) { @issue }
it 'allows filtering by issues with no specified assignee' do
- visit project_issues_path(project, assignee_id: IssuableFinder::Params::FILTER_NONE)
+ visit project_issues_path(project, assignee_id: IssuableFinder::Params::FILTER_NONE.capitalize)
expect(page).to have_content 'foobar'
expect(page).not_to have_content 'barbaz'
diff --git a/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb b/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb
index 1fa8f533869..5aae5abaf10 100644
--- a/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb
+++ b/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe 'User scrolls to deep-linked note' do
context 'on issue page', :js do
it 'on comment' do
+ stub_feature_flags(gl_avatar_for_all_user_avatars: false)
visit project_issue_path(project, issue, anchor: "note_#{comment_1.id}")
wait_for_requests
diff --git a/spec/features/issues/user_sees_breadcrumb_links_spec.rb b/spec/features/issues/user_sees_breadcrumb_links_spec.rb
index 669c7c45411..1577d7d5ce8 100644
--- a/spec/features/issues/user_sees_breadcrumb_links_spec.rb
+++ b/spec/features/issues/user_sees_breadcrumb_links_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'New issue breadcrumb' do
let(:user) { project.creator }
before do
+ stub_feature_flags(vue_issues_list: true)
+
sign_in(user)
visit(new_project_issue_path(project))
end
@@ -24,10 +26,10 @@ RSpec.describe 'New issue breadcrumb' do
visit project_issue_path(project, issue)
- expect(find('.breadcrumbs-sub-title a')[:href]).to end_with(issue_path(issue))
+ expect(find('[data-testid="breadcrumb-current-link"] a')[:href]).to end_with(issue_path(issue))
end
- it 'excludes award_emoji from comment count' do
+ it 'excludes award_emoji from comment count', :js do
issue = create(:issue, author: user, assignees: [user], project: project, title: 'foobar')
create(:award_emoji, awardable: issue)
diff --git a/spec/features/issues/user_sorts_issues_spec.rb b/spec/features/issues/user_sorts_issues_spec.rb
index 86bdaf5d706..4af313576ed 100644
--- a/spec/features/issues/user_sorts_issues_spec.rb
+++ b/spec/features/issues/user_sorts_issues_spec.rb
@@ -16,6 +16,8 @@ RSpec.describe "User sorts issues" do
let_it_be(:later_due_milestone) { create(:milestone, project: project, due_date: '2013-12-12') }
before do
+ stub_feature_flags(vue_issues_list: true)
+
create_list(:award_emoji, 2, :upvote, awardable: issue1)
create_list(:award_emoji, 2, :downvote, awardable: issue2)
create(:award_emoji, :downvote, awardable: issue1)
@@ -24,26 +26,23 @@ RSpec.describe "User sorts issues" do
sign_in(user)
end
- it 'keeps the sort option' do
+ it 'keeps the sort option', :js do
visit(project_issues_path(project))
- find('.filter-dropdown-container .dropdown').click
-
- page.within('ul.dropdown-menu.dropdown-menu-right li') do
- click_link('Milestone')
- end
+ click_button 'Created date'
+ click_button 'Milestone'
visit(issues_dashboard_path(assignee_username: user.username))
- expect(find('.issues-filters a.is-active')).to have_content('Milestone')
+ expect(page).to have_button 'Milestone'
visit(project_issues_path(project))
- expect(find('.issues-filters a.is-active')).to have_content('Milestone')
+ expect(page).to have_button 'Milestone'
visit(issues_group_path(group))
- expect(find('.issues-filters a.is-active')).to have_content('Milestone')
+ expect(page).to have_button 'Milestone'
end
it 'sorts by popularity', :js do
diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb
index 479199b72b7..ea888d4b254 100644
--- a/spec/features/labels_hierarchy_spec.rb
+++ b/spec/features/labels_hierarchy_spec.rb
@@ -17,6 +17,8 @@ RSpec.describe 'Labels Hierarchy', :js do
let!(:project_label_1) { create(:label, project: project_1, title: 'Label_4') }
before do
+ stub_feature_flags(vue_issues_list: true)
+
grandparent.add_owner(user)
sign_in(user)
@@ -34,8 +36,6 @@ RSpec.describe 'Labels Hierarchy', :js do
click_on 'Close'
end
- wait_for_requests
-
expect(page).to have_selector('.gl-label', text: label.title)
end
end
@@ -44,8 +44,6 @@ RSpec.describe 'Labels Hierarchy', :js do
page.within('.block.labels') do
click_on 'Edit'
- wait_for_requests
-
expect(page).not_to have_text(child_group_label.title)
end
end
@@ -54,15 +52,21 @@ RSpec.describe 'Labels Hierarchy', :js do
shared_examples 'filtering by ancestor labels for projects' do |board = false|
it 'filters by ancestor labels' do
[grandparent_group_label, parent_group_label, project_label_1].each do |label|
- select_label_on_dropdown(label.title)
-
- wait_for_requests
-
if board
+ select_label_on_dropdown(label.title)
+
expect(page).to have_selector('.board-card-title') do |card|
expect(card).to have_selector('a', text: labeled_issue.title)
end
else
+ within '[data-testid="filtered-search-input"]' do
+ click_filtered_search_bar
+ click_on 'Label'
+ click_on '= is'
+ click_on label.title
+ send_keys :enter
+ end
+
expect_issues_list_count(1)
expect(page).to have_selector('.issue-title', text: labeled_issue.title)
end
@@ -70,9 +74,11 @@ RSpec.describe 'Labels Hierarchy', :js do
end
it 'does not filter by descendant group labels' do
- filtered_search.set("label=")
-
- wait_for_requests
+ if board
+ filtered_search.set("label=")
+ else
+ select_tokens 'Label', '='
+ end
expect(page).not_to have_link child_group_label.title
end
@@ -93,11 +99,9 @@ RSpec.describe 'Labels Hierarchy', :js do
it 'filters by ancestors and current group labels' do
[grandparent_group_label, parent_group_label].each do |label|
- select_label_on_dropdown(label.title)
-
- wait_for_requests
-
if board
+ select_label_on_dropdown(label.title)
+
expect(page).to have_selector('.board-card-title') do |card|
expect(card).to have_selector('a', text: labeled_issue.title)
end
@@ -106,6 +110,14 @@ RSpec.describe 'Labels Hierarchy', :js do
expect(card).to have_selector('a', text: labeled_issue_2.title)
end
else
+ within '[data-testid="filtered-search-input"]' do
+ click_filtered_search_bar
+ click_on 'Label'
+ click_on '= is'
+ click_on label.title
+ send_keys :enter
+ end
+
expect_issues_list_count(3)
expect(page).to have_selector('.issue-title', text: labeled_issue.title)
expect(page).to have_selector('.issue-title', text: labeled_issue_2.title)
@@ -115,11 +127,9 @@ RSpec.describe 'Labels Hierarchy', :js do
end
it 'filters by descendant group labels' do
- wait_for_requests
-
- select_label_on_dropdown(group_label_3.title)
-
if board
+ select_label_on_dropdown(group_label_3.title)
+
expect(page).to have_selector('.board-card-title') do |card|
expect(card).not_to have_selector('a', text: labeled_issue_2.title)
end
@@ -128,17 +138,23 @@ RSpec.describe 'Labels Hierarchy', :js do
expect(card).to have_selector('a', text: labeled_issue_3.title)
end
else
+ select_tokens 'Label', '=', group_label_3.title, submit: true
+
expect_issues_list_count(1)
expect(page).to have_selector('.issue-title', text: labeled_issue_3.title)
end
end
it 'does not filter by descendant group project labels' do
- filtered_search.set("label=")
+ if board
+ filtered_search.set("label=")
- wait_for_requests
+ expect(page).not_to have_selector('.btn-link', text: project_label_3.title)
+ else
+ select_tokens 'Label', '='
- expect(page).not_to have_selector('.btn-link', text: project_label_3.title)
+ expect(page).not_to have_link project_label_3.title
+ end
end
end
@@ -195,9 +211,7 @@ RSpec.describe 'Labels Hierarchy', :js do
it_behaves_like 'filtering by ancestor labels for projects'
it 'does not filter by descendant group labels' do
- filtered_search.set("label=")
-
- wait_for_requests
+ select_tokens 'Label', '='
expect(page).not_to have_link child_group_label.title
end
diff --git a/spec/features/markdown/mermaid_spec.rb b/spec/features/markdown/mermaid_spec.rb
index 6a91d4e03c1..322b5306a00 100644
--- a/spec/features/markdown/mermaid_spec.rb
+++ b/spec/features/markdown/mermaid_spec.rb
@@ -5,6 +5,9 @@ require 'spec_helper'
RSpec.describe 'Mermaid rendering', :js do
let_it_be(:project) { create(:project, :public) }
+ let(:is_mac) { page.evaluate_script('navigator.platform').include?('Mac') }
+ let(:modifier_key) { is_mac ? :command : :control }
+
before do
stub_feature_flags(sandboxed_mermaid: false)
end
@@ -48,8 +51,8 @@ RSpec.describe 'Mermaid rendering', :js do
wait_for_requests
wait_for_mermaid
- # From https://github.com/mermaid-js/mermaid/blob/d3f8f03a7d03a052e1fe0251d5a6d8d1f48d67ee/src/dagre-wrapper/createLabel.js#L79-L82
- expected = %(<div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; white-space: nowrap;">Line 1<br>Line 2</div>)
+ # From # From https://github.com/mermaid-js/mermaid/blob/170ed89e9ef3e33dc84f8656eed1725379d505df/src/dagre-wrapper/createLabel.js#L39-L42
+ expected = %(<div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml">Line 1<br>Line 2</div>)
expect(page.html.scan(expected).count).to be(4)
end
@@ -70,8 +73,8 @@ RSpec.describe 'Mermaid rendering', :js do
wait_for_requests
wait_for_mermaid
- # From https://github.com/mermaid-js/mermaid/blob/d3f8f03a7d03a052e1fe0251d5a6d8d1f48d67ee/src/dagre-wrapper/createLabel.js#L79-L82
- expected = %(<div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; white-space: nowrap;">CLICK_HERE_AND_GET_BONUS</div>)
+ # From https://github.com/mermaid-js/mermaid/blob/170ed89e9ef3e33dc84f8656eed1725379d505df/src/dagre-wrapper/createLabel.js#L39-L42
+ expected = %(<div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml">CLICK_HERE_AND_GET_BONUS</div>)
expect(page.html).to include(expected)
end
@@ -300,6 +303,40 @@ RSpec.describe 'Mermaid rendering', :js do
expect(page).not_to have_xpath("//iframe")
end
end
+
+ it 'correctly copies and pastes to/from the clipboard' do
+ stub_feature_flags(sandboxed_mermaid: true)
+
+ description = <<~MERMAID
+ ```mermaid
+ graph TD;
+ A-->B;
+ A-->C;
+ ```
+ MERMAID
+
+ issue = create(:issue, project: project, description: description)
+
+ user = create(:user)
+ sign_in(user)
+ visit project_issue_path(project, issue)
+
+ wait_for_requests
+ wait_for_mermaid
+
+ find('pre.language-mermaid').hover
+ find('copy-code button').click
+
+ sleep 2
+
+ find('#note-body').send_keys [modifier_key, 'v']
+
+ wait_for_requests
+
+ # The codefences do actually get included, but we can't get spec to pass
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83202#note_880621264
+ expect(find('#note-body').value.strip).to eq("graph TD;\n A-->B;\n A-->C;")
+ end
end
def wait_for_mermaid
diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb
index eb98c7d5061..9b54d95be6b 100644
--- a/spec/features/merge_request/batch_comments_spec.rb
+++ b/spec/features/merge_request/batch_comments_spec.rb
@@ -146,6 +146,7 @@ RSpec.describe 'Merge request > Batch comments', :js do
before do
find('.js-show-diff-settings').click
click_button 'Side-by-side'
+ find('.js-show-diff-settings').click
end
it 'adds draft comments to both sides' do
@@ -171,9 +172,8 @@ RSpec.describe 'Merge request > Batch comments', :js do
write_reply_to_discussion(button_text: 'Add comment now', resolve: true)
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -188,9 +188,8 @@ RSpec.describe 'Merge request > Batch comments', :js do
wait_for_requests
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
end
@@ -211,9 +210,8 @@ RSpec.describe 'Merge request > Batch comments', :js do
write_reply_to_discussion(button_text: 'Add comment now', unresolve: true)
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
- expect(page).not_to have_selector('.line-resolve-btn.is-active')
end
end
@@ -230,9 +228,8 @@ RSpec.describe 'Merge request > Batch comments', :js do
wait_for_requests
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
- expect(page).not_to have_selector('.line-resolve-btn.is-active')
end
end
end
diff --git a/spec/features/merge_request/close_reopen_report_toggle_spec.rb b/spec/features/merge_request/close_reopen_report_toggle_spec.rb
index 8a4277d87c9..dea9a10a4ec 100644
--- a/spec/features/merge_request/close_reopen_report_toggle_spec.rb
+++ b/spec/features/merge_request/close_reopen_report_toggle_spec.rb
@@ -28,7 +28,6 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
expect(container).to have_link("Close merge request")
expect(container).to have_link('Report abuse')
- expect(container).to have_text("Report merge requests that are abusive, inappropriate or spam.")
end
it 'links to Report Abuse' do
@@ -43,10 +42,12 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
let(:issuable) { create(:merge_request, :opened, source_project: project) }
it 'shows the `Edit` and `Mark as draft` buttons' do
+ click_button 'Toggle dropdown'
+
expect(container).to have_link('Edit')
expect(container).to have_link('Mark as draft')
- expect(container).not_to have_button('Report abuse')
- expect(container).not_to have_button('Close merge request')
+ expect(container).to have_link('Close merge request')
+ expect(container).to have_link('Report abuse')
expect(container).not_to have_link('Reopen merge request')
end
end
@@ -55,21 +56,24 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
let(:issuable) { create(:merge_request, :closed, source_project: project) }
it 'shows both the `Edit` and `Reopen` button' do
+ click_button 'Toggle dropdown'
+
expect(container).to have_link('Edit')
- expect(container).not_to have_button('Report abuse')
- expect(container).not_to have_button('Close merge request')
+ expect(container).to have_link('Report abuse')
expect(container).to have_link('Reopen merge request')
+ expect(container).not_to have_link('Close merge request')
end
context 'when the merge request author is the current user' do
let(:issuable) { create(:merge_request, :closed, source_project: project, author: user) }
it 'shows both the `Edit` and `Reopen` button' do
+ click_button 'Toggle dropdown'
+
expect(container).to have_link('Edit')
- expect(container).not_to have_link('Report abuse')
- expect(container).not_to have_selector('button.dropdown-toggle')
- expect(container).not_to have_button('Close merge request')
expect(container).to have_link('Reopen merge request')
+ expect(container).not_to have_link('Close merge request')
+ expect(container).not_to have_link('Report abuse')
end
end
end
diff --git a/spec/features/merge_request/merge_request_discussion_lock_spec.rb b/spec/features/merge_request/merge_request_discussion_lock_spec.rb
index 4e0265839f6..a7bc2a062af 100644
--- a/spec/features/merge_request/merge_request_discussion_lock_spec.rb
+++ b/spec/features/merge_request/merge_request_discussion_lock_spec.rb
@@ -8,59 +8,91 @@ RSpec.describe 'Merge Request Discussion Lock', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project, author: user) }
+ let(:moved_mr_sidebar_enabled) { false }
before do
+ stub_feature_flags(moved_mr_sidebar: moved_mr_sidebar_enabled)
sign_in(user)
end
- context 'when a user is a team member' do
- before do
- project.add_developer(user)
- end
+ context 'moved sidebar flag disabled' do
+ context 'when a user is a team member' do
+ before do
+ project.add_developer(user)
+ end
- context 'when the discussion is unlocked' do
- it 'the user can lock the merge_request' do
- visit project_merge_request_path(merge_request.project, merge_request)
+ context 'when the discussion is unlocked' do
+ it 'the user can lock the merge_request' do
+ visit project_merge_request_path(merge_request.project, merge_request)
+
+ expect(find('.issuable-sidebar')).to have_content('Unlocked')
- expect(find('.issuable-sidebar')).to have_content('Unlocked')
+ page.within('.issuable-sidebar') do
+ find('.lock-edit').click
+ click_button('Lock')
+ end
- page.within('.issuable-sidebar') do
- find('.lock-edit').click
- click_button('Lock')
+ expect(find('[data-testid="lock-status"]')).to have_content('Locked')
end
+ end
+
+ context 'when the discussion is locked' do
+ before do
+ merge_request.update_attribute(:discussion_locked, true)
+ visit project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ it 'the user can unlock the merge_request' do
+ expect(find('.issuable-sidebar')).to have_content('Locked')
- expect(find('[data-testid="lock-status"]')).to have_content('Locked')
+ page.within('.issuable-sidebar') do
+ find('.lock-edit').click
+ click_button('Unlock')
+ end
+
+ expect(find('[data-testid="lock-status"]')).to have_content('Unlocked')
+ end
end
end
- context 'when the discussion is locked' do
- before do
- merge_request.update_attribute(:discussion_locked, true)
- visit project_merge_request_path(merge_request.project, merge_request)
- end
+ context 'when a user is not a team member' do
+ context 'when the discussion is unlocked' do
+ before do
+ visit project_merge_request_path(merge_request.project, merge_request)
+ end
- it 'the user can unlock the merge_request' do
- expect(find('.issuable-sidebar')).to have_content('Locked')
+ it 'the user can not lock the merge_request' do
+ expect(find('.issuable-sidebar')).to have_content('Unlocked')
+ expect(find('.issuable-sidebar')).not_to have_selector('.lock-edit')
+ end
+ end
- page.within('.issuable-sidebar') do
- find('.lock-edit').click
- click_button('Unlock')
+ context 'when the discussion is locked' do
+ before do
+ merge_request.update_attribute(:discussion_locked, true)
+ visit project_merge_request_path(merge_request.project, merge_request)
end
- expect(find('[data-testid="lock-status"]')).to have_content('Unlocked')
+ it 'the user can not unlock the merge_request' do
+ expect(find('.issuable-sidebar')).to have_content('Locked')
+ expect(find('.issuable-sidebar')).not_to have_selector('.lock-edit')
+ end
end
end
end
- context 'when a user is not a team member' do
+ context 'moved sidebar flag enabled' do
+ let(:moved_mr_sidebar_enabled) { true }
+
context 'when the discussion is unlocked' do
before do
visit project_merge_request_path(merge_request.project, merge_request)
end
- it 'the user can not lock the merge_request' do
- expect(find('.issuable-sidebar')).to have_content('Unlocked')
- expect(find('.issuable-sidebar')).not_to have_selector('.lock-edit')
+ it 'the user can lock the merge_request' do
+ click_button 'Toggle dropdown'
+
+ expect(page).to have_content('Lock merge request')
end
end
@@ -70,9 +102,10 @@ RSpec.describe 'Merge Request Discussion Lock', :js do
visit project_merge_request_path(merge_request.project, merge_request)
end
- it 'the user can not unlock the merge_request' do
- expect(find('.issuable-sidebar')).to have_content('Locked')
- expect(find('.issuable-sidebar')).not_to have_selector('.lock-edit')
+ it 'the user can unlock the merge_request' do
+ click_button 'Toggle dropdown'
+
+ expect(page).to have_content('Unlock merge request')
end
end
end
diff --git a/spec/features/merge_request/user_accepts_merge_request_spec.rb b/spec/features/merge_request/user_accepts_merge_request_spec.rb
index d4b185a82e9..159306b28d8 100644
--- a/spec/features/merge_request/user_accepts_merge_request_spec.rb
+++ b/spec/features/merge_request/user_accepts_merge_request_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
click_button('Merge')
- expect(page).to have_content("The changes were merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
+ expect(page).to have_content("Changes merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
end
context 'when merge method is set to fast-forward merge' do
@@ -31,7 +31,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
click_button('Merge')
- expect(page).to have_content("The changes were merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
+ expect(page).to have_content("Changes merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
end
it 'accepts a merge request with squash and merge' do
@@ -41,7 +41,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
click_button('Merge')
- expect(page).to have_content("The changes were merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
+ expect(page).to have_content("Changes merged into #{merge_request.target_branch} with #{merge_request.short_merged_commit_sha}")
end
end
end
@@ -55,7 +55,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
check('Delete source branch')
click_button('Merge')
- expect(page).to have_content('The changes were merged into')
+ expect(page).to have_content('Changes merged into')
expect(page).not_to have_selector('.js-remove-branch-button')
# Wait for View Resource requests to complete so they don't blow up if they are
@@ -72,7 +72,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
it 'accepts a merge request' do
click_button('Merge')
- expect(page).to have_content('The changes were merged into')
+ expect(page).to have_content('Changes merged into')
expect(page).to have_selector('.js-remove-branch-button')
# Wait for View Resource requests to complete so they don't blow up if they are
@@ -90,7 +90,7 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
check('Delete source branch')
click_button('Merge')
- expect(page).to have_content('The changes were merged into')
+ expect(page).to have_content('Changes merged into')
expect(page).not_to have_selector('.js-remove-branch-button')
# Wait for View Resource requests to complete so they don't blow up if they are
@@ -107,14 +107,12 @@ RSpec.describe 'User accepts a merge request', :js, :sidekiq_might_not_need_inli
end
it 'accepts a merge request' do
- find('.js-mr-widget-commits-count').click
+ find('[data-testid="widget_edit_commit_message"]').click
fill_in('merge-message-edit', with: 'wow such merge')
click_button('Merge')
- page.within('.status-box') do
- expect(page).to have_content('Merged')
- end
+ expect(page).to have_selector('.gl-badge', text: 'Merged')
end
end
end
diff --git a/spec/features/merge_request/user_assigns_themselves_spec.rb b/spec/features/merge_request/user_assigns_themselves_spec.rb
index fc925781a3b..2aaddc7791b 100644
--- a/spec/features/merge_request/user_assigns_themselves_spec.rb
+++ b/spec/features/merge_request/user_assigns_themselves_spec.rb
@@ -30,12 +30,6 @@ RSpec.describe 'Merge request > User assigns themselves' do
end.to change { merge_request.reload.updated_at }
end
- it 'returns user to the merge request', :js do
- click_link 'Assign yourself to these issues'
-
- expect(page).to have_content merge_request.description
- end
-
context 'when related issues are already assigned' do
before do
[issue1, issue2].each { |issue| issue.update!(assignees: [user]) }
diff --git a/spec/features/merge_request/user_awards_emoji_spec.rb b/spec/features/merge_request/user_awards_emoji_spec.rb
index 35eadb34799..81a88cad458 100644
--- a/spec/features/merge_request/user_awards_emoji_spec.rb
+++ b/spec/features/merge_request/user_awards_emoji_spec.rb
@@ -38,6 +38,10 @@ RSpec.describe 'Merge request > User awards emoji', :js do
it 'adds awards to note' do
page.within('.note-actions') do
first('.note-emoji-button').click
+
+ # make sure emoji popup is visible
+ execute_script("window.scrollBy(0, 200)")
+
find('gl-emoji[data-name="8ball"]').click
end
diff --git a/spec/features/merge_request/user_comments_on_diff_spec.rb b/spec/features/merge_request/user_comments_on_diff_spec.rb
index c06019f6b77..99756da51e4 100644
--- a/spec/features/merge_request/user_comments_on_diff_spec.rb
+++ b/spec/features/merge_request/user_comments_on_diff_spec.rb
@@ -103,15 +103,22 @@ RSpec.describe 'User comments on a diff', :js do
end
# Check the same comments in the side-by-side view.
+ execute_script "window.scrollTo(0,0)"
find('.js-show-diff-settings').click
click_button 'Side-by-side'
+ second_line_element = find_by_scrolling("[id='#{sample_compare.changes[1][:line_code]}']")
+ second_root_element = second_line_element.ancestor('[data-path]')
+
wait_for_requests
page.within(second_root_element) do
expect(page).to have_content('Line is wrong')
end
+ first_line_element = find_by_scrolling("[id='#{sample_compare.changes[0][:line_code]}']").find(:xpath, "..")
+ first_root_element = first_line_element.ancestor('[data-path]')
+
page.within(first_root_element) do
expect(page).to have_content('Line is correct')
end
@@ -154,7 +161,7 @@ RSpec.describe 'User comments on a diff', :js do
it 'allows comments on previously hidden lines the middle of a file' do
# Click 27, expand up, select 18, add and verify comment
page.within find_by_scrolling('[data-path="files/ruby/popen.rb"]') do
- all('.js-unfold-all')[1].click
+ first('.js-unfold-all').click
end
click_diff_line(find('div[data-path="files/ruby/popen.rb"] .left-side a[data-linenumber="21"]').find(:xpath, '../..'), 'left')
add_comment('18', '21')
@@ -163,10 +170,10 @@ RSpec.describe 'User comments on a diff', :js do
it 'allows comments on previously hidden lines at the bottom of a file' do
# Click +28, expand down, select 37 add and verify comment
page.within find_by_scrolling('[data-path="files/ruby/popen.rb"]') do
- all('.js-unfold-down:not([disabled])')[1].click
+ first('.js-unfold-down').click
end
click_diff_line(find('div[data-path="files/ruby/popen.rb"] .left-side a[data-linenumber="30"]').find(:xpath, '../..'), 'left')
- add_comment('+28', '37')
+ add_comment('+28', '30')
end
end
diff --git a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb
index 15f186b649a..bd5048374d5 100644
--- a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb
@@ -258,7 +258,7 @@ RSpec.describe 'Merge request > User creates image diff notes', :js do
end
it 'resizes image' do
- expect(find('.onion-skin-frame')['style']).to match('width: 228px; height: 240px;')
+ expect(find('.onion-skin-frame')['style']).to match('width: 198px; height: 210px;')
end
it_behaves_like 'onion skin'
diff --git a/spec/features/merge_request/user_creates_merge_request_spec.rb b/spec/features/merge_request/user_creates_merge_request_spec.rb
index 617aceae54c..a3dc3079374 100644
--- a/spec/features/merge_request/user_creates_merge_request_spec.rb
+++ b/spec/features/merge_request/user_creates_merge_request_spec.rb
@@ -15,28 +15,40 @@ RSpec.describe "User creates a merge request", :js do
sign_in(user)
end
- it "creates a merge request" do
- visit(project_new_merge_request_path(project))
+ context 'when completed the compare branches form' do
+ before do
+ visit(project_new_merge_request_path(project))
- find(".js-source-branch").click
- click_link("fix")
+ find(".js-source-branch").click
+ click_link("fix")
- find(".js-target-branch").click
- click_link("feature")
+ find(".js-target-branch").click
+ click_link("feature")
- click_button("Compare branches")
+ click_button("Compare branches")
+ end
- page.within('.merge-request-form') do
- expect(page.find('#merge_request_title')['placeholder']).to eq 'Title'
- expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.'
+ it "shows merge request form" do
+ page.within('.merge-request-form') do
+ expect(page.find('#merge_request_title')['placeholder']).to eq 'Title'
+ expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.'
+ end
end
- fill_in("Title", with: title)
- click_button("Create merge request")
+ context "when completed the merge request form" do
+ before do
+ fill_in("Title", with: title)
+ click_button("Create merge request")
+ end
- page.within(".merge-request") do
- expect(page).to have_content(title)
+ it "creates a merge request" do
+ page.within(".merge-request") do
+ expect(page).to have_content(title)
+ end
+ end
end
+
+ it_behaves_like 'merge request author auto assign'
end
context "XSS branch name exists" do
@@ -106,7 +118,7 @@ RSpec.describe "User creates a merge request", :js do
click_button("Create merge request")
- expect(page).to have_content(title).and have_content("Request to merge #{user.namespace.path}:#{source_branch} into master")
+ expect(page).to have_content(title).and have_content("requested to merge #{forked_project.full_path}:#{source_branch} into master")
end
end
end
diff --git a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb
index 67a232607cd..059e1eb89c5 100644
--- a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb
+++ b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb
@@ -41,7 +41,7 @@ RSpec.describe 'Merge request < User customizes merge commit message', :js do
it 'has commit message without description' do
expect(page).not_to have_selector('#merge-message-edit')
- first('.js-mr-widget-commits-count').click
+ find('[data-testid="widget_edit_commit_message"]').click
expect(merge_textbox).to be_visible
expect(merge_textbox.value).to eq(default_merge_commit_message)
end
@@ -51,7 +51,7 @@ RSpec.describe 'Merge request < User customizes merge commit message', :js do
it 'uses merge commit template' do
expect(page).not_to have_selector('#merge-message-edit')
- first('.js-mr-widget-commits-count').click
+ find('[data-testid="widget_edit_commit_message"]').click
expect(merge_textbox).to be_visible
expect(merge_textbox.value).to eq(merge_request.title)
end
@@ -62,7 +62,7 @@ RSpec.describe 'Merge request < User customizes merge commit message', :js do
it 'has default message with merge request title' do
expect(page).not_to have_selector('#squash-message-edit')
- first('.js-mr-widget-commits-count').click
+ find('[data-testid="widget_edit_commit_message"]').click
expect(squash_textbox).to be_visible
expect(merge_textbox).to be_visible
expect(squash_textbox.value).to eq(merge_request.title)
@@ -74,7 +74,7 @@ RSpec.describe 'Merge request < User customizes merge commit message', :js do
it 'uses squash commit template' do
expect(page).not_to have_selector('#squash-message-edit')
- first('.js-mr-widget-commits-count').click
+ find('[data-testid="widget_edit_commit_message"]').click
expect(squash_textbox).to be_visible
expect(merge_textbox).to be_visible
expect(squash_textbox.value).to eq(merge_request.description)
diff --git a/spec/features/merge_request/user_edits_merge_request_spec.rb b/spec/features/merge_request/user_edits_merge_request_spec.rb
index 364af8d8a76..0b4b9d7452a 100644
--- a/spec/features/merge_request/user_edits_merge_request_spec.rb
+++ b/spec/features/merge_request/user_edits_merge_request_spec.rb
@@ -92,7 +92,7 @@ RSpec.describe 'User edits a merge request', :js do
select2('merge-test', from: '#merge_request_target_branch')
click_button('Save changes')
- expect(page).to have_content("Request to merge #{merge_request.source_branch} into merge-test")
+ expect(page).to have_content("requested to merge #{merge_request.source_branch} into merge-test")
expect(page).to have_content("changed target branch from #{merge_request.target_branch} to merge-test")
end
diff --git a/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb b/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb
index 7d67cde4bbb..f5b5460769e 100644
--- a/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb
+++ b/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb
@@ -59,7 +59,6 @@ RSpec.describe 'Batch diffs', :js do
# Confirm scrolled to correct UI element
expect(get_first_diff.find('.discussion-notes .timeline-entry li.note[id]').obscured?).to be_falsey
- expect(get_second_diff.find('.discussion-notes .timeline-entry li.note[id]').obscured?).to be_truthy
end
end
diff --git a/spec/features/merge_request/user_manages_subscription_spec.rb b/spec/features/merge_request/user_manages_subscription_spec.rb
index 3cdb22000f6..c64c761b8d1 100644
--- a/spec/features/merge_request/user_manages_subscription_spec.rb
+++ b/spec/features/merge_request/user_manages_subscription_spec.rb
@@ -6,29 +6,60 @@ RSpec.describe 'User manages subscription', :js do
let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:user) { create(:user) }
+ let(:moved_mr_sidebar_enabled) { false }
before do
+ stub_feature_flags(moved_mr_sidebar: moved_mr_sidebar_enabled)
+
project.add_maintainer(user)
sign_in(user)
visit(merge_request_path(merge_request))
end
- it 'toggles subscription' do
- page.within('[data-testid="subscription-toggle"]') do
+ context 'moved sidebar flag disabled' do
+ it 'toggles subscription' do
+ page.within('[data-testid="subscription-toggle"]') do
+ wait_for_requests
+
+ expect(page).to have_css 'button:not(.is-checked)'
+ find('button:not(.is-checked)').click
+
+ wait_for_requests
+
+ expect(page).to have_css 'button.is-checked'
+ find('button.is-checked').click
+
+ wait_for_requests
+
+ expect(page).to have_css 'button:not(.is-checked)'
+ end
+ end
+ end
+
+ context 'moved sidebar flag enabled' do
+ let(:moved_mr_sidebar_enabled) { true }
+
+ it 'toggles subscription' do
wait_for_requests
- expect(page).to have_css 'button:not(.is-checked)'
- find('button:not(.is-checked)').click
+ click_button 'Toggle dropdown'
+
+ expect(page).to have_content('Turn on notifications')
+ click_button 'Turn on notifications'
wait_for_requests
- expect(page).to have_css 'button.is-checked'
- find('button.is-checked').click
+ click_button 'Toggle dropdown'
+
+ expect(page).to have_content('Turn off notifications')
+ click_button 'Turn off notifications'
wait_for_requests
- expect(page).to have_css 'button:not(.is-checked)'
+ click_button 'Toggle dropdown'
+
+ expect(page).to have_content('Turn on notifications')
end
end
end
diff --git a/spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb b/spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb
index f5bca7cf015..c3a61476442 100644
--- a/spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb
+++ b/spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb
@@ -16,10 +16,13 @@ RSpec.describe 'Merge request > User marks merge request as draft', :js do
end
it 'toggles draft status' do
+ click_button 'Toggle dropdown'
click_link 'Mark as draft'
expect(page).to have_content("Draft: #{merge_request.title}")
+ click_button 'Toggle dropdown'
+
page.within('.detail-page-header-actions') do
click_link 'Mark as ready'
end
diff --git a/spec/features/merge_request/user_merges_immediately_spec.rb b/spec/features/merge_request/user_merges_immediately_spec.rb
index 3a05f35a671..91327059e0f 100644
--- a/spec/features/merge_request/user_merges_immediately_spec.rb
+++ b/spec/features/merge_request/user_merges_immediately_spec.rb
@@ -30,17 +30,17 @@ RSpec.describe 'Merge requests > User merges immediately', :js do
it 'enables merge immediately' do
wait_for_requests
- page.within '.mr-widget-body' do
+ page.within '[data-testid="ready_to_merge_state"]' do
find('.dropdown-toggle').click
Sidekiq::Testing.fake! do
click_button 'Merge immediately'
-
- expect(find('.media-body h4')).to have_content('Merging!')
-
- wait_for_requests
end
end
+
+ expect(find('.media-body h4')).to have_content('Merging!')
+
+ wait_for_requests
end
end
end
diff --git a/spec/features/merge_request/user_merges_merge_request_spec.rb b/spec/features/merge_request/user_merges_merge_request_spec.rb
index a861ca2eea5..6a9a30953df 100644
--- a/spec/features/merge_request/user_merges_merge_request_spec.rb
+++ b/spec/features/merge_request/user_merges_merge_request_spec.rb
@@ -17,9 +17,7 @@ RSpec.describe "User merges a merge request", :js do
click_button("Merge")
end
- page.within(".status-box") do
- expect(page).to have_content("Merged")
- end
+ expect(page).to have_selector('.gl-badge', text: 'Merged')
end
end
@@ -27,6 +25,7 @@ RSpec.describe "User merges a merge request", :js do
let(:project) { create(:project, :public, :repository, merge_requests_ff_only_enabled: true) }
before do
+ stub_feature_flags(restructured_mr_widget: false)
visit(merge_request_path(merge_request))
end
diff --git a/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb
index 4d7ee11e366..d6b132b18da 100644
--- a/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb
+++ b/spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb
@@ -56,7 +56,7 @@ RSpec.describe 'Merge request > User merges only if pipeline succeeds', :js do
wait_for_requests
- expect(page).to have_css('button[disabled="disabled"]', text: 'Merge')
+ expect(page).not_to have_button('Merge')
expect(page).to have_content('Merge blocked: pipeline must succeed. Push a commit that fixes the failure, or learn about other solutions.')
end
end
diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
index 9057b96bff0..21f96299958 100644
--- a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
+++ b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
click_button "Merge when pipeline succeeds"
expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds"
- expect(page).to have_content "Does not delete the source branch"
+ expect(page).to have_content "Source branch will not be deleted"
expect(page).to have_selector ".js-cancel-auto-merge"
visit project_merge_request_path(project, merge_request) # Needed to refresh the page
expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i
@@ -64,6 +64,9 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
context 'when enabled after it was previously canceled' do
before do
click_button "Merge when pipeline succeeds"
+
+ wait_for_requests
+
click_button "Cancel auto-merge"
wait_for_requests
@@ -123,12 +126,6 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
expect(page).to have_content "canceled the automatic merge"
end
- it 'allows to delete source branch' do
- click_button "Delete source branch"
-
- expect(page).to have_content "Deletes the source branch"
- end
-
context 'when pipeline succeeds' do
before do
build.success
@@ -136,7 +133,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
end
it 'merges merge request', :sidekiq_might_not_need_inline do
- expect(page).to have_content 'The changes were merged'
+ expect(page).to have_content 'Changes merged'
expect(merge_request.reload).to be_merged
end
end
diff --git a/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb b/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb
new file mode 100644
index 00000000000..4d2c59665bb
--- /dev/null
+++ b/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Merge request > User opens checkout branch modal', :js do
+ include ProjectForksHelper
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ describe 'for fork' do
+ let(:author) { create(:user) }
+ let(:source_project) { fork_project(project, author, repository: true) }
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: source_project,
+ target_project: project,
+ source_branch: 'fix',
+ target_branch: 'master',
+ author: author,
+ allow_collaboration: true)
+ end
+
+ it 'shows instructions' do
+ visit project_merge_request_path(project, merge_request)
+
+ click_button 'Code'
+ click_button 'Check out branch'
+
+ expect(page).to have_content(source_project.http_url_to_repo)
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb
index d803aec5895..64715f9234a 100644
--- a/spec/features/merge_request/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb
@@ -239,7 +239,7 @@ RSpec.describe 'Merge request > User posts diff notes', :js do
def should_allow_dismissing_a_comment(line_holder, diff_side = nil)
write_comment_on_line(line_holder, diff_side)
- accept_gl_confirm(s_('Notes|Are you sure you want to cancel creating this comment?')) do
+ accept_gl_confirm(s_('Notes|Are you sure you want to cancel creating this comment?'), button_text: _('Discard changes')) do
find('.js-close-discussion-note-form').click
end
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index 231722c166d..e09ec11f095 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
context 'single thread' do
it 'shows text with how many threads' do
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
@@ -55,9 +55,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).to have_selector('.btn', text: 'Unresolve thread')
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -72,9 +71,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).to have_selector('.line-resolve-btn.is-active')
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -84,7 +82,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
click_button 'Unresolve thread'
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
@@ -155,7 +153,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
wait_for_requests
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
end
end
@@ -167,9 +165,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
wait_for_requests
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
- expect(page).not_to have_selector('.line-resolve-btn.is-active')
end
end
@@ -184,7 +181,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
wait_for_requests
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
@@ -196,9 +193,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
find('button[data-qa-selector="resolve_discussion_button"]').click # rubocop:disable QA/SelectorUsage
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -213,14 +209,13 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
click_button 'Add comment now'
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
it 'allows user to quickly scroll to next unresolved thread' do
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
page.find('.discussion-next-btn').click
end
@@ -269,7 +264,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(first('.line-resolve-btn')['aria-label']).to eq("Resolved by #{user.name}")
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
end
end
@@ -286,7 +281,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(button['aria-label']).to eq("Resolved by #{user.name}")
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
end
end
@@ -299,7 +294,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
end
it 'shows text with how many threads' do
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('2 unresolved threads')
end
end
@@ -307,7 +302,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to mark a single note as resolved' do
click_button('Resolve thread', match: :first)
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
@@ -317,9 +312,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
btn.click
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -330,9 +324,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
end
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -341,7 +334,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
find('button[data-qa-selector="resolve_discussion_button"]').click # rubocop:disable QA/SelectorUsage
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
page.find('.discussion-next-btn').click
end
@@ -370,7 +363,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).not_to have_selector('.btn', text: 'Resolve thread')
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
page.find('.discussion-next-btn').click
end
@@ -386,7 +379,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
context 'changes tab' do
it 'shows text with how many threads' do
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
@@ -402,9 +395,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).to have_selector('.btn', text: 'Unresolve thread')
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -417,9 +409,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).to have_selector('.line-resolve-btn.is-active')
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -429,7 +420,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
click_button 'Unresolve thread'
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
@@ -445,9 +436,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
click_button 'Add comment now'
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
@@ -462,7 +452,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
click_button 'Add comment now'
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
@@ -485,7 +475,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).not_to have_selector('.line-resolve-btn')
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
@@ -515,9 +505,8 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).to have_selector('.btn', text: 'Unresolve thread')
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('All threads resolved')
- expect(page).to have_selector('.line-resolve-btn.is-active')
end
end
end
@@ -534,32 +523,11 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
expect(page).not_to have_selector('.line-resolve-btn')
end
- page.within '.line-resolve-all-container' do
+ page.within '.discussions-counter' do
expect(page).to have_content('1 unresolved thread')
end
end
end
-
- context 'resolved comment' do
- before do
- note.resolve!(user)
- visit_merge_request
- end
-
- it 'shows resolved icon' do
- expect(page).to have_content 'All threads resolved'
-
- click_button _('Show thread')
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
-
- it 'does not allow user to click resolve button' do
- expect(page).to have_selector('.line-resolve-btn.is-active')
- click_button _('Show thread')
-
- expect(page).to have_selector('.line-resolve-btn.is-active')
- end
- end
end
def visit_merge_request(mr = nil)
diff --git a/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb b/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
index 38546fd629d..5827266d4b7 100644
--- a/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
+++ b/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Merge request > User sees check out branch modal', :js do
sign_in(user)
visit project_merge_request_path(project, merge_request)
wait_for_requests
+
+ click_button 'Code'
click_button('Check out branch')
end
diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb
index c9b21d4a4ae..2dafd66b406 100644
--- a/spec/features/merge_request/user_sees_merge_widget_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -20,6 +20,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
stub_feature_flags(refactor_mr_widgets_extensions: false)
stub_feature_flags(refactor_mr_widgets_extensions_user: false)
+ stub_feature_flags(refactor_mr_widget_test_summary: false)
end
context 'new merge request', :sidekiq_might_not_need_inline do
@@ -320,8 +321,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
# Wait for the `ci_status` and `merge_check` requests
wait_for_requests
- expect(page).to have_selector('.accept-merge-request')
- expect(find('.accept-merge-request')['disabled']).not_to be(true)
+ expect(page).not_to have_selector('.accept-merge-request')
end
end
@@ -384,9 +384,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
# Wait for the `ci_status` and `merge_check` requests
wait_for_requests
- page.within('.mr-widget-body') do
- expect(page).to have_content('Merge Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.')
- end
+ expect(page).to have_content('Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.')
end
end
@@ -444,7 +442,6 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
it 'user cannot remove source branch', :sidekiq_might_not_need_inline do
expect(page).not_to have_field('remove-source-branch-input')
- expect(page).to have_content('Deletes the source branch')
end
end
diff --git a/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb
index 23b03e33f5d..03f9f6ef565 100644
--- a/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb
+++ b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb
@@ -61,38 +61,6 @@ RSpec.describe 'Merge request < User sees mini pipeline graph', :js do
wait_for_requests
end
- # Status icon button styles should update as described in
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/42769
- it 'has unique styles for default, :hover, :active, and :focus states' do
- default_background_color, default_foreground_color, default_box_shadow = get_toggle_colors(dropdown_selector)
-
- toggle.hover
- hover_background_color, hover_foreground_color, hover_box_shadow = get_toggle_colors(dropdown_selector)
-
- page.driver.browser.action.click_and_hold(toggle.native).perform
- active_background_color, active_foreground_color, active_box_shadow = get_toggle_colors(dropdown_selector)
- page.driver.browser.action.release(toggle.native).perform
-
- page.driver.browser.action.click(toggle.native).move_by(100, 100).perform
- focus_background_color, focus_foreground_color, focus_box_shadow = get_toggle_colors(dropdown_selector)
-
- expect(default_background_color).not_to eq(hover_background_color)
- expect(hover_background_color).not_to eq(active_background_color)
- expect(default_background_color).not_to eq(active_background_color)
-
- expect(default_foreground_color).not_to eq(hover_foreground_color)
- expect(hover_foreground_color).not_to eq(active_foreground_color)
- expect(default_foreground_color).not_to eq(active_foreground_color)
-
- expect(focus_background_color).to eq(hover_background_color)
- expect(focus_foreground_color).to eq(hover_foreground_color)
-
- expect(default_box_shadow).to eq('none')
- expect(hover_box_shadow).to eq('none')
- expect(active_box_shadow).not_to eq('none')
- expect(focus_box_shadow).not_to eq('none')
- end
-
it 'shows tooltip when hovered' do
toggle.hover
@@ -147,15 +115,4 @@ RSpec.describe 'Merge request < User sees mini pipeline graph', :js do
end
end
end
-
- private
-
- def get_toggle_colors(selector)
- find(selector)
- [
- evaluate_script("$('#{selector} button:visible').css('background-color');"),
- evaluate_script("$('#{selector} button:visible svg').css('fill');"),
- evaluate_script("$('#{selector} button:visible').css('box-shadow');")
- ]
- end
end
diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
index d2bde320c54..bcc6abd4f65 100644
--- a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
+++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
@@ -62,6 +62,7 @@ RSpec.describe 'Merge request > User selects branches for new MR', :js do
fill_in "merge_request_title", with: "Orphaned MR test"
click_button "Create merge request"
+ click_button 'Code'
click_button "Check out branch"
expect(page).to have_content 'git checkout -b \'orphaned-branch\' \'origin/orphaned-branch\''
diff --git a/spec/features/merge_request/user_squashes_merge_request_spec.rb b/spec/features/merge_request/user_squashes_merge_request_spec.rb
index 2a48657ac4f..da0d4ca23d1 100644
--- a/spec/features/merge_request/user_squashes_merge_request_spec.rb
+++ b/spec/features/merge_request/user_squashes_merge_request_spec.rb
@@ -79,7 +79,7 @@ RSpec.describe 'User squashes a merge request', :js do
context 'when squash message is the same as existing commit message' do
before do
- click_button("Modify commit messages")
+ find('[data-testid="widget_edit_commit_message"]').click
fill_in('Squash commit message', with: project.commit(source_branch).safe_message)
accept_mr
end
diff --git a/spec/features/merge_request/user_views_diffs_spec.rb b/spec/features/merge_request/user_views_diffs_spec.rb
index 208ed1f01e7..894292c99eb 100644
--- a/spec/features/merge_request/user_views_diffs_spec.rb
+++ b/spec/features/merge_request/user_views_diffs_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe 'User views diffs', :js do
it 'unfolds diffs in the middle' do
page.within('.file-holder[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd"]') do
- all('.js-unfold-all')[1].click
+ first('.js-unfold-all').click
expect(page).to have_selector('[data-interop-type="new"] [data-linenumber="24"]', count: 1)
expect(page).not_to have_selector('[data-interop-type="new"] [data-linenumber="1"]')
diff --git a/spec/features/merge_request/user_views_open_merge_request_spec.rb b/spec/features/merge_request/user_views_open_merge_request_spec.rb
index a145bcb976b..1f4682b4a46 100644
--- a/spec/features/merge_request/user_views_open_merge_request_spec.rb
+++ b/spec/features/merge_request/user_views_open_merge_request_spec.rb
@@ -71,13 +71,14 @@ RSpec.describe 'User views an open merge request' do
let(:merge_request) { create(:merge_request, :rebased, source_project: project, target_project: project) }
before do
+ project.add_maintainer(project.creator)
+ sign_in(project.creator)
+
visit(merge_request_path(merge_request))
end
it 'does not show diverged commits count' do
- page.within('.mr-source-target') do
- expect(page).not_to have_content(/([0-9]+ commits? behind)/)
- end
+ expect(page).not_to have_content(/([0-9]+ commits? behind)/)
end
end
@@ -85,13 +86,14 @@ RSpec.describe 'User views an open merge request' do
let(:merge_request) { create(:merge_request, :diverged, source_project: project, target_project: project) }
before do
+ project.add_maintainer(project.creator)
+ sign_in(project.creator)
+
visit(merge_request_path(merge_request))
end
it 'shows diverged commits count' do
- page.within('.mr-source-target') do
- expect(page).to have_content(/([0-9]+ commits behind)/)
- end
+ expect(page).not_to have_content(/([0-9]+ commits? behind)/)
end
end
@@ -117,6 +119,8 @@ RSpec.describe 'User views an open merge request' do
let(:source_branch) { "&#39;&gt;&lt;iframe/srcdoc=&#39;&#39;&gt;&lt;/iframe&gt;" }
before do
+ stub_feature_flags(moved_mr_sidebar: false)
+
project.repository.create_branch(source_branch, "master")
mr = create(:merge_request, source_project: project, target_project: project, source_branch: source_branch)
diff --git a/spec/features/merge_request/user_views_user_status_on_merge_request_spec.rb b/spec/features/merge_request/user_views_user_status_on_merge_request_spec.rb
index a6de443e96f..f637186ec67 100644
--- a/spec/features/merge_request/user_views_user_status_on_merge_request_spec.rb
+++ b/spec/features/merge_request/user_views_user_status_on_merge_request_spec.rb
@@ -11,6 +11,10 @@ RSpec.describe 'Project > Merge request > View user status' do
subject { visit merge_request_path(merge_request) }
describe 'the status of the merge request author' do
+ before do
+ stub_feature_flags(updated_mr_header: false)
+ end
+
it_behaves_like 'showing user status' do
let(:user_with_status) { merge_request.author }
end
diff --git a/spec/features/merge_requests/user_lists_merge_requests_spec.rb b/spec/features/merge_requests/user_lists_merge_requests_spec.rb
index 8c1d9dd38b0..2743f7e8ed4 100644
--- a/spec/features/merge_requests/user_lists_merge_requests_spec.rb
+++ b/spec/features/merge_requests/user_lists_merge_requests_spec.rb
@@ -109,7 +109,7 @@ RSpec.describe 'Merge requests > User lists merge requests' do
expect(count_merge_requests).to eq(4)
end
- it 'sorts by milestone' do
+ it 'sorts by milestone due date' do
visit_merge_requests(project, sort: sort_value_milestone)
expect(first_merge_request).to include('fix')
@@ -130,12 +130,12 @@ RSpec.describe 'Merge requests > User lists merge requests' do
expect(count_merge_requests).to eq(4)
end
- it 'filters on one label and sorts by due date' do
+ it 'filters on one label and sorts by milestone due date' do
label = create(:label, project: project)
create(:label_link, label: label, target: @fix)
visit_merge_requests(project, label_name: [label.name],
- sort: sort_value_due_date)
+ sort: sort_value_milestone)
expect(first_merge_request).to include('fix')
expect(count_merge_requests).to eq(1)
@@ -150,19 +150,19 @@ RSpec.describe 'Merge requests > User lists merge requests' do
create(:label_link, label: label2, target: @fix)
end
- it 'sorts by due date' do
+ it 'sorts by milestone due date' do
visit_merge_requests(project, label_name: [label.name, label2.name],
- sort: sort_value_due_date)
+ sort: sort_value_milestone)
expect(first_merge_request).to include('fix')
expect(count_merge_requests).to eq(1)
end
context 'filter on assignee and' do
- it 'sorts by due soon' do
+ it 'sorts by milestone due date' do
visit_merge_requests(project, label_name: [label.name, label2.name],
assignee_id: user.id,
- sort: sort_value_due_date)
+ sort: sort_value_milestone)
expect(first_merge_request).to include('fix')
expect(count_merge_requests).to eq(1)
diff --git a/spec/features/merge_requests/user_mass_updates_spec.rb b/spec/features/merge_requests/user_mass_updates_spec.rb
index a15b6072e70..fa866beb773 100644
--- a/spec/features/merge_requests/user_mass_updates_spec.rb
+++ b/spec/features/merge_requests/user_mass_updates_spec.rb
@@ -5,12 +5,14 @@ require 'spec_helper'
RSpec.describe 'Merge requests > User mass updates', :js do
let(:project) { create(:project, :repository) }
let(:user) { project.creator }
+ let(:user2) { create(:user) }
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
before do
stub_feature_flags(mr_attention_requests: false)
project.add_maintainer(user)
+ project.add_maintainer(user2)
sign_in(user)
end
@@ -68,9 +70,9 @@ RSpec.describe 'Merge requests > User mass updates', :js do
end
it 'updates merge request with assignee' do
- change_assignee(user.name)
+ change_assignee(user2.name)
- expect(find('.issuable-meta a.author-link')[:title]).to eq "Attention requested from assignee #{user.name}"
+ expect(find('.issuable-meta a.author-link')[:title]).to eq "Attention requested from assignee #{user2.name}"
end
end
end
diff --git a/spec/features/merge_requests/user_sorts_merge_requests_spec.rb b/spec/features/merge_requests/user_sorts_merge_requests_spec.rb
index 99473f3b1ea..459145d3ef0 100644
--- a/spec/features/merge_requests/user_sorts_merge_requests_spec.rb
+++ b/spec/features/merge_requests/user_sorts_merge_requests_spec.rb
@@ -2,8 +2,9 @@
require 'spec_helper'
-RSpec.describe 'User sorts merge requests' do
+RSpec.describe 'User sorts merge requests', :js do
include CookieHelper
+ include Spec::Support::Helpers::Features::SortingHelpers
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let!(:merge_request2) do
@@ -16,29 +17,27 @@ RSpec.describe 'User sorts merge requests' do
let_it_be(:project) { create(:project, :public, group: group) }
before do
+ stub_feature_flags(vue_issues_list: true)
+
sign_in(user)
visit(project_merge_requests_path(project))
end
it 'keeps the sort option' do
- find('.filter-dropdown-container .dropdown').click
-
- page.within('ul.dropdown-menu.dropdown-menu-right li') do
- click_link('Milestone')
- end
+ pajamas_sort_by(s_('SortOptions|Milestone'))
visit(merge_requests_dashboard_path(assignee_username: user.username))
- expect(find('.issues-filters a.is-active')).to have_content('Milestone')
+ expect(find('.filter-dropdown-container button.dropdown-toggle')).to have_content('Milestone')
visit(project_merge_requests_path(project))
- expect(find('.issues-filters a.is-active')).to have_content('Milestone')
+ expect(find('.filter-dropdown-container button.dropdown-toggle')).to have_content('Milestone')
visit(merge_requests_group_path(group))
- expect(find('.issues-filters a.is-active')).to have_content('Milestone')
+ expect(find('.filter-dropdown-container button.dropdown-toggle')).to have_content('Milestone')
end
it 'fallbacks to issuable_sort cookie key when remembering the sorting option' do
@@ -46,17 +45,13 @@ RSpec.describe 'User sorts merge requests' do
visit(merge_requests_dashboard_path(assignee_username: user.username))
- expect(find('.issues-filters a.is-active')).to have_content('Milestone')
+ expect(find('.filter-dropdown-container button.dropdown-toggle')).to have_content('Milestone')
end
- it 'separates remember sorting with issues' do
+ it 'separates remember sorting with issues', :js do
create(:issue, project: project)
- find('.filter-dropdown-container .dropdown').click
-
- page.within('ul.dropdown-menu.dropdown-menu-right li') do
- click_link('Milestone')
- end
+ pajamas_sort_by(s_('SortOptions|Milestone'))
visit(project_issues_path(project))
@@ -73,11 +68,7 @@ RSpec.describe 'User sorts merge requests' do
end
it 'sorts by popularity' do
- find('.filter-dropdown-container .dropdown').click
-
- page.within('ul.dropdown-menu.dropdown-menu-right li') do
- click_link('Popularity')
- end
+ pajamas_sort_by(s_('SortOptions|Popularity'))
page.within('.mr-list') do
page.within('li.merge-request:nth-child(1)') do
diff --git a/spec/features/monitor_sidebar_link_spec.rb b/spec/features/monitor_sidebar_link_spec.rb
index fcef0fa0eff..3c59cd65cdb 100644
--- a/spec/features/monitor_sidebar_link_spec.rb
+++ b/spec/features/monitor_sidebar_link_spec.rb
@@ -45,7 +45,6 @@ RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures do
expect(page).not_to have_link('Alerts', href: project_alert_management_index_path(project))
expect(page).not_to have_link('Error Tracking', href: project_error_tracking_index_path(project))
expect(page).not_to have_link('Product Analytics', href: project_product_analytics_path(project))
- expect(page).not_to have_link('Serverless', href: project_serverless_functions_path(project))
expect(page).not_to have_link('Logs', href: project_logs_path(project))
expect(page).not_to have_link('Kubernetes', href: project_clusters_path(project))
end
@@ -79,7 +78,6 @@ RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures do
expect(page).not_to have_link('Alerts', href: project_alert_management_index_path(project))
expect(page).not_to have_link('Error Tracking', href: project_error_tracking_index_path(project))
expect(page).not_to have_link('Product Analytics', href: project_product_analytics_path(project))
- expect(page).not_to have_link('Serverless', href: project_serverless_functions_path(project))
expect(page).not_to have_link('Logs', href: project_logs_path(project))
expect(page).not_to have_link('Kubernetes', href: project_clusters_path(project))
end
@@ -98,7 +96,6 @@ RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures do
expect(page).to have_link('Product Analytics', href: project_product_analytics_path(project))
expect(page).not_to have_link('Alerts', href: project_alert_management_index_path(project))
- expect(page).not_to have_link('Serverless', href: project_serverless_functions_path(project))
expect(page).not_to have_link('Logs', href: project_logs_path(project))
expect(page).not_to have_link('Kubernetes', href: project_clusters_path(project))
end
@@ -117,7 +114,6 @@ RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures do
expect(page).to have_link('Error Tracking', href: project_error_tracking_index_path(project))
expect(page).to have_link('Product Analytics', href: project_product_analytics_path(project))
expect(page).to have_link('Logs', href: project_logs_path(project))
- expect(page).to have_link('Serverless', href: project_serverless_functions_path(project))
expect(page).to have_link('Kubernetes', href: project_clusters_path(project))
end
@@ -134,7 +130,6 @@ RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures do
expect(page).to have_link('Environments', href: project_environments_path(project))
expect(page).to have_link('Error Tracking', href: project_error_tracking_index_path(project))
expect(page).to have_link('Product Analytics', href: project_product_analytics_path(project))
- expect(page).to have_link('Serverless', href: project_serverless_functions_path(project))
expect(page).to have_link('Logs', href: project_logs_path(project))
expect(page).to have_link('Kubernetes', href: project_clusters_path(project))
end
diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb
index ea5bb8c33b2..fca8972b56c 100644
--- a/spec/features/oauth_login_spec.rb
+++ b/spec/features/oauth_login_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'OAuth Login', :js, :allow_forgery_protection do
+RSpec.describe 'OAuth Login', :allow_forgery_protection do
include DeviseHelpers
def enter_code(code)
@@ -28,7 +28,7 @@ RSpec.describe 'OAuth Login', :js, :allow_forgery_protection do
end
providers.each do |provider|
- context "when the user logs in using the #{provider} provider" do
+ context "when the user logs in using the #{provider} provider", :js do
let(:uid) { 'my-uid' }
let(:remember_me) { false }
let(:user) { create(:omniauth_user, extern_uid: uid, provider: provider.to_s) }
@@ -126,4 +126,55 @@ RSpec.describe 'OAuth Login', :js, :allow_forgery_protection do
end
end
end
+
+ context 'using GitLab as an OAuth provider' do
+ let_it_be(:user) { create(:user) }
+
+ let(:redirect_uri) { Gitlab::Routing.url_helpers.root_url }
+
+ # We can't use let_it_be to set the redirect_uri when creating the
+ # record as the host / port depends on whether or not the spec uses
+ # JS.
+ let(:application) do
+ create(:oauth_application, scopes: 'api', redirect_uri: redirect_uri, confidential: false)
+ end
+
+ let(:params) do
+ {
+ response_type: 'code',
+ client_id: application.uid,
+ redirect_uri: redirect_uri,
+ state: 'state'
+ }
+ end
+
+ before do
+ sign_in(user)
+
+ create(:oauth_access_token, application: application, resource_owner_id: user.id, scopes: 'api')
+ end
+
+ context 'when JS is enabled', :js do
+ it 'includes the fragment in the redirect if it is simple' do
+ visit "#{Gitlab::Routing.url_helpers.oauth_authorization_url(params)}#a_test-hash"
+
+ expect(page).to have_current_path("#{Gitlab::Routing.url_helpers.root_url}#a_test-hash", ignore_query: true)
+ end
+
+ it 'does not include the fragment if it contains forbidden characters' do
+ visit "#{Gitlab::Routing.url_helpers.oauth_authorization_url(params)}#a_test-hash."
+
+ expect(page).to have_current_path(Gitlab::Routing.url_helpers.root_url, ignore_query: true)
+ end
+ end
+
+ context 'when JS is disabled' do
+ it 'provides a basic HTML page including a link without the fragment' do
+ visit "#{Gitlab::Routing.url_helpers.oauth_authorization_url(params)}#a_test-hash"
+
+ expect(page).to have_current_path(oauth_authorization_path(params))
+ expect(page).to have_selector("a[href^='#{redirect_uri}']")
+ end
+ end
+ end
end
diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb
index fde85a731a1..65944f5a537 100644
--- a/spec/features/profiles/keys_spec.rb
+++ b/spec/features/profiles/keys_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe 'Profile > SSH Keys' do
expect(page).to have_content("Title: #{attrs[:title]}")
expect(page).to have_content(attrs[:key])
- expect(find('.breadcrumbs-sub-title')).to have_link(attrs[:title])
+ expect(find('[data-testid="breadcrumb-current-link"]')).to have_link(attrs[:title])
end
it 'shows a confirmable warning if the key begins with an algorithm name that is unsupported' do
diff --git a/spec/features/profiles/oauth_applications_spec.rb b/spec/features/profiles/oauth_applications_spec.rb
index 6827dff5434..9d79041dc9d 100644
--- a/spec/features/profiles/oauth_applications_spec.rb
+++ b/spec/features/profiles/oauth_applications_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'Profile > Applications' do
visit oauth_application_path(application)
expect(page).to have_content("Application: #{application.name}")
- expect(find('.breadcrumbs-sub-title')).to have_link(application.name)
+ expect(find('[data-testid="breadcrumb-current-link"]')).to have_link(application.name)
end
it 'deletes an application' do
diff --git a/spec/features/projects/active_tabs_spec.rb b/spec/features/projects/active_tabs_spec.rb
index 2601dcf55c9..ff97d3c9503 100644
--- a/spec/features/projects/active_tabs_spec.rb
+++ b/spec/features/projects/active_tabs_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Project active tab' do
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:project) { create(:project, :repository, :with_namespace_settings) }
let(:user) { project.first_owner }
diff --git a/spec/features/projects/blobs/blame_spec.rb b/spec/features/projects/blobs/blame_spec.rb
new file mode 100644
index 00000000000..bb3b5cd931c
--- /dev/null
+++ b/spec/features/projects/blobs/blame_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'File blame', :js do
+ include TreeHelper
+
+ let_it_be(:project) { create(:project, :public, :repository) }
+
+ let(:path) { 'CHANGELOG' }
+
+ def visit_blob_blame(path)
+ visit project_blame_path(project, tree_join('master', path))
+ wait_for_all_requests
+ end
+
+ it 'displays the blame page without pagination' do
+ visit_blob_blame(path)
+
+ expect(page).to have_css('.blame-commit')
+ expect(page).not_to have_css('.gl-pagination')
+ end
+
+ context 'when blob length is over the blame range limit' do
+ before do
+ stub_const('Projects::BlameService::PER_PAGE', 2)
+ end
+
+ it 'displays two first lines of the file with pagination' do
+ visit_blob_blame(path)
+
+ expect(page).to have_css('.blame-commit')
+ expect(page).to have_css('.gl-pagination')
+
+ expect(page).to have_css('#L1')
+ expect(page).not_to have_css('#L3')
+ expect(find('.page-link.active')).to have_text('1')
+ end
+
+ context 'when user clicks on the next button' do
+ before do
+ visit_blob_blame(path)
+
+ find('.js-next-button').click
+ end
+
+ it 'displays next two lines of the file with pagination' do
+ expect(page).not_to have_css('#L1')
+ expect(page).to have_css('#L3')
+ expect(find('.page-link.active')).to have_text('2')
+ end
+ end
+
+ context 'when feature flag disabled' do
+ before do
+ stub_feature_flags(blame_page_pagination: false)
+ end
+
+ it 'displays the blame page without pagination' do
+ visit_blob_blame(path)
+
+ expect(page).to have_css('.blame-commit')
+ expect(page).not_to have_css('.gl-pagination')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/ci/editor_spec.rb b/spec/features/projects/ci/editor_spec.rb
index ad4381a526a..2f960c09936 100644
--- a/spec/features/projects/ci/editor_spec.rb
+++ b/spec/features/projects/ci/editor_spec.rb
@@ -12,6 +12,8 @@ RSpec.describe 'Pipeline Editor', :js do
let(:other_branch) { 'test' }
before do
+ stub_feature_flags(pipeline_editor_file_tree: false)
+
sign_in(user)
project.add_developer(user)
@@ -22,11 +24,7 @@ RSpec.describe 'Pipeline Editor', :js do
wait_for_requests
end
- it 'user sees the Pipeline Editor page' do
- expect(page).to have_content('Pipeline Editor')
- end
-
- describe 'Branch switcher' do
+ shared_examples 'default branch switcher behavior' do
def switch_to_branch(branch)
find('[data-testid="branch-selector"]').click
@@ -53,7 +51,7 @@ RSpec.describe 'Pipeline Editor', :js do
end
it 'displays new branch as selected after commiting on a new branch' do
- find('#target-branch-field').set('new_branch', clear: :backspace)
+ find('#source-branch-field').set('new_branch', clear: :backspace)
page.within('#source-editor-') do
find('textarea').send_keys '123'
@@ -68,6 +66,28 @@ RSpec.describe 'Pipeline Editor', :js do
end
end
+ it 'user sees the Pipeline Editor page' do
+ expect(page).to have_content('Pipeline Editor')
+ end
+
+ describe 'Branch Switcher (pipeline_editor_file_tree disabled)' do
+ it_behaves_like 'default branch switcher behavior'
+ end
+
+ describe 'Branch Switcher (pipeline_editor_file_tree enabled)' do
+ before do
+ stub_feature_flags(pipeline_editor_file_tree: true)
+
+ visit project_ci_pipeline_editor_path(project)
+ wait_for_requests
+
+ # close button for the popover
+ find('[data-testid="close-button"]').click
+ end
+
+ it_behaves_like 'default branch switcher behavior'
+ end
+
describe 'Editor navigation' do
context 'when no change is made' do
it 'user can navigate away without a browser alert' do
@@ -112,7 +132,7 @@ RSpec.describe 'Pipeline Editor', :js do
it 'user who creates a MR is taken to the merge request page without warnings' do
expect(page).not_to have_content('New merge request')
- find_field('Target Branch').set 'new_branch'
+ find_field('Branch').set 'new_branch'
find_field('Start a new merge request with these changes').click
click_button 'Commit changes'
diff --git a/spec/features/projects/ci/secure_files_spec.rb b/spec/features/projects/ci/secure_files_spec.rb
index 65c41eaf2ac..a0e9d663d35 100644
--- a/spec/features/projects/ci/secure_files_spec.rb
+++ b/spec/features/projects/ci/secure_files_spec.rb
@@ -7,13 +7,55 @@ RSpec.describe 'Secure Files', :js do
let(:user) { create(:user) }
before do
+ stub_feature_flags(ci_secure_files_read_only: false)
project.add_maintainer(user)
sign_in(user)
+ end
+ it 'user sees the Secure Files list component' do
visit project_ci_secure_files_path(project)
+ expect(page).to have_content('There are no records to show')
end
- it 'user sees the Secure Files list component' do
+ it 'prompts the user to confirm before deleting a file' do
+ file = create(:ci_secure_file, project: project)
+
+ visit project_ci_secure_files_path(project)
+
+ expect(page).to have_content(file.name)
+
+ find('button.btn-danger').click
+
+ expect(page).to have_content("Delete #{file.name}?")
+
+ click_on('Delete secure file')
+
+ visit project_ci_secure_files_path(project)
+
+ expect(page).not_to have_content(file.name)
+ end
+
+ it 'displays an uploaded file in the file list' do
+ visit project_ci_secure_files_path(project)
expect(page).to have_content('There are no records to show')
+
+ page.attach_file('spec/fixtures/ci_secure_files/upload-keystore.jks') do
+ click_button 'Upload File'
+ end
+
+ expect(page).to have_content('upload-keystore.jks')
+ end
+
+ it 'displays an error when a duplicate file upload is attempted' do
+ create(:ci_secure_file, project: project, name: 'upload-keystore.jks')
+ visit project_ci_secure_files_path(project)
+
+ expect(page).to have_content('upload-keystore.jks')
+
+ page.attach_file('spec/fixtures/ci_secure_files/upload-keystore.jks') do
+ click_button 'Upload File'
+ end
+
+ expect(page).to have_content('A file with this name already exists.')
end
end
diff --git a/spec/features/projects/clusters/eks_spec.rb b/spec/features/projects/clusters/eks_spec.rb
deleted file mode 100644
index 7e599ff1198..00000000000
--- a/spec/features/projects/clusters/eks_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'AWS EKS Cluster', :js do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
-
- before do
- project.add_maintainer(user)
- gitlab_sign_in(user)
- allow(Projects::ClustersController).to receive(:STATUS_POLLING_INTERVAL) { 100 }
- stub_application_setting(eks_integration_enabled: true)
- end
-
- context 'when user does not have a cluster and visits cluster index page' do
- let(:project_id) { 'test-project-1234' }
-
- before do
- visit project_clusters_path(project)
-
- click_button(class: 'dropdown-toggle-split')
- click_link 'Create a cluster (certificate - deprecated)'
- end
-
- context 'when user creates a cluster on AWS EKS' do
- before do
- click_link 'Amazon EKS'
- end
-
- it 'highlights Amazon EKS logo' do
- expect(page).to have_css('.js-create-aws-cluster-button.active')
- end
- end
- end
-end
diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb
index 491121a3743..a8a23ba1c85 100644
--- a/spec/features/projects/clusters/gcp_spec.rb
+++ b/spec/features/projects/clusters/gcp_spec.rb
@@ -14,11 +14,6 @@ RSpec.describe 'Gcp Cluster', :js do
allow(Projects::ClustersController).to receive(:STATUS_POLLING_INTERVAL) { 100 }
end
- def submit_form
- execute_script('document.querySelector(".js-gke-cluster-creation-submit").removeAttribute("disabled")')
- execute_script('document.querySelector(".js-gke-cluster-creation-submit").click()')
- end
-
context 'when user has signed with Google' do
let(:project_id) { 'test-project-1234' }
@@ -29,82 +24,7 @@ RSpec.describe 'Gcp Cluster', :js do
.to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s)
end
- context 'when user does not have a cluster and visits cluster index page' do
- before do
- visit project_clusters_path(project)
-
- visit_create_cluster_page
- click_link 'Google GKE'
- end
-
- it 'highlights Google GKE logo' do
- expect(page).to have_css('.js-create-gcp-cluster-button.active')
- end
-
- context 'when user filled form with valid parameters' do
- subject { submit_form }
-
- before do
- allow_any_instance_of(GoogleApi::CloudPlatform::Client)
- .to receive(:projects_zones_clusters_create) do
- double(
- 'cluster',
- self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123',
- status: 'RUNNING'
- )
- end
-
- allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
-
- expect(page).to have_css('.js-gcp-project-id-dropdown')
-
- execute_script('document.querySelector(".js-gcp-project-id-dropdown input").setAttribute("type", "text")')
- execute_script('document.querySelector(".js-gcp-zone-dropdown input").setAttribute("type", "text")')
- execute_script('document.querySelector(".js-gcp-machine-type-dropdown input").setAttribute("type", "text")')
-
- fill_in 'cluster[name]', with: 'dev-cluster'
- fill_in 'cluster[provider_gcp_attributes][gcp_project_id]', with: 'gcp-project-123'
- fill_in 'cluster[provider_gcp_attributes][zone]', with: 'us-central1-a'
- fill_in 'cluster[provider_gcp_attributes][machine_type]', with: 'n1-standard-2'
- end
-
- it 'users sees a form with the GCP token' do
- expect(page).to have_selector(:css, 'form[data-token="token"]')
- end
-
- it 'user sees a cluster details page and creation status' do
- subject
-
- expect(page).to have_content('Kubernetes cluster is being created...')
-
- Clusters::Cluster.last.provider.make_created!
-
- expect(page).to have_content('Kubernetes cluster was successfully created')
- end
-
- it 'user sees a error if something wrong during creation' do
- subject
-
- expect(page).to have_content('Kubernetes cluster is being created...')
-
- Clusters::Cluster.last.provider.make_errored!('Something wrong!')
-
- expect(page).to have_content('Something wrong!')
- end
- end
-
- context 'when user filled form with invalid parameters' do
- before do
- submit_form
- end
-
- it 'user sees a validation error' do
- expect(page).to have_css('.gl-field-error')
- end
- end
- end
-
- context 'when user does have a cluster and visits cluster page' do
+ context 'when user have a cluster and visits cluster page' do
let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
before do
@@ -167,12 +87,6 @@ RSpec.describe 'Gcp Cluster', :js do
it 'user sees offer on cluster index page' do
expect(page).to have_css('.gcp-signup-offer')
end
-
- it 'user sees offer on cluster create page' do
- visit_create_cluster_page
-
- expect(page).to have_css('.gcp-signup-offer')
- end
end
context 'when user has dismissed GCP signup offer' do
@@ -187,8 +101,6 @@ RSpec.describe 'Gcp Cluster', :js do
find('.gcp-signup-offer .js-close').click
wait_for_requests
- visit_create_cluster_page
-
expect(page).not_to have_css('.gcp-signup-offer')
end
end
@@ -216,9 +128,4 @@ RSpec.describe 'Gcp Cluster', :js do
expect(page).not_to have_css('.gcp-signup-offer')
end
end
-
- def visit_create_cluster_page
- click_button(class: 'dropdown-toggle-split')
- click_link 'Create a cluster (certificate - deprecated)'
- end
end
diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb
index 0ecd7795964..9e1d66bc73e 100644
--- a/spec/features/projects/clusters_spec.rb
+++ b/spec/features/projects/clusters_spec.rb
@@ -81,101 +81,6 @@ RSpec.describe 'Clusters', :js do
end
end
end
-
- context 'when user adds a Google Kubernetes Engine cluster' do
- before do
- allow_any_instance_of(Projects::ClustersController)
- .to receive(:token_in_session).and_return('token')
- allow_any_instance_of(Projects::ClustersController)
- .to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s)
-
- allow_any_instance_of(Projects::ClustersController).to receive(:authorize_google_project_billing)
- allow_any_instance_of(Projects::ClustersController).to receive(:google_project_billing_status).and_return(true)
-
- allow_any_instance_of(GoogleApi::CloudPlatform::Client)
- .to receive(:projects_zones_clusters_create) do
- double(
- 'cluster_control',
- self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123',
- status: 'RUNNING'
- )
- end
-
- allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
-
- create(:cluster, :provided_by_gcp, name: 'default-cluster', environment_scope: '*', projects: [project])
- visit project_clusters_path(project)
- click_link 'Certificate'
- end
-
- context 'when user filled form with environment scope' do
- before do
- visit_create_cluster_page
- click_link 'Google GKE'
-
- sleep 2 # wait for ajax
- execute_script('document.querySelector(".js-gcp-project-id-dropdown input").setAttribute("type", "text")')
- execute_script('document.querySelector(".js-gcp-zone-dropdown input").setAttribute("type", "text")')
- execute_script('document.querySelector(".js-gcp-machine-type-dropdown input").setAttribute("type", "text")')
- execute_script('document.querySelector(".js-gke-cluster-creation-submit").removeAttribute("disabled")')
-
- fill_in 'cluster_name', with: 'staging-cluster'
- fill_in 'cluster_environment_scope', with: 'staging/*'
- fill_in 'cluster[provider_gcp_attributes][gcp_project_id]', with: 'gcp-project-123'
- fill_in 'cluster[provider_gcp_attributes][zone]', with: 'us-central1-a'
- fill_in 'cluster[provider_gcp_attributes][machine_type]', with: 'n1-standard-2'
- click_button 'Create Kubernetes cluster'
-
- # The frontend won't show the details until the cluster is
- # created, and we don't want to make calls out to GCP.
- provider = Clusters::Cluster.last.provider
- provider.make_created
- end
-
- it 'user sees a cluster details page' do
- expect(page).to have_content('GitLab Integration')
- expect(page.find_field('cluster[environment_scope]').value).to eq('staging/*')
- end
- end
-
- context 'when user updates environment scope' do
- before do
- click_link 'default-cluster'
- fill_in 'cluster_environment_scope', with: 'production/*'
- within ".js-cluster-details-form" do
- click_button 'Save changes'
- end
- end
-
- it 'updates the environment scope' do
- expect(page.find_field('cluster[environment_scope]').value).to eq('production/*')
- end
- end
-
- context 'when user updates duplicated environment scope' do
- before do
- visit_create_cluster_page
- click_link 'Google GKE'
-
- sleep 2 # wait for ajax
- execute_script('document.querySelector(".js-gcp-project-id-dropdown input").setAttribute("type", "text")')
- execute_script('document.querySelector(".js-gcp-zone-dropdown input").setAttribute("type", "text")')
- execute_script('document.querySelector(".js-gcp-machine-type-dropdown input").setAttribute("type", "text")')
- execute_script('document.querySelector(".js-gke-cluster-creation-submit").removeAttribute("disabled")')
-
- fill_in 'cluster_name', with: 'staging-cluster'
- fill_in 'cluster_environment_scope', with: '*'
- fill_in 'cluster[provider_gcp_attributes][gcp_project_id]', with: 'gcp-project-123'
- fill_in 'cluster[provider_gcp_attributes][zone]', with: 'us-central1-a'
- fill_in 'cluster[provider_gcp_attributes][machine_type]', with: 'n1-standard-2'
- click_button 'Create Kubernetes cluster'
- end
-
- it 'users sees an environment scope validation error' do
- expect(page).to have_content('cannot add duplicated environment scope')
- end
- end
- end
end
context 'when user has a cluster and visits cluster index page' do
@@ -221,7 +126,7 @@ RSpec.describe 'Clusters', :js do
visit project_clusters_path(project)
click_button(class: 'dropdown-toggle-split')
- click_link 'Create a cluster (certificate - deprecated)'
+ click_link 'Create a cluster'
end
def visit_connect_cluster_page
diff --git a/spec/features/projects/commit/mini_pipeline_graph_spec.rb b/spec/features/projects/commit/mini_pipeline_graph_spec.rb
index 57b35d81bb8..e472cff38ce 100644
--- a/spec/features/projects/commit/mini_pipeline_graph_spec.rb
+++ b/spec/features/projects/commit/mini_pipeline_graph_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe 'Mini Pipeline Graph in Commit View', :js do
it 'displays a mini pipeline graph' do
expect(page).to have_selector('[data-testid="commit-box-mini-graph"]')
- first('.mini-pipeline-graph-dropdown-toggle').click
+ first('[data-testid="mini-pipeline-graph-dropdown"]').click
wait_for_requests
diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb
index 7e039a087c7..0b628ad1e9a 100644
--- a/spec/features/projects/graph_spec.rb
+++ b/spec/features/projects/graph_spec.rb
@@ -63,6 +63,23 @@ RSpec.describe 'Project Graph', :js do
end
end
+ context 'charts graph ref switcher' do
+ it 'switches ref to branch' do
+ ref_name = 'feature'
+ visit charts_project_graph_path(project, 'master')
+ first('.js-project-refs-dropdown').click
+
+ page.within '.project-refs-form' do
+ click_link ref_name
+ end
+
+ expect(page).to have_selector '.dropdown-menu-toggle', text: ref_name
+ page.within '.tree-ref-header' do
+ expect(page).to have_content ref_name
+ end
+ end
+ end
+
context 'when CI enabled' do
before do
project.enable_ci
diff --git a/spec/features/projects/integrations/prometheus_external_alerts_spec.rb b/spec/features/projects/integrations/prometheus_external_alerts_spec.rb
deleted file mode 100644
index 7e56ca13e23..00000000000
--- a/spec/features/projects/integrations/prometheus_external_alerts_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Prometheus external alerts', :js do
- include_context 'project integration activation'
-
- let(:alerts_section_selector) { '.js-prometheus-alerts' }
- let(:alerts_section) { page.find(alerts_section_selector) }
-
- context 'with manual configuration' do
- before do
- create(:prometheus_integration, project: project, api_url: 'http://prometheus.example.com', manual_configuration: '1', active: true)
- end
-
- it 'shows the Alerts section' do
- visit_project_integration('Prometheus')
-
- expect(alerts_section).to have_content('Alerts')
- expect(alerts_section).to have_content('Receive alerts from manually configured Prometheus servers.')
- expect(alerts_section).to have_content('URL')
- expect(alerts_section).to have_content('Authorization key')
- end
- end
-
- context 'with no configuration' do
- it 'does not show the Alerts section' do
- visit_project_integration('Prometheus')
- wait_for_requests
-
- expect(page).not_to have_css(alerts_section_selector)
- end
- end
-end
diff --git a/spec/features/projects/integrations/user_activates_prometheus_spec.rb b/spec/features/projects/integrations/user_activates_prometheus_spec.rb
index 80629af6fce..56b895919b8 100644
--- a/spec/features/projects/integrations/user_activates_prometheus_spec.rb
+++ b/spec/features/projects/integrations/user_activates_prometheus_spec.rb
@@ -9,14 +9,13 @@ RSpec.describe 'User activates Prometheus' do
stub_request(:get, /.*prometheus.example.com.*/)
end
- it 'does not activate integration and informs about deprecation', :js do
+ it 'saves and activates integration', :js do
visit_project_integration('Prometheus')
check('Active')
fill_in('API URL', with: 'http://prometheus.example.com')
click_button('Save changes')
- expect(page).not_to have_content('Prometheus settings saved and active.')
- expect(page).to have_content('Fields on this page have been deprecated.')
+ expect(page).to have_content('Prometheus settings saved and active.')
end
end
diff --git a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
index 48ae70d3ec9..27d0be23aec 100644
--- a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
+++ b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
@@ -10,9 +10,6 @@ RSpec.describe 'User uploads new design', :js do
let(:issue) { create(:issue, project: project) }
before do
- # Cause of raising query limiting threshold https://gitlab.com/gitlab-org/gitlab/-/issues/358845
- stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 106)
-
sign_in(user)
enable_design_management(feature_enabled)
visit project_issue_path(project, issue)
diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb
index 3b70d177fce..07b7a54974a 100644
--- a/spec/features/projects/jobs/user_browses_jobs_spec.rb
+++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe 'User browses jobs' do
stub_feature_flags(jobs_table_vue: false)
project.add_maintainer(user)
project.enable_ci
- project.update_attribute(:build_coverage_regex, /Coverage (\d+)%/)
+ build.update!(coverage_regex: '/Coverage (\d+)%/')
sign_in(user)
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index b34a615e651..befaf85fc1e 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -484,7 +484,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
end
context 'when job has an initial trace' do
- it 'loads job trace' do
+ it 'loads job logs' do
expect(page).to have_content 'BUILD TRACE'
job.trace.write(+'a+b') do |stream|
diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb
index 9bd6476f836..821b9249aa8 100644
--- a/spec/features/projects/members/groups_with_access_list_spec.rb
+++ b/spec/features/projects/members/groups_with_access_list_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
- let_it_be(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public, :with_namespace_settings) }
let_it_be(:expiration_date) { 5.days.from_now.to_date }
let(:additional_link_attrs) { {} }
diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/manage_groups_spec.rb
index a48229249e0..006fa3b6eff 100644
--- a/spec/features/projects/members/invite_group_spec.rb
+++ b/spec/features/projects/members/manage_groups_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Project > Members > Invite group', :js do
+RSpec.describe 'Project > Members > Manage groups', :js do
include ActionView::Helpers::DateHelper
include Spec::Support::Helpers::Features::MembersHelpers
include Spec::Support::Helpers::Features::InviteMembersModalHelper
@@ -126,7 +126,7 @@ RSpec.describe 'Project > Members > Invite group', :js do
end
describe 'setting an expiration date for a group link' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :with_namespace_settings) }
let!(:group) { create(:group) }
let_it_be(:expiration_date) { 5.days.from_now.to_date }
@@ -153,81 +153,18 @@ RSpec.describe 'Project > Members > Invite group', :js do
end
end
- describe 'the groups dropdown' do
+ describe 'group search results' do
let_it_be(:parent_group) { create(:group, :public) }
let_it_be(:project_group) { create(:group, :public, parent: parent_group) }
let_it_be(:project) { create(:project, group: project_group) }
- context 'with instance admin considerations' do
- let_it_be(:group_to_share) { create(:group) }
-
- context 'when user is an admin' do
- let_it_be(:admin) { create(:admin) }
-
- before do
- sign_in(admin)
- gitlab_enable_admin_mode_sign_in(admin)
- end
-
- it 'shows groups where the admin has no direct membership' do
- visit project_project_members_path(project)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_to_share)
- end
- end
-
- it 'shows groups where the admin has at least guest level membership' do
- group_to_share.add_guest(admin)
-
- visit project_project_members_path(project)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_to_share)
- end
- end
- end
-
- context 'when user is not an admin' do
- before do
- project.add_maintainer(maintainer)
- sign_in(maintainer)
- end
-
- it 'does not show groups where the user has no direct membership' do
- visit project_project_members_path(project)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_not_to_have_group(group_to_share)
- end
- end
-
- it 'shows groups where the user has at least guest level membership' do
- group_to_share.add_guest(maintainer)
-
- visit project_project_members_path(project)
-
- click_on 'Invite a group'
- click_on 'Select a group'
- wait_for_requests
-
- page.within(group_dropdown_selector) do
- expect_to_have_group(group_to_share)
- end
- end
- end
+ it_behaves_like 'inviting groups search results' do
+ let_it_be(:user) { maintainer }
+ let_it_be(:group) { parent_group }
+ let_it_be(:group_within_hierarchy) { create(:group, parent: group) }
+ let_it_be(:project_within_hierarchy) { create(:project, group: group_within_hierarchy)}
+ let_it_be(:members_page_path) { project_project_members_path(project) }
+ let_it_be(:members_page_path_within_hierarchy) { project_project_members_path(project_within_hierarchy) }
end
context 'for a project in a nested group' do
diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
index 830ada29a2e..bd0874316ac 100644
--- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
+++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe 'Projects > Members > Maintainer adds member with expiration date
include Spec::Support::Helpers::Features::InviteMembersModalHelper
let_it_be(:maintainer) { create(:user) }
- let_it_be(:project) { create(:project) }
+ let_it_be(:project) { create(:project, :with_namespace_settings) }
let_it_be(:three_days_from_now) { 3.days.from_now.to_date }
let_it_be(:five_days_from_now) { 5.days.from_now.to_date }
diff --git a/spec/features/projects/members/master_manages_access_requests_spec.rb b/spec/features/projects/members/master_manages_access_requests_spec.rb
index 4c3eaa93352..f4e8c55e3cc 100644
--- a/spec/features/projects/members/master_manages_access_requests_spec.rb
+++ b/spec/features/projects/members/master_manages_access_requests_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'Projects > Members > Maintainer manages access requests' do
it_behaves_like 'Maintainer manages access requests' do
- let(:entity) { create(:project, :public) }
+ let(:entity) { create(:project, :public, :with_namespace_settings) }
let(:members_page_path) { project_project_members_path(entity) }
end
end
diff --git a/spec/features/projects/members/member_leaves_project_spec.rb b/spec/features/projects/members/member_leaves_project_spec.rb
index 78a0a384d2c..67c40c1dbee 100644
--- a/spec/features/projects/members/member_leaves_project_spec.rb
+++ b/spec/features/projects/members/member_leaves_project_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe 'Projects > Members > Member leaves project' do
include Spec::Support::Helpers::Features::MembersHelpers
let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :with_namespace_settings) }
before do
project.add_developer(user)
diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb
index 5098908857a..023601b0b1e 100644
--- a/spec/features/projects/navbar_spec.rb
+++ b/spec/features/projects/navbar_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe 'Project navbar' do
stub_config(registry: { enabled: false })
stub_feature_flags(harbor_registry_integration: false)
- insert_package_nav(_('Infrastructure'))
+ insert_package_nav(_('Deployments'))
insert_infrastructure_registry_nav
insert_infrastructure_google_cloud_nav
end
@@ -49,7 +49,7 @@ RSpec.describe 'Project navbar' do
stub_config(pages: { enabled: true })
insert_after_sub_nav_item(
- _('Monitor'),
+ _('CI/CD'),
within: _('Settings'),
new_sub_nav_item_name: _('Pages')
)
@@ -67,7 +67,7 @@ RSpec.describe 'Project navbar' do
insert_container_nav
insert_after_sub_nav_item(
- _('Monitor'),
+ _('CI/CD'),
within: _('Settings'),
new_sub_nav_item_name: _('Packages & Registries')
)
diff --git a/spec/features/projects/packages_spec.rb b/spec/features/projects/packages_spec.rb
index 8180f6b9aff..f518cc1fc63 100644
--- a/spec/features/projects/packages_spec.rb
+++ b/spec/features/projects/packages_spec.rb
@@ -47,7 +47,8 @@ RSpec.describe 'Packages' do
let_it_be(:package) { create(:package, project: project) }
it 'allows you to delete a package' do
- first('[title="Remove package"]').click
+ find('[data-testid="delete-dropdown"]').click
+ find('[data-testid="action-delete"]').click
click_button('Delete package')
expect(page).to have_content 'Package deleted successfully'
diff --git a/spec/features/projects/pipelines/legacy_pipeline_spec.rb b/spec/features/projects/pipelines/legacy_pipeline_spec.rb
new file mode 100644
index 00000000000..a29cef393a8
--- /dev/null
+++ b/spec/features/projects/pipelines/legacy_pipeline_spec.rb
@@ -0,0 +1,1073 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Pipeline', :js do
+ include RoutesHelpers
+ include ProjectForksHelper
+ include ::ExclusiveLeaseHelpers
+
+ let_it_be(:project) { create(:project) }
+
+ let(:user) { create(:user) }
+ let(:role) { :developer }
+
+ before do
+ sign_in(user)
+ project.add_role(user, role)
+ stub_feature_flags(pipeline_tabs_vue: false)
+ end
+
+ shared_context 'pipeline builds' do
+ let!(:build_passed) do
+ create(:ci_build, :success,
+ pipeline: pipeline, stage: 'build', stage_idx: 0, name: 'build')
+ end
+
+ let!(:build_failed) do
+ create(:ci_build, :failed,
+ pipeline: pipeline, stage: 'test', stage_idx: 1, name: 'test')
+ end
+
+ let!(:build_preparing) do
+ create(:ci_build, :preparing,
+ pipeline: pipeline, stage: 'deploy', stage_idx: 2, name: 'prepare')
+ end
+
+ let!(:build_running) do
+ create(:ci_build, :running,
+ pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'deploy')
+ end
+
+ let!(:build_manual) do
+ create(:ci_build, :manual,
+ pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'manual-build')
+ end
+
+ let!(:build_scheduled) do
+ create(:ci_build, :scheduled,
+ pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'delayed-job')
+ end
+
+ let!(:build_external) do
+ create(:generic_commit_status, status: 'success',
+ pipeline: pipeline,
+ name: 'jenkins',
+ stage: 'external',
+ ref: 'master',
+ target_url: 'http://gitlab.com/status')
+ end
+ end
+
+ describe 'GET /:project/-/pipelines/:id' do
+ include_context 'pipeline builds'
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project, reload: true) { create(:project, :repository, group: group) }
+
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) }
+
+ subject(:visit_pipeline) { visit project_pipeline_path(project, pipeline) }
+
+ it 'shows the pipeline graph' do
+ visit_pipeline
+
+ expect(page).to have_selector('.js-pipeline-graph')
+ expect(page).to have_content('Build')
+ expect(page).to have_content('Test')
+ expect(page).to have_content('Deploy')
+ expect(page).to have_content('Retry')
+ expect(page).to have_content('Cancel running')
+ end
+
+ it 'shows Pipeline tab pane as active' do
+ visit_pipeline
+
+ expect(page).to have_css('#js-tab-pipeline.active')
+ end
+
+ it 'shows link to the pipeline ref' do
+ visit_pipeline
+
+ expect(page).to have_link(pipeline.ref)
+ end
+
+ it 'shows the pipeline information' do
+ visit_pipeline
+
+ within '.pipeline-info' do
+ expect(page).to have_content("#{pipeline.statuses.count} jobs " \
+ "for #{pipeline.ref}")
+ expect(page).to have_link(pipeline.ref,
+ href: project_commits_path(pipeline.project, pipeline.ref))
+ end
+ end
+
+ describe 'related merge requests' do
+ context 'when there are no related merge requests' do
+ it 'shows a "no related merge requests" message' do
+ visit_pipeline
+
+ within '.related-merge-request-info' do
+ expect(page).to have_content('No related merge requests found.')
+ end
+ end
+ end
+
+ context 'when there is one related merge request' do
+ let!(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: pipeline.ref)
+ end
+
+ it 'shows a link to the merge request' do
+ visit_pipeline
+
+ within '.related-merge-requests' do
+ expect(page).to have_content('1 related merge request: ')
+ expect(page).to have_selector('.js-truncated-mr-list')
+ expect(page).to have_link("#{merge_request.to_reference} #{merge_request.title}")
+
+ expect(page).not_to have_selector('.js-full-mr-list')
+ expect(page).not_to have_selector('.text-expander')
+ end
+ end
+ end
+
+ context 'when there are two related merge requests' do
+ let!(:merge_request1) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: pipeline.ref)
+ end
+
+ let!(:merge_request2) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: pipeline.ref,
+ target_branch: 'fix')
+ end
+
+ it 'links to the most recent related merge request' do
+ visit_pipeline
+
+ within '.related-merge-requests' do
+ expect(page).to have_content('2 related merge requests: ')
+ expect(page).to have_link("#{merge_request2.to_reference} #{merge_request2.title}")
+ expect(page).to have_selector('.text-expander')
+ expect(page).to have_selector('.js-full-mr-list', visible: false)
+ end
+ end
+
+ it 'expands to show links to all related merge requests' do
+ visit_pipeline
+
+ within '.related-merge-requests' do
+ find('.text-expander').click
+
+ expect(page).to have_selector('.js-full-mr-list', visible: true)
+
+ pipeline.all_merge_requests.map do |merge_request|
+ expect(page).to have_link(href: project_merge_request_path(project, merge_request))
+ end
+ end
+ end
+ end
+ end
+
+ describe 'pipelines details view' do
+ let!(:status) { create(:user_status, user: pipeline.user, emoji: 'smirk', message: 'Authoring this object') }
+
+ it 'pipeline header shows the user status and emoji' do
+ visit project_pipeline_path(project, pipeline)
+
+ within '[data-testid="ci-header-content"]' do
+ expect(page).to have_selector("[data-testid='#{status.message}']")
+ expect(page).to have_selector("[data-name='#{status.emoji}']")
+ end
+ end
+ end
+
+ describe 'pipeline graph' do
+ before do
+ visit_pipeline
+ end
+
+ context 'when pipeline has running builds' do
+ it 'shows a running icon and a cancel action for the running build' do
+ page.within('#ci-badge-deploy') do
+ expect(page).to have_selector('.js-ci-status-icon-running')
+ expect(page).to have_selector('.js-icon-cancel')
+ expect(page).to have_content('deploy')
+ end
+ end
+
+ it 'cancels the running build and shows retry button', :sidekiq_might_not_need_inline do
+ find('#ci-badge-deploy .ci-action-icon-container').click
+
+ page.within('#ci-badge-deploy') do
+ expect(page).to have_css('.js-icon-retry')
+ end
+ end
+ end
+
+ context 'when pipeline has preparing builds' do
+ it 'shows a preparing icon and a cancel action' do
+ page.within('#ci-badge-prepare') do
+ expect(page).to have_selector('.js-ci-status-icon-preparing')
+ expect(page).to have_selector('.js-icon-cancel')
+ expect(page).to have_content('prepare')
+ end
+ end
+
+ it 'cancels the preparing build and shows retry button', :sidekiq_might_not_need_inline do
+ find('#ci-badge-deploy .ci-action-icon-container').click
+
+ page.within('#ci-badge-deploy') do
+ expect(page).to have_css('.js-icon-retry')
+ end
+ end
+ end
+
+ context 'when pipeline has successful builds' do
+ it 'shows the success icon and a retry action for the successful build' do
+ page.within('#ci-badge-build') do
+ expect(page).to have_selector('.js-ci-status-icon-success')
+ expect(page).to have_content('build')
+ end
+
+ page.within('#ci-badge-build .ci-action-icon-container.js-icon-retry') do
+ expect(page).to have_selector('svg')
+ end
+ end
+
+ it 'is possible to retry the success job', :sidekiq_might_not_need_inline do
+ find('#ci-badge-build .ci-action-icon-container').click
+ wait_for_requests
+
+ expect(page).not_to have_content('Retry job')
+ within('.js-pipeline-header-container') do
+ expect(page).to have_selector('.js-ci-status-icon-running')
+ end
+ end
+ end
+
+ context 'when pipeline has a delayed job' do
+ let(:project) { create(:project, :repository, group: group) }
+
+ it 'shows the scheduled icon and an unschedule action for the delayed job' do
+ page.within('#ci-badge-delayed-job') do
+ expect(page).to have_selector('.js-ci-status-icon-scheduled')
+ expect(page).to have_content('delayed-job')
+ end
+
+ page.within('#ci-badge-delayed-job .ci-action-icon-container.js-icon-time-out') do
+ expect(page).to have_selector('svg')
+ end
+ end
+
+ it 'unschedules the delayed job and shows play button as a manual job', :sidekiq_might_not_need_inline do
+ find('#ci-badge-delayed-job .ci-action-icon-container').click
+
+ page.within('#ci-badge-delayed-job') do
+ expect(page).to have_css('.js-icon-play')
+ end
+ end
+ end
+
+ context 'when pipeline has failed builds' do
+ it 'shows the failed icon and a retry action for the failed build' do
+ page.within('#ci-badge-test') do
+ expect(page).to have_selector('.js-ci-status-icon-failed')
+ expect(page).to have_content('test')
+ end
+
+ page.within('#ci-badge-test .ci-action-icon-container.js-icon-retry') do
+ expect(page).to have_selector('svg')
+ end
+ end
+
+ it 'is possible to retry the failed build', :sidekiq_might_not_need_inline do
+ find('#ci-badge-test .ci-action-icon-container').click
+ wait_for_requests
+
+ expect(page).not_to have_content('Retry job')
+ within('.js-pipeline-header-container') do
+ expect(page).to have_selector('.js-ci-status-icon-running')
+ end
+ end
+
+ it 'includes the failure reason' do
+ page.within('#ci-badge-test') do
+ build_link = page.find('.js-pipeline-graph-job-link')
+ expect(build_link['title']).to eq('test - failed - (unknown failure)')
+ end
+ end
+ end
+
+ context 'when pipeline has manual jobs' do
+ it 'shows the skipped icon and a play action for the manual build' do
+ page.within('#ci-badge-manual-build') do
+ expect(page).to have_selector('.js-ci-status-icon-manual')
+ expect(page).to have_content('manual')
+ end
+
+ page.within('#ci-badge-manual-build .ci-action-icon-container.js-icon-play') do
+ expect(page).to have_selector('svg')
+ end
+ end
+
+ it 'is possible to play the manual job', :sidekiq_might_not_need_inline do
+ find('#ci-badge-manual-build .ci-action-icon-container').click
+ wait_for_requests
+
+ expect(page).not_to have_content('Play job')
+ within('.js-pipeline-header-container') do
+ expect(page).to have_selector('.js-ci-status-icon-running')
+ end
+ end
+ end
+
+ context 'when pipeline has external job' do
+ it 'shows the success icon and the generic comit status build' do
+ expect(page).to have_selector('.js-ci-status-icon-success')
+ expect(page).to have_content('jenkins')
+ expect(page).to have_link('jenkins', href: 'http://gitlab.com/status')
+ end
+ end
+ end
+
+ context 'when the pipeline has manual stage' do
+ before do
+ create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'CentOS')
+ create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'Debian')
+ create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'OpenSUDE')
+
+ # force to update stages statuses
+ Ci::ProcessPipelineService.new(pipeline).execute
+
+ visit_pipeline
+ end
+
+ it 'displays play all button' do
+ expect(page).to have_selector('.js-stage-action')
+ end
+ end
+
+ context 'page tabs' do
+ before do
+ visit_pipeline
+ end
+
+ it 'shows Pipeline, Jobs, DAG and Failed Jobs tabs with link' do
+ expect(page).to have_link('Pipeline')
+ expect(page).to have_link('Jobs')
+ expect(page).to have_link('Needs')
+ expect(page).to have_link('Failed Jobs')
+ end
+
+ it 'shows counter in Jobs tab' do
+ expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s)
+ end
+
+ it 'shows Pipeline tab as active' do
+ expect(page).to have_css('.js-pipeline-tab-link .active')
+ end
+
+ context 'without permission to access builds' do
+ let(:project) { create(:project, :public, :repository, public_builds: false) }
+ let(:role) { :guest }
+
+ it 'does not show the pipeline details page' do
+ expect(page).to have_content('Not Found')
+ end
+ end
+ end
+
+ context 'retrying jobs' do
+ before do
+ visit_pipeline
+ end
+
+ it { expect(page).not_to have_content('retried') }
+
+ context 'when retrying' do
+ before do
+ find('[data-testid="retryPipeline"]').click
+ wait_for_requests
+ end
+
+ it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do
+ expect(page).not_to have_content('Retry')
+ end
+
+ it 'shows running status in pipeline header', :sidekiq_might_not_need_inline do
+ within('.js-pipeline-header-container') do
+ expect(page).to have_selector('.js-ci-status-icon-running')
+ end
+ end
+ end
+ end
+
+ context 'canceling jobs' do
+ before do
+ visit_pipeline
+ end
+
+ it { expect(page).not_to have_selector('.ci-canceled') }
+
+ context 'when canceling' do
+ before do
+ click_on 'Cancel running'
+ end
+
+ it 'does not show a "Cancel running" button', :sidekiq_might_not_need_inline do
+ expect(page).not_to have_content('Cancel running')
+ end
+ end
+ end
+
+ context 'when user can not delete' do
+ before do
+ visit_pipeline
+ end
+
+ it { expect(page).not_to have_button('Delete') }
+ end
+
+ context 'when deleting' do
+ before do
+ group.add_owner(user)
+
+ visit_pipeline
+
+ click_button 'Delete'
+ click_button 'Delete pipeline'
+ end
+
+ it 'redirects to pipeline overview page', :sidekiq_inline do
+ expect(page).to have_content('The pipeline has been deleted')
+ expect(page).to have_current_path(project_pipelines_path(project), ignore_query: true)
+ end
+ end
+
+ context 'when pipeline ref does not exist in repository anymore' do
+ let(:pipeline) do
+ create(:ci_empty_pipeline, project: project,
+ ref: 'non-existent',
+ sha: project.commit.id,
+ user: user)
+ end
+
+ before do
+ visit_pipeline
+ end
+
+ it 'does not render link to the pipeline ref' do
+ expect(page).not_to have_link(pipeline.ref)
+ expect(page).to have_content(pipeline.ref)
+ end
+
+ it 'does not render render raw HTML to the pipeline ref' do
+ page.within '.pipeline-info' do
+ expect(page).not_to have_content('<span class="ref-name"')
+ end
+ end
+ end
+
+ context 'when pipeline is detached merge request pipeline' do
+ let(:source_project) { project }
+ let(:target_project) { project }
+
+ let(:merge_request) do
+ create(:merge_request,
+ :with_detached_merge_request_pipeline,
+ source_project: source_project,
+ target_project: target_project)
+ end
+
+ let(:pipeline) do
+ merge_request.all_pipelines.last
+ end
+
+ it 'shows the pipeline information' do
+ visit_pipeline
+
+ within '.pipeline-info' do
+ expect(page).to have_content("#{pipeline.statuses.count} jobs " \
+ "for !#{merge_request.iid} " \
+ "with #{merge_request.source_branch}")
+ expect(page).to have_link("!#{merge_request.iid}",
+ href: project_merge_request_path(project, merge_request))
+ expect(page).to have_link(merge_request.source_branch,
+ href: project_commits_path(merge_request.source_project, merge_request.source_branch))
+ end
+ end
+
+ context 'when source branch does not exist' do
+ before do
+ project.repository.rm_branch(user, merge_request.source_branch)
+ end
+
+ it 'does not link to the source branch commit path' do
+ visit_pipeline
+
+ within '.pipeline-info' do
+ expect(page).not_to have_link(merge_request.source_branch)
+ expect(page).to have_content(merge_request.source_branch)
+ end
+ end
+ end
+
+ context 'when source project is a forked project' do
+ let(:source_project) { fork_project(project, user, repository: true) }
+
+ before do
+ visit project_pipeline_path(source_project, pipeline)
+ end
+
+ it 'shows the pipeline information', :sidekiq_might_not_need_inline do
+ within '.pipeline-info' do
+ expect(page).to have_content("#{pipeline.statuses.count} jobs " \
+ "for !#{merge_request.iid} " \
+ "with #{merge_request.source_branch}")
+ expect(page).to have_link("!#{merge_request.iid}",
+ href: project_merge_request_path(project, merge_request))
+ expect(page).to have_link(merge_request.source_branch,
+ href: project_commits_path(merge_request.source_project, merge_request.source_branch))
+ end
+ end
+ end
+ end
+
+ context 'when pipeline is merge request pipeline' do
+ let(:project) { create(:project, :repository, group: group) }
+ let(:source_project) { project }
+ let(:target_project) { project }
+
+ let(:merge_request) do
+ create(:merge_request,
+ :with_merge_request_pipeline,
+ source_project: source_project,
+ target_project: target_project,
+ merge_sha: project.commit.id)
+ end
+
+ let(:pipeline) do
+ merge_request.all_pipelines.last
+ end
+
+ before do
+ pipeline.update!(user: user)
+ end
+
+ it 'shows the pipeline information' do
+ visit_pipeline
+
+ within '.pipeline-info' do
+ expect(page).to have_content("#{pipeline.statuses.count} jobs " \
+ "for !#{merge_request.iid} " \
+ "with #{merge_request.source_branch} " \
+ "into #{merge_request.target_branch}")
+ expect(page).to have_link("!#{merge_request.iid}",
+ href: project_merge_request_path(project, merge_request))
+ expect(page).to have_link(merge_request.source_branch,
+ href: project_commits_path(merge_request.source_project, merge_request.source_branch))
+ expect(page).to have_link(merge_request.target_branch,
+ href: project_commits_path(merge_request.target_project, merge_request.target_branch))
+ end
+ end
+
+ context 'when target branch does not exist' do
+ before do
+ project.repository.rm_branch(user, merge_request.target_branch)
+ end
+
+ it 'does not link to the target branch commit path' do
+ visit_pipeline
+
+ within '.pipeline-info' do
+ expect(page).not_to have_link(merge_request.target_branch)
+ expect(page).to have_content(merge_request.target_branch)
+ end
+ end
+ end
+
+ context 'when source project is a forked project' do
+ let(:source_project) { fork_project(project, user, repository: true) }
+
+ before do
+ visit project_pipeline_path(source_project, pipeline)
+ end
+
+ it 'shows the pipeline information', :sidekiq_might_not_need_inline do
+ within '.pipeline-info' do
+ expect(page).to have_content("#{pipeline.statuses.count} jobs " \
+ "for !#{merge_request.iid} " \
+ "with #{merge_request.source_branch} " \
+ "into #{merge_request.target_branch}")
+ expect(page).to have_link("!#{merge_request.iid}",
+ href: project_merge_request_path(project, merge_request))
+ expect(page).to have_link(merge_request.source_branch,
+ href: project_commits_path(merge_request.source_project, merge_request.source_branch))
+ expect(page).to have_link(merge_request.target_branch,
+ href: project_commits_path(merge_request.target_project, merge_request.target_branch))
+ end
+ end
+ end
+ end
+ end
+
+ context 'when user does not have access to read jobs' do
+ before do
+ project.update!(public_builds: false)
+ end
+
+ describe 'GET /:project/-/pipelines/:id' do
+ include_context 'pipeline builds'
+
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) }
+
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it 'shows the pipeline graph' do
+ expect(page).to have_selector('.js-pipeline-graph')
+ expect(page).to have_content('Build')
+ expect(page).to have_content('Test')
+ expect(page).to have_content('Deploy')
+ expect(page).to have_content('Retry')
+ expect(page).to have_content('Cancel running')
+ end
+
+ it 'does not link to job' do
+ expect(page).not_to have_selector('.js-pipeline-graph-job-link')
+ end
+ end
+ end
+
+ context 'when a bridge job exists' do
+ include_context 'pipeline builds'
+
+ let(:project) { create(:project, :repository) }
+ let(:downstream) { create(:project, :repository) }
+
+ let(:pipeline) do
+ create(:ci_pipeline, project: project,
+ ref: 'master',
+ sha: project.commit.id,
+ user: user)
+ end
+
+ let!(:bridge) do
+ create(:ci_bridge, pipeline: pipeline,
+ name: 'cross-build',
+ user: user,
+ downstream: downstream)
+ end
+
+ describe 'GET /:project/-/pipelines/:id' do
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it 'shows the pipeline with a bridge job' do
+ expect(page).to have_selector('.js-pipeline-graph')
+ expect(page).to have_content('cross-build')
+ end
+
+ context 'when a scheduled pipeline is created by a blocked user' do
+ let(:project) { create(:project, :repository) }
+
+ let(:schedule) do
+ create(:ci_pipeline_schedule,
+ project: project,
+ owner: project.first_owner,
+ description: 'blocked user schedule'
+ ).tap do |schedule|
+ schedule.update_column(:next_run_at, 1.minute.ago)
+ end
+ end
+
+ before do
+ schedule.owner.block!
+
+ begin
+ PipelineScheduleWorker.new.perform
+ rescue Ci::CreatePipelineService::CreateError
+ # Do nothing, assert view code after the Pipeline failed to create.
+ end
+ end
+
+ it 'displays the PipelineSchedule in an inactive state' do
+ visit project_pipeline_schedules_path(project)
+ page.click_link('Inactive')
+
+ expect(page).to have_selector('table.ci-table > tbody > tr > td', text: 'blocked user schedule')
+ end
+
+ it 'does not create a new Pipeline' do
+ visit project_pipelines_path(project)
+
+ expect(page).not_to have_selector('.ci-table')
+ expect(schedule.last_pipeline).to be_nil
+ end
+ end
+ end
+
+ describe 'GET /:project/-/pipelines/:id/builds' do
+ before do
+ visit builds_project_pipeline_path(project, pipeline)
+ end
+
+ it 'shows a bridge job on a list' do
+ expect(page).to have_content('cross-build')
+ expect(page).to have_content(bridge.id)
+ end
+ end
+ end
+
+ context 'when build requires resource', :sidekiq_inline do
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:resource_group) { create(:ci_resource_group, project: project) }
+
+ let!(:test_job) do
+ create(:ci_build, :pending, stage: 'test', name: 'test',
+ stage_idx: 1, pipeline: pipeline, project: project)
+ end
+
+ let!(:deploy_job) do
+ create(:ci_build, :created, stage: 'deploy', name: 'deploy',
+ stage_idx: 2, pipeline: pipeline, project: project, resource_group: resource_group)
+ end
+
+ describe 'GET /:project/-/pipelines/:id' do
+ subject { visit project_pipeline_path(project, pipeline) }
+
+ it 'shows deploy job as created' do
+ subject
+
+ within('.js-pipeline-header-container') do
+ expect(page).to have_content('pending')
+ end
+
+ within('.js-pipeline-graph') do
+ within(all('[data-testid="stage-column"]')[0]) do
+ expect(page).to have_content('test')
+ expect(page).to have_css('.ci-status-icon-pending')
+ end
+
+ within(all('[data-testid="stage-column"]')[1]) do
+ expect(page).to have_content('deploy')
+ expect(page).to have_css('.ci-status-icon-created')
+ end
+ end
+ end
+
+ context 'when test job succeeded' do
+ before do
+ test_job.success!
+ end
+
+ it 'shows deploy job as pending' do
+ subject
+
+ within('.js-pipeline-header-container') do
+ expect(page).to have_content('running')
+ end
+
+ within('.js-pipeline-graph') do
+ within(all('[data-testid="stage-column"]')[0]) do
+ expect(page).to have_content('test')
+ expect(page).to have_css('.ci-status-icon-success')
+ end
+
+ within(all('[data-testid="stage-column"]')[1]) do
+ expect(page).to have_content('deploy')
+ expect(page).to have_css('.ci-status-icon-pending')
+ end
+ end
+ end
+ end
+
+ context 'when test job succeeded but there are no available resources' do
+ let(:another_job) { create(:ci_build, :running, project: project, resource_group: resource_group) }
+
+ before do
+ resource_group.assign_resource_to(another_job)
+ test_job.success!
+ end
+
+ it 'shows deploy job as waiting for resource' do
+ subject
+
+ within('.js-pipeline-header-container') do
+ expect(page).to have_content('waiting')
+ end
+
+ within('.js-pipeline-graph') do
+ within(all('[data-testid="stage-column"]')[1]) do
+ expect(page).to have_content('deploy')
+ expect(page).to have_css('.ci-status-icon-waiting-for-resource')
+ end
+ end
+ end
+
+ context 'when resource is released from another job' do
+ before do
+ another_job.success!
+ end
+
+ it 'shows deploy job as pending' do
+ subject
+
+ within('.js-pipeline-header-container') do
+ expect(page).to have_content('running')
+ end
+
+ within('.js-pipeline-graph') do
+ within(all('[data-testid="stage-column"]')[1]) do
+ expect(page).to have_content('deploy')
+ expect(page).to have_css('.ci-status-icon-pending')
+ end
+ end
+ end
+ end
+
+ context 'when deploy job is a bridge to trigger a downstream pipeline' do
+ let!(:deploy_job) do
+ create(:ci_bridge, :created, stage: 'deploy', name: 'deploy',
+ stage_idx: 2, pipeline: pipeline, project: project, resource_group: resource_group)
+ end
+
+ it 'shows deploy job as waiting for resource' do
+ subject
+
+ within('.js-pipeline-header-container') do
+ expect(page).to have_content('waiting')
+ end
+
+ within('.js-pipeline-graph') do
+ within(all('[data-testid="stage-column"]')[1]) do
+ expect(page).to have_content('deploy')
+ expect(page).to have_css('.ci-status-icon-waiting-for-resource')
+ end
+ end
+ end
+ end
+
+ context 'when deploy job is a bridge to trigger a downstream pipeline' do
+ let!(:deploy_job) do
+ create(:ci_bridge, :created, stage: 'deploy', name: 'deploy',
+ stage_idx: 2, pipeline: pipeline, project: project, resource_group: resource_group)
+ end
+
+ it 'shows deploy job as waiting for resource' do
+ subject
+
+ within('.js-pipeline-header-container') do
+ expect(page).to have_content('waiting')
+ end
+
+ within('.js-pipeline-graph') do
+ within(all('[data-testid="stage-column"]')[1]) do
+ expect(page).to have_content('deploy')
+ expect(page).to have_css('.ci-status-icon-waiting-for-resource')
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe 'GET /:project/-/pipelines/:id/dag' do
+ include_context 'pipeline builds'
+
+ let_it_be(:project) { create(:project, :repository) }
+
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
+
+ before do
+ visit dag_project_pipeline_path(project, pipeline)
+ end
+
+ it 'shows DAG tab pane as active' do
+ expect(page).to have_css('#js-tab-dag.active', visible: false)
+ end
+
+ context 'page tabs' do
+ it 'shows Pipeline, Jobs and DAG tabs with link' do
+ expect(page).to have_link('Pipeline')
+ expect(page).to have_link('Jobs')
+ expect(page).to have_link('DAG')
+ end
+
+ it 'shows counter in Jobs tab' do
+ expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s)
+ end
+
+ it 'shows DAG tab as active' do
+ expect(page).to have_css('li.js-dag-tab-link .active')
+ end
+ end
+ end
+
+ context 'when user sees pipeline flags in a pipeline detail page' do
+ let_it_be(:project) { create(:project, :repository) }
+
+ context 'when pipeline is latest' do
+ include_context 'pipeline builds'
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ project: project,
+ ref: 'master',
+ sha: project.commit.id,
+ user: user)
+ end
+
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it 'contains badge that indicates it is the latest build' do
+ page.within(all('.well-segment')[1]) do
+ expect(page).to have_content 'latest'
+ end
+ end
+ end
+
+ context 'when pipeline has configuration errors' do
+ let(:pipeline) do
+ create(:ci_pipeline,
+ :invalid,
+ project: project,
+ ref: 'master',
+ sha: project.commit.id,
+ user: user)
+ end
+
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it 'contains badge that indicates errors' do
+ page.within(all('.well-segment')[1]) do
+ expect(page).to have_content 'yaml invalid'
+ end
+ end
+
+ it 'contains badge with tooltip which contains error' do
+ expect(pipeline).to have_yaml_errors
+
+ page.within(all('.well-segment')[1]) do
+ expect(page).to have_selector(
+ %Q{span[title="#{pipeline.yaml_errors}"]})
+ end
+ end
+
+ it 'contains badge that indicates failure reason' do
+ expect(page).to have_content 'error'
+ end
+
+ it 'contains badge with tooltip which contains failure reason' do
+ expect(pipeline.failure_reason?).to eq true
+
+ page.within(all('.well-segment')[1]) do
+ expect(page).to have_selector(
+ %Q{span[title="#{pipeline.present.failure_reason}"]})
+ end
+ end
+
+ it 'contains a pipeline header with title' do
+ expect(page).to have_content "Pipeline ##{pipeline.id}"
+ end
+ end
+
+ context 'when pipeline is stuck' do
+ include_context 'pipeline builds'
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ project: project,
+ ref: 'master',
+ sha: project.commit.id,
+ user: user)
+ end
+
+ before do
+ create(:ci_build, :pending, pipeline: pipeline)
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it 'contains badge that indicates being stuck' do
+ page.within(all('.well-segment')[1]) do
+ expect(page).to have_content 'stuck'
+ end
+ end
+ end
+
+ context 'when pipeline uses auto devops' do
+ include_context 'pipeline builds'
+
+ let(:project) { create(:project, :repository, auto_devops_attributes: { enabled: true }) }
+ let(:pipeline) do
+ create(:ci_pipeline,
+ :auto_devops_source,
+ project: project,
+ ref: 'master',
+ sha: project.commit.id,
+ user: user)
+ end
+
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it 'contains badge that indicates using auto devops' do
+ page.within(all('.well-segment')[1]) do
+ expect(page).to have_content 'Auto DevOps'
+ end
+ end
+ end
+
+ context 'when pipeline runs in a merge request context' do
+ include_context 'pipeline builds'
+
+ let(:pipeline) do
+ create(:ci_pipeline,
+ source: :merge_request_event,
+ project: merge_request.source_project,
+ ref: 'feature',
+ sha: merge_request.diff_head_sha,
+ user: user,
+ merge_request: merge_request)
+ end
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: 'feature',
+ target_project: project,
+ target_branch: 'master')
+ end
+
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it 'contains badge that indicates detached merge request pipeline' do
+ page.within(all('.well-segment')[1]) do
+ expect(page).to have_content 'merge request'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/pipelines/legacy_pipelines_spec.rb b/spec/features/projects/pipelines/legacy_pipelines_spec.rb
new file mode 100644
index 00000000000..3f89e344c51
--- /dev/null
+++ b/spec/features/projects/pipelines/legacy_pipelines_spec.rb
@@ -0,0 +1,847 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Pipelines', :js do
+ include ProjectForksHelper
+ include Spec::Support::Helpers::ModalHelpers
+
+ let(:project) { create(:project) }
+ let(:expected_detached_mr_tag) {'merge request'}
+
+ context 'when user is logged in' do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+
+ project.add_developer(user)
+ project.update!(auto_devops_attributes: { enabled: false })
+
+ stub_feature_flags(pipeline_tabs_vue: false)
+ end
+
+ describe 'GET /:project/-/pipelines' do
+ let(:project) { create(:project, :repository) }
+
+ let!(:pipeline) do
+ create(
+ :ci_empty_pipeline,
+ project: project,
+ ref: 'master',
+ status: 'running',
+ sha: project.commit.id
+ )
+ end
+
+ context 'scope' do
+ before do
+ create(:ci_empty_pipeline, status: 'pending', project: project, sha: project.commit.id, ref: 'master')
+ create(:ci_empty_pipeline, status: 'running', project: project, sha: project.commit.id, ref: 'master')
+ create(:ci_empty_pipeline, status: 'created', project: project, sha: project.commit.id, ref: 'master')
+ create(:ci_empty_pipeline, status: 'success', project: project, sha: project.commit.id, ref: 'master')
+ end
+
+ [:all, :running, :pending, :finished, :branches].each do |scope|
+ context "when displaying #{scope}" do
+ before do
+ visit_project_pipelines(scope: scope)
+ end
+
+ it 'contains pipeline commit short SHA' do
+ expect(page).to have_content(pipeline.short_sha)
+ end
+
+ it 'contains branch name' do
+ expect(page).to have_content(pipeline.ref)
+ end
+ end
+ end
+ end
+
+ context 'header tabs' do
+ before do
+ visit project_pipelines_path(project)
+ wait_for_requests
+ end
+
+ it 'shows a tab for All pipelines and count' do
+ expect(page.find('.js-pipelines-tab-all').text).to include('All')
+ expect(page.find('.js-pipelines-tab-all .badge').text).to include('1')
+ end
+
+ it 'shows a tab for Finished pipelines and count' do
+ expect(page.find('.js-pipelines-tab-finished').text).to include('Finished')
+ end
+
+ it 'shows a tab for Branches' do
+ expect(page.find('.js-pipelines-tab-branches').text).to include('Branches')
+ end
+
+ it 'shows a tab for Tags' do
+ expect(page.find('.js-pipelines-tab-tags').text).to include('Tags')
+ end
+
+ it 'updates content when tab is clicked' do
+ page.find('.js-pipelines-tab-finished').click
+ wait_for_requests
+ expect(page).to have_content('There are currently no finished pipelines.')
+ end
+ end
+
+ context 'navigation links' do
+ before do
+ visit project_pipelines_path(project)
+ wait_for_requests
+ end
+
+ it 'renders "CI lint" link' do
+ expect(page).to have_link('CI lint')
+ end
+
+ it 'renders "Run pipeline" link' do
+ expect(page).to have_link('Run pipeline')
+ end
+ end
+
+ context 'when pipeline is cancelable' do
+ let!(:build) do
+ create(:ci_build, pipeline: pipeline,
+ stage: 'test')
+ end
+
+ before do
+ build.run
+ visit_project_pipelines
+ end
+
+ it 'indicates that pipeline can be canceled' do
+ expect(page).to have_selector('.js-pipelines-cancel-button')
+ expect(page).to have_selector('.ci-running')
+ end
+
+ context 'when canceling' do
+ before do
+ find('.js-pipelines-cancel-button').click
+ click_button 'Stop pipeline'
+ wait_for_requests
+ end
+
+ it 'indicated that pipelines was canceled', :sidekiq_might_not_need_inline do
+ expect(page).not_to have_selector('.js-pipelines-cancel-button')
+ expect(page).to have_selector('.ci-canceled')
+ end
+ end
+ end
+
+ context 'when pipeline is retryable', :sidekiq_might_not_need_inline do
+ let!(:build) do
+ create(:ci_build, pipeline: pipeline,
+ stage: 'test')
+ end
+
+ before do
+ build.drop
+ visit_project_pipelines
+ end
+
+ it 'indicates that pipeline can be retried' do
+ expect(page).to have_selector('.js-pipelines-retry-button')
+ expect(page).to have_selector('.ci-failed')
+ end
+
+ context 'when retrying' do
+ before do
+ find('.js-pipelines-retry-button').click
+ wait_for_requests
+ end
+
+ it 'shows running pipeline that is not retryable' do
+ expect(page).not_to have_selector('.js-pipelines-retry-button')
+ expect(page).to have_selector('.ci-running')
+ end
+ end
+ end
+
+ context 'when pipeline is detached merge request pipeline' do
+ let(:merge_request) do
+ create(:merge_request,
+ :with_detached_merge_request_pipeline,
+ source_project: source_project,
+ target_project: target_project)
+ end
+
+ let!(:pipeline) { merge_request.all_pipelines.first }
+ let(:source_project) { project }
+ let(:target_project) { project }
+
+ before do
+ visit project_pipelines_path(source_project)
+ end
+
+ shared_examples_for 'detached merge request pipeline' do
+ it 'shows pipeline information without pipeline ref', :sidekiq_might_not_need_inline do
+ within '.pipeline-tags' do
+ expect(page).to have_content(expected_detached_mr_tag)
+
+ expect(page).to have_link(merge_request.iid,
+ href: project_merge_request_path(project, merge_request))
+
+ expect(page).not_to have_link(pipeline.ref)
+ end
+ end
+ end
+
+ it_behaves_like 'detached merge request pipeline'
+
+ context 'when source project is a forked project' do
+ let(:source_project) { fork_project(project, user, repository: true) }
+
+ it_behaves_like 'detached merge request pipeline'
+ end
+ end
+
+ context 'when pipeline is merge request pipeline' do
+ let(:merge_request) do
+ create(:merge_request,
+ :with_merge_request_pipeline,
+ source_project: source_project,
+ target_project: target_project,
+ merge_sha: target_project.commit.sha)
+ end
+
+ let!(:pipeline) { merge_request.all_pipelines.first }
+ let(:source_project) { project }
+ let(:target_project) { project }
+
+ before do
+ visit project_pipelines_path(source_project)
+ end
+
+ shared_examples_for 'Correct merge request pipeline information' do
+ it 'does not show detached tag for the pipeline, and shows the link of the merge request' \
+ 'and does not show the ref of the pipeline', :sidekiq_might_not_need_inline do
+ within '.pipeline-tags' do
+ expect(page).not_to have_content(expected_detached_mr_tag)
+
+ expect(page).to have_link(merge_request.iid,
+ href: project_merge_request_path(project, merge_request))
+
+ expect(page).not_to have_link(pipeline.ref)
+ end
+ end
+ end
+
+ it_behaves_like 'Correct merge request pipeline information'
+
+ context 'when source project is a forked project' do
+ let(:source_project) { fork_project(project, user, repository: true) }
+
+ it_behaves_like 'Correct merge request pipeline information'
+ end
+ end
+
+ context 'when pipeline has configuration errors' do
+ let(:pipeline) do
+ create(:ci_pipeline, :invalid, project: project)
+ end
+
+ before do
+ visit_project_pipelines
+ end
+
+ it 'contains badge that indicates errors' do
+ expect(page).to have_content 'yaml invalid'
+ end
+
+ it 'contains badge with tooltip which contains error' do
+ expect(pipeline).to have_yaml_errors
+ expect(page).to have_selector(
+ %Q{span[title="#{pipeline.yaml_errors}"]})
+ end
+
+ it 'contains badge that indicates failure reason' do
+ expect(page).to have_content 'error'
+ end
+
+ it 'contains badge with tooltip which contains failure reason' do
+ expect(pipeline.failure_reason?).to eq true
+ expect(page).to have_selector(
+ %Q{span[title="#{pipeline.present.failure_reason}"]})
+ end
+ end
+
+ context 'with manual actions' do
+ let!(:manual) do
+ create(:ci_build, :manual,
+ pipeline: pipeline,
+ name: 'manual build',
+ stage: 'test')
+ end
+
+ before do
+ visit_project_pipelines
+ end
+
+ it 'has a dropdown with play button' do
+ expect(page).to have_selector('[data-testid="pipelines-manual-actions-dropdown"] [data-testid="play-icon"]')
+ end
+
+ it 'has link to the manual action' do
+ find('[data-testid="pipelines-manual-actions-dropdown"]').click
+
+ expect(page).to have_button('manual build')
+ end
+
+ context 'when manual action was played' do
+ before do
+ find('[data-testid="pipelines-manual-actions-dropdown"]').click
+ click_button('manual build')
+ end
+
+ it 'enqueues manual action job' do
+ expect(page).to have_selector(
+ '[data-testid="pipelines-manual-actions-dropdown"] .gl-dropdown-toggle:disabled'
+ )
+ end
+ end
+ end
+
+ context 'when there is a delayed job' do
+ let!(:delayed_job) do
+ create(:ci_build, :scheduled,
+ pipeline: pipeline,
+ name: 'delayed job 1',
+ stage: 'test')
+ end
+
+ before do
+ stub_feature_flags(bootstrap_confirmation_modals: false)
+ visit_project_pipelines
+ end
+
+ it 'has a dropdown for actionable jobs' do
+ expect(page).to have_selector('[data-testid="pipelines-manual-actions-dropdown"] [data-testid="play-icon"]')
+ end
+
+ it "has link to the delayed job's action" do
+ find('[data-testid="pipelines-manual-actions-dropdown"]').click
+
+ time_diff = [0, delayed_job.scheduled_at - Time.zone.now].max
+ expect(page).to have_button('delayed job 1')
+ expect(page).to have_content(Time.at(time_diff).utc.strftime("%H:%M:%S"))
+ end
+
+ context 'when delayed job is expired already' do
+ let!(:delayed_job) do
+ create(:ci_build, :expired_scheduled,
+ pipeline: pipeline,
+ name: 'delayed job 1',
+ stage: 'test')
+ end
+
+ it "shows 00:00:00 as the remaining time" do
+ find('[data-testid="pipelines-manual-actions-dropdown"]').click
+
+ expect(page).to have_content("00:00:00")
+ end
+ end
+
+ context 'when user played a delayed job immediately' do
+ before do
+ find('[data-testid="pipelines-manual-actions-dropdown"]').click
+ accept_gl_confirm do
+ click_button 'delayed job 1'
+ end
+ wait_for_requests
+ end
+
+ it 'enqueues the delayed job', :js do
+ expect(delayed_job.reload).to be_pending
+ end
+ end
+ end
+
+ context 'for generic statuses' do
+ context 'when preparing' do
+ let!(:pipeline) do
+ create(:ci_empty_pipeline,
+ status: 'preparing', project: project)
+ end
+
+ let!(:status) do
+ create(:generic_commit_status,
+ :preparing, pipeline: pipeline)
+ end
+
+ before do
+ visit_project_pipelines
+ end
+
+ it 'is cancelable' do
+ expect(page).to have_selector('.js-pipelines-cancel-button')
+ end
+
+ it 'shows the pipeline as preparing' do
+ expect(page).to have_selector('.ci-preparing')
+ end
+ end
+
+ context 'when running' do
+ let!(:running) do
+ create(:generic_commit_status,
+ status: 'running',
+ pipeline: pipeline,
+ stage: 'test')
+ end
+
+ before do
+ visit_project_pipelines
+ end
+
+ it 'is cancelable' do
+ expect(page).to have_selector('.js-pipelines-cancel-button')
+ end
+
+ it 'has pipeline running' do
+ expect(page).to have_selector('.ci-running')
+ end
+
+ context 'when canceling' do
+ before do
+ find('.js-pipelines-cancel-button').click
+ click_button 'Stop pipeline'
+ end
+
+ it 'indicates that pipeline was canceled', :sidekiq_might_not_need_inline do
+ expect(page).not_to have_selector('.js-pipelines-cancel-button')
+ expect(page).to have_selector('.ci-canceled')
+ end
+ end
+ end
+
+ context 'when failed' do
+ let!(:status) do
+ create(:generic_commit_status, :pending,
+ pipeline: pipeline,
+ stage: 'test')
+ end
+
+ before do
+ status.drop
+ visit_project_pipelines
+ end
+
+ it 'is not retryable' do
+ expect(page).not_to have_selector('.js-pipelines-retry-button')
+ end
+
+ it 'has failed pipeline', :sidekiq_might_not_need_inline do
+ expect(page).to have_selector('.ci-failed')
+ end
+ end
+ end
+
+ context 'downloadable pipelines' do
+ context 'with artifacts' do
+ let!(:with_artifacts) do
+ build = create(:ci_build, :success,
+ pipeline: pipeline,
+ name: 'rspec tests',
+ stage: 'test')
+
+ create(:ci_job_artifact, :codequality, job: build)
+ end
+
+ before do
+ visit_project_pipelines
+ end
+
+ it 'has artifacts dropdown' do
+ expect(page).to have_selector('[data-testid="pipeline-multi-actions-dropdown"]')
+ end
+ end
+
+ context 'with artifacts expired' do
+ let!(:with_artifacts_expired) do
+ create(:ci_build, :expired, :success,
+ pipeline: pipeline,
+ name: 'rspec',
+ stage: 'test')
+ end
+
+ before do
+ visit_project_pipelines
+ end
+
+ it { expect(page).not_to have_selector('[data-testid="artifact-item"]') }
+ end
+
+ context 'without artifacts' do
+ let!(:without_artifacts) do
+ create(:ci_build, :success,
+ pipeline: pipeline,
+ name: 'rspec',
+ stage: 'test')
+ end
+
+ before do
+ visit_project_pipelines
+ end
+
+ it { expect(page).not_to have_selector('[data-testid="artifact-item"]') }
+ end
+
+ context 'with trace artifact' do
+ before do
+ create(:ci_build, :success, :trace_artifact, pipeline: pipeline)
+
+ visit_project_pipelines
+ end
+
+ it 'does not show trace artifact as artifacts' do
+ expect(page).not_to have_selector('[data-testid="artifact-item"]')
+ end
+ end
+ end
+
+ context 'mini pipeline graph' do
+ let!(:build) do
+ create(:ci_build, :pending, pipeline: pipeline,
+ stage: 'build',
+ name: 'build')
+ end
+
+ dropdown_selector = '[data-testid="mini-pipeline-graph-dropdown"]'
+
+ before do
+ visit_project_pipelines
+ end
+
+ it 'renders a mini pipeline graph' do
+ expect(page).to have_selector('[data-testid="pipeline-mini-graph"]')
+ expect(page).to have_selector(dropdown_selector)
+ end
+
+ context 'when clicking a stage badge' do
+ it 'opens a dropdown' do
+ find(dropdown_selector).click
+
+ expect(page).to have_link build.name
+ end
+
+ it 'is possible to cancel pending build' do
+ find(dropdown_selector).click
+ find('.js-ci-action').click
+ wait_for_requests
+
+ expect(build.reload).to be_canceled
+ end
+ end
+
+ context 'for a failed pipeline' do
+ let!(:build) do
+ create(:ci_build, :failed, pipeline: pipeline,
+ stage: 'build',
+ name: 'build')
+ end
+
+ it 'displays the failure reason' do
+ find(dropdown_selector).click
+
+ within('.js-builds-dropdown-list') do
+ build_element = page.find('.mini-pipeline-graph-dropdown-item')
+ expect(build_element['title']).to eq('build - failed - (unknown failure)')
+ end
+ end
+ end
+ end
+
+ context 'with pagination' do
+ before do
+ allow(Ci::Pipeline).to receive(:default_per_page).and_return(1)
+ create(:ci_empty_pipeline, project: project)
+ end
+
+ it 'renders pagination' do
+ visit project_pipelines_path(project)
+ wait_for_requests
+
+ expect(page).to have_selector('.gl-pagination')
+ end
+
+ it 'renders second page of pipelines' do
+ visit project_pipelines_path(project, page: '2')
+ wait_for_requests
+
+ expect(page).to have_selector('.gl-pagination .page-link', count: 4)
+ end
+
+ it 'shows updated content' do
+ visit project_pipelines_path(project)
+ wait_for_requests
+ page.find('.page-link.next-page-item').click
+
+ expect(page).to have_selector('.gl-pagination .page-link', count: 4)
+ end
+ end
+
+ context 'with pipeline key selection' do
+ before do
+ visit project_pipelines_path(project)
+ wait_for_requests
+ end
+
+ it 'changes the Pipeline ID column for Pipeline IID' do
+ page.find('[data-testid="pipeline-key-dropdown"]').click
+
+ within '.gl-new-dropdown-contents' do
+ dropdown_options = page.find_all '.gl-new-dropdown-item'
+
+ dropdown_options[1].click
+ end
+
+ expect(page.find('[data-testid="pipeline-th"]')).to have_content 'Pipeline'
+ expect(page.find('[data-testid="pipeline-url-link"]')).to have_content "##{pipeline.iid}"
+ end
+ end
+ end
+
+ describe 'GET /:project/-/pipelines/show' do
+ let(:project) { create(:project, :repository) }
+
+ let(:pipeline) do
+ create(:ci_empty_pipeline,
+ project: project,
+ sha: project.commit.id,
+ user: user)
+ end
+
+ before do
+ create_build('build', 0, 'build', :success)
+ create_build('test', 1, 'rspec 0:2', :pending)
+ create_build('test', 1, 'rspec 1:2', :running)
+ create_build('test', 1, 'spinach 0:2', :created)
+ create_build('test', 1, 'spinach 1:2', :created)
+ create_build('test', 1, 'audit', :created)
+ create_build('deploy', 2, 'production', :created)
+
+ create(
+ :generic_commit_status,
+ pipeline: pipeline,
+ stage: 'external',
+ name: 'jenkins',
+ stage_idx: 3,
+ ref: 'master'
+ )
+
+ visit project_pipeline_path(project, pipeline)
+ wait_for_requests
+ end
+
+ it 'shows a graph with grouped stages' do
+ expect(page).to have_css('.js-pipeline-graph')
+
+ # header
+ expect(page).to have_text("##{pipeline.id}")
+ expect(page).to have_selector(%Q(img[src="#{pipeline.user.avatar_url}"]))
+ expect(page).to have_link(pipeline.user.name, href: user_path(pipeline.user))
+
+ # stages
+ expect(page).to have_text('Build')
+ expect(page).to have_text('Test')
+ expect(page).to have_text('Deploy')
+ expect(page).to have_text('External')
+
+ # builds
+ expect(page).to have_text('rspec')
+ expect(page).to have_text('spinach')
+ expect(page).to have_text('rspec')
+ expect(page).to have_text('production')
+ expect(page).to have_text('jenkins')
+ end
+
+ def create_build(stage, stage_idx, name, status)
+ create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status)
+ end
+ end
+
+ describe 'POST /:project/-/pipelines' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ visit new_project_pipeline_path(project)
+ end
+
+ context 'for valid commit', :js do
+ before do
+ click_button project.default_branch
+ wait_for_requests
+
+ find('p', text: 'master').click
+ wait_for_requests
+ end
+
+ context 'with gitlab-ci.yml', :js do
+ before do
+ stub_ci_pipeline_to_return_yaml_file
+ end
+
+ it 'creates a new pipeline' do
+ expect do
+ click_on 'Run pipeline'
+ wait_for_requests
+ end
+ .to change { Ci::Pipeline.count }.by(1)
+
+ expect(Ci::Pipeline.last).to be_web
+ end
+
+ context 'when variables are specified' do
+ it 'creates a new pipeline with variables' do
+ page.within(find("[data-testid='ci-variable-row']")) do
+ find("[data-testid='pipeline-form-ci-variable-key']").set('key_name')
+ find("[data-testid='pipeline-form-ci-variable-value']").set('value')
+ end
+
+ expect do
+ click_on 'Run pipeline'
+ wait_for_requests
+ end
+ .to change { Ci::Pipeline.count }.by(1)
+
+ expect(Ci::Pipeline.last.variables.map { |var| var.slice(:key, :secret_value) })
+ .to eq [{ key: "key_name", secret_value: "value" }.with_indifferent_access]
+ end
+ end
+ end
+
+ context 'without gitlab-ci.yml' do
+ before do
+ click_on 'Run pipeline'
+ wait_for_requests
+ end
+
+ it { expect(page).to have_content('Missing CI config file') }
+ it 'creates a pipeline after first request failed and a valid gitlab-ci.yml file' \
+ 'is available when trying again' do
+ stub_ci_pipeline_to_return_yaml_file
+
+ expect do
+ click_on 'Run pipeline'
+ wait_for_requests
+ end
+ .to change { Ci::Pipeline.count }.by(1)
+ end
+ end
+ end
+ end
+
+ describe 'Reset runner caches' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ create(:ci_empty_pipeline, status: 'success', project: project, sha: project.commit.id, ref: 'master')
+ project.add_maintainer(user)
+ visit project_pipelines_path(project)
+ end
+
+ it 'has a clear caches button' do
+ expect(page).to have_button 'Clear runner caches'
+ end
+
+ describe 'user clicks the button' do
+ context 'when project already has jobs_cache_index' do
+ before do
+ project.update!(jobs_cache_index: 1)
+ end
+
+ it 'increments jobs_cache_index' do
+ click_button 'Clear runner caches'
+ wait_for_requests
+ expect(page.find('[data-testid="alert-info"]')).to have_content 'Project cache successfully reset.'
+ end
+ end
+
+ context 'when project does not have jobs_cache_index' do
+ it 'sets jobs_cache_index to 1' do
+ click_button 'Clear runner caches'
+ wait_for_requests
+ expect(page.find('[data-testid="alert-info"]')).to have_content 'Project cache successfully reset.'
+ end
+ end
+ end
+ end
+
+ describe 'Run Pipelines' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ visit new_project_pipeline_path(project)
+ end
+
+ describe 'new pipeline page' do
+ it 'has field to add a new pipeline' do
+ expect(page).to have_selector('[data-testid="ref-select"]')
+ expect(find('[data-testid="ref-select"]')).to have_content project.default_branch
+ expect(page).to have_content('Run for')
+ end
+ end
+
+ describe 'find pipelines' do
+ it 'shows filtered pipelines', :js do
+ click_button project.default_branch
+
+ page.within '[data-testid="ref-select"]' do
+ find('[data-testid="search-refs"]').native.send_keys('fix')
+
+ page.within '.gl-new-dropdown-contents' do
+ expect(page).to have_content('fix')
+ end
+ end
+ end
+ end
+ end
+
+ describe 'Empty State' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ visit project_pipelines_path(project)
+ end
+
+ it 'renders empty state' do
+ expect(page).to have_content 'Try test template'
+ end
+ end
+ end
+
+ context 'when user is not logged in' do
+ before do
+ project.update!(auto_devops_attributes: { enabled: false })
+ visit project_pipelines_path(project)
+ end
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public, :repository) }
+
+ context 'without pipelines' do
+ it { expect(page).to have_content 'This project is not currently set up to run pipelines.' }
+ end
+ end
+
+ context 'when project is private' do
+ let(:project) { create(:project, :private, :repository) }
+
+ it 'redirects the user to sign_in and displays the flash alert' do
+ expect(page).to have_content 'You need to sign in'
+ expect(page).to have_current_path("/users/sign_in")
+ end
+ end
+ end
+
+ def visit_project_pipelines(**query)
+ visit project_pipelines_path(project, query)
+ wait_for_requests
+ end
+end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 219c8ec0070..9eda05f695d 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -15,7 +15,6 @@ RSpec.describe 'Pipeline', :js do
before do
sign_in(user)
project.add_role(user, role)
- stub_feature_flags(pipeline_tabs_vue: false)
end
shared_context 'pipeline builds' do
@@ -80,12 +79,6 @@ RSpec.describe 'Pipeline', :js do
expect(page).to have_content('Cancel running')
end
- it 'shows Pipeline tab pane as active' do
- visit_pipeline
-
- expect(page).to have_css('#js-tab-pipeline.active')
- end
-
it 'shows link to the pipeline ref' do
visit_pipeline
@@ -190,11 +183,11 @@ RSpec.describe 'Pipeline', :js do
end
describe 'pipeline graph' do
- before do
- visit_pipeline
- end
-
context 'when pipeline has running builds' do
+ before do
+ visit_pipeline
+ end
+
it 'shows a running icon and a cancel action for the running build' do
page.within('#ci-badge-deploy') do
expect(page).to have_selector('.js-ci-status-icon-running')
@@ -213,6 +206,10 @@ RSpec.describe 'Pipeline', :js do
end
context 'when pipeline has preparing builds' do
+ before do
+ visit_pipeline
+ end
+
it 'shows a preparing icon and a cancel action' do
page.within('#ci-badge-prepare') do
expect(page).to have_selector('.js-ci-status-icon-preparing')
@@ -231,6 +228,10 @@ RSpec.describe 'Pipeline', :js do
end
context 'when pipeline has successful builds' do
+ before do
+ visit_pipeline
+ end
+
it 'shows the success icon and a retry action for the successful build' do
page.within('#ci-badge-build') do
expect(page).to have_selector('.js-ci-status-icon-success')
@@ -254,6 +255,10 @@ RSpec.describe 'Pipeline', :js do
end
context 'when pipeline has a delayed job' do
+ before do
+ visit_pipeline
+ end
+
let(:project) { create(:project, :repository, group: group) }
it 'shows the scheduled icon and an unschedule action for the delayed job' do
@@ -277,6 +282,10 @@ RSpec.describe 'Pipeline', :js do
end
context 'when pipeline has failed builds' do
+ before do
+ visit_pipeline
+ end
+
it 'shows the failed icon and a retry action for the failed build' do
page.within('#ci-badge-test') do
expect(page).to have_selector('.js-ci-status-icon-failed')
@@ -307,6 +316,10 @@ RSpec.describe 'Pipeline', :js do
end
context 'when pipeline has manual jobs' do
+ before do
+ visit_pipeline
+ end
+
it 'shows the skipped icon and a play action for the manual build' do
page.within('#ci-badge-manual-build') do
expect(page).to have_selector('.js-ci-status-icon-manual')
@@ -330,12 +343,139 @@ RSpec.describe 'Pipeline', :js do
end
context 'when pipeline has external job' do
+ before do
+ visit_pipeline
+ end
+
it 'shows the success icon and the generic comit status build' do
expect(page).to have_selector('.js-ci-status-icon-success')
expect(page).to have_content('jenkins')
expect(page).to have_link('jenkins', href: 'http://gitlab.com/status')
end
end
+
+ context 'when pipeline has a downstream pipeline' do
+ let(:downstream_project) { create(:project, :repository, group: group) }
+ let(:downstream_pipeline) do
+ create(:ci_pipeline,
+ status,
+ user: user,
+ project: downstream_project,
+ ref: 'master',
+ sha: downstream_project.commit.id,
+ child_of: pipeline )
+ end
+
+ let!(:build) { create(:ci_build, status, pipeline: downstream_pipeline, user: user) }
+
+ before do
+ downstream_pipeline.project.add_developer(user)
+ end
+
+ context 'and user has permission' do
+ before do
+ visit_pipeline
+ end
+
+ context 'with a successful downstream' do
+ let(:status) { :success }
+
+ it 'does not show the cancel or retry action' do
+ expect(page).to have_selector('.ci-status-icon-success')
+ expect(page).not_to have_selector('button[aria-label="Retry downstream pipeline"]')
+ expect(page).not_to have_selector('button[aria-label="Cancel downstream pipeline"]')
+ end
+ end
+
+ context 'with a running downstream' do
+ let(:status) { :running }
+
+ it 'shows the cancel action' do
+ expect(page).to have_selector('button[aria-label="Cancel downstream pipeline"]')
+ end
+
+ context 'when canceling' do
+ before do
+ find('button[aria-label="Cancel downstream pipeline"]').click
+ wait_for_requests
+ end
+
+ it 'shows the pipeline as canceled with the retry action' do
+ expect(page).to have_selector('button[aria-label="Retry downstream pipeline"]')
+ expect(page).to have_selector('.ci-status-icon-canceled')
+ end
+ end
+ end
+
+ context 'with a failed downstream' do
+ let(:status) { :failed }
+
+ it 'indicates that pipeline can be retried' do
+ expect(page).to have_selector('button[aria-label="Retry downstream pipeline"]')
+ end
+
+ context 'and the FF downstream_retry_action is disabled' do
+ before do
+ stub_feature_flags(downstream_retry_action: false)
+ end
+
+ it 'does not show the retry action' do
+ expect(page).not_to have_selector('button[aria-label="Retry downstream pipeline"]')
+ end
+ end
+
+ context 'when retrying' do
+ before do
+ find('button[aria-label="Retry downstream pipeline"]').click
+ wait_for_requests
+ end
+
+ it 'shows running pipeline with the cancel action' do
+ expect(page).to have_selector('.ci-status-icon-running')
+ expect(page).to have_selector('button[aria-label="Cancel downstream pipeline"]')
+ end
+ end
+ end
+
+ context 'with a canceled downstream' do
+ let(:status) { :canceled }
+
+ it 'indicates that pipeline can be retried' do
+ expect(page).to have_selector('button[aria-label="Retry downstream pipeline"]')
+ end
+
+ context 'when retrying' do
+ before do
+ find('button[aria-label="Retry downstream pipeline"]').click
+ wait_for_requests
+ end
+
+ it 'shows running pipeline with the cancel action' do
+ expect(page).to have_selector('.ci-status-icon-running')
+ expect(page).to have_selector('button[aria-label="Cancel downstream pipeline"]')
+ end
+ end
+ end
+ end
+
+ context 'when user does not have permissions' do
+ let(:status) { :failed }
+
+ before do
+ new_user = create(:user)
+ project.add_role(new_user, :guest)
+ downstream_project.add_role(new_user, :guest)
+ sign_in(new_user)
+
+ visit_pipeline
+ end
+
+ it 'does not show the retry button' do
+ expect(page).to have_selector('.ci-status-icon-failed')
+ expect(page).not_to have_selector('button[aria-label="Retry downstream pipeline"]')
+ end
+ end
+ end
end
context 'when the pipeline has manual stage' do
@@ -357,7 +497,6 @@ RSpec.describe 'Pipeline', :js do
context 'page tabs' do
before do
- stub_feature_flags(pipeline_tabs_vue: false)
visit_pipeline
end
@@ -369,13 +508,10 @@ RSpec.describe 'Pipeline', :js do
end
it 'shows counter in Jobs tab' do
+ skip('Enable in jobs `pipeline_tabs_vue` MR')
expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s)
end
- it 'shows Pipeline tab as active' do
- expect(page).to have_css('.js-pipeline-tab-link .active')
- end
-
context 'without permission to access builds' do
let(:project) { create(:project, :public, :repository, public_builds: false) }
let(:role) { :guest }
@@ -753,6 +889,7 @@ RSpec.describe 'Pipeline', :js do
describe 'GET /:project/-/pipelines/:id/builds' do
before do
+ stub_feature_flags(pipeline_tabs_vue: false)
visit builds_project_pipeline_path(project, pipeline)
end
@@ -893,28 +1030,6 @@ RSpec.describe 'Pipeline', :js do
end
end
end
-
- context 'when deploy job is a bridge to trigger a downstream pipeline' do
- let!(:deploy_job) do
- create(:ci_bridge, :created, stage: 'deploy', name: 'deploy',
- stage_idx: 2, pipeline: pipeline, project: project, resource_group: resource_group)
- end
-
- it 'shows deploy job as waiting for resource' do
- subject
-
- within('.js-pipeline-header-container') do
- expect(page).to have_content('waiting')
- end
-
- within('.js-pipeline-graph') do
- within(all('[data-testid="stage-column"]')[1]) do
- expect(page).to have_content('deploy')
- expect(page).to have_css('.ci-status-icon-waiting-for-resource')
- end
- end
- end
- end
end
end
end
@@ -943,15 +1058,7 @@ RSpec.describe 'Pipeline', :js do
expect(page).to have_button('Play')
end
- it 'shows jobs tab pane as active' do
- expect(page).to have_css('#js-tab-builds.active')
- end
-
context 'page tabs' do
- before do
- stub_feature_flags(pipeline_tabs_vue: false)
- end
-
it 'shows Pipeline, Jobs and DAG tabs with link' do
expect(page).to have_link('Pipeline')
expect(page).to have_link('Jobs')
@@ -959,12 +1066,9 @@ RSpec.describe 'Pipeline', :js do
end
it 'shows counter in Jobs tab' do
+ skip('unskip when jobs tab is implemented with ff `pipeline_tabs_vue`')
expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s)
end
-
- it 'shows Jobs tab as active' do
- expect(page).to have_css('li.js-builds-tab-link .active')
- end
end
context 'retrying jobs' do
@@ -1022,14 +1126,14 @@ RSpec.describe 'Pipeline', :js do
end
describe 'GET /:project/-/pipelines/:id/failures' do
- before do
- stub_feature_flags(pipeline_tabs_vue: false)
- end
-
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: '1234') }
let(:pipeline_failures_page) { failures_project_pipeline_path(project, pipeline) }
let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) }
+ before do
+ stub_feature_flags(pipeline_tabs_vue: false)
+ end
+
subject { visit pipeline_failures_page }
context 'with failed build' do
@@ -1037,13 +1141,6 @@ RSpec.describe 'Pipeline', :js do
failed_build.trace.set('4 examples, 1 failure')
end
- it 'shows jobs tab pane as active' do
- subject
-
- expect(page).to have_content('Failed Jobs')
- expect(page).to have_css('#js-tab-failures.active')
- end
-
it 'lists failed builds' do
subject
@@ -1063,12 +1160,43 @@ RSpec.describe 'Pipeline', :js do
expect(page).to have_content('There is an unknown failure, please try again')
end
+ context 'when failed_jobs_tab_vue feature flag is disabled' do
+ before do
+ stub_feature_flags(failed_jobs_tab_vue: false)
+ end
+
+ context 'when user does not have permission to retry build' do
+ it 'shows retry button for failed build' do
+ subject
+
+ page.within(find('.build-failures', match: :first)) do
+ expect(page).not_to have_link('Retry')
+ end
+ end
+ end
+
+ context 'when user does have permission to retry build' do
+ before do
+ create(:protected_branch, :developers_can_merge,
+ name: pipeline.ref, project: project)
+ end
+
+ it 'shows retry button for failed build' do
+ subject
+
+ page.within(find('.build-failures', match: :first)) do
+ expect(page).to have_link('Retry')
+ end
+ end
+ end
+ end
+
context 'when user does not have permission to retry build' do
it 'shows retry button for failed build' do
subject
- page.within(find('.build-failures', match: :first)) do
- expect(page).not_to have_link('Retry')
+ page.within(find('#js-tab-failures', match: :first)) do
+ expect(page).not_to have_button('Retry')
end
end
end
@@ -1082,21 +1210,14 @@ RSpec.describe 'Pipeline', :js do
it 'shows retry button for failed build' do
subject
- page.within(find('.build-failures', match: :first)) do
- expect(page).to have_link('Retry')
+ page.within(find('#js-tab-failures', match: :first)) do
+ expect(page).to have_button('Retry')
end
end
end
end
context 'when missing build logs' do
- it 'shows jobs tab pane as active' do
- subject
-
- expect(page).to have_content('Failed Jobs')
- expect(page).to have_css('#js-tab-failures.active')
- end
-
it 'lists failed builds' do
subject
@@ -1133,11 +1254,17 @@ RSpec.describe 'Pipeline', :js do
failed_build.update!(status: :success)
end
+ it 'does not show the failure tab' do
+ skip('unskip when the failure tab has been implemented in ff `pipeline_tabs_vue`')
+ subject
+
+ expect(page).not_to have_content('Failed Jobs')
+ end
+
it 'displays the pipeline graph' do
subject
expect(page).to have_current_path(pipeline_path(pipeline), ignore_query: true)
- expect(page).not_to have_content('Failed Jobs')
expect(page).to have_selector('.js-pipeline-graph')
end
end
@@ -1151,27 +1278,14 @@ RSpec.describe 'Pipeline', :js do
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
- stub_feature_flags(pipeline_tabs_vue: false)
visit dag_project_pipeline_path(project, pipeline)
end
- it 'shows DAG tab pane as active' do
- expect(page).to have_css('#js-tab-dag.active', visible: false)
- end
-
context 'page tabs' do
it 'shows Pipeline, Jobs and DAG tabs with link' do
expect(page).to have_link('Pipeline')
expect(page).to have_link('Jobs')
- expect(page).to have_link('DAG')
- end
-
- it 'shows counter in Jobs tab' do
- expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s)
- end
-
- it 'shows DAG tab as active' do
- expect(page).to have_css('li.js-dag-tab-link .active')
+ expect(page).to have_link('Needs')
end
end
end
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index 8b1a22ae05a..a18bf7c5caf 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -623,7 +623,6 @@ RSpec.describe 'Pipelines', :js do
create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3, ref: 'master')
- stub_feature_flags(pipeline_tabs_vue: false)
visit project_pipeline_path(project, pipeline)
wait_for_requests
end
@@ -794,12 +793,29 @@ RSpec.describe 'Pipelines', :js do
describe 'Empty State' do
let(:project) { create(:project, :repository) }
- before do
- visit project_pipelines_path(project)
+ context 'when `ios_specific_templates` is not enabled' do
+ before do
+ visit project_pipelines_path(project)
+ end
+
+ it 'renders empty state' do
+ expect(page).to have_content 'Try test template'
+ end
end
- it 'renders empty state' do
- expect(page).to have_content 'Try test template'
+ describe 'when the `ios_specific_templates` experiment is enabled and the "Set up a runner" button is clicked' do
+ before do
+ stub_experiments(ios_specific_templates: :candidate)
+ create(:project_setting, project: project, target_platforms: %w(ios))
+ visit project_pipelines_path(project)
+ click_button 'Set up a runner'
+ end
+
+ it 'displays a modal with the macOS platform selected and an explanation popover' do
+ expect(page).to have_button 'macOS', class: 'selected'
+ expect(page).to have_selector('#runner-instructions-modal___BV_modal_content_')
+ expect(page).to have_selector('.popover')
+ end
end
end
end
diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb
deleted file mode 100644
index db8c2a24f2f..00000000000
--- a/spec/features/projects/serverless/functions_spec.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Functions', :js do
- include KubernetesHelpers
- include ReactiveCachingHelpers
-
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
-
- before do
- project.add_maintainer(user)
- gitlab_sign_in(user)
- end
-
- shared_examples "it's missing knative installation" do
- before do
- functions_finder = Projects::Serverless::FunctionsFinder.new(project)
- visit project_serverless_functions_path(project)
- allow(Projects::Serverless::FunctionsFinder)
- .to receive(:new)
- .and_return(functions_finder)
- synchronous_reactive_cache(functions_finder)
- end
-
- it 'sees an empty state require Knative installation' do
- expect(page).to have_selector('.empty-state')
- end
- end
-
- context 'when user does not have a cluster and visits the serverless page' do
- it_behaves_like "it's missing knative installation"
- end
-
- context 'when the user does have a cluster and visits the serverless page' do
- let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
-
- it_behaves_like "it's missing knative installation"
- end
-
- context 'when the user has a cluster and knative installed and visits the serverless page', :kubeclient do
- let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
- let(:service) { cluster.platform_kubernetes }
- let(:environment) { create(:environment, project: project) }
- let!(:deployment) { create(:deployment, :success, cluster: cluster, environment: environment) }
- let(:knative_services_finder) { environment.knative_services_finder }
- let(:namespace) do
- create(:cluster_kubernetes_namespace,
- cluster: cluster,
- project: cluster.cluster_project.project,
- environment: environment)
- end
-
- before do
- allow(Clusters::KnativeServicesFinder)
- .to receive(:new)
- .and_return(knative_services_finder)
- synchronous_reactive_cache(knative_services_finder)
- stub_kubeclient_knative_services(stub_get_services_options)
- stub_kubeclient_service_pods(nil, namespace: namespace.namespace)
- visit project_serverless_functions_path(project)
- end
-
- context 'when there are no functions' do
- let(:stub_get_services_options) do
- {
- namespace: namespace.namespace,
- response: kube_response({ "kind" => "ServiceList", "items" => [] })
- }
- end
-
- it 'sees an empty listing of serverless functions' do
- expect(page).to have_selector('.empty-state')
- expect(page).not_to have_selector('.content-list')
- end
- end
-
- context 'when there are functions' do
- let(:stub_get_services_options) { { namespace: namespace.namespace } }
-
- it 'does not see an empty listing of serverless functions' do
- expect(page).not_to have_selector('.empty-state')
- expect(page).to have_selector('.content-list')
- end
- end
- end
-end
diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb
index 39c4315bf0f..a64f81430d1 100644
--- a/spec/features/projects/settings/pipelines_settings_spec.rb
+++ b/spec/features/projects/settings/pipelines_settings_spec.rb
@@ -25,24 +25,6 @@ RSpec.describe "Projects > Settings > Pipelines settings" do
context 'for maintainer' do
let(:role) { :maintainer }
- it 'be allowed to change' do
- visit project_settings_ci_cd_path(project)
-
- fill_in('Test coverage parsing', with: 'coverage_regex')
-
- page.within '#js-general-pipeline-settings' do
- click_on 'Save changes'
- end
-
- expect(page.status_code).to eq(200)
-
- page.within '#js-general-pipeline-settings' do
- expect(page).to have_button('Save changes', disabled: false)
- end
-
- expect(page).to have_field('Test coverage parsing', with: 'coverage_regex')
- end
-
it 'updates auto_cancel_pending_pipelines' do
visit project_settings_ci_cd_path(project)
diff --git a/spec/features/projects/settings/secure_files_settings_spec.rb b/spec/features/projects/settings/secure_files_settings_spec.rb
new file mode 100644
index 00000000000..c7c9cafc420
--- /dev/null
+++ b/spec/features/projects/settings/secure_files_settings_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Secure Files Settings' do
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:project) { create(:project, creator_id: maintainer.id) }
+
+ before_all do
+ project.add_maintainer(maintainer)
+ end
+
+ context 'when the :ci_secure_files feature flag is enabled' do
+ before do
+ stub_feature_flags(ci_secure_files: true)
+
+ sign_in(user)
+ visit project_settings_ci_cd_path(project)
+ end
+
+ context 'authenticated user with admin permissions' do
+ let(:user) { maintainer }
+
+ it 'shows the secure files settings' do
+ expect(page).to have_content('Secure Files')
+ end
+ end
+ end
+
+ context 'when the :ci_secure_files feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_secure_files: false)
+
+ sign_in(user)
+ visit project_settings_ci_cd_path(project)
+ end
+
+ context 'authenticated user with admin permissions' do
+ let(:user) { maintainer }
+
+ it 'does not shows the secure files settings' do
+ expect(page).not_to have_content('Secure Files')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
index 77be351f3d8..6aa59f72d2a 100644
--- a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
+++ b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
context 'when Pipelines are initially enabled' do
it 'shows the Merge Requests settings' do
expect(page).to have_content 'Pipelines must succeed'
- expect(page).to have_content 'All discussions must be resolved'
+ expect(page).to have_content 'All threads must be resolved'
within('.sharing-permissions-form') do
find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .gl-toggle').click
@@ -58,7 +58,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
end
expect(page).not_to have_content 'Pipelines must succeed'
- expect(page).not_to have_content 'All discussions must be resolved'
+ expect(page).not_to have_content 'All threads must be resolved'
end
end
@@ -70,7 +70,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
it 'shows the Merge Requests settings that do not depend on Builds feature' do
expect(page).to have_content 'Pipelines must succeed'
- expect(page).to have_content 'All discussions must be resolved'
+ expect(page).to have_content 'All threads must be resolved'
within('.sharing-permissions-form') do
find('.project-feature-controls[data-for="project[project_feature_attributes][builds_access_level]"] .gl-toggle').click
@@ -78,7 +78,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
end
expect(page).to have_content 'Pipelines must succeed'
- expect(page).to have_content 'All discussions must be resolved'
+ expect(page).to have_content 'All threads must be resolved'
end
end
end
@@ -91,7 +91,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
it 'does not show the Merge Requests settings' do
expect(page).not_to have_content 'Pipelines must succeed'
- expect(page).not_to have_content 'All discussions must be resolved'
+ expect(page).not_to have_content 'All threads must be resolved'
within('.sharing-permissions-form') do
find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .gl-toggle').click
@@ -99,7 +99,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
end
expect(page).to have_content 'Pipelines must succeed'
- expect(page).to have_content 'All discussions must be resolved'
+ expect(page).to have_content 'All threads must be resolved'
end
end
diff --git a/spec/features/projects/settings/user_manages_project_members_spec.rb b/spec/features/projects/settings/user_manages_project_members_spec.rb
index 2fe06414b32..1d258582b3a 100644
--- a/spec/features/projects/settings/user_manages_project_members_spec.rb
+++ b/spec/features/projects/settings/user_manages_project_members_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe 'Projects > Settings > User manages project members' do
include Spec::Support::Helpers::ModalHelpers
let(:group) { create(:group, name: 'OpenSource') }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :with_namespace_settings) }
let(:project2) { create(:project) }
let(:user) { create(:user) }
let(:user_dmitriy) { create(:user, name: 'Dmitriy') }
diff --git a/spec/features/projects/show/user_uploads_files_spec.rb b/spec/features/projects/show/user_uploads_files_spec.rb
index 92b54d83ef3..a222d6b42ab 100644
--- a/spec/features/projects/show/user_uploads_files_spec.rb
+++ b/spec/features/projects/show/user_uploads_files_spec.rb
@@ -55,16 +55,4 @@ RSpec.describe 'Projects > Show > User uploads files' do
include_examples 'uploads and commits a new text file via "upload file" button', drop: value
end
end
-
- context 'with a nonempty repo' do
- let(:project) { create(:project, :repository, creator: user) }
-
- before do
- visit(project_path(project))
- end
-
- [true, false].each do |value|
- include_examples 'uploads and commits a new text file via "upload file" button', drop: value
- end
- end
end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
index 0ed9e23c7f8..cbdf6d6852e 100644
--- a/spec/features/projects/snippets/create_snippet_spec.rb
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -85,13 +85,4 @@ RSpec.describe 'Projects > Snippets > Create Snippet', :js do
expect(page).to have_content('New Snippet')
end
end
-
- it 'does not allow submitting the form without title and content' do
- snippet_fill_in_title(title)
-
- expect(page).not_to have_button('Create snippet')
-
- snippet_fill_in_form(title: title, content: file_content)
- expect(page).to have_button('Create snippet')
- end
end
diff --git a/spec/features/projects/tags/user_views_tags_spec.rb b/spec/features/projects/tags/user_views_tags_spec.rb
index e1962ad3df5..dfb5d5d9221 100644
--- a/spec/features/projects/tags/user_views_tags_spec.rb
+++ b/spec/features/projects/tags/user_views_tags_spec.rb
@@ -2,6 +2,36 @@
require 'spec_helper'
RSpec.describe 'User views tags', :feature do
+ context 'with html' do
+ let(:project) { create(:project, :repository, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
+ let(:user) { create(:user) }
+ let(:tag_name) { "stable" }
+ let!(:release) { create(:release, project: project, tag: tag_name) }
+
+ before do
+ project.add_developer(user)
+ project.repository.add_tag(user, tag_name, project.default_branch_or_main)
+
+ sign_in(user)
+ end
+
+ shared_examples 'renders the tag index page' do
+ it do
+ visit project_tags_path(project)
+
+ expect(page).to have_content tag_name
+ end
+ end
+
+ it_behaves_like 'renders the tag index page'
+
+ context 'when tag name contains a slash' do
+ let(:tag_name) { "stable/v0.1" }
+
+ it_behaves_like 'renders the tag index page'
+ end
+ end
+
context 'rss' do
shared_examples 'has access to the tags RSS feed' do
it do
diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb
index cd94e6da018..53e89cd2959 100644
--- a/spec/features/projects/tree/tree_show_spec.rb
+++ b/spec/features/projects/tree/tree_show_spec.rb
@@ -81,7 +81,7 @@ RSpec.describe 'Projects tree', :js do
wait_for_requests
page.within('.project-last-commit') do
- expect(page).to have_selector('.user-avatar-link')
+ expect(page).to have_selector('.gl-avatar')
expect(page).to have_content('Merge branch')
end
end
@@ -152,4 +152,18 @@ RSpec.describe 'Projects tree', :js do
end
end
end
+
+ context 'ref switcher' do
+ it 'switches ref to branch' do
+ ref_name = 'feature'
+ visit project_tree_path(project, 'master')
+ first('.js-project-refs-dropdown').click
+
+ page.within '.project-refs-form' do
+ click_link ref_name
+ end
+
+ expect(page).to have_selector '.dropdown-menu-toggle', text: ref_name
+ end
+ end
end
diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb
index 2dddcd62a6c..534da71e39a 100644
--- a/spec/features/runners_spec.rb
+++ b/spec/features/runners_spec.rb
@@ -346,206 +346,4 @@ RSpec.describe 'Runners' do
end
end
end
-
- context 'group runners in group settings' do
- let(:group) { create(:group) }
-
- before do
- group.add_owner(user)
- stub_feature_flags(runner_list_group_view_vue_ui: false)
- end
-
- context 'group with no runners' do
- it 'there are no runners displayed' do
- visit group_settings_ci_cd_path(group)
-
- expect(page).to have_content 'No runners found'
- end
- end
-
- context 'group with a runner' do
- let!(:runner) { create(:ci_runner, :group, groups: [group], description: 'group-runner') }
-
- it 'the runner is visible' do
- visit group_settings_ci_cd_path(group)
-
- expect(page).not_to have_content 'No runners found'
- expect(page).to have_content 'Available runners: 1'
- expect(page).to have_content 'group-runner'
- end
-
- it 'user can pause and resume the group runner' do
- visit group_settings_ci_cd_path(group)
-
- expect(page).to have_link href: pause_group_runner_path(group, runner)
- expect(page).not_to have_link href: resume_group_runner_path(group, runner)
-
- click_link href: pause_group_runner_path(group, runner)
-
- expect(page).not_to have_link href: pause_group_runner_path(group, runner)
- expect(page).to have_link href: resume_group_runner_path(group, runner)
-
- click_link href: resume_group_runner_path(group, runner)
-
- expect(page).to have_link href: pause_group_runner_path(group, runner)
- expect(page).not_to have_link href: resume_group_runner_path(group, runner)
- end
-
- it 'user can view runner details' do
- visit group_settings_ci_cd_path(group)
-
- expect(page).to have_content(runner.display_name)
-
- click_on runner.short_sha
-
- expect(page).to have_content(runner.platform)
- end
-
- it 'user can remove a group runner' do
- visit group_settings_ci_cd_path(group)
-
- all(:link, href: group_runner_path(group, runner))[1].click
-
- expect(page).not_to have_content(runner.display_name)
- end
-
- it 'user edits the runner to be protected' do
- visit group_settings_ci_cd_path(group)
-
- click_link href: edit_group_runner_path(group, runner)
-
- expect(page.find_field('runner[access_level]')).not_to be_checked
-
- check 'runner_access_level'
- click_button 'Save changes'
-
- expect(page).to have_content 'Protected Yes'
- end
-
- context 'when a runner has a tag' do
- before do
- runner.update!(tag_list: ['tag'])
- end
-
- it 'user edits runner not to run untagged jobs' do
- visit group_settings_ci_cd_path(group)
-
- click_link href: edit_group_runner_path(group, runner)
-
- expect(page.find_field('runner[run_untagged]')).to be_checked
-
- uncheck 'runner_run_untagged'
- click_button 'Save changes'
-
- expect(page).to have_content 'Can run untagged jobs No'
- end
- end
- end
-
- context 'group with a project runner' do
- let(:project) { create(:project, group: group) }
- let!(:runner) { create(:ci_runner, :project, projects: [project], description: 'project-runner') }
-
- it 'the runner is visible' do
- visit group_settings_ci_cd_path(group)
-
- expect(page).not_to have_content 'No runners found'
- expect(page).to have_content 'Available runners: 1'
- expect(page).to have_content 'project-runner'
- end
-
- it 'user can pause and resume the project runner' do
- visit group_settings_ci_cd_path(group)
-
- expect(page).to have_link href: pause_group_runner_path(group, runner)
- expect(page).not_to have_link href: resume_group_runner_path(group, runner)
-
- click_link href: pause_group_runner_path(group, runner)
-
- expect(page).not_to have_link href: pause_group_runner_path(group, runner)
- expect(page).to have_link href: resume_group_runner_path(group, runner)
-
- click_link href: resume_group_runner_path(group, runner)
-
- expect(page).to have_link href: pause_group_runner_path(group, runner)
- expect(page).not_to have_link href: resume_group_runner_path(group, runner)
- end
-
- it 'user can view runner details' do
- visit group_settings_ci_cd_path(group)
-
- expect(page).to have_content(runner.display_name)
-
- click_on runner.short_sha
-
- expect(page).to have_content(runner.platform)
- end
-
- it 'user can remove a project runner' do
- visit group_settings_ci_cd_path(group)
-
- all(:link, href: group_runner_path(group, runner))[1].click
-
- expect(page).not_to have_content(runner.display_name)
- end
-
- it 'user edits the runner to be protected' do
- visit group_settings_ci_cd_path(group)
-
- click_link href: edit_group_runner_path(group, runner)
-
- expect(page.find_field('runner[access_level]')).not_to be_checked
-
- check 'runner_access_level'
- click_button 'Save changes'
-
- expect(page).to have_content 'Protected Yes'
- end
-
- context 'when a runner has a tag' do
- before do
- runner.update!(tag_list: ['tag'])
- end
-
- it 'user edits runner not to run untagged jobs' do
- visit group_settings_ci_cd_path(group)
-
- click_link href: edit_group_runner_path(group, runner)
-
- expect(page.find_field('runner[run_untagged]')).to be_checked
-
- uncheck 'runner_run_untagged'
- click_button 'Save changes'
-
- expect(page).to have_content 'Can run untagged jobs No'
- end
- end
- end
-
- context 'group with a multi-project runner' do
- let(:project) { create(:project, group: group) }
- let(:project_2) { create(:project, group: group) }
- let!(:runner) { create(:ci_runner, :project, projects: [project, project_2], description: 'group-runner') }
-
- it 'user cannot remove the project runner' do
- visit group_settings_ci_cd_path(group)
-
- expect(all(:link, href: group_runner_path(group, runner)).length).to eq(1)
- end
- end
-
- context 'filtered search' do
- it 'allows user to search by status and type', :js do
- visit group_settings_ci_cd_path(group)
-
- find('.filtered-search').click
-
- page.within('#js-dropdown-hint') do
- expect(page).to have_content('Status')
- expect(page).to have_content('Type')
- expect(page).not_to have_content('Tag')
- end
- end
- end
- end
end
diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb
index 4012a302196..48cee4b1f19 100644
--- a/spec/features/security/project/internal_access_spec.rb
+++ b/spec/features/security/project/internal_access_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe "Internal Project Access" do
include AccessMatchers
- let_it_be(:project, reload: true) { create(:project, :internal, :repository) }
+ let_it_be(:project, reload: true) { create(:project, :internal, :repository, :with_namespace_settings) }
describe "Project should be internal" do
describe '#internal?' do
diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb
index aa34ccce2c1..c06b1e5da54 100644
--- a/spec/features/security/project/private_access_spec.rb
+++ b/spec/features/security/project/private_access_spec.rb
@@ -5,7 +5,9 @@ require 'spec_helper'
RSpec.describe "Private Project Access" do
include AccessMatchers
- let_it_be(:project, reload: true) { create(:project, :private, :repository, public_builds: false) }
+ let_it_be(:project, reload: true) do
+ create(:project, :private, :repository, :with_namespace_settings, public_builds: false)
+ end
describe "Project should be private" do
describe '#private?' do
diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb
index abe128c6f78..d2112430638 100644
--- a/spec/features/security/project/public_access_spec.rb
+++ b/spec/features/security/project/public_access_spec.rb
@@ -5,7 +5,9 @@ require 'spec_helper'
RSpec.describe "Public Project Access" do
include AccessMatchers
- let_it_be(:project, reload: true) { create(:project, :public, :repository) }
+ let_it_be(:project, reload: true) do
+ create(:project, :public, :repository, :with_namespace_settings)
+ end
describe "Project should be public" do
describe '#public?' do
diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb
index 82fe895d397..c682ad06977 100644
--- a/spec/features/snippets/user_creates_snippet_spec.rb
+++ b/spec/features/snippets/user_creates_snippet_spec.rb
@@ -109,13 +109,22 @@ RSpec.describe 'User creates snippet', :js do
end
end
- it 'validation fails for the first time' do
- fill_in snippet_title_field, with: title
+ it 'shows validation errors' do
+ title_validation_message = _("This field is required.")
+ files_validation_message = _("Snippets can't contain empty files. Ensure all files have content, or delete them.")
- expect(page).not_to have_button('Create snippet')
+ click_button('Create snippet')
+
+ expect(page).to have_content(title_validation_message)
+ expect(page).to have_content(files_validation_message)
+
+ snippet_fill_in_title(title)
+
+ expect(page).not_to have_content(title_validation_message)
snippet_fill_in_form(title: title, content: file_content)
- expect(page).to have_button('Create snippet')
+
+ expect(page).not_to have_content(files_validation_message)
end
it 'previews a snippet with file' do
diff --git a/spec/features/topic_show_spec.rb b/spec/features/topic_show_spec.rb
index 3a9865a6503..196fc34e3ea 100644
--- a/spec/features/topic_show_spec.rb
+++ b/spec/features/topic_show_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'Topic show page' do
- let_it_be(:topic) { create(:topic, name: 'my-topic', description: 'This is **my** topic https://google.com/ :poop: ```\ncode\n```', avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) }
+ let_it_be(:topic) { create(:topic, name: 'my-topic', title: 'My Topic', description: 'This is **my** topic https://google.com/ :poop: ```\ncode\n```', avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) }
context 'when topic does not exist' do
let(:path) { topic_explore_projects_path(topic_name: 'non-existing') }
@@ -20,8 +20,9 @@ RSpec.describe 'Topic show page' do
visit topic_explore_projects_path(topic_name: topic.name)
end
- it 'shows name, avatar and description as markdown' do
- expect(page).to have_content(topic.name)
+ it 'shows title, avatar and description as markdown' do
+ expect(page).to have_content(topic.title)
+ expect(page).not_to have_content(topic.name)
expect(page).to have_selector('.avatar-container > img.topic-avatar')
expect(find('.topic-description')).to have_selector('p > strong')
expect(find('.topic-description')).to have_selector('p > a[rel]')
diff --git a/spec/features/user_sorts_things_spec.rb b/spec/features/user_sorts_things_spec.rb
index fa37d692225..bcf3defe9c6 100644
--- a/spec/features/user_sorts_things_spec.rb
+++ b/spec/features/user_sorts_things_spec.rb
@@ -6,7 +6,7 @@ require "spec_helper"
# to check if the sorting option set by user is being kept persisted while going through pages.
# The `it`s are named here by convention `starting point -> some pages -> final point`.
# All those specs are moved out to this spec intentionally to keep them all in one place.
-RSpec.describe "User sorts things" do
+RSpec.describe "User sorts things", :js do
include Spec::Support::Helpers::Features::SortingHelpers
include DashboardHelper
@@ -16,29 +16,32 @@ RSpec.describe "User sorts things" do
let_it_be(:merge_request) { create(:merge_request, target_project: project, source_project: project, author: current_user) }
before do
+ stub_feature_flags(vue_issues_list: true)
+
project.add_developer(current_user)
sign_in(current_user)
end
it "issues -> project home page -> issues" do
- sort_option = 'Updated date'
+ sort_option = s_('SortOptions|Updated date')
visit(project_issues_path(project))
- sort_by(sort_option)
+ click_button s_('SortOptions|Created date')
+ click_button sort_option
visit(project_path(project))
visit(project_issues_path(project))
- expect(find(".issues-filters")).to have_content(sort_option)
+ expect(page).to have_button(sort_option)
end
it "merge requests -> dashboard merge requests" do
- sort_option = 'Updated date'
+ sort_option = s_('SortOptions|Updated date')
visit(project_merge_requests_path(project))
- sort_by(sort_option)
+ pajamas_sort_by(sort_option)
visit(assigned_mrs_dashboard_path)
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 822bf898034..efb7c98d63a 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -167,7 +167,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do
it 'does not update Devise trackable attributes' do
expect { gitlab_sign_in(user, password: user.password) }
- .not_to change { User.ghost.reload.sign_in_count }
+ .not_to change { user.reload.sign_in_count }
end
end
diff --git a/spec/finders/error_tracking/errors_finder_spec.rb b/spec/finders/error_tracking/errors_finder_spec.rb
deleted file mode 100644
index 66eb7769a4c..00000000000
--- a/spec/finders/error_tracking/errors_finder_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ErrorTracking::ErrorsFinder do
- let_it_be(:project) { create(:project) }
- let_it_be(:user) { project.creator }
- let_it_be(:error) { create(:error_tracking_error, project: project) }
- let_it_be(:error_resolved) { create(:error_tracking_error, :resolved, project: project, first_seen_at: 2.hours.ago) }
- let_it_be(:error_yesterday) { create(:error_tracking_error, project: project, first_seen_at: Time.zone.now.yesterday) }
-
- before do
- project.add_maintainer(user)
- end
-
- describe '#execute' do
- let(:params) { {} }
-
- subject { described_class.new(user, project, params).execute }
-
- it { is_expected.to contain_exactly(error, error_resolved, error_yesterday) }
-
- context 'with status parameter' do
- let(:params) { { status: 'resolved' } }
-
- it { is_expected.to contain_exactly(error_resolved) }
- end
-
- context 'with sort parameter' do
- let(:params) { { status: 'unresolved', sort: 'first_seen' } }
-
- it { expect(subject.to_a).to eq([error, error_yesterday]) }
- end
-
- context 'pagination' do
- let(:params) { { limit: '1', sort: 'first_seen' } }
-
- # Sort by first_seen is DESC by default, so the most recent error is `error`
- it { is_expected.to contain_exactly(error) }
-
- it { expect(subject.has_next_page?).to be_truthy }
-
- it 'returns next page by cursor' do
- params_with_cursor = params.merge(cursor: subject.cursor_for_next_page)
- errors = described_class.new(user, project, params_with_cursor).execute
-
- expect(errors).to contain_exactly(error_resolved)
- expect(errors.has_next_page?).to be_truthy
- expect(errors.has_previous_page?).to be_truthy
- end
- end
- end
-end
diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb
index a9a8e9d19b8..00aa14209a2 100644
--- a/spec/finders/group_members_finder_spec.rb
+++ b/spec/finders/group_members_finder_spec.rb
@@ -195,4 +195,37 @@ RSpec.describe GroupMembersFinder, '#execute' do
expect(result.to_a).to match_array([member1])
end
end
+
+ context 'filter by access levels' do
+ let!(:owner1) { group.add_owner(user2) }
+ let!(:owner2) { group.add_owner(user3) }
+ let!(:maintainer1) { group.add_maintainer(user4) }
+ let!(:maintainer2) { group.add_maintainer(user5) }
+
+ subject(:by_access_levels) { described_class.new(group, user1, params: { access_levels: access_levels }).execute }
+
+ context 'by owner' do
+ let(:access_levels) { ::Gitlab::Access::OWNER }
+
+ it 'returns owners' do
+ expect(by_access_levels).to match_array([owner1, owner2])
+ end
+ end
+
+ context 'by maintainer' do
+ let(:access_levels) { ::Gitlab::Access::MAINTAINER }
+
+ it 'returns owners' do
+ expect(by_access_levels).to match_array([maintainer1, maintainer2])
+ end
+ end
+
+ context 'by owner and maintainer' do
+ let(:access_levels) { [::Gitlab::Access::OWNER, ::Gitlab::Access::MAINTAINER] }
+
+ it 'returns owners and maintainers' do
+ expect(by_access_levels).to match_array([owner1, owner2, maintainer1, maintainer2])
+ end
+ end
+ end
end
diff --git a/spec/finders/groups/projects_requiring_authorizations_refresh/on_direct_membership_finder_spec.rb b/spec/finders/groups/projects_requiring_authorizations_refresh/on_direct_membership_finder_spec.rb
new file mode 100644
index 00000000000..8cdfa13ba3a
--- /dev/null
+++ b/spec/finders/groups/projects_requiring_authorizations_refresh/on_direct_membership_finder_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::ProjectsRequiringAuthorizationsRefresh::OnDirectMembershipFinder do
+ # rubocop:disable Layout/LineLength
+
+ # Group X Group A ------shared with-------------> Group B Group C
+ # | Group X_subgroup_1 | | |
+ # | | Project X_subgroup_1 ---shared with----->| Group A_subgroup_1 | Group B_subgroup_1 <--shared with--------- | Group C_subgroup_1
+ # | | | Project A_subgroup_1 | | Project B_subgroup_1 | | Project C_subgroup_1
+ # | Group A_subgroup_2 | Group B_subgroup_2 <----shared with ------- Project C
+ # | |Project A_subgroup_2 | | Project B_subgroup_2
+
+ # rubocop:enable Layout/LineLength
+
+ let_it_be(:group_x) { create(:group) }
+ let_it_be(:group_a) { create(:group) }
+ let_it_be(:group_b) { create(:group) }
+ let_it_be(:group_c) { create(:group) }
+ let_it_be(:group_x_subgroup_1) { create(:group, parent: group_x) }
+ let_it_be(:group_a_subgroup_1) { create(:group, parent: group_a) }
+ let_it_be(:group_a_subgroup_2) { create(:group, parent: group_a) }
+ let_it_be(:group_b_subgroup_1) { create(:group, parent: group_b) }
+ let_it_be(:group_b_subgroup_2) { create(:group, parent: group_b) }
+ let_it_be(:group_c_subgroup_1) { create(:group, parent: group_c) }
+ let_it_be(:project_x_subgroup_1) { create(:project, group: group_x_subgroup_1, name: 'project_x_subgroup_1') }
+ let_it_be(:project_a_subgroup_1) { create(:project, group: group_a_subgroup_1, name: 'project_a_subgroup_1') }
+ let_it_be(:project_a_subgroup_2) { create(:project, group: group_a_subgroup_2, name: 'project_a_subgroup_2') }
+ let_it_be(:project_b_subgroup_1) { create(:project, group: group_b_subgroup_1, name: 'project_b_subgroup_1') }
+ let_it_be(:project_b_subgroup_2) { create(:project, group: group_b_subgroup_2, name: 'project_b_subgroup_2') }
+ let_it_be(:project_c_subgroup_1) { create(:project, group: group_c_subgroup_1, name: 'project_c_subgroup_1') }
+ let_it_be(:project_c) { create(:project, group: group_c, name: 'project_c') }
+
+ describe '#execute' do
+ context 'projects affected when a new member is added to a specific group (here, `Group B`)' do
+ subject(:result) { described_class.new(group_b).execute }
+
+ before do
+ create(:project_group_link, project: project_x_subgroup_1, group: group_a_subgroup_1)
+ create(:project_group_link, project: project_c, group: group_b_subgroup_2)
+ create(:group_group_link, shared_group: group_a, shared_with_group: group_b)
+ create(:group_group_link, shared_group: group_c_subgroup_1, shared_with_group: group_b_subgroup_1)
+ end
+
+ it 'returns all projects IDs where authorizations need to be created for the user'\
+ 'due to their new membership being created in `Group B`' do
+ new_user = create(:user)
+ group_b.add_maintainer(new_user)
+
+ expect(result).to match_array(new_user.authorized_projects.ids)
+ end
+
+ it 'includes only the expected projects' do
+ expected_projects = Project.id_in(
+ [
+ project_b_subgroup_1, # direct member of Group B gets access to this project due to group hierarchy
+ project_b_subgroup_2, # direct member of Group B gets access to this project due to group hierarchy
+ project_c, # direct member of Group B gets access to this project via project-group share
+ project_a_subgroup_1, # direct member of Group B gets access to this project via group share
+ project_a_subgroup_2, # direct member of Group B gets access to this project via group share
+
+ # direct member of Group B gets access to any projects shared with groups within its shared groups.
+ project_x_subgroup_1
+ ]
+ )
+ # project_c_subgroup_1 is not included in the list because only 'direct' members of
+ # `group_b_subgroup_1` gets access to that project via the group-group share.
+ expect(result).to match_array(expected_projects.ids)
+ end
+ end
+ end
+end
diff --git a/spec/finders/groups/projects_requiring_authorizations_refresh/on_transfer_finder_spec.rb b/spec/finders/groups/projects_requiring_authorizations_refresh/on_transfer_finder_spec.rb
new file mode 100644
index 00000000000..103cef44c94
--- /dev/null
+++ b/spec/finders/groups/projects_requiring_authorizations_refresh/on_transfer_finder_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::ProjectsRequiringAuthorizationsRefresh::OnTransferFinder do
+ # rubocop:disable Layout/LineLength
+
+ # Group X Group A ------shared with-------------> Group B Group C
+ # | Group X_subgroup_1 | | |
+ # | | Project X_subgroup_1 ---shared with----->| Group A_subgroup_1 | Group B_subgroup_1 <--shared with--------- | Group C_subgroup_1
+ # | | | Project A_subgroup_1 | | Project B_subgroup_1 | | Project C_subgroup_1
+ # | Group A_subgroup_2 | Group B_subgroup_2 <----shared with ------- Project C
+ # | |Project A_subgroup_2 | | Project B_subgroup_2
+
+ # rubocop:enable Layout/LineLength
+
+ let_it_be(:group_x) { create(:group) }
+ let_it_be(:group_a) { create(:group) }
+ let_it_be(:group_b) { create(:group) }
+ let_it_be(:group_c) { create(:group) }
+ let_it_be(:group_x_subgroup_1) { create(:group, parent: group_x) }
+ let_it_be(:group_a_subgroup_1) { create(:group, parent: group_a) }
+ let_it_be(:group_a_subgroup_2) { create(:group, parent: group_a) }
+ let_it_be(:group_b_subgroup_1) { create(:group, parent: group_b) }
+ let_it_be(:group_b_subgroup_2) { create(:group, parent: group_b) }
+ let_it_be(:group_c_subgroup_1) { create(:group, parent: group_c) }
+ let_it_be(:project_x_subgroup_1) { create(:project, group: group_x_subgroup_1, name: 'project_x_subgroup_1') }
+ let_it_be(:project_a_subgroup_1) { create(:project, group: group_a_subgroup_1, name: 'project_a_subgroup_1') }
+ let_it_be(:project_a_subgroup_2) { create(:project, group: group_a_subgroup_2, name: 'project_a_subgroup_2') }
+ let_it_be(:project_b_subgroup_1) { create(:project, group: group_b_subgroup_1, name: 'project_b_subgroup_1') }
+ let_it_be(:project_b_subgroup_2) { create(:project, group: group_b_subgroup_2, name: 'project_b_subgroup_2') }
+ let_it_be(:project_c_subgroup_1) { create(:project, group: group_c_subgroup_1, name: 'project_c_subgroup_1') }
+ let_it_be(:project_c) { create(:project, group: group_c, name: 'project_c') }
+
+ describe '#execute' do
+ context 'projects requiring authorizations refresh when a group is transferred (here, `Group B`)' do
+ subject(:result) { described_class.new(group_b).execute }
+
+ before do
+ create(:project_group_link, project: project_x_subgroup_1, group: group_a_subgroup_1)
+ create(:project_group_link, project: project_c, group: group_b_subgroup_2)
+ create(:group_group_link, shared_group: group_a, shared_with_group: group_b)
+ create(:group_group_link, shared_group: group_c_subgroup_1, shared_with_group: group_b_subgroup_1)
+ end
+
+ it 'includes only the expected projects' do
+ expected_projects = Project.id_in(
+ [
+ project_b_subgroup_1,
+ project_b_subgroup_2,
+ project_c
+ ]
+ )
+
+ expect(result).to match_array(expected_projects.ids)
+ end
+ end
+ end
+end
diff --git a/spec/finders/incident_management/timeline_events_finder_spec.rb b/spec/finders/incident_management/timeline_events_finder_spec.rb
new file mode 100644
index 00000000000..aa01391c343
--- /dev/null
+++ b/spec/finders/incident_management/timeline_events_finder_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::TimelineEventsFinder do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:another_incident) { create(:incident, project: project) }
+
+ let_it_be(:timeline_event1) do
+ create(:incident_management_timeline_event, project: project, incident: incident, occurred_at: Time.current)
+ end
+
+ let_it_be(:timeline_event2) do
+ create(:incident_management_timeline_event, project: project, incident: incident, occurred_at: 1.minute.ago)
+ end
+
+ let_it_be(:timeline_event_of_another_incident) do
+ create(:incident_management_timeline_event, project: project, incident: another_incident)
+ end
+
+ let(:params) { {} }
+
+ describe '#execute' do
+ subject(:execute) { described_class.new(user, incident, params).execute }
+
+ context 'when user has permissions' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns timeline events' do
+ is_expected.to match_array([timeline_event2, timeline_event1])
+ end
+
+ context 'when filtering by ID' do
+ let(:params) { { id: timeline_event1 } }
+
+ it 'returns only matched timeline event' do
+ is_expected.to contain_exactly(timeline_event1)
+ end
+ end
+
+ context 'when incident is nil' do
+ let_it_be(:incident) { nil }
+
+ it { is_expected.to eq(IncidentManagement::TimelineEvent.none) }
+ end
+ end
+
+ context 'when user has no permissions' do
+ it { is_expected.to eq(IncidentManagement::TimelineEvent.none) }
+ end
+ end
+end
diff --git a/spec/finders/issues_finder/params_spec.rb b/spec/finders/issues_finder/params_spec.rb
deleted file mode 100644
index 879ecc364a2..00000000000
--- a/spec/finders/issues_finder/params_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe IssuesFinder::Params do
- describe '#include_hidden' do
- subject { described_class.new(params, user, IssuesFinder) }
-
- context 'when param is not set' do
- let(:params) { {} }
-
- context 'with an admin', :enable_admin_mode do
- let(:user) { create(:user, :admin) }
-
- it 'returns true' do
- expect(subject.include_hidden?).to be_truthy
- end
- end
-
- context 'with a regular user' do
- let(:user) { create(:user) }
-
- it 'returns false' do
- expect(subject.include_hidden?).to be_falsey
- end
- end
- end
-
- context 'when param is set' do
- let(:params) { { include_hidden: true } }
-
- context 'with an admin', :enable_admin_mode do
- let(:user) { create(:user, :admin) }
-
- it 'returns true' do
- expect(subject.include_hidden?).to be_truthy
- end
- end
-
- context 'with a regular user' do
- let(:user) { create(:user) }
-
- it 'returns false' do
- expect(subject.include_hidden?).to be_falsey
- end
- end
- end
- end
-end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index aa9357a686a..3f5a55410d2 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -12,52 +12,8 @@ RSpec.describe IssuesFinder do
context 'scope: all' do
let(:scope) { 'all' }
- context 'include_hidden and public_only params' do
- let_it_be(:banned_user) { create(:user, :banned) }
- let_it_be(:hidden_issue) { create(:issue, project: project1, author: banned_user) }
- let_it_be(:confidential_issue) { create(:issue, project: project1, confidential: true) }
-
- context 'when user is an admin', :enable_admin_mode do
- let(:user) { create(:user, :admin) }
-
- it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, hidden_issue, confidential_issue)
- end
- end
-
- context 'when user is not an admin' do
- context 'when public_only is true' do
- let(:params) { { public_only: true } }
-
- it 'returns public issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5)
- end
- end
-
- context 'when public_only is false' do
- let(:params) { { public_only: false } }
-
- it 'returns public and confidential issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, confidential_issue)
- end
- end
-
- context 'when public_only is not set' do
- it 'returns public and confidential issue' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, confidential_issue)
- end
- end
-
- context 'when ban_user_feature_flag is false' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, hidden_issue, confidential_issue)
- end
- end
- end
+ it 'returns all issues' do
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5)
end
context 'user does not have read permissions' do
@@ -1148,64 +1104,132 @@ RSpec.describe IssuesFinder do
end
describe '#with_confidentiality_access_check' do
- let(:user) { create(:user) }
+ let(:guest) { create(:user) }
let_it_be(:authorized_user) { create(:user) }
+ let_it_be(:banned_user) { create(:user, :banned) }
let_it_be(:project) { create(:project, namespace: authorized_user.namespace) }
let_it_be(:public_issue) { create(:issue, project: project) }
let_it_be(:confidential_issue) { create(:issue, project: project, confidential: true) }
+ let_it_be(:hidden_issue) { create(:issue, project: project, author: banned_user) }
- shared_examples 'returns public, does not return confidential' do
+ shared_examples 'returns public, does not return hidden or confidential' do
it 'returns only public issues' do
expect(subject).to include(public_issue)
- expect(subject).not_to include(confidential_issue)
+ expect(subject).not_to include(confidential_issue, hidden_issue)
end
end
- shared_examples 'returns public and confidential' do
- it 'returns public and confidential issues' do
+ shared_examples 'returns public and confidential, does not return hidden' do
+ it 'returns only public and confidential issues' do
expect(subject).to include(public_issue, confidential_issue)
+ expect(subject).not_to include(hidden_issue)
end
end
- subject { described_class.new(user, params).with_confidentiality_access_check }
+ shared_examples 'returns public and hidden, does not return confidential' do
+ it 'returns only public and hidden issues' do
+ expect(subject).to include(public_issue, hidden_issue)
+ expect(subject).not_to include(confidential_issue)
+ end
+ end
+
+ shared_examples 'returns public, confidential, and hidden' do
+ it 'returns all issues' do
+ expect(subject).to include(public_issue, confidential_issue, hidden_issue)
+ end
+ end
context 'when no project filter is given' do
let(:params) { {} }
context 'for an anonymous user' do
- it_behaves_like 'returns public, does not return confidential'
+ subject { described_class.new(nil, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
end
context 'for a user without project membership' do
- it_behaves_like 'returns public, does not return confidential'
+ subject { described_class.new(user, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
end
context 'for a guest user' do
+ subject { described_class.new(guest, params).with_confidentiality_access_check }
+
before do
- project.add_guest(user)
+ project.add_guest(guest)
end
- it_behaves_like 'returns public, does not return confidential'
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
end
context 'for a project member with access to view confidential issues' do
- before do
- project.add_reporter(user)
- end
+ subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
- it_behaves_like 'returns public and confidential'
+ it_behaves_like 'returns public and confidential, does not return hidden'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public, confidential, and hidden'
+ end
end
context 'for an admin' do
- let(:user) { create(:user, :admin) }
+ let(:admin_user) { create(:user, :admin) }
+
+ subject { described_class.new(admin_user, params).with_confidentiality_access_check }
context 'when admin mode is enabled', :enable_admin_mode do
- it_behaves_like 'returns public and confidential'
+ it_behaves_like 'returns public, confidential, and hidden'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public, confidential, and hidden'
+ end
end
context 'when admin mode is disabled' do
- it_behaves_like 'returns public, does not return confidential'
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
end
end
end
@@ -1214,9 +1238,17 @@ RSpec.describe IssuesFinder do
let(:params) { { project_id: project.id } }
context 'for an anonymous user' do
- let(:user) { nil }
+ subject { described_class.new(nil, params).with_confidentiality_access_check }
- it_behaves_like 'returns public, does not return confidential'
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
it 'does not filter by confidentiality' do
expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
@@ -1225,7 +1257,17 @@ RSpec.describe IssuesFinder do
end
context 'for a user without project membership' do
- it_behaves_like 'returns public, does not return confidential'
+ subject { described_class.new(user, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
it 'filters by confidentiality' do
expect(subject.to_sql).to match("issues.confidential")
@@ -1233,11 +1275,21 @@ RSpec.describe IssuesFinder do
end
context 'for a guest user' do
+ subject { described_class.new(guest, params).with_confidentiality_access_check }
+
before do
- project.add_guest(user)
+ project.add_guest(guest)
end
- it_behaves_like 'returns public, does not return confidential'
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
it 'filters by confidentiality' do
expect(subject.to_sql).to match("issues.confidential")
@@ -1245,18 +1297,40 @@ RSpec.describe IssuesFinder do
end
context 'for a project member with access to view confidential issues' do
- before do
- project.add_reporter(user)
+ subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public and confidential, does not return hidden'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public, confidential, and hidden'
end
- it_behaves_like 'returns public and confidential'
+ it 'does not filter by confidentiality' do
+ expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
+
+ subject
+ end
end
context 'for an admin' do
- let(:user) { create(:user, :admin) }
+ let(:admin_user) { create(:user, :admin) }
+
+ subject { described_class.new(admin_user, params).with_confidentiality_access_check }
context 'when admin mode is enabled', :enable_admin_mode do
- it_behaves_like 'returns public and confidential'
+ it_behaves_like 'returns public, confidential, and hidden'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public, confidential, and hidden'
+ end
it 'does not filter by confidentiality' do
expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
@@ -1266,7 +1340,19 @@ RSpec.describe IssuesFinder do
end
context 'when admin mode is disabled' do
- it_behaves_like 'returns public, does not return confidential'
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+
+ it 'filters by confidentiality' do
+ expect(subject.to_sql).to match("issues.confidential")
+ end
end
end
end
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index 1f63f69a411..96466e99105 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -414,6 +414,9 @@ RSpec.describe MergeRequestsFinder do
before do
reviewer = merge_request1.find_reviewer(user2)
reviewer.update!(state: :reviewed)
+
+ merge_request2.find_reviewer(user2).update!(state: :attention_requested)
+ merge_request3.find_assignee(user2).update!(state: :attention_requested)
end
context 'by username' do
diff --git a/spec/finders/packages/build_infos_finder_spec.rb b/spec/finders/packages/build_infos_finder_spec.rb
index 23425de4316..6e7f0623030 100644
--- a/spec/finders/packages/build_infos_finder_spec.rb
+++ b/spec/finders/packages/build_infos_finder_spec.rb
@@ -9,7 +9,20 @@ RSpec.describe ::Packages::BuildInfosFinder do
let_it_be(:build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: package) }
let_it_be(:build_info_with_empty_pipeline) { create(:package_build_info, package: package) }
- let(:finder) { described_class.new(package, params) }
+ let_it_be(:other_package) { create(:package) }
+ let_it_be(:other_build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: other_package) }
+ let_it_be(:other_build_info_with_empty_pipeline) { create(:package_build_info, package: other_package) }
+
+ let_it_be(:all_build_infos) { build_infos + other_build_infos }
+
+ let(:finder) { described_class.new(packages, params) }
+ let(:packages) { nil }
+ let(:first) { nil }
+ let(:last) { nil }
+ let(:after) { nil }
+ let(:before) { nil }
+ let(:max_page_size) { nil }
+ let(:support_next_page) { false }
let(:params) do
{
first: first,
@@ -24,41 +37,100 @@ RSpec.describe ::Packages::BuildInfosFinder do
describe '#execute' do
subject { finder.execute }
- where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do
- # F L AI BI MPS SNP
- nil | nil | nil | nil | nil | false | [4, 3, 2, 1, 0]
- nil | nil | nil | nil | 10 | false | [4, 3, 2, 1, 0]
- nil | nil | nil | nil | 2 | false | [4, 3]
- 2 | nil | nil | nil | nil | false | [4, 3]
- 2 | nil | nil | nil | nil | true | [4, 3, 2]
- 2 | nil | 3 | nil | nil | false | [2, 1]
- 2 | nil | 3 | nil | nil | true | [2, 1, 0]
- 3 | nil | 4 | nil | 2 | false | [3, 2]
- 3 | nil | 4 | nil | 2 | true | [3, 2, 1]
- nil | 2 | nil | nil | nil | false | [0, 1]
- nil | 2 | nil | nil | nil | true | [0, 1, 2]
- nil | 2 | nil | 1 | nil | false | [2, 3]
- nil | 2 | nil | 1 | nil | true | [2, 3, 4]
- nil | 3 | nil | 0 | 2 | false | [1, 2]
- nil | 3 | nil | 0 | 2 | true | [1, 2, 3]
- end
-
- with_them do
+ shared_examples 'returning the expected build infos' do
let(:expected_build_infos) do
expected_build_infos_indexes.map do |idx|
- build_infos[idx]
+ all_build_infos[idx]
end
end
let(:after) do
- build_infos[after_index].pipeline_id if after_index
+ all_build_infos[after_index].pipeline_id if after_index
end
let(:before) do
- build_infos[before_index].pipeline_id if before_index
+ all_build_infos[before_index].pipeline_id if before_index
end
it { is_expected.to eq(expected_build_infos) }
end
+
+ context 'with nil packages' do
+ let(:packages) { nil }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with [] packages' do
+ let(:packages) { [] }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with empy scope packages' do
+ let(:packages) { Packages::Package.none }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with a single package' do
+ let(:packages) { package.id }
+
+ # rubocop: disable Layout/LineLength
+ where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do
+ # F L AI BI MPS SNP
+ nil | nil | nil | nil | nil | false | [4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 10 | false | [4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 2 | false | [4, 3]
+ 2 | nil | nil | nil | nil | false | [4, 3]
+ 2 | nil | nil | nil | nil | true | [4, 3, 2]
+ 2 | nil | 3 | nil | nil | false | [2, 1]
+ 2 | nil | 3 | nil | nil | true | [2, 1, 0]
+ 3 | nil | 4 | nil | 2 | false | [3, 2]
+ 3 | nil | 4 | nil | 2 | true | [3, 2, 1]
+ nil | 2 | nil | nil | nil | false | [1, 0]
+ nil | 2 | nil | nil | nil | true | [2, 1, 0]
+ nil | 2 | nil | 1 | nil | false | [3, 2]
+ nil | 2 | nil | 1 | nil | true | [4, 3, 2]
+ nil | 3 | nil | 0 | 2 | false | [2, 1]
+ nil | 3 | nil | 0 | 2 | true | [3, 2, 1]
+ end
+ # rubocop: enable Layout/LineLength
+
+ with_them do
+ it_behaves_like 'returning the expected build infos'
+ end
+ end
+
+ context 'with many packages' do
+ let(:packages) { [package.id, other_package.id] }
+
+ # using after_index/before_index when receiving multiple packages doesn't
+ # make sense but we still verify here that the behavior is coherent.
+ # rubocop: disable Layout/LineLength
+ where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do
+ # F L AI BI MPS SNP
+ nil | nil | nil | nil | nil | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 10 | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 2 | false | [9, 8, 4, 3]
+ 2 | nil | nil | nil | nil | false | [9, 8, 4, 3]
+ 2 | nil | nil | nil | nil | true | [9, 8, 7, 4, 3, 2]
+ 2 | nil | 3 | nil | nil | false | [2, 1]
+ 2 | nil | 3 | nil | nil | true | [2, 1, 0]
+ 3 | nil | 4 | nil | 2 | false | [3, 2]
+ 3 | nil | 4 | nil | 2 | true | [3, 2, 1]
+ nil | 2 | nil | nil | nil | false | [6, 5, 1, 0]
+ nil | 2 | nil | nil | nil | true | [7, 6, 5, 2, 1, 0]
+ nil | 2 | nil | 1 | nil | false | [6, 5, 3, 2]
+ nil | 2 | nil | 1 | nil | true | [7, 6, 5, 4, 3, 2]
+ nil | 3 | nil | 0 | 2 | false | [6, 5, 2, 1]
+ nil | 3 | nil | 0 | 2 | true | [7, 6, 5, 3, 2, 1]
+ end
+
+ with_them do
+ it_behaves_like 'returning the expected build infos'
+ end
+ # rubocop: enable Layout/LineLength
+ end
end
end
diff --git a/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb b/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb
deleted file mode 100644
index f3c79d0c825..00000000000
--- a/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb
+++ /dev/null
@@ -1,136 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ::Packages::BuildInfosForManyPackagesFinder do
- using RSpec::Parameterized::TableSyntax
-
- let_it_be(:package) { create(:package) }
- let_it_be(:build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: package) }
- let_it_be(:build_info_with_empty_pipeline) { create(:package_build_info, package: package) }
-
- let_it_be(:other_package) { create(:package) }
- let_it_be(:other_build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: other_package) }
- let_it_be(:other_build_info_with_empty_pipeline) { create(:package_build_info, package: other_package) }
-
- let_it_be(:all_build_infos) { build_infos + other_build_infos }
-
- let(:finder) { described_class.new(packages, params) }
- let(:packages) { nil }
- let(:first) { nil }
- let(:last) { nil }
- let(:after) { nil }
- let(:before) { nil }
- let(:max_page_size) { nil }
- let(:support_next_page) { false }
- let(:params) do
- {
- first: first,
- last: last,
- after: after,
- before: before,
- max_page_size: max_page_size,
- support_next_page: support_next_page
- }
- end
-
- describe '#execute' do
- subject { finder.execute }
-
- shared_examples 'returning the expected build infos' do
- let(:expected_build_infos) do
- expected_build_infos_indexes.map do |idx|
- all_build_infos[idx]
- end
- end
-
- let(:after) do
- all_build_infos[after_index].pipeline_id if after_index
- end
-
- let(:before) do
- all_build_infos[before_index].pipeline_id if before_index
- end
-
- it { is_expected.to eq(expected_build_infos) }
- end
-
- context 'with nil packages' do
- let(:packages) { nil }
-
- it { is_expected.to be_empty }
- end
-
- context 'with [] packages' do
- let(:packages) { [] }
-
- it { is_expected.to be_empty }
- end
-
- context 'with empy scope packages' do
- let(:packages) { Packages::Package.none }
-
- it { is_expected.to be_empty }
- end
-
- context 'with a single package' do
- let(:packages) { package.id }
-
- # rubocop: disable Layout/LineLength
- where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do
- # F L AI BI MPS SNP
- nil | nil | nil | nil | nil | false | [4, 3, 2, 1, 0]
- nil | nil | nil | nil | 10 | false | [4, 3, 2, 1, 0]
- nil | nil | nil | nil | 2 | false | [4, 3]
- 2 | nil | nil | nil | nil | false | [4, 3]
- 2 | nil | nil | nil | nil | true | [4, 3, 2]
- 2 | nil | 3 | nil | nil | false | [2, 1]
- 2 | nil | 3 | nil | nil | true | [2, 1, 0]
- 3 | nil | 4 | nil | 2 | false | [3, 2]
- 3 | nil | 4 | nil | 2 | true | [3, 2, 1]
- nil | 2 | nil | nil | nil | false | [1, 0]
- nil | 2 | nil | nil | nil | true | [2, 1, 0]
- nil | 2 | nil | 1 | nil | false | [3, 2]
- nil | 2 | nil | 1 | nil | true | [4, 3, 2]
- nil | 3 | nil | 0 | 2 | false | [2, 1]
- nil | 3 | nil | 0 | 2 | true | [3, 2, 1]
- end
- # rubocop: enable Layout/LineLength
-
- with_them do
- it_behaves_like 'returning the expected build infos'
- end
- end
-
- context 'with many packages' do
- let(:packages) { [package.id, other_package.id] }
-
- # using after_index/before_index when receiving multiple packages doesn't
- # make sense but we still verify here that the behavior is coherent.
- # rubocop: disable Layout/LineLength
- where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do
- # F L AI BI MPS SNP
- nil | nil | nil | nil | nil | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
- nil | nil | nil | nil | 10 | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
- nil | nil | nil | nil | 2 | false | [9, 8, 4, 3]
- 2 | nil | nil | nil | nil | false | [9, 8, 4, 3]
- 2 | nil | nil | nil | nil | true | [9, 8, 7, 4, 3, 2]
- 2 | nil | 3 | nil | nil | false | [2, 1]
- 2 | nil | 3 | nil | nil | true | [2, 1, 0]
- 3 | nil | 4 | nil | 2 | false | [3, 2]
- 3 | nil | 4 | nil | 2 | true | [3, 2, 1]
- nil | 2 | nil | nil | nil | false | [6, 5, 1, 0]
- nil | 2 | nil | nil | nil | true | [7, 6, 5, 2, 1, 0]
- nil | 2 | nil | 1 | nil | false | [6, 5, 3, 2]
- nil | 2 | nil | 1 | nil | true | [7, 6, 5, 4, 3, 2]
- nil | 3 | nil | 0 | 2 | false | [6, 5, 2, 1]
- nil | 3 | nil | 0 | 2 | true | [7, 6, 5, 3, 2, 1]
- end
-
- with_them do
- it_behaves_like 'returning the expected build infos'
- end
- # rubocop: enable Layout/LineLength
- end
- end
-end
diff --git a/spec/finders/personal_access_tokens_finder_spec.rb b/spec/finders/personal_access_tokens_finder_spec.rb
index 7607d08dc64..f22bff62082 100644
--- a/spec/finders/personal_access_tokens_finder_spec.rb
+++ b/spec/finders/personal_access_tokens_finder_spec.rb
@@ -286,24 +286,6 @@ RSpec.describe PersonalAccessTokensFinder do
end
end
- describe 'with active or expired state' do
- before do
- params[:state] = 'active_or_expired'
- end
-
- it 'includes active tokens' do
- is_expected.to include(active_personal_access_token, active_impersonation_token)
- end
-
- it 'includes expired tokens' do
- is_expected.to include(expired_personal_access_token, expired_impersonation_token)
- end
-
- it 'does not include revoked tokens' do
- is_expected.not_to include(revoked_personal_access_token, revoked_impersonation_token)
- end
- end
-
describe 'with id' do
subject { finder(params).find_by_id(active_personal_access_token.id) }
diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb
deleted file mode 100644
index 9b58da2e398..00000000000
--- a/spec/finders/projects/serverless/functions_finder_spec.rb
+++ /dev/null
@@ -1,185 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Serverless::FunctionsFinder do
- include KubernetesHelpers
- include PrometheusHelpers
- include ReactiveCachingHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
- let(:service) { cluster.platform_kubernetes }
- let(:environment) { create(:environment, project: project) }
- let!(:deployment) { create(:deployment, :success, environment: environment, cluster: cluster) }
- let(:knative_services_finder) { environment.knative_services_finder }
-
- let(:namespace) do
- create(:cluster_kubernetes_namespace,
- cluster: cluster,
- project: project,
- environment: environment)
- end
-
- before do
- project.add_maintainer(user)
- end
-
- describe '#knative_installed' do
- context 'when environment does not exist yet' do
- shared_examples 'before first deployment' do
- let(:service) { cluster.platform_kubernetes }
- let(:deployment) { nil }
-
- it 'returns true if Knative is installed on cluster' do
- stub_kubeclient_discover_knative_found(service.api_url)
- function_finder = described_class.new(project)
- synchronous_reactive_cache(function_finder)
-
- expect(function_finder.knative_installed).to be true
- end
-
- it 'returns false if Knative is not installed on cluster' do
- stub_kubeclient_discover_knative_not_found(service.api_url)
- function_finder = described_class.new(project)
- synchronous_reactive_cache(function_finder)
-
- expect(function_finder.knative_installed).to be false
- end
- end
-
- context 'when project level cluster is present and enabled' do
- it_behaves_like 'before first deployment' do
- let(:cluster) { create(:cluster, :project, :provided_by_gcp, enabled: true) }
- let(:project) { cluster.project }
- end
- end
-
- context 'when group level cluster is present and enabled' do
- it_behaves_like 'before first deployment' do
- let(:cluster) { create(:cluster, :group, :provided_by_gcp, enabled: true) }
- let(:project) { create(:project, group: cluster.groups.first) }
- end
- end
-
- context 'when instance level cluster is present and enabled' do
- it_behaves_like 'before first deployment' do
- let(:project) { create(:project) }
- let(:cluster) { create(:cluster, :instance, :provided_by_gcp, enabled: true) }
- end
- end
-
- context 'when project level cluster is present, but disabled' do
- let(:cluster) { create(:cluster, :project, :provided_by_gcp, enabled: false) }
- let(:project) { cluster.project }
- let(:service) { cluster.platform_kubernetes }
- let(:deployment) { nil }
-
- it 'returns false even if Knative is installed on cluster' do
- stub_kubeclient_discover_knative_found(service.api_url)
- function_finder = described_class.new(project)
- synchronous_reactive_cache(function_finder)
-
- expect(function_finder.knative_installed).to be false
- end
- end
- end
-
- context 'when reactive_caching is still fetching data' do
- it 'returns "checking"' do
- expect(described_class.new(project).knative_installed).to eq 'checking'
- end
- end
-
- context 'when reactive_caching has finished' do
- before do
- allow(Clusters::KnativeServicesFinder)
- .to receive(:new)
- .and_return(knative_services_finder)
- synchronous_reactive_cache(knative_services_finder)
- end
-
- context 'when knative is not installed' do
- it 'returns false' do
- stub_kubeclient_discover_knative_not_found(service.api_url)
-
- expect(described_class.new(project).knative_installed).to eq false
- end
- end
-
- context 'reactive_caching is finished and knative is installed' do
- it 'returns true' do
- stub_kubeclient_knative_services(namespace: namespace.namespace)
- stub_kubeclient_service_pods(nil, namespace: namespace.namespace)
-
- expect(described_class.new(project).knative_installed).to be true
- end
- end
- end
- end
-
- describe 'retrieve data from knative' do
- context 'does not have knative installed' do
- it { expect(described_class.new(project).execute).to be_empty }
- end
-
- context 'has knative installed' do
- let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
- let(:finder) { described_class.new(project) }
-
- it 'there are no functions' do
- expect(finder.execute).to be_empty
- end
-
- it 'there are functions', :use_clean_rails_memory_store_caching do
- stub_kubeclient_service_pods
- stub_reactive_cache(knative_services_finder,
- {
- services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
- pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
- },
- *knative_services_finder.cache_args)
-
- expect(finder.execute).not_to be_empty
- end
-
- it 'has a function', :use_clean_rails_memory_store_caching do
- stub_kubeclient_service_pods
- stub_reactive_cache(knative_services_finder,
- {
- services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
- pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
- },
- *knative_services_finder.cache_args)
-
- result = finder.service(cluster.environment_scope, cluster.project.name)
- expect(result).to be_present
- expect(result.name).to be_eql(cluster.project.name)
- end
-
- it 'has metrics', :use_clean_rails_memory_store_caching do
- end
- end
-
- context 'has prometheus' do
- let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
- let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
- let!(:prometheus) { create(:clusters_integrations_prometheus, cluster: cluster) }
- let(:finder) { described_class.new(project) }
-
- before do
- allow(Gitlab::Prometheus::Adapter).to receive(:new).and_return(double(prometheus_adapter: prometheus_adapter))
- allow(prometheus_adapter).to receive(:query).and_return(prometheus_empty_body('matrix'))
- end
-
- it 'is available' do
- expect(finder.has_prometheus?("*")).to be true
- end
-
- it 'has query data' do
- expect(finder.invocation_metrics("*", cluster.project.name)).not_to be_nil
- end
- end
- end
-end
diff --git a/spec/finders/releases_finder_spec.rb b/spec/finders/releases_finder_spec.rb
index b0fa1177245..858a0e566f6 100644
--- a/spec/finders/releases_finder_spec.rb
+++ b/spec/finders/releases_finder_spec.rb
@@ -107,130 +107,4 @@ RSpec.describe ReleasesFinder do
it_behaves_like 'when a tag parameter is passed'
end
end
-
- describe 'when parent is a group' do
- context 'without subgroups' do
- let(:project2) { create(:project, :repository, namespace: group) }
- let!(:v6) { create(:release, project: project2, tag: 'v6') }
-
- subject { described_class.new(group, user, params).execute(**args) }
-
- it_behaves_like 'when the user is not part of the group'
-
- context 'when the user is a project guest on one sibling project' do
- before do
- project.add_guest(user)
- v1_0_0.update_attribute(:released_at, 3.days.ago)
- v1_1_0.update_attribute(:released_at, 1.day.ago)
- end
-
- it 'does not return any releases' do
- expect(subject.size).to eq(0)
- expect(subject).to eq([])
- end
- end
-
- context 'when the user is a guest on the group' do
- before do
- group.add_guest(user)
- v1_0_0.update_attribute(:released_at, 3.days.ago)
- v6.update_attribute(:released_at, 2.days.ago)
- v1_1_0.update_attribute(:released_at, 1.day.ago)
- end
-
- it 'sorts by release date' do
- expect(subject.size).to eq(3)
- expect(subject).to eq([v1_1_0, v6, v1_0_0])
- end
-
- it_behaves_like 'when a tag parameter is passed'
- end
- end
-
- describe 'with subgroups' do
- let(:params) { { include_subgroups: true } }
-
- subject { described_class.new(group, user, params).execute(**args) }
-
- context 'with a single-level subgroup' do
- let(:subgroup) { create :group, parent: group }
- let(:project2) { create(:project, :repository, namespace: subgroup) }
- let!(:v6) { create(:release, project: project2, tag: 'v6') }
-
- it_behaves_like 'when the user is not part of the group'
-
- context 'when the user a project guest in the subgroup project' do
- before do
- project2.add_guest(user)
- end
-
- it 'does not return any releases' do
- expect(subject).to match_array([])
- end
- end
-
- context 'when the user is a guest on the group' do
- before do
- group.add_guest(user)
- v6.update_attribute(:released_at, 2.days.ago)
- end
-
- it 'returns all releases' do
- expect(subject).to match_array([v1_1_0, v1_0_0, v6])
- end
-
- it_behaves_like 'when a tag parameter is passed'
- end
- end
-
- context 'with a multi-level subgroup' do
- let(:subgroup) { create :group, parent: group }
- let(:subsubgroup) { create :group, parent: subgroup }
- let(:project2) { create(:project, :repository, namespace: subgroup) }
- let(:project3) { create(:project, :repository, namespace: subsubgroup) }
- let!(:v6) { create(:release, project: project2, tag: 'v6') }
- let!(:p3) { create(:release, project: project3, tag: 'p3') }
-
- before do
- v6.update_attribute(:released_at, 2.days.ago)
- p3.update_attribute(:released_at, 3.days.ago)
- end
-
- it_behaves_like 'when the user is not part of the group'
-
- context 'when the user a project guest in the subgroup and subsubgroup project' do
- before do
- project2.add_guest(user)
- project3.add_guest(user)
- end
-
- it 'does not return any releases' do
- expect(subject).to match_array([])
- end
- end
-
- context 'when the user a project guest in the subsubgroup project' do
- before do
- project3.add_guest(user)
- end
-
- it 'does not return any releases' do
- expect(subject).to match_array([])
- end
- end
-
- context 'when the user a guest on the group' do
- before do
- group.add_guest(user)
- end
-
- it 'returns all releases' do
- expect(subject).to match_array([v1_1_0, v6, v1_0_0, p3])
- end
-
- it_behaves_like 'when a tag parameter is passed'
- end
- end
- end
- end
end
diff --git a/spec/fixtures/api/schemas/graphql/container_repository.json b/spec/fixtures/api/schemas/graphql/container_repository.json
index 04e67f73844..2bb598a14cb 100644
--- a/spec/fixtures/api/schemas/graphql/container_repository.json
+++ b/spec/fixtures/api/schemas/graphql/container_repository.json
@@ -1,6 +1,6 @@
{
"type": "object",
- "required": ["id", "name", "path", "location", "createdAt", "updatedAt", "tagsCount", "canDelete", "expirationPolicyCleanupStatus", "project"],
+ "required": ["id", "name", "path", "location", "createdAt", "updatedAt", "tagsCount", "canDelete", "expirationPolicyCleanupStatus", "project", "lastCleanupDeletedTagsCount"],
"properties": {
"id": {
"type": "string"
@@ -38,6 +38,9 @@
},
"project": {
"type": "object"
+ },
+ "lastCleanupDeletedTagsCount": {
+ "type": ["string", "null"]
}
}
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/agent_token.json b/spec/fixtures/api/schemas/public_api/v4/agent_token.json
new file mode 100644
index 00000000000..8251ddd4de1
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/agent_token.json
@@ -0,0 +1,24 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "agent_id",
+ "status",
+ "created_at",
+ "created_by_user_id",
+ "last_used_at"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "agent_id": { "type": "integer" },
+ "status": { "type": "string" },
+ "created_at": { "type": "string", "format": "date-time" },
+ "created_by_user_id": { "type": "integer" },
+ "last_used_at": { "type": ["string", "null"], "format": "date-time" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/agent_token_basic.json b/spec/fixtures/api/schemas/public_api/v4/agent_token_basic.json
new file mode 100644
index 00000000000..90d9f35d0e1
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/agent_token_basic.json
@@ -0,0 +1,22 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "agent_id",
+ "status",
+ "created_at",
+ "created_by_user_id"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "agent_id": { "type": "integer" },
+ "status": { "type": "string" },
+ "created_at": { "type": "string", "format": "date-time" },
+ "created_by_user_id": { "type": "integer" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/agent_token_with_token.json b/spec/fixtures/api/schemas/public_api/v4/agent_token_with_token.json
new file mode 100644
index 00000000000..99d80817d0f
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/agent_token_with_token.json
@@ -0,0 +1,26 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "description",
+ "agent_id",
+ "status",
+ "created_at",
+ "created_by_user_id",
+ "last_used_at",
+ "token"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "agent_id": { "type": "integer" },
+ "status": { "type": "string" },
+ "created_at": { "type": "string", "format": "date-time" },
+ "created_by_user_id": { "type": "integer" },
+ "last_used_at": { "type": ["string", "null"], "format": "date-time" },
+ "token": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/agent_tokens.json b/spec/fixtures/api/schemas/public_api/v4/agent_tokens.json
new file mode 100644
index 00000000000..f3d14d09b3d
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/agent_tokens.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "agent_token_basic.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/environment.json b/spec/fixtures/api/schemas/public_api/v4/environment.json
index 30104adaf5c..3a4c18343e3 100644
--- a/spec/fixtures/api/schemas/public_api/v4/environment.json
+++ b/spec/fixtures/api/schemas/public_api/v4/environment.json
@@ -4,6 +4,7 @@
"id",
"name",
"slug",
+ "tier",
"external_url",
"state",
"created_at",
@@ -13,6 +14,7 @@
"id": { "type": "integer" },
"name": { "type": "string" },
"slug": { "type": "string" },
+ "tier": { "type": "string" },
"external_url": { "$ref": "../../types/nullable_string.json" },
"last_deployment": {
"oneOf": [
diff --git a/spec/fixtures/glfm/example_snapshots/examples_index.yml b/spec/fixtures/glfm/example_snapshots/examples_index.yml
new file mode 100644
index 00000000000..98463a30cb6
--- /dev/null
+++ b/spec/fixtures/glfm/example_snapshots/examples_index.yml
@@ -0,0 +1,2020 @@
+---
+02_01__preliminaries__tabs__01:
+ spec_txt_example_position: 1
+ source_specification: commonmark
+02_01__preliminaries__tabs__02:
+ spec_txt_example_position: 2
+ source_specification: commonmark
+02_01__preliminaries__tabs__03:
+ spec_txt_example_position: 3
+ source_specification: commonmark
+02_01__preliminaries__tabs__04:
+ spec_txt_example_position: 4
+ source_specification: commonmark
+02_01__preliminaries__tabs__05:
+ spec_txt_example_position: 5
+ source_specification: commonmark
+02_01__preliminaries__tabs__06:
+ spec_txt_example_position: 6
+ source_specification: commonmark
+02_01__preliminaries__tabs__07:
+ spec_txt_example_position: 7
+ source_specification: commonmark
+02_01__preliminaries__tabs__08:
+ spec_txt_example_position: 8
+ source_specification: commonmark
+02_01__preliminaries__tabs__09:
+ spec_txt_example_position: 9
+ source_specification: commonmark
+02_01__preliminaries__tabs__10:
+ spec_txt_example_position: 10
+ source_specification: commonmark
+02_01__preliminaries__tabs__11:
+ spec_txt_example_position: 11
+ source_specification: commonmark
+03_01__blocks_and_inlines__precedence__01:
+ spec_txt_example_position: 12
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__01:
+ spec_txt_example_position: 13
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__02:
+ spec_txt_example_position: 14
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__03:
+ spec_txt_example_position: 15
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__04:
+ spec_txt_example_position: 16
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__05:
+ spec_txt_example_position: 17
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__06:
+ spec_txt_example_position: 18
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__07:
+ spec_txt_example_position: 19
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__08:
+ spec_txt_example_position: 20
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__09:
+ spec_txt_example_position: 21
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__10:
+ spec_txt_example_position: 22
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__11:
+ spec_txt_example_position: 23
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__12:
+ spec_txt_example_position: 24
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__13:
+ spec_txt_example_position: 25
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__14:
+ spec_txt_example_position: 26
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__15:
+ spec_txt_example_position: 27
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__16:
+ spec_txt_example_position: 28
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__17:
+ spec_txt_example_position: 29
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__18:
+ spec_txt_example_position: 30
+ source_specification: commonmark
+04_01__leaf_blocks__thematic_breaks__19:
+ spec_txt_example_position: 31
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__01:
+ spec_txt_example_position: 32
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__02:
+ spec_txt_example_position: 33
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__03:
+ spec_txt_example_position: 34
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__04:
+ spec_txt_example_position: 35
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__05:
+ spec_txt_example_position: 36
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__06:
+ spec_txt_example_position: 37
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__07:
+ spec_txt_example_position: 38
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__08:
+ spec_txt_example_position: 39
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__09:
+ spec_txt_example_position: 40
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__10:
+ spec_txt_example_position: 41
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__11:
+ spec_txt_example_position: 42
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__12:
+ spec_txt_example_position: 43
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__13:
+ spec_txt_example_position: 44
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__14:
+ spec_txt_example_position: 45
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__15:
+ spec_txt_example_position: 46
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__16:
+ spec_txt_example_position: 47
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__17:
+ spec_txt_example_position: 48
+ source_specification: commonmark
+04_02__leaf_blocks__atx_headings__18:
+ spec_txt_example_position: 49
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__01:
+ spec_txt_example_position: 50
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__02:
+ spec_txt_example_position: 51
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__03:
+ spec_txt_example_position: 52
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__04:
+ spec_txt_example_position: 53
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__05:
+ spec_txt_example_position: 54
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__06:
+ spec_txt_example_position: 55
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__07:
+ spec_txt_example_position: 56
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__08:
+ spec_txt_example_position: 57
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__09:
+ spec_txt_example_position: 58
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__10:
+ spec_txt_example_position: 59
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__11:
+ spec_txt_example_position: 60
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__12:
+ spec_txt_example_position: 61
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__13:
+ spec_txt_example_position: 62
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__14:
+ spec_txt_example_position: 63
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__15:
+ spec_txt_example_position: 64
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__16:
+ spec_txt_example_position: 65
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__17:
+ spec_txt_example_position: 66
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__18:
+ spec_txt_example_position: 67
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__19:
+ spec_txt_example_position: 68
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__20:
+ spec_txt_example_position: 69
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__21:
+ spec_txt_example_position: 70
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__22:
+ spec_txt_example_position: 71
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__23:
+ spec_txt_example_position: 72
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__24:
+ spec_txt_example_position: 73
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__25:
+ spec_txt_example_position: 74
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__26:
+ spec_txt_example_position: 75
+ source_specification: commonmark
+04_03__leaf_blocks__setext_headings__27:
+ spec_txt_example_position: 76
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__01:
+ spec_txt_example_position: 77
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__02:
+ spec_txt_example_position: 78
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__03:
+ spec_txt_example_position: 79
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__04:
+ spec_txt_example_position: 80
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__05:
+ spec_txt_example_position: 81
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__06:
+ spec_txt_example_position: 82
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__07:
+ spec_txt_example_position: 83
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__08:
+ spec_txt_example_position: 84
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__09:
+ spec_txt_example_position: 85
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__10:
+ spec_txt_example_position: 86
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__11:
+ spec_txt_example_position: 87
+ source_specification: commonmark
+04_04__leaf_blocks__indented_code_blocks__12:
+ spec_txt_example_position: 88
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__01:
+ spec_txt_example_position: 89
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__02:
+ spec_txt_example_position: 90
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__03:
+ spec_txt_example_position: 91
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__04:
+ spec_txt_example_position: 92
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__05:
+ spec_txt_example_position: 93
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__06:
+ spec_txt_example_position: 94
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__07:
+ spec_txt_example_position: 95
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__08:
+ spec_txt_example_position: 96
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__09:
+ spec_txt_example_position: 97
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__10:
+ spec_txt_example_position: 98
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__11:
+ spec_txt_example_position: 99
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__12:
+ spec_txt_example_position: 100
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__13:
+ spec_txt_example_position: 101
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__14:
+ spec_txt_example_position: 102
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__15:
+ spec_txt_example_position: 103
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__16:
+ spec_txt_example_position: 104
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__17:
+ spec_txt_example_position: 105
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__18:
+ spec_txt_example_position: 106
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__19:
+ spec_txt_example_position: 107
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__20:
+ spec_txt_example_position: 108
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__21:
+ spec_txt_example_position: 109
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__22:
+ spec_txt_example_position: 110
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__23:
+ spec_txt_example_position: 111
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__24:
+ spec_txt_example_position: 112
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__25:
+ spec_txt_example_position: 113
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__26:
+ spec_txt_example_position: 114
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__27:
+ spec_txt_example_position: 115
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__28:
+ spec_txt_example_position: 116
+ source_specification: commonmark
+04_05__leaf_blocks__fenced_code_blocks__29:
+ spec_txt_example_position: 117
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__01:
+ spec_txt_example_position: 118
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__02:
+ spec_txt_example_position: 119
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__03:
+ spec_txt_example_position: 120
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__04:
+ spec_txt_example_position: 121
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__05:
+ spec_txt_example_position: 122
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__06:
+ spec_txt_example_position: 123
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__07:
+ spec_txt_example_position: 124
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__08:
+ spec_txt_example_position: 125
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__09:
+ spec_txt_example_position: 126
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__10:
+ spec_txt_example_position: 127
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__11:
+ spec_txt_example_position: 128
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__12:
+ spec_txt_example_position: 129
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__13:
+ spec_txt_example_position: 130
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__14:
+ spec_txt_example_position: 131
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__15:
+ spec_txt_example_position: 132
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__16:
+ spec_txt_example_position: 133
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__17:
+ spec_txt_example_position: 134
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__18:
+ spec_txt_example_position: 135
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__19:
+ spec_txt_example_position: 136
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__20:
+ spec_txt_example_position: 137
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__21:
+ spec_txt_example_position: 138
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__22:
+ spec_txt_example_position: 139
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__23:
+ spec_txt_example_position: 140
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__24:
+ spec_txt_example_position: 141
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__25:
+ spec_txt_example_position: 142
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__26:
+ spec_txt_example_position: 143
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__27:
+ spec_txt_example_position: 144
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__28:
+ spec_txt_example_position: 145
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__29:
+ spec_txt_example_position: 146
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__30:
+ spec_txt_example_position: 147
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__31:
+ spec_txt_example_position: 148
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__32:
+ spec_txt_example_position: 149
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__33:
+ spec_txt_example_position: 150
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__34:
+ spec_txt_example_position: 151
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__35:
+ spec_txt_example_position: 152
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__36:
+ spec_txt_example_position: 153
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__37:
+ spec_txt_example_position: 154
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__38:
+ spec_txt_example_position: 155
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__39:
+ spec_txt_example_position: 156
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__40:
+ spec_txt_example_position: 157
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__41:
+ spec_txt_example_position: 158
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__42:
+ spec_txt_example_position: 159
+ source_specification: commonmark
+04_06__leaf_blocks__html_blocks__43:
+ spec_txt_example_position: 160
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__01:
+ spec_txt_example_position: 161
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__02:
+ spec_txt_example_position: 162
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__03:
+ spec_txt_example_position: 163
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__04:
+ spec_txt_example_position: 164
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__05:
+ spec_txt_example_position: 165
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__06:
+ spec_txt_example_position: 166
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__07:
+ spec_txt_example_position: 167
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__08:
+ spec_txt_example_position: 168
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__09:
+ spec_txt_example_position: 169
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__10:
+ spec_txt_example_position: 170
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__11:
+ spec_txt_example_position: 171
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__12:
+ spec_txt_example_position: 172
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__13:
+ spec_txt_example_position: 173
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__14:
+ spec_txt_example_position: 174
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__15:
+ spec_txt_example_position: 175
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__16:
+ spec_txt_example_position: 176
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__17:
+ spec_txt_example_position: 177
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__18:
+ spec_txt_example_position: 178
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__19:
+ spec_txt_example_position: 179
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__20:
+ spec_txt_example_position: 180
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__21:
+ spec_txt_example_position: 181
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__22:
+ spec_txt_example_position: 182
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__23:
+ spec_txt_example_position: 183
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__24:
+ spec_txt_example_position: 184
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__25:
+ spec_txt_example_position: 185
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__26:
+ spec_txt_example_position: 186
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__27:
+ spec_txt_example_position: 187
+ source_specification: commonmark
+04_07__leaf_blocks__link_reference_definitions__28:
+ spec_txt_example_position: 188
+ source_specification: commonmark
+04_08__leaf_blocks__paragraphs__01:
+ spec_txt_example_position: 189
+ source_specification: commonmark
+04_08__leaf_blocks__paragraphs__02:
+ spec_txt_example_position: 190
+ source_specification: commonmark
+04_08__leaf_blocks__paragraphs__03:
+ spec_txt_example_position: 191
+ source_specification: commonmark
+04_08__leaf_blocks__paragraphs__04:
+ spec_txt_example_position: 192
+ source_specification: commonmark
+04_08__leaf_blocks__paragraphs__05:
+ spec_txt_example_position: 193
+ source_specification: commonmark
+04_08__leaf_blocks__paragraphs__06:
+ spec_txt_example_position: 194
+ source_specification: commonmark
+04_08__leaf_blocks__paragraphs__07:
+ spec_txt_example_position: 195
+ source_specification: commonmark
+04_08__leaf_blocks__paragraphs__08:
+ spec_txt_example_position: 196
+ source_specification: commonmark
+04_09__leaf_blocks__blank_lines__01:
+ spec_txt_example_position: 197
+ source_specification: commonmark
+04_10__leaf_blocks__tables_extension__01:
+ spec_txt_example_position: 198
+ source_specification: github
+04_10__leaf_blocks__tables_extension__02:
+ spec_txt_example_position: 199
+ source_specification: github
+04_10__leaf_blocks__tables_extension__03:
+ spec_txt_example_position: 200
+ source_specification: github
+04_10__leaf_blocks__tables_extension__04:
+ spec_txt_example_position: 201
+ source_specification: github
+04_10__leaf_blocks__tables_extension__05:
+ spec_txt_example_position: 202
+ source_specification: github
+04_10__leaf_blocks__tables_extension__06:
+ spec_txt_example_position: 203
+ source_specification: github
+04_10__leaf_blocks__tables_extension__07:
+ spec_txt_example_position: 204
+ source_specification: github
+04_10__leaf_blocks__tables_extension__08:
+ spec_txt_example_position: 205
+ source_specification: github
+05_01__container_blocks__block_quotes__01:
+ spec_txt_example_position: 206
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__02:
+ spec_txt_example_position: 207
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__03:
+ spec_txt_example_position: 208
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__04:
+ spec_txt_example_position: 209
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__05:
+ spec_txt_example_position: 210
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__06:
+ spec_txt_example_position: 211
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__07:
+ spec_txt_example_position: 212
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__08:
+ spec_txt_example_position: 213
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__09:
+ spec_txt_example_position: 214
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__10:
+ spec_txt_example_position: 215
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__11:
+ spec_txt_example_position: 216
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__12:
+ spec_txt_example_position: 217
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__13:
+ spec_txt_example_position: 218
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__14:
+ spec_txt_example_position: 219
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__15:
+ spec_txt_example_position: 220
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__16:
+ spec_txt_example_position: 221
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__17:
+ spec_txt_example_position: 222
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__18:
+ spec_txt_example_position: 223
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__19:
+ spec_txt_example_position: 224
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__20:
+ spec_txt_example_position: 225
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__21:
+ spec_txt_example_position: 226
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__22:
+ spec_txt_example_position: 227
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__23:
+ spec_txt_example_position: 228
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__24:
+ spec_txt_example_position: 229
+ source_specification: commonmark
+05_01__container_blocks__block_quotes__25:
+ spec_txt_example_position: 230
+ source_specification: commonmark
+05_02__container_blocks__list_items__01:
+ spec_txt_example_position: 231
+ source_specification: commonmark
+05_02__container_blocks__list_items__02:
+ spec_txt_example_position: 232
+ source_specification: commonmark
+05_02__container_blocks__list_items__03:
+ spec_txt_example_position: 233
+ source_specification: commonmark
+05_02__container_blocks__list_items__04:
+ spec_txt_example_position: 234
+ source_specification: commonmark
+05_02__container_blocks__list_items__05:
+ spec_txt_example_position: 235
+ source_specification: commonmark
+05_02__container_blocks__list_items__06:
+ spec_txt_example_position: 236
+ source_specification: commonmark
+05_02__container_blocks__list_items__07:
+ spec_txt_example_position: 237
+ source_specification: commonmark
+05_02__container_blocks__list_items__08:
+ spec_txt_example_position: 238
+ source_specification: commonmark
+05_02__container_blocks__list_items__09:
+ spec_txt_example_position: 239
+ source_specification: commonmark
+05_02__container_blocks__list_items__10:
+ spec_txt_example_position: 240
+ source_specification: commonmark
+05_02__container_blocks__list_items__11:
+ spec_txt_example_position: 241
+ source_specification: commonmark
+05_02__container_blocks__list_items__12:
+ spec_txt_example_position: 242
+ source_specification: commonmark
+05_02__container_blocks__list_items__13:
+ spec_txt_example_position: 243
+ source_specification: commonmark
+05_02__container_blocks__list_items__14:
+ spec_txt_example_position: 244
+ source_specification: commonmark
+05_02__container_blocks__list_items__15:
+ spec_txt_example_position: 245
+ source_specification: commonmark
+05_02__container_blocks__list_items__16:
+ spec_txt_example_position: 246
+ source_specification: commonmark
+05_02__container_blocks__list_items__17:
+ spec_txt_example_position: 247
+ source_specification: commonmark
+05_02__container_blocks__list_items__18:
+ spec_txt_example_position: 248
+ source_specification: commonmark
+05_02__container_blocks__list_items__19:
+ spec_txt_example_position: 249
+ source_specification: commonmark
+05_02__container_blocks__list_items__20:
+ spec_txt_example_position: 250
+ source_specification: commonmark
+05_02__container_blocks__list_items__21:
+ spec_txt_example_position: 251
+ source_specification: commonmark
+05_02__container_blocks__list_items__22:
+ spec_txt_example_position: 252
+ source_specification: commonmark
+05_02__container_blocks__list_items__23:
+ spec_txt_example_position: 253
+ source_specification: commonmark
+05_02__container_blocks__list_items__24:
+ spec_txt_example_position: 254
+ source_specification: commonmark
+05_02__container_blocks__list_items__25:
+ spec_txt_example_position: 255
+ source_specification: commonmark
+05_02__container_blocks__list_items__26:
+ spec_txt_example_position: 256
+ source_specification: commonmark
+05_02__container_blocks__list_items__27:
+ spec_txt_example_position: 257
+ source_specification: commonmark
+05_02__container_blocks__list_items__28:
+ spec_txt_example_position: 258
+ source_specification: commonmark
+05_02__container_blocks__list_items__29:
+ spec_txt_example_position: 259
+ source_specification: commonmark
+05_02__container_blocks__list_items__30:
+ spec_txt_example_position: 260
+ source_specification: commonmark
+05_02__container_blocks__list_items__31:
+ spec_txt_example_position: 261
+ source_specification: commonmark
+05_02__container_blocks__list_items__32:
+ spec_txt_example_position: 262
+ source_specification: commonmark
+05_02__container_blocks__list_items__33:
+ spec_txt_example_position: 263
+ source_specification: commonmark
+05_02__container_blocks__list_items__34:
+ spec_txt_example_position: 264
+ source_specification: commonmark
+05_02__container_blocks__list_items__35:
+ spec_txt_example_position: 265
+ source_specification: commonmark
+05_02__container_blocks__list_items__36:
+ spec_txt_example_position: 266
+ source_specification: commonmark
+05_02__container_blocks__list_items__37:
+ spec_txt_example_position: 267
+ source_specification: commonmark
+05_02__container_blocks__list_items__38:
+ spec_txt_example_position: 268
+ source_specification: commonmark
+05_02__container_blocks__list_items__39:
+ spec_txt_example_position: 269
+ source_specification: commonmark
+05_02__container_blocks__list_items__40:
+ spec_txt_example_position: 270
+ source_specification: commonmark
+05_02__container_blocks__list_items__41:
+ spec_txt_example_position: 271
+ source_specification: commonmark
+05_02__container_blocks__list_items__42:
+ spec_txt_example_position: 272
+ source_specification: commonmark
+05_02__container_blocks__list_items__43:
+ spec_txt_example_position: 273
+ source_specification: commonmark
+05_02__container_blocks__list_items__44:
+ spec_txt_example_position: 274
+ source_specification: commonmark
+05_02__container_blocks__list_items__45:
+ spec_txt_example_position: 275
+ source_specification: commonmark
+05_02__container_blocks__list_items__46:
+ spec_txt_example_position: 276
+ source_specification: commonmark
+05_02__container_blocks__list_items__47:
+ spec_txt_example_position: 277
+ source_specification: commonmark
+05_02__container_blocks__list_items__48:
+ spec_txt_example_position: 278
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__49:
+ spec_txt_example_position: 281
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__50:
+ spec_txt_example_position: 282
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__51:
+ spec_txt_example_position: 283
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__52:
+ spec_txt_example_position: 284
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__53:
+ spec_txt_example_position: 285
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__54:
+ spec_txt_example_position: 286
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__55:
+ spec_txt_example_position: 287
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__56:
+ spec_txt_example_position: 288
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__57:
+ spec_txt_example_position: 289
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__58:
+ spec_txt_example_position: 290
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__59:
+ spec_txt_example_position: 291
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__60:
+ spec_txt_example_position: 292
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__61:
+ spec_txt_example_position: 293
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__62:
+ spec_txt_example_position: 294
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__63:
+ spec_txt_example_position: 295
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__64:
+ spec_txt_example_position: 296
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__65:
+ spec_txt_example_position: 297
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__66:
+ spec_txt_example_position: 298
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__67:
+ spec_txt_example_position: 299
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__68:
+ spec_txt_example_position: 300
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__69:
+ spec_txt_example_position: 301
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__70:
+ spec_txt_example_position: 302
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__71:
+ spec_txt_example_position: 303
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__72:
+ spec_txt_example_position: 304
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__73:
+ spec_txt_example_position: 305
+ source_specification: commonmark
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__74:
+ spec_txt_example_position: 306
+ source_specification: commonmark
+06_01__inlines__01:
+ spec_txt_example_position: 307
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__01:
+ spec_txt_example_position: 308
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__02:
+ spec_txt_example_position: 309
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__03:
+ spec_txt_example_position: 310
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__04:
+ spec_txt_example_position: 311
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__05:
+ spec_txt_example_position: 312
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__06:
+ spec_txt_example_position: 313
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__07:
+ spec_txt_example_position: 314
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__08:
+ spec_txt_example_position: 315
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__09:
+ spec_txt_example_position: 316
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__10:
+ spec_txt_example_position: 317
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__11:
+ spec_txt_example_position: 318
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__12:
+ spec_txt_example_position: 319
+ source_specification: commonmark
+06_02__inlines__backslash_escapes__13:
+ spec_txt_example_position: 320
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__01:
+ spec_txt_example_position: 321
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__02:
+ spec_txt_example_position: 322
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__03:
+ spec_txt_example_position: 323
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__04:
+ spec_txt_example_position: 324
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__05:
+ spec_txt_example_position: 325
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__06:
+ spec_txt_example_position: 326
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__07:
+ spec_txt_example_position: 327
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__08:
+ spec_txt_example_position: 328
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__09:
+ spec_txt_example_position: 329
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__10:
+ spec_txt_example_position: 330
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__11:
+ spec_txt_example_position: 331
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__12:
+ spec_txt_example_position: 332
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__13:
+ spec_txt_example_position: 333
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__14:
+ spec_txt_example_position: 334
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__15:
+ spec_txt_example_position: 335
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__16:
+ spec_txt_example_position: 336
+ source_specification: commonmark
+06_03__inlines__entity_and_numeric_character_references__17:
+ spec_txt_example_position: 337
+ source_specification: commonmark
+06_04__inlines__code_spans__01:
+ spec_txt_example_position: 338
+ source_specification: commonmark
+06_04__inlines__code_spans__02:
+ spec_txt_example_position: 339
+ source_specification: commonmark
+06_04__inlines__code_spans__03:
+ spec_txt_example_position: 340
+ source_specification: commonmark
+06_04__inlines__code_spans__04:
+ spec_txt_example_position: 341
+ source_specification: commonmark
+06_04__inlines__code_spans__05:
+ spec_txt_example_position: 342
+ source_specification: commonmark
+06_04__inlines__code_spans__06:
+ spec_txt_example_position: 343
+ source_specification: commonmark
+06_04__inlines__code_spans__07:
+ spec_txt_example_position: 344
+ source_specification: commonmark
+06_04__inlines__code_spans__08:
+ spec_txt_example_position: 345
+ source_specification: commonmark
+06_04__inlines__code_spans__09:
+ spec_txt_example_position: 346
+ source_specification: commonmark
+06_04__inlines__code_spans__10:
+ spec_txt_example_position: 347
+ source_specification: commonmark
+06_04__inlines__code_spans__11:
+ spec_txt_example_position: 348
+ source_specification: commonmark
+06_04__inlines__code_spans__12:
+ spec_txt_example_position: 349
+ source_specification: commonmark
+06_04__inlines__code_spans__13:
+ spec_txt_example_position: 350
+ source_specification: commonmark
+06_04__inlines__code_spans__14:
+ spec_txt_example_position: 351
+ source_specification: commonmark
+06_04__inlines__code_spans__15:
+ spec_txt_example_position: 352
+ source_specification: commonmark
+06_04__inlines__code_spans__16:
+ spec_txt_example_position: 353
+ source_specification: commonmark
+06_04__inlines__code_spans__17:
+ spec_txt_example_position: 354
+ source_specification: commonmark
+06_04__inlines__code_spans__18:
+ spec_txt_example_position: 355
+ source_specification: commonmark
+06_04__inlines__code_spans__19:
+ spec_txt_example_position: 356
+ source_specification: commonmark
+06_04__inlines__code_spans__20:
+ spec_txt_example_position: 357
+ source_specification: commonmark
+06_04__inlines__code_spans__21:
+ spec_txt_example_position: 358
+ source_specification: commonmark
+06_04__inlines__code_spans__22:
+ spec_txt_example_position: 359
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__01:
+ spec_txt_example_position: 360
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__02:
+ spec_txt_example_position: 361
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__03:
+ spec_txt_example_position: 362
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__04:
+ spec_txt_example_position: 363
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__05:
+ spec_txt_example_position: 364
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__06:
+ spec_txt_example_position: 365
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__07:
+ spec_txt_example_position: 366
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__08:
+ spec_txt_example_position: 367
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__09:
+ spec_txt_example_position: 368
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__10:
+ spec_txt_example_position: 369
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__11:
+ spec_txt_example_position: 370
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__12:
+ spec_txt_example_position: 371
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__13:
+ spec_txt_example_position: 372
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__14:
+ spec_txt_example_position: 373
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__15:
+ spec_txt_example_position: 374
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__16:
+ spec_txt_example_position: 375
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__17:
+ spec_txt_example_position: 376
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__18:
+ spec_txt_example_position: 377
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__19:
+ spec_txt_example_position: 378
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__20:
+ spec_txt_example_position: 379
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__21:
+ spec_txt_example_position: 380
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__22:
+ spec_txt_example_position: 381
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__23:
+ spec_txt_example_position: 382
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__24:
+ spec_txt_example_position: 383
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__25:
+ spec_txt_example_position: 384
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__26:
+ spec_txt_example_position: 385
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__27:
+ spec_txt_example_position: 386
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__28:
+ spec_txt_example_position: 387
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__29:
+ spec_txt_example_position: 388
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__30:
+ spec_txt_example_position: 389
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__31:
+ spec_txt_example_position: 390
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__32:
+ spec_txt_example_position: 391
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__33:
+ spec_txt_example_position: 392
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__34:
+ spec_txt_example_position: 393
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__35:
+ spec_txt_example_position: 394
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__36:
+ spec_txt_example_position: 395
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__37:
+ spec_txt_example_position: 396
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__38:
+ spec_txt_example_position: 397
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__39:
+ spec_txt_example_position: 398
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__40:
+ spec_txt_example_position: 399
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__41:
+ spec_txt_example_position: 400
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__42:
+ spec_txt_example_position: 401
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__43:
+ spec_txt_example_position: 402
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__44:
+ spec_txt_example_position: 403
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__45:
+ spec_txt_example_position: 404
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__46:
+ spec_txt_example_position: 405
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__47:
+ spec_txt_example_position: 406
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__48:
+ spec_txt_example_position: 407
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__49:
+ spec_txt_example_position: 408
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__50:
+ spec_txt_example_position: 409
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__51:
+ spec_txt_example_position: 410
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__52:
+ spec_txt_example_position: 411
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__53:
+ spec_txt_example_position: 412
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__54:
+ spec_txt_example_position: 413
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__55:
+ spec_txt_example_position: 414
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__56:
+ spec_txt_example_position: 415
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__57:
+ spec_txt_example_position: 416
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__58:
+ spec_txt_example_position: 417
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__59:
+ spec_txt_example_position: 418
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__60:
+ spec_txt_example_position: 419
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__61:
+ spec_txt_example_position: 420
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__62:
+ spec_txt_example_position: 421
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__63:
+ spec_txt_example_position: 422
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__64:
+ spec_txt_example_position: 423
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__65:
+ spec_txt_example_position: 424
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__66:
+ spec_txt_example_position: 425
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__67:
+ spec_txt_example_position: 426
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__68:
+ spec_txt_example_position: 427
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__69:
+ spec_txt_example_position: 428
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__70:
+ spec_txt_example_position: 429
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__71:
+ spec_txt_example_position: 430
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__72:
+ spec_txt_example_position: 431
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__73:
+ spec_txt_example_position: 432
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__74:
+ spec_txt_example_position: 433
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__75:
+ spec_txt_example_position: 434
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__76:
+ spec_txt_example_position: 435
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__77:
+ spec_txt_example_position: 436
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__78:
+ spec_txt_example_position: 437
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__79:
+ spec_txt_example_position: 438
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__80:
+ spec_txt_example_position: 439
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__81:
+ spec_txt_example_position: 440
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__82:
+ spec_txt_example_position: 441
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__83:
+ spec_txt_example_position: 442
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__84:
+ spec_txt_example_position: 443
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__85:
+ spec_txt_example_position: 444
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__86:
+ spec_txt_example_position: 445
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__87:
+ spec_txt_example_position: 446
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__88:
+ spec_txt_example_position: 447
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__89:
+ spec_txt_example_position: 448
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__90:
+ spec_txt_example_position: 449
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__91:
+ spec_txt_example_position: 450
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__92:
+ spec_txt_example_position: 451
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__93:
+ spec_txt_example_position: 452
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__94:
+ spec_txt_example_position: 453
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__95:
+ spec_txt_example_position: 454
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__96:
+ spec_txt_example_position: 455
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__97:
+ spec_txt_example_position: 456
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__98:
+ spec_txt_example_position: 457
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__99:
+ spec_txt_example_position: 458
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__100:
+ spec_txt_example_position: 459
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__101:
+ spec_txt_example_position: 460
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__102:
+ spec_txt_example_position: 461
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__103:
+ spec_txt_example_position: 462
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__104:
+ spec_txt_example_position: 463
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__105:
+ spec_txt_example_position: 464
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__106:
+ spec_txt_example_position: 465
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__107:
+ spec_txt_example_position: 466
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__108:
+ spec_txt_example_position: 467
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__109:
+ spec_txt_example_position: 468
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__110:
+ spec_txt_example_position: 469
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__111:
+ spec_txt_example_position: 470
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__112:
+ spec_txt_example_position: 471
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__113:
+ spec_txt_example_position: 472
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__114:
+ spec_txt_example_position: 473
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__115:
+ spec_txt_example_position: 474
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__116:
+ spec_txt_example_position: 475
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__117:
+ spec_txt_example_position: 476
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__118:
+ spec_txt_example_position: 477
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__119:
+ spec_txt_example_position: 478
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__120:
+ spec_txt_example_position: 479
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__121:
+ spec_txt_example_position: 480
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__122:
+ spec_txt_example_position: 481
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__123:
+ spec_txt_example_position: 482
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__124:
+ spec_txt_example_position: 483
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__125:
+ spec_txt_example_position: 484
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__126:
+ spec_txt_example_position: 485
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__127:
+ spec_txt_example_position: 486
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__128:
+ spec_txt_example_position: 487
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__129:
+ spec_txt_example_position: 488
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__130:
+ spec_txt_example_position: 489
+ source_specification: commonmark
+06_05__inlines__emphasis_and_strong_emphasis__131:
+ spec_txt_example_position: 490
+ source_specification: commonmark
+06_06__inlines__strikethrough_extension__01:
+ spec_txt_example_position: 491
+ source_specification: github
+06_06__inlines__strikethrough_extension__02:
+ spec_txt_example_position: 492
+ source_specification: github
+06_07__inlines__links__01:
+ spec_txt_example_position: 493
+ source_specification: commonmark
+06_07__inlines__links__02:
+ spec_txt_example_position: 494
+ source_specification: commonmark
+06_07__inlines__links__03:
+ spec_txt_example_position: 495
+ source_specification: commonmark
+06_07__inlines__links__04:
+ spec_txt_example_position: 496
+ source_specification: commonmark
+06_07__inlines__links__05:
+ spec_txt_example_position: 497
+ source_specification: commonmark
+06_07__inlines__links__06:
+ spec_txt_example_position: 498
+ source_specification: commonmark
+06_07__inlines__links__07:
+ spec_txt_example_position: 499
+ source_specification: commonmark
+06_07__inlines__links__08:
+ spec_txt_example_position: 500
+ source_specification: commonmark
+06_07__inlines__links__09:
+ spec_txt_example_position: 501
+ source_specification: commonmark
+06_07__inlines__links__10:
+ spec_txt_example_position: 502
+ source_specification: commonmark
+06_07__inlines__links__11:
+ spec_txt_example_position: 503
+ source_specification: commonmark
+06_07__inlines__links__12:
+ spec_txt_example_position: 504
+ source_specification: commonmark
+06_07__inlines__links__13:
+ spec_txt_example_position: 505
+ source_specification: commonmark
+06_07__inlines__links__14:
+ spec_txt_example_position: 506
+ source_specification: commonmark
+06_07__inlines__links__15:
+ spec_txt_example_position: 507
+ source_specification: commonmark
+06_07__inlines__links__16:
+ spec_txt_example_position: 508
+ source_specification: commonmark
+06_07__inlines__links__17:
+ spec_txt_example_position: 509
+ source_specification: commonmark
+06_07__inlines__links__18:
+ spec_txt_example_position: 510
+ source_specification: commonmark
+06_07__inlines__links__19:
+ spec_txt_example_position: 511
+ source_specification: commonmark
+06_07__inlines__links__20:
+ spec_txt_example_position: 512
+ source_specification: commonmark
+06_07__inlines__links__21:
+ spec_txt_example_position: 513
+ source_specification: commonmark
+06_07__inlines__links__22:
+ spec_txt_example_position: 514
+ source_specification: commonmark
+06_07__inlines__links__23:
+ spec_txt_example_position: 515
+ source_specification: commonmark
+06_07__inlines__links__24:
+ spec_txt_example_position: 516
+ source_specification: commonmark
+06_07__inlines__links__25:
+ spec_txt_example_position: 517
+ source_specification: commonmark
+06_07__inlines__links__26:
+ spec_txt_example_position: 518
+ source_specification: commonmark
+06_07__inlines__links__27:
+ spec_txt_example_position: 519
+ source_specification: commonmark
+06_07__inlines__links__28:
+ spec_txt_example_position: 520
+ source_specification: commonmark
+06_07__inlines__links__29:
+ spec_txt_example_position: 521
+ source_specification: commonmark
+06_07__inlines__links__30:
+ spec_txt_example_position: 522
+ source_specification: commonmark
+06_07__inlines__links__31:
+ spec_txt_example_position: 523
+ source_specification: commonmark
+06_07__inlines__links__32:
+ spec_txt_example_position: 524
+ source_specification: commonmark
+06_07__inlines__links__33:
+ spec_txt_example_position: 525
+ source_specification: commonmark
+06_07__inlines__links__34:
+ spec_txt_example_position: 526
+ source_specification: commonmark
+06_07__inlines__links__35:
+ spec_txt_example_position: 527
+ source_specification: commonmark
+06_07__inlines__links__36:
+ spec_txt_example_position: 528
+ source_specification: commonmark
+06_07__inlines__links__37:
+ spec_txt_example_position: 529
+ source_specification: commonmark
+06_07__inlines__links__38:
+ spec_txt_example_position: 530
+ source_specification: commonmark
+06_07__inlines__links__39:
+ spec_txt_example_position: 531
+ source_specification: commonmark
+06_07__inlines__links__40:
+ spec_txt_example_position: 532
+ source_specification: commonmark
+06_07__inlines__links__41:
+ spec_txt_example_position: 533
+ source_specification: commonmark
+06_07__inlines__links__42:
+ spec_txt_example_position: 534
+ source_specification: commonmark
+06_07__inlines__links__43:
+ spec_txt_example_position: 535
+ source_specification: commonmark
+06_07__inlines__links__44:
+ spec_txt_example_position: 536
+ source_specification: commonmark
+06_07__inlines__links__45:
+ spec_txt_example_position: 537
+ source_specification: commonmark
+06_07__inlines__links__46:
+ spec_txt_example_position: 538
+ source_specification: commonmark
+06_07__inlines__links__47:
+ spec_txt_example_position: 539
+ source_specification: commonmark
+06_07__inlines__links__48:
+ spec_txt_example_position: 540
+ source_specification: commonmark
+06_07__inlines__links__49:
+ spec_txt_example_position: 541
+ source_specification: commonmark
+06_07__inlines__links__50:
+ spec_txt_example_position: 542
+ source_specification: commonmark
+06_07__inlines__links__51:
+ spec_txt_example_position: 543
+ source_specification: commonmark
+06_07__inlines__links__52:
+ spec_txt_example_position: 544
+ source_specification: commonmark
+06_07__inlines__links__53:
+ spec_txt_example_position: 545
+ source_specification: commonmark
+06_07__inlines__links__54:
+ spec_txt_example_position: 546
+ source_specification: commonmark
+06_07__inlines__links__55:
+ spec_txt_example_position: 547
+ source_specification: commonmark
+06_07__inlines__links__56:
+ spec_txt_example_position: 548
+ source_specification: commonmark
+06_07__inlines__links__57:
+ spec_txt_example_position: 549
+ source_specification: commonmark
+06_07__inlines__links__58:
+ spec_txt_example_position: 550
+ source_specification: commonmark
+06_07__inlines__links__59:
+ spec_txt_example_position: 551
+ source_specification: commonmark
+06_07__inlines__links__60:
+ spec_txt_example_position: 552
+ source_specification: commonmark
+06_07__inlines__links__61:
+ spec_txt_example_position: 553
+ source_specification: commonmark
+06_07__inlines__links__62:
+ spec_txt_example_position: 554
+ source_specification: commonmark
+06_07__inlines__links__63:
+ spec_txt_example_position: 555
+ source_specification: commonmark
+06_07__inlines__links__64:
+ spec_txt_example_position: 556
+ source_specification: commonmark
+06_07__inlines__links__65:
+ spec_txt_example_position: 557
+ source_specification: commonmark
+06_07__inlines__links__66:
+ spec_txt_example_position: 558
+ source_specification: commonmark
+06_07__inlines__links__67:
+ spec_txt_example_position: 559
+ source_specification: commonmark
+06_07__inlines__links__68:
+ spec_txt_example_position: 560
+ source_specification: commonmark
+06_07__inlines__links__69:
+ spec_txt_example_position: 561
+ source_specification: commonmark
+06_07__inlines__links__70:
+ spec_txt_example_position: 562
+ source_specification: commonmark
+06_07__inlines__links__71:
+ spec_txt_example_position: 563
+ source_specification: commonmark
+06_07__inlines__links__72:
+ spec_txt_example_position: 564
+ source_specification: commonmark
+06_07__inlines__links__73:
+ spec_txt_example_position: 565
+ source_specification: commonmark
+06_07__inlines__links__74:
+ spec_txt_example_position: 566
+ source_specification: commonmark
+06_07__inlines__links__75:
+ spec_txt_example_position: 567
+ source_specification: commonmark
+06_07__inlines__links__76:
+ spec_txt_example_position: 568
+ source_specification: commonmark
+06_07__inlines__links__77:
+ spec_txt_example_position: 569
+ source_specification: commonmark
+06_07__inlines__links__78:
+ spec_txt_example_position: 570
+ source_specification: commonmark
+06_07__inlines__links__79:
+ spec_txt_example_position: 571
+ source_specification: commonmark
+06_07__inlines__links__80:
+ spec_txt_example_position: 572
+ source_specification: commonmark
+06_07__inlines__links__81:
+ spec_txt_example_position: 573
+ source_specification: commonmark
+06_07__inlines__links__82:
+ spec_txt_example_position: 574
+ source_specification: commonmark
+06_07__inlines__links__83:
+ spec_txt_example_position: 575
+ source_specification: commonmark
+06_07__inlines__links__84:
+ spec_txt_example_position: 576
+ source_specification: commonmark
+06_07__inlines__links__85:
+ spec_txt_example_position: 577
+ source_specification: commonmark
+06_07__inlines__links__86:
+ spec_txt_example_position: 578
+ source_specification: commonmark
+06_07__inlines__links__87:
+ spec_txt_example_position: 579
+ source_specification: commonmark
+06_08__inlines__images__01:
+ spec_txt_example_position: 580
+ source_specification: commonmark
+06_08__inlines__images__02:
+ spec_txt_example_position: 581
+ source_specification: commonmark
+06_08__inlines__images__03:
+ spec_txt_example_position: 582
+ source_specification: commonmark
+06_08__inlines__images__04:
+ spec_txt_example_position: 583
+ source_specification: commonmark
+06_08__inlines__images__05:
+ spec_txt_example_position: 584
+ source_specification: commonmark
+06_08__inlines__images__06:
+ spec_txt_example_position: 585
+ source_specification: commonmark
+06_08__inlines__images__07:
+ spec_txt_example_position: 586
+ source_specification: commonmark
+06_08__inlines__images__08:
+ spec_txt_example_position: 587
+ source_specification: commonmark
+06_08__inlines__images__09:
+ spec_txt_example_position: 588
+ source_specification: commonmark
+06_08__inlines__images__10:
+ spec_txt_example_position: 589
+ source_specification: commonmark
+06_08__inlines__images__11:
+ spec_txt_example_position: 590
+ source_specification: commonmark
+06_08__inlines__images__12:
+ spec_txt_example_position: 591
+ source_specification: commonmark
+06_08__inlines__images__13:
+ spec_txt_example_position: 592
+ source_specification: commonmark
+06_08__inlines__images__14:
+ spec_txt_example_position: 593
+ source_specification: commonmark
+06_08__inlines__images__15:
+ spec_txt_example_position: 594
+ source_specification: commonmark
+06_08__inlines__images__16:
+ spec_txt_example_position: 595
+ source_specification: commonmark
+06_08__inlines__images__17:
+ spec_txt_example_position: 596
+ source_specification: commonmark
+06_08__inlines__images__18:
+ spec_txt_example_position: 597
+ source_specification: commonmark
+06_08__inlines__images__19:
+ spec_txt_example_position: 598
+ source_specification: commonmark
+06_08__inlines__images__20:
+ spec_txt_example_position: 599
+ source_specification: commonmark
+06_08__inlines__images__21:
+ spec_txt_example_position: 600
+ source_specification: commonmark
+06_08__inlines__images__22:
+ spec_txt_example_position: 601
+ source_specification: commonmark
+06_09__inlines__autolinks__01:
+ spec_txt_example_position: 602
+ source_specification: commonmark
+06_09__inlines__autolinks__02:
+ spec_txt_example_position: 603
+ source_specification: commonmark
+06_09__inlines__autolinks__03:
+ spec_txt_example_position: 604
+ source_specification: commonmark
+06_09__inlines__autolinks__04:
+ spec_txt_example_position: 605
+ source_specification: commonmark
+06_09__inlines__autolinks__05:
+ spec_txt_example_position: 606
+ source_specification: commonmark
+06_09__inlines__autolinks__06:
+ spec_txt_example_position: 607
+ source_specification: commonmark
+06_09__inlines__autolinks__07:
+ spec_txt_example_position: 608
+ source_specification: commonmark
+06_09__inlines__autolinks__08:
+ spec_txt_example_position: 609
+ source_specification: commonmark
+06_09__inlines__autolinks__09:
+ spec_txt_example_position: 610
+ source_specification: commonmark
+06_09__inlines__autolinks__10:
+ spec_txt_example_position: 611
+ source_specification: commonmark
+06_09__inlines__autolinks__11:
+ spec_txt_example_position: 612
+ source_specification: commonmark
+06_09__inlines__autolinks__12:
+ spec_txt_example_position: 613
+ source_specification: commonmark
+06_09__inlines__autolinks__13:
+ spec_txt_example_position: 614
+ source_specification: commonmark
+06_09__inlines__autolinks__14:
+ spec_txt_example_position: 615
+ source_specification: commonmark
+06_09__inlines__autolinks__15:
+ spec_txt_example_position: 616
+ source_specification: commonmark
+06_09__inlines__autolinks__16:
+ spec_txt_example_position: 617
+ source_specification: commonmark
+06_09__inlines__autolinks__17:
+ spec_txt_example_position: 618
+ source_specification: commonmark
+06_09__inlines__autolinks__18:
+ spec_txt_example_position: 619
+ source_specification: commonmark
+06_09__inlines__autolinks__19:
+ spec_txt_example_position: 620
+ source_specification: commonmark
+06_10__inlines__autolinks_extension__01:
+ spec_txt_example_position: 621
+ source_specification: github
+06_10__inlines__autolinks_extension__02:
+ spec_txt_example_position: 622
+ source_specification: github
+06_10__inlines__autolinks_extension__03:
+ spec_txt_example_position: 623
+ source_specification: github
+06_10__inlines__autolinks_extension__04:
+ spec_txt_example_position: 624
+ source_specification: github
+06_10__inlines__autolinks_extension__05:
+ spec_txt_example_position: 625
+ source_specification: github
+06_10__inlines__autolinks_extension__06:
+ spec_txt_example_position: 626
+ source_specification: github
+06_10__inlines__autolinks_extension__07:
+ spec_txt_example_position: 627
+ source_specification: github
+06_10__inlines__autolinks_extension__08:
+ spec_txt_example_position: 628
+ source_specification: github
+06_10__inlines__autolinks_extension__09:
+ spec_txt_example_position: 629
+ source_specification: github
+06_10__inlines__autolinks_extension__10:
+ spec_txt_example_position: 630
+ source_specification: github
+06_10__inlines__autolinks_extension__11:
+ spec_txt_example_position: 631
+ source_specification: github
+06_11__inlines__raw_html__01:
+ spec_txt_example_position: 632
+ source_specification: commonmark
+06_11__inlines__raw_html__02:
+ spec_txt_example_position: 633
+ source_specification: commonmark
+06_11__inlines__raw_html__03:
+ spec_txt_example_position: 634
+ source_specification: commonmark
+06_11__inlines__raw_html__04:
+ spec_txt_example_position: 635
+ source_specification: commonmark
+06_11__inlines__raw_html__05:
+ spec_txt_example_position: 636
+ source_specification: commonmark
+06_11__inlines__raw_html__06:
+ spec_txt_example_position: 637
+ source_specification: commonmark
+06_11__inlines__raw_html__07:
+ spec_txt_example_position: 638
+ source_specification: commonmark
+06_11__inlines__raw_html__08:
+ spec_txt_example_position: 639
+ source_specification: commonmark
+06_11__inlines__raw_html__09:
+ spec_txt_example_position: 640
+ source_specification: commonmark
+06_11__inlines__raw_html__10:
+ spec_txt_example_position: 641
+ source_specification: commonmark
+06_11__inlines__raw_html__11:
+ spec_txt_example_position: 642
+ source_specification: commonmark
+06_11__inlines__raw_html__12:
+ spec_txt_example_position: 643
+ source_specification: commonmark
+06_11__inlines__raw_html__13:
+ spec_txt_example_position: 644
+ source_specification: commonmark
+06_11__inlines__raw_html__14:
+ spec_txt_example_position: 645
+ source_specification: commonmark
+06_11__inlines__raw_html__15:
+ spec_txt_example_position: 646
+ source_specification: commonmark
+06_11__inlines__raw_html__16:
+ spec_txt_example_position: 647
+ source_specification: commonmark
+06_11__inlines__raw_html__17:
+ spec_txt_example_position: 648
+ source_specification: commonmark
+06_11__inlines__raw_html__18:
+ spec_txt_example_position: 649
+ source_specification: commonmark
+06_11__inlines__raw_html__19:
+ spec_txt_example_position: 650
+ source_specification: commonmark
+06_11__inlines__raw_html__20:
+ spec_txt_example_position: 651
+ source_specification: commonmark
+06_11__inlines__raw_html__21:
+ spec_txt_example_position: 652
+ source_specification: commonmark
+06_12__inlines__disallowed_raw_html_extension__01:
+ spec_txt_example_position: 653
+ source_specification: github
+06_13__inlines__hard_line_breaks__01:
+ spec_txt_example_position: 654
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__02:
+ spec_txt_example_position: 655
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__03:
+ spec_txt_example_position: 656
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__04:
+ spec_txt_example_position: 657
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__05:
+ spec_txt_example_position: 658
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__06:
+ spec_txt_example_position: 659
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__07:
+ spec_txt_example_position: 660
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__08:
+ spec_txt_example_position: 661
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__09:
+ spec_txt_example_position: 662
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__10:
+ spec_txt_example_position: 663
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__11:
+ spec_txt_example_position: 664
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__12:
+ spec_txt_example_position: 665
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__13:
+ spec_txt_example_position: 666
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__14:
+ spec_txt_example_position: 667
+ source_specification: commonmark
+06_13__inlines__hard_line_breaks__15:
+ spec_txt_example_position: 668
+ source_specification: commonmark
+06_14__inlines__soft_line_breaks__01:
+ spec_txt_example_position: 669
+ source_specification: commonmark
+06_14__inlines__soft_line_breaks__02:
+ spec_txt_example_position: 670
+ source_specification: commonmark
+06_15__inlines__textual_content__01:
+ spec_txt_example_position: 671
+ source_specification: commonmark
+06_15__inlines__textual_content__02:
+ spec_txt_example_position: 672
+ source_specification: commonmark
+06_15__inlines__textual_content__03:
+ spec_txt_example_position: 673
+ source_specification: commonmark
+07_01__first_gitlab_specific_section_with_examples__strong_but_with_two_asterisks__01:
+ spec_txt_example_position: 674
+ source_specification: commonmark
+08_01__second_gitlab_specific_section_with_examples__strong_but_with_html__01:
+ spec_txt_example_position: 675
+ source_specification: commonmark
diff --git a/spec/fixtures/glfm/example_snapshots/html.yml b/spec/fixtures/glfm/example_snapshots/html.yml
new file mode 100644
index 00000000000..a536b5a4834
--- /dev/null
+++ b/spec/fixtures/glfm/example_snapshots/html.yml
@@ -0,0 +1,6097 @@
+---
+02_01__preliminaries__tabs__01:
+ canonical: "<pre><code>foo\tbaz\t\tbim\n</code></pre>\n"
+ static: "<div class=\"gl-relative markdown-code-block js-markdown-code\">&#x000A;<pre
+ data-sourcepos=\"1:2-1:13\" class=\"code highlight js-syntax-highlight language-plaintext\"
+ lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">foo\tbaz\t\tbim</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>"
+ wysiwyg: "<pre class=\"content-editor-code-block undefined code highlight\"><code>foo\tbaz\t\tbim</code></pre>"
+02_01__preliminaries__tabs__02:
+ canonical: "<pre><code>foo\tbaz\t\tbim\n</code></pre>\n"
+ static: "<div class=\"gl-relative markdown-code-block js-markdown-code\">&#x000A;<pre
+ data-sourcepos=\"1:4-1:15\" class=\"code highlight js-syntax-highlight language-plaintext\"
+ lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">foo\tbaz\t\tbim</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>"
+ wysiwyg: "<pre class=\"content-editor-code-block undefined code highlight\"><code>foo\tbaz\t\tbim</code></pre>"
+02_01__preliminaries__tabs__03:
+ canonical: "<pre><code>a\ta\nὐ\ta\n</code></pre>\n"
+ static: "<div class=\"gl-relative markdown-code-block js-markdown-code\">&#x000A;<pre
+ data-sourcepos=\"1:5-2:9\" class=\"code highlight js-syntax-highlight language-plaintext\"
+ lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">a\ta</span>&#x000A;<span
+ id=\"LC2\" class=\"line\" lang=\"plaintext\">ὐ\ta</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>"
+ wysiwyg: "<pre class=\"content-editor-code-block undefined code highlight\"><code>a\ta\nὐ\ta</code></pre>"
+02_01__preliminaries__tabs__04:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ <p>bar</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:3-3:4" dir="auto">&#x000A;<li data-sourcepos="1:3-3:4">&#x000A;<p data-sourcepos="1:5-1:7">foo</p>&#x000A;<p data-sourcepos="3:2-3:4">bar</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p><p>bar</p></li></ul>
+02_01__preliminaries__tabs__05:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ <pre><code> bar
+ </code></pre>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-3:5" dir="auto">&#x000A;<li data-sourcepos="1:1-3:5">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:2-3:5" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"> bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p><pre class="content-editor-code-block undefined code highlight"><code> bar</code></pre></li></ul>
+02_01__preliminaries__tabs__06:
+ canonical: |
+ <blockquote>
+ <pre><code> foo
+ </code></pre>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:6" dir="auto">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:3-1:6" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"> foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><pre class="content-editor-code-block undefined code highlight"><code> foo</code></pre></blockquote>
+02_01__preliminaries__tabs__07:
+ canonical: |
+ <ul>
+ <li>
+ <pre><code> foo
+ </code></pre>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-1:6" dir="auto">&#x000A;<li data-sourcepos="1:1-1:6">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:3-1:6" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"> foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p></p><pre class="content-editor-code-block undefined code highlight"><code> foo</code></pre></li></ul>
+02_01__preliminaries__tabs__08:
+ canonical: |
+ <pre><code>foo
+ bar
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-2:4" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span>&#x000A;<span id="LC2" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>foo
+ bar</code></pre>
+02_01__preliminaries__tabs__09:
+ canonical: |
+ <ul>
+ <li>foo
+ <ul>
+ <li>bar
+ <ul>
+ <li>baz</li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:2-3:7" dir="auto">&#x000A;<li data-sourcepos="1:2-3:7">foo&#x000A;<ul data-sourcepos="2:4-3:7">&#x000A;<li data-sourcepos="2:4-3:7">bar&#x000A;<ul data-sourcepos="3:3-3:7">&#x000A;<li data-sourcepos="3:3-3:7">baz</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo
+ </p><ul bullet="*"><li><p>bar
+ </p><ul bullet="*"><li><p>baz</p></li></ul></li></ul></li></ul>
+02_01__preliminaries__tabs__10:
+ canonical: |
+ <h1>Foo</h1>
+ static: |-
+ <h1 data-sourcepos="1:1-1:5" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h1>
+ wysiwyg: |-
+ <h1>Foo</h1>
+02_01__preliminaries__tabs__11:
+ canonical: |
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:1-1:6">
+ wysiwyg: |-
+ <hr>
+03_01__blocks_and_inlines__precedence__01:
+ canonical: |
+ <ul>
+ <li>`one</li>
+ <li>two`</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-2:6" dir="auto">&#x000A;<li data-sourcepos="1:1-1:6">`one</li>&#x000A;<li data-sourcepos="2:1-2:6">two`</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>`one</p></li><li><p>two`</p></li></ul>
+04_01__leaf_blocks__thematic_breaks__01:
+ canonical: |
+ <hr />
+ <hr />
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:1-1:3">&#x000A;<hr data-sourcepos="2:1-2:3">&#x000A;<hr data-sourcepos="3:1-3:3">
+ wysiwyg: |-
+ <hr>
+04_01__leaf_blocks__thematic_breaks__02:
+ canonical: |
+ <p>+++</p>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">+++</p>
+ wysiwyg: |-
+ <p>+++</p>
+04_01__leaf_blocks__thematic_breaks__03:
+ canonical: |
+ <p>===</p>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">===</p>
+ wysiwyg: |-
+ <p>===</p>
+04_01__leaf_blocks__thematic_breaks__04:
+ canonical: |
+ <p>--
+ **
+ __</p>
+ static: |-
+ <p data-sourcepos="1:1-3:2" dir="auto">--&#x000A;**&#x000A;__</p>
+ wysiwyg: |-
+ <p>--
+ **
+ __</p>
+04_01__leaf_blocks__thematic_breaks__05:
+ canonical: |
+ <hr />
+ <hr />
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:2-1:4">&#x000A;<hr data-sourcepos="2:3-2:5">&#x000A;<hr data-sourcepos="3:4-3:6">
+ wysiwyg: |-
+ <hr>
+04_01__leaf_blocks__thematic_breaks__06:
+ canonical: |
+ <pre><code>***
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-1:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">***</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>***</code></pre>
+04_01__leaf_blocks__thematic_breaks__07:
+ canonical: |
+ <p>Foo
+ ***</p>
+ static: |-
+ <p data-sourcepos="1:1-2:7" dir="auto">Foo&#x000A;***</p>
+ wysiwyg: |-
+ <p>Foo
+ ***</p>
+04_01__leaf_blocks__thematic_breaks__08:
+ canonical: |
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:1-1:37">
+ wysiwyg: |-
+ <hr>
+04_01__leaf_blocks__thematic_breaks__09:
+ canonical: |
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:2-1:6">
+ wysiwyg: |-
+ <hr>
+04_01__leaf_blocks__thematic_breaks__10:
+ canonical: |
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:2-1:19">
+ wysiwyg: |-
+ <hr>
+04_01__leaf_blocks__thematic_breaks__11:
+ canonical: |
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:1-1:21">
+ wysiwyg: |-
+ <hr>
+04_01__leaf_blocks__thematic_breaks__12:
+ canonical: |
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:1-1:11">
+ wysiwyg: |-
+ <hr>
+04_01__leaf_blocks__thematic_breaks__13:
+ canonical: |
+ <p>_ _ _ _ a</p>
+ <p>a------</p>
+ <p>---a---</p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">_ _ _ _ a</p>&#x000A;<p data-sourcepos="3:1-3:7" dir="auto">a------</p>&#x000A;<p data-sourcepos="5:1-5:7" dir="auto">---a---</p>
+ wysiwyg: |-
+ <p>_ _ _ _ a</p>
+04_01__leaf_blocks__thematic_breaks__14:
+ canonical: |
+ <p><em>-</em></p>
+ static: |-
+ <p data-sourcepos="1:2-1:4" dir="auto"><em>-</em></p>
+ wysiwyg: |-
+ <p><em>-</em></p>
+04_01__leaf_blocks__thematic_breaks__15:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ </ul>
+ <hr />
+ <ul>
+ <li>bar</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-1:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">foo</li>&#x000A;</ul>&#x000A;<hr data-sourcepos="2:1-2:3">&#x000A;<ul data-sourcepos="3:1-3:5" dir="auto">&#x000A;<li data-sourcepos="3:1-3:5">bar</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li></ul>
+04_01__leaf_blocks__thematic_breaks__16:
+ canonical: |
+ <p>Foo</p>
+ <hr />
+ <p>bar</p>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">Foo</p>&#x000A;<hr data-sourcepos="2:1-2:3">&#x000A;<p data-sourcepos="3:1-3:3" dir="auto">bar</p>
+ wysiwyg: |-
+ <p>Foo</p>
+04_01__leaf_blocks__thematic_breaks__17:
+ canonical: |
+ <h2>Foo</h2>
+ <p>bar</p>
+ static: |-
+ <h2 data-sourcepos="1:1-3:3" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h2>&#x000A;<p data-sourcepos="3:1-3:3" dir="auto">bar</p>
+ wysiwyg: |-
+ <h2>Foo</h2>
+04_01__leaf_blocks__thematic_breaks__18:
+ canonical: |
+ <ul>
+ <li>Foo</li>
+ </ul>
+ <hr />
+ <ul>
+ <li>Bar</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-1:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">Foo</li>&#x000A;</ul>&#x000A;<hr data-sourcepos="2:1-2:5">&#x000A;<ul data-sourcepos="3:1-3:5" dir="auto">&#x000A;<li data-sourcepos="3:1-3:5">Bar</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>Foo</p></li></ul>
+04_01__leaf_blocks__thematic_breaks__19:
+ canonical: |
+ <ul>
+ <li>Foo</li>
+ <li>
+ <hr />
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-2:7" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">Foo</li>&#x000A;<li data-sourcepos="2:1-2:7">&#x000A;<hr data-sourcepos="2:3-2:7">&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>Foo</p></li><li><p></p><hr></li></ul>
+04_02__leaf_blocks__atx_headings__01:
+ canonical: |
+ <h1>foo</h1>
+ <h2>foo</h2>
+ <h3>foo</h3>
+ <h4>foo</h4>
+ <h5>foo</h5>
+ <h6>foo</h6>
+ static: |-
+ <h1 data-sourcepos="1:1-1:5" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h1>&#x000A;<h2 data-sourcepos="2:1-2:6" dir="auto">&#x000A;<a id="user-content-foo-1" class="anchor" href="#foo-1" aria-hidden="true"></a>foo</h2>&#x000A;<h3 data-sourcepos="3:1-3:7" dir="auto">&#x000A;<a id="user-content-foo-2" class="anchor" href="#foo-2" aria-hidden="true"></a>foo</h3>&#x000A;<h4 data-sourcepos="4:1-4:8" dir="auto">&#x000A;<a id="user-content-foo-3" class="anchor" href="#foo-3" aria-hidden="true"></a>foo</h4>&#x000A;<h5 data-sourcepos="5:1-5:9" dir="auto">&#x000A;<a id="user-content-foo-4" class="anchor" href="#foo-4" aria-hidden="true"></a>foo</h5>&#x000A;<h6 data-sourcepos="6:1-6:10" dir="auto">&#x000A;<a id="user-content-foo-5" class="anchor" href="#foo-5" aria-hidden="true"></a>foo</h6>
+ wysiwyg: |-
+ <h1>foo</h1>
+04_02__leaf_blocks__atx_headings__02:
+ canonical: |
+ <p>####### foo</p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto">####### foo</p>
+ wysiwyg: |-
+ <p>####### foo</p>
+04_02__leaf_blocks__atx_headings__03:
+ canonical: |
+ <p>#5 bolt</p>
+ <p>#hashtag</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">#5 bolt</p>&#x000A;<p data-sourcepos="3:1-3:8" dir="auto">#hashtag</p>
+ wysiwyg: |-
+ <p>#5 bolt</p>
+04_02__leaf_blocks__atx_headings__04:
+ canonical: |
+ <p>## foo</p>
+ static: |-
+ <p data-sourcepos="1:1-1:27" dir="auto"><span>#</span># foo</p>
+ wysiwyg: |-
+ <p>## foo</p>
+04_02__leaf_blocks__atx_headings__05:
+ canonical: |
+ <h1>foo <em>bar</em> *baz*</h1>
+ static: |-
+ <h1 data-sourcepos="1:1-1:19" dir="auto">&#x000A;<a id="user-content-foo-bar-baz" class="anchor" href="#foo-bar-baz" aria-hidden="true"></a>foo <em>bar</em> *baz*</h1>
+ wysiwyg: |-
+ <h1>foo <em>bar</em> *baz*</h1>
+04_02__leaf_blocks__atx_headings__06:
+ canonical: |
+ <h1>foo</h1>
+ static: |-
+ <h1 data-sourcepos="1:1-1:22" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h1>
+ wysiwyg: |-
+ <h1>foo</h1>
+04_02__leaf_blocks__atx_headings__07:
+ canonical: |
+ <h3>foo</h3>
+ <h2>foo</h2>
+ <h1>foo</h1>
+ static: |-
+ <h3 data-sourcepos="1:2-1:8" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h3>&#x000A;<h2 data-sourcepos="2:3-2:8" dir="auto">&#x000A;<a id="user-content-foo-1" class="anchor" href="#foo-1" aria-hidden="true"></a>foo</h2>&#x000A;<h1 data-sourcepos="3:4-3:8" dir="auto">&#x000A;<a id="user-content-foo-2" class="anchor" href="#foo-2" aria-hidden="true"></a>foo</h1>
+ wysiwyg: |-
+ <h3>foo</h3>
+04_02__leaf_blocks__atx_headings__08:
+ canonical: |
+ <pre><code># foo
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-1:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"># foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code># foo</code></pre>
+04_02__leaf_blocks__atx_headings__09:
+ canonical: |
+ <p>foo
+ # bar</p>
+ static: |-
+ <p data-sourcepos="1:1-2:9" dir="auto">foo&#x000A;# bar</p>
+ wysiwyg: |-
+ <p>foo
+ # bar</p>
+04_02__leaf_blocks__atx_headings__10:
+ canonical: |
+ <h2>foo</h2>
+ <h3>bar</h3>
+ static: |-
+ <h2 data-sourcepos="1:1-1:6" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h2>&#x000A;<h3 data-sourcepos="2:3-2:11" dir="auto">&#x000A;<a id="user-content-bar" class="anchor" href="#bar" aria-hidden="true"></a>bar</h3>
+ wysiwyg: |-
+ <h2>foo</h2>
+04_02__leaf_blocks__atx_headings__11:
+ canonical: |
+ <h1>foo</h1>
+ <h5>foo</h5>
+ static: |-
+ <h1 data-sourcepos="1:1-1:5" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h1>&#x000A;<h5 data-sourcepos="2:1-2:9" dir="auto">&#x000A;<a id="user-content-foo-1" class="anchor" href="#foo-1" aria-hidden="true"></a>foo</h5>
+ wysiwyg: |-
+ <h1>foo</h1>
+04_02__leaf_blocks__atx_headings__12:
+ canonical: |
+ <h3>foo</h3>
+ static: |-
+ <h3 data-sourcepos="1:1-1:7" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h3>
+ wysiwyg: |-
+ <h3>foo</h3>
+04_02__leaf_blocks__atx_headings__13:
+ canonical: |
+ <h3>foo ### b</h3>
+ static: |-
+ <h3 data-sourcepos="1:1-1:13" dir="auto">&#x000A;<a id="user-content-foo-b" class="anchor" href="#foo-b" aria-hidden="true"></a>foo ### b</h3>
+ wysiwyg: |-
+ <h3>foo ### b</h3>
+04_02__leaf_blocks__atx_headings__14:
+ canonical: |
+ <h1>foo#</h1>
+ static: |-
+ <h1 data-sourcepos="1:1-1:6" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo#</h1>
+ wysiwyg: |-
+ <h1>foo#</h1>
+04_02__leaf_blocks__atx_headings__15:
+ canonical: |
+ <h3>foo ###</h3>
+ <h2>foo ###</h2>
+ <h1>foo #</h1>
+ static: |-
+ <h3 data-sourcepos="1:1-1:32" dir="auto">&#x000A;<a id="user-content-foo-" class="anchor" href="#foo-" aria-hidden="true"></a>foo <span>#</span>##</h3>&#x000A;<h2 data-sourcepos="2:1-2:31" dir="auto">&#x000A;<a id="user-content-foo--1" class="anchor" href="#foo--1" aria-hidden="true"></a>foo #<span>#</span>#</h2>&#x000A;<h1 data-sourcepos="3:1-3:28" dir="auto">&#x000A;<a id="user-content-foo--2" class="anchor" href="#foo--2" aria-hidden="true"></a>foo <span>#</span>&#x000A;</h1>
+ wysiwyg: |-
+ <h3>foo ###</h3>
+04_02__leaf_blocks__atx_headings__16:
+ canonical: |
+ <hr />
+ <h2>foo</h2>
+ <hr />
+ static: |-
+ <hr data-sourcepos="1:1-1:4">&#x000A;<h2 data-sourcepos="2:1-2:6" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h2>&#x000A;<hr data-sourcepos="3:1-3:4">
+ wysiwyg: |-
+ <hr>
+04_02__leaf_blocks__atx_headings__17:
+ canonical: |
+ <p>Foo bar</p>
+ <h1>baz</h1>
+ <p>Bar foo</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">Foo bar</p>&#x000A;<h1 data-sourcepos="2:1-2:5" dir="auto">&#x000A;<a id="user-content-baz" class="anchor" href="#baz" aria-hidden="true"></a>baz</h1>&#x000A;<p data-sourcepos="3:1-3:7" dir="auto">Bar foo</p>
+ wysiwyg: |-
+ <p>Foo bar</p>
+04_02__leaf_blocks__atx_headings__18:
+ canonical: |
+ <h2></h2>
+ <h1></h1>
+ <h3></h3>
+ static: |-
+ <h2 data-sourcepos="1:1-1:3" dir="auto"></h2>&#x000A;<h1 data-sourcepos="2:1-2:1" dir="auto"></h1>&#x000A;<h3 data-sourcepos="3:1-3:3" dir="auto"></h3>
+ wysiwyg: |-
+ <h2></h2>
+04_03__leaf_blocks__setext_headings__01:
+ canonical: |
+ <h1>Foo <em>bar</em></h1>
+ <h2>Foo <em>bar</em></h2>
+ static: |-
+ <h1 data-sourcepos="1:1-3:0" dir="auto">&#x000A;<a id="user-content-foo-bar" class="anchor" href="#foo-bar" aria-hidden="true"></a>Foo <em>bar</em>&#x000A;</h1>&#x000A;<h2 data-sourcepos="4:1-5:9" dir="auto">&#x000A;<a id="user-content-foo-bar-1" class="anchor" href="#foo-bar-1" aria-hidden="true"></a>Foo <em>bar</em>&#x000A;</h2>
+ wysiwyg: |-
+ <h1>Foo <em>bar</em></h1>
+04_03__leaf_blocks__setext_headings__02:
+ canonical: |
+ <h1>Foo <em>bar
+ baz</em></h1>
+ static: |-
+ <h1 data-sourcepos="1:1-3:4" dir="auto">&#x000A;<a id="user-content-foo-barbaz" class="anchor" href="#foo-barbaz" aria-hidden="true"></a>Foo <em>bar&#x000A;baz</em>&#x000A;</h1>
+ wysiwyg: |-
+ <h1>Foo <em>bar
+ baz</em></h1>
+04_03__leaf_blocks__setext_headings__03:
+ canonical: |
+ <h1>Foo <em>bar
+ baz</em></h1>
+ static: |-
+ <h1 data-sourcepos="1:3-3:4" dir="auto">&#x000A;<a id="user-content-foo-barbaz" class="anchor" href="#foo-barbaz" aria-hidden="true"></a>Foo <em>bar&#x000A;baz</em>&#x000A;</h1>
+ wysiwyg: |-
+ <h1>Foo <em>bar
+ baz</em></h1>
+04_03__leaf_blocks__setext_headings__04:
+ canonical: |
+ <h2>Foo</h2>
+ <h1>Foo</h1>
+ static: |-
+ <h2 data-sourcepos="1:1-3:0" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h2>&#x000A;<h1 data-sourcepos="4:1-5:1" dir="auto">&#x000A;<a id="user-content-foo-1" class="anchor" href="#foo-1" aria-hidden="true"></a>Foo</h1>
+ wysiwyg: |-
+ <h2>Foo</h2>
+04_03__leaf_blocks__setext_headings__05:
+ canonical: |
+ <h2>Foo</h2>
+ <h2>Foo</h2>
+ <h1>Foo</h1>
+ static: |-
+ <h2 data-sourcepos="1:4-3:0" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h2>&#x000A;<h2 data-sourcepos="4:3-6:0" dir="auto">&#x000A;<a id="user-content-foo-1" class="anchor" href="#foo-1" aria-hidden="true"></a>Foo</h2>&#x000A;<h1 data-sourcepos="7:3-8:5" dir="auto">&#x000A;<a id="user-content-foo-2" class="anchor" href="#foo-2" aria-hidden="true"></a>Foo</h1>
+ wysiwyg: |-
+ <h2>Foo</h2>
+04_03__leaf_blocks__setext_headings__06:
+ canonical: |
+ <pre><code>Foo
+ ---
+
+ Foo
+ </code></pre>
+ <hr />
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-4:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">Foo</span>&#x000A;<span id="LC2" class="line" lang="plaintext">---</span>&#x000A;<span id="LC3" class="line" lang="plaintext"></span>&#x000A;<span id="LC4" class="line" lang="plaintext">Foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<hr data-sourcepos="5:1-5:3">
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>Foo
+ ---
+
+ Foo</code></pre>
+04_03__leaf_blocks__setext_headings__07:
+ canonical: |
+ <h2>Foo</h2>
+ static: |-
+ <h2 data-sourcepos="1:1-2:13" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h2>
+ wysiwyg: |-
+ <h2>Foo</h2>
+04_03__leaf_blocks__setext_headings__08:
+ canonical: |
+ <p>Foo
+ ---</p>
+ static: |-
+ <p data-sourcepos="1:1-2:7" dir="auto">Foo&#x000A;---</p>
+ wysiwyg: |-
+ <p>Foo
+ ---</p>
+04_03__leaf_blocks__setext_headings__09:
+ canonical: |
+ <p>Foo
+ = =</p>
+ <p>Foo</p>
+ <hr />
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">Foo&#x000A;= =</p>&#x000A;<p data-sourcepos="4:1-4:3" dir="auto">Foo</p>&#x000A;<hr data-sourcepos="5:1-5:5">
+ wysiwyg: |-
+ <p>Foo
+ = =</p>
+04_03__leaf_blocks__setext_headings__10:
+ canonical: |
+ <h2>Foo</h2>
+ static: |-
+ <h2 data-sourcepos="1:1-2:5" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h2>
+ wysiwyg: |-
+ <h2>Foo</h2>
+04_03__leaf_blocks__setext_headings__11:
+ canonical: |
+ <h2>Foo\</h2>
+ static: |-
+ <h2 data-sourcepos="1:1-2:4" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo\</h2>
+ wysiwyg: |-
+ <h2>Foo\</h2>
+04_03__leaf_blocks__setext_headings__12:
+ canonical: |
+ <h2>`Foo</h2>
+ <p>`</p>
+ <h2>&lt;a title=&quot;a lot</h2>
+ <p>of dashes&quot;/&gt;</p>
+ static: |-
+ <h2 data-sourcepos="1:1-3:1" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>`Foo</h2>&#x000A;<p data-sourcepos="3:1-3:1" dir="auto">`</p>&#x000A;<h2 data-sourcepos="5:1-7:12" dir="auto">&#x000A;<a id="user-content-a-titlea-lot" class="anchor" href="#a-titlea-lot" aria-hidden="true"></a>&lt;a title="a lot</h2>&#x000A;<p data-sourcepos="7:1-7:12" dir="auto">of dashes"/&gt;</p>
+ wysiwyg: |-
+ <h2>`Foo</h2>
+04_03__leaf_blocks__setext_headings__13:
+ canonical: |
+ <blockquote>
+ <p>Foo</p>
+ </blockquote>
+ <hr />
+ static: |-
+ <blockquote data-sourcepos="1:1-1:5" dir="auto">&#x000A;<p data-sourcepos="1:3-1:5">Foo</p>&#x000A;</blockquote>&#x000A;<hr data-sourcepos="2:1-2:3">
+ wysiwyg: |-
+ <blockquote multiline="false"><p>Foo</p></blockquote>
+04_03__leaf_blocks__setext_headings__14:
+ canonical: |
+ <blockquote>
+ <p>foo
+ bar
+ ===</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:3" dir="auto">&#x000A;<p data-sourcepos="1:3-3:3">foo&#x000A;bar&#x000A;===</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>foo
+ bar
+ ===</p></blockquote>
+04_03__leaf_blocks__setext_headings__15:
+ canonical: |
+ <ul>
+ <li>Foo</li>
+ </ul>
+ <hr />
+ static: |-
+ <ul data-sourcepos="1:1-1:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">Foo</li>&#x000A;</ul>&#x000A;<hr data-sourcepos="2:1-2:3">
+ wysiwyg: |-
+ <ul bullet="*"><li><p>Foo</p></li></ul>
+04_03__leaf_blocks__setext_headings__16:
+ canonical: |
+ <h2>Foo
+ Bar</h2>
+ static: |-
+ <h2 data-sourcepos="1:1-3:3" dir="auto">&#x000A;<a id="user-content-foobar" class="anchor" href="#foobar" aria-hidden="true"></a>Foo&#x000A;Bar</h2>
+ wysiwyg: |-
+ <h2>Foo
+ Bar</h2>
+04_03__leaf_blocks__setext_headings__17:
+ canonical: |
+ <hr />
+ <h2>Foo</h2>
+ <h2>Bar</h2>
+ <p>Baz</p>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-yaml" lang="yaml" data-lang-params="frontmatter" v-pre="true"><code><span id="LC1" class="line" lang="yaml"><span class="s">Foo</span></span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<h2 data-sourcepos="4:1-6:3" dir="auto">&#x000A;<a id="user-content-bar" class="anchor" href="#bar" aria-hidden="true"></a>Bar</h2>&#x000A;<p data-sourcepos="6:1-6:3" dir="auto">Baz</p>
+ wysiwyg: |-
+ <hr>
+04_03__leaf_blocks__setext_headings__18:
+ canonical: |
+ <p>====</p>
+ static: |-
+ <p data-sourcepos="2:1-2:4" dir="auto">====</p>
+ wysiwyg: |-
+ <p>====</p>
+04_03__leaf_blocks__setext_headings__19:
+ canonical: |
+ <hr />
+ <hr />
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-2:3" class="code highlight js-syntax-highlight language-yaml" lang="yaml" data-lang-params="frontmatter" v-pre="true"><code></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <hr>
+04_03__leaf_blocks__setext_headings__20:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ </ul>
+ <hr />
+ static: |-
+ <ul data-sourcepos="1:1-1:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">foo</li>&#x000A;</ul>&#x000A;<hr data-sourcepos="2:1-2:5">
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li></ul>
+04_03__leaf_blocks__setext_headings__21:
+ canonical: |
+ <pre><code>foo
+ </code></pre>
+ <hr />
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-1:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<hr data-sourcepos="2:1-2:3">
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>foo</code></pre>
+04_03__leaf_blocks__setext_headings__22:
+ canonical: |
+ <blockquote>
+ <p>foo</p>
+ </blockquote>
+ <hr />
+ static: |-
+ <blockquote data-sourcepos="1:1-1:5" dir="auto">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;</blockquote>&#x000A;<hr data-sourcepos="2:1-2:5">
+ wysiwyg: |-
+ <blockquote multiline="false"><p>foo</p></blockquote>
+04_03__leaf_blocks__setext_headings__23:
+ canonical: |
+ <h2>&gt; foo</h2>
+ static: |-
+ <h2 data-sourcepos="1:1-2:6" dir="auto">&#x000A;<a id="user-content--foo" class="anchor" href="#-foo" aria-hidden="true"></a>&gt; foo</h2>
+ wysiwyg: |-
+ <h2>&gt; foo</h2>
+04_03__leaf_blocks__setext_headings__24:
+ canonical: |
+ <p>Foo</p>
+ <h2>bar</h2>
+ <p>baz</p>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">Foo</p>&#x000A;<h2 data-sourcepos="3:1-5:3" dir="auto">&#x000A;<a id="user-content-bar" class="anchor" href="#bar" aria-hidden="true"></a>bar</h2>&#x000A;<p data-sourcepos="5:1-5:3" dir="auto">baz</p>
+ wysiwyg: |-
+ <p>Foo</p>
+04_03__leaf_blocks__setext_headings__25:
+ canonical: |
+ <p>Foo
+ bar</p>
+ <hr />
+ <p>baz</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">Foo&#x000A;bar</p>&#x000A;<hr data-sourcepos="4:1-5:0">&#x000A;<p data-sourcepos="6:1-6:3" dir="auto">baz</p>
+ wysiwyg: |-
+ <p>Foo
+ bar</p>
+04_03__leaf_blocks__setext_headings__26:
+ canonical: |
+ <p>Foo
+ bar</p>
+ <hr />
+ <p>baz</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">Foo&#x000A;bar</p>&#x000A;<hr data-sourcepos="3:1-3:5">&#x000A;<p data-sourcepos="4:1-4:3" dir="auto">baz</p>
+ wysiwyg: |-
+ <p>Foo
+ bar</p>
+04_03__leaf_blocks__setext_headings__27:
+ canonical: |
+ <p>Foo
+ bar
+ ---
+ baz</p>
+ static: |-
+ <p data-sourcepos="1:1-4:3" dir="auto">Foo&#x000A;bar&#x000A;---&#x000A;baz</p>
+ wysiwyg: |-
+ <p>Foo
+ bar
+ ---
+ baz</p>
+04_04__leaf_blocks__indented_code_blocks__01:
+ canonical: |
+ <pre><code>a simple
+ indented code block
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-2:25" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">a simple</span>&#x000A;<span id="LC2" class="line" lang="plaintext"> indented code block</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>a simple
+ indented code block</code></pre>
+04_04__leaf_blocks__indented_code_blocks__02:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ <p>bar</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:3-3:7" dir="auto">&#x000A;<li data-sourcepos="1:3-3:7">&#x000A;<p data-sourcepos="1:5-1:7">foo</p>&#x000A;<p data-sourcepos="3:5-3:7">bar</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p><p>bar</p></li></ul>
+04_04__leaf_blocks__indented_code_blocks__03:
+ canonical: |
+ <ol>
+ <li>
+ <p>foo</p>
+ <ul>
+ <li>bar</li>
+ </ul>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-3:9" dir="auto">&#x000A;<li data-sourcepos="1:1-3:9">&#x000A;<p data-sourcepos="1:5-1:7">foo</p>&#x000A;<ul data-sourcepos="3:5-3:9">&#x000A;<li data-sourcepos="3:5-3:9">bar</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>foo</p><ul bullet="*"><li><p>bar</p></li></ul></li></ol>
+04_04__leaf_blocks__indented_code_blocks__04:
+ canonical: |
+ <pre><code>&lt;a/&gt;
+ *hi*
+
+ - one
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-4:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">&lt;a/&gt;</span>&#x000A;<span id="LC2" class="line" lang="plaintext">*hi*</span>&#x000A;<span id="LC3" class="line" lang="plaintext"></span>&#x000A;<span id="LC4" class="line" lang="plaintext">- one</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>&lt;a/&gt;
+ *hi*
+
+ - one</code></pre>
+04_04__leaf_blocks__indented_code_blocks__05:
+ canonical: |
+ <pre><code>chunk1
+
+ chunk2
+
+
+
+ chunk3
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-7:10" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">chunk1</span>&#x000A;<span id="LC2" class="line" lang="plaintext"></span>&#x000A;<span id="LC3" class="line" lang="plaintext">chunk2</span>&#x000A;<span id="LC4" class="line" lang="plaintext"></span>&#x000A;<span id="LC5" class="line" lang="plaintext"></span>&#x000A;<span id="LC6" class="line" lang="plaintext"></span>&#x000A;<span id="LC7" class="line" lang="plaintext">chunk3</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>chunk1
+
+ chunk2
+
+
+
+ chunk3</code></pre>
+04_04__leaf_blocks__indented_code_blocks__06:
+ canonical: "<pre><code>chunk1\n \n chunk2\n</code></pre>\n"
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-3:12" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">chunk1</span>&#x000A;<span id="LC2" class="line" lang="plaintext"> </span>&#x000A;<span id="LC3" class="line" lang="plaintext"> chunk2</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: "<pre class=\"content-editor-code-block undefined code highlight\"><code>chunk1\n
+ \ \n chunk2</code></pre>"
+04_04__leaf_blocks__indented_code_blocks__07:
+ canonical: |
+ <p>Foo
+ bar</p>
+ static: |-
+ <p data-sourcepos="1:1-2:7" dir="auto">Foo&#x000A;bar</p>
+ wysiwyg: |-
+ <p>Foo
+ bar</p>
+04_04__leaf_blocks__indented_code_blocks__08:
+ canonical: |
+ <pre><code>foo
+ </code></pre>
+ <p>bar</p>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-1:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="2:1-2:3" dir="auto">bar</p>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>foo</code></pre>
+04_04__leaf_blocks__indented_code_blocks__09:
+ canonical: |
+ <h1>Heading</h1>
+ <pre><code>foo
+ </code></pre>
+ <h2>Heading</h2>
+ <pre><code>foo
+ </code></pre>
+ <hr />
+ static: |-
+ <h1 data-sourcepos="1:1-1:9" dir="auto">&#x000A;<a id="user-content-heading" class="anchor" href="#heading" aria-hidden="true"></a>Heading</h1>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="2:5-2:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<h2 data-sourcepos="3:1-5:7" dir="auto">&#x000A;<a id="user-content-heading-1" class="anchor" href="#heading-1" aria-hidden="true"></a>Heading</h2>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="5:5-5:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<hr data-sourcepos="6:1-6:4">
+ wysiwyg: |-
+ <h1>Heading</h1>
+04_04__leaf_blocks__indented_code_blocks__10:
+ canonical: |
+ <pre><code> foo
+ bar
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-2:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"> foo</span>&#x000A;<span id="LC2" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code> foo
+ bar</code></pre>
+04_04__leaf_blocks__indented_code_blocks__11:
+ canonical: |
+ <pre><code>foo
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:5-5:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>foo</code></pre>
+04_04__leaf_blocks__indented_code_blocks__12:
+ canonical: "<pre><code>foo \n</code></pre>\n"
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-1:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo </span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>foo </code></pre>
+04_05__leaf_blocks__fenced_code_blocks__01:
+ canonical: |
+ <pre><code>&lt;
+ &gt;
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-4:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">&lt;</span>&#x000A;<span id="LC2" class="line" lang="plaintext"> &gt;</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>&lt;
+ &gt;</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__02:
+ canonical: |
+ <pre><code>&lt;
+ &gt;
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-4:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">&lt;</span>&#x000A;<span id="LC2" class="line" lang="plaintext"> &gt;</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>&lt;
+ &gt;</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__03:
+ canonical: |
+ <p><code>foo</code></p>
+ static: |-
+ <p data-sourcepos="1:1-3:2" dir="auto"><code>foo</code></p>
+ wysiwyg: |-
+ <p><code>foo</code></p>
+04_05__leaf_blocks__fenced_code_blocks__04:
+ canonical: |
+ <pre><code>aaa
+ ~~~
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-4:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext">~~~</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ ~~~</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__05:
+ canonical: |
+ <pre><code>aaa
+ ```
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-4:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext">```</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ ```</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__06:
+ canonical: |
+ <pre><code>aaa
+ ```
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-4:6" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext">```</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ ```</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__07:
+ canonical: |
+ <pre><code>aaa
+ ~~~
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-4:4" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext">~~~</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ ~~~</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__08:
+ canonical: |
+ <pre><code></code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-1:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code></code></pre>
+04_05__leaf_blocks__fenced_code_blocks__09:
+ canonical: |
+ <pre><code>
+ ```
+ aaa
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-4:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"></span>&#x000A;<span id="LC2" class="line" lang="plaintext">```</span>&#x000A;<span id="LC3" class="line" lang="plaintext">aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>
+ ```
+ aaa</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__10:
+ canonical: |
+ <blockquote>
+ <pre><code>aaa
+ </code></pre>
+ </blockquote>
+ <p>bbb</p>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:5" dir="auto">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:3-3:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</blockquote>&#x000A;<p data-sourcepos="4:1-4:3" dir="auto">bbb</p>
+ wysiwyg: |-
+ <blockquote multiline="false"><pre class="content-editor-code-block undefined code highlight"><code>aaa</code></pre></blockquote>
+04_05__leaf_blocks__fenced_code_blocks__11:
+ canonical: "<pre><code>\n \n</code></pre>\n"
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-4:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"></span>&#x000A;<span id="LC2" class="line" lang="plaintext"> </span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>
+ </code></pre>
+04_05__leaf_blocks__fenced_code_blocks__12:
+ canonical: |
+ <pre><code></code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-2:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code></code></pre>
+04_05__leaf_blocks__fenced_code_blocks__13:
+ canonical: |
+ <pre><code>aaa
+ aaa
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:2-4:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext">aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ aaa</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__14:
+ canonical: |
+ <pre><code>aaa
+ aaa
+ aaa
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:3-5:5" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC3" class="line" lang="plaintext">aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ aaa
+ aaa</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__15:
+ canonical: |
+ <pre><code>aaa
+ aaa
+ aaa
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:4-5:6" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext"> aaa</span>&#x000A;<span id="LC3" class="line" lang="plaintext">aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ aaa
+ aaa</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__16:
+ canonical: |
+ <pre><code>```
+ aaa
+ ```
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-3:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">```</span>&#x000A;<span id="LC2" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC3" class="line" lang="plaintext">```</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>```
+ aaa
+ ```</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__17:
+ canonical: |
+ <pre><code>aaa
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:5" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__18:
+ canonical: |
+ <pre><code>aaa
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:4-3:5" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__19:
+ canonical: |
+ <pre><code>aaa
+ ```
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext"> ```</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ ```</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__20:
+ canonical: |
+ <p><code> </code>
+ aaa</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto"><code> </code>&#x000A;aaa</p>
+ wysiwyg: |-
+ <p><code>
+ aaa</code></p>
+04_05__leaf_blocks__fenced_code_blocks__21:
+ canonical: |
+ <pre><code>aaa
+ ~~~ ~~
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:6" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span>&#x000A;<span id="LC2" class="line" lang="plaintext">~~~ ~~</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa
+ ~~~ ~~</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__22:
+ canonical: |
+ <p>foo</p>
+ <pre><code>bar
+ </code></pre>
+ <p>baz</p>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">foo</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="2:1-4:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="5:1-5:3" dir="auto">baz</p>
+ wysiwyg: |-
+ <p>foo</p>
+04_05__leaf_blocks__fenced_code_blocks__23:
+ canonical: |
+ <h2>foo</h2>
+ <pre><code>bar
+ </code></pre>
+ <h1>baz</h1>
+ static: |-
+ <h2 data-sourcepos="1:1-3:3" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h2>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:1-5:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<h1 data-sourcepos="6:1-6:5" dir="auto">&#x000A;<a id="user-content-baz" class="anchor" href="#baz" aria-hidden="true"></a>baz</h1>
+ wysiwyg: |-
+ <h2>foo</h2>
+04_05__leaf_blocks__fenced_code_blocks__24:
+ canonical: |
+ <pre><code class="language-ruby">def foo(x)
+ return 3
+ end
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-5:3" class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">foo</span><span class="p">(</span><span class="n">x</span><span class="p">)</span></span>&#x000A;<span id="LC2" class="line" lang="ruby"> <span class="k">return</span> <span class="mi">3</span></span>&#x000A;<span id="LC3" class="line" lang="ruby"><span class="k">end</span></span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre language="ruby" class="content-editor-code-block undefined code highlight"><code>def foo(x)
+ return 3
+ end</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__25:
+ canonical: |
+ <pre><code class="language-ruby">def foo(x)
+ return 3
+ end
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-5:7" class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">foo</span><span class="p">(</span><span class="n">x</span><span class="p">)</span></span>&#x000A;<span id="LC2" class="line" lang="ruby"> <span class="k">return</span> <span class="mi">3</span></span>&#x000A;<span id="LC3" class="line" lang="ruby"><span class="k">end</span></span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre language="ruby" class="content-editor-code-block undefined code highlight"><code>def foo(x)
+ return 3
+ end</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__26:
+ canonical: |
+ <pre><code class="language-;"></code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-2:4" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre language=";" class="content-editor-code-block undefined code highlight"><code></code></pre>
+04_05__leaf_blocks__fenced_code_blocks__27:
+ canonical: |
+ <p><code>aa</code>
+ foo</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto"><code>aa</code>&#x000A;foo</p>
+ wysiwyg: |-
+ <p><code>aa</code>
+ foo</p>
+04_05__leaf_blocks__fenced_code_blocks__28:
+ canonical: |
+ <pre><code class="language-aa">foo
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre language="aa" class="content-editor-code-block undefined code highlight"><code>foo</code></pre>
+04_05__leaf_blocks__fenced_code_blocks__29:
+ canonical: |
+ <pre><code>``` aaa
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">``` aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>``` aaa</code></pre>
+04_06__leaf_blocks__html_blocks__01:
+ canonical: |
+ <table><tr><td>
+ <pre>
+ **Hello**,
+ <p><em>world</em>.
+ </pre></p>
+ </td></tr></table>
+ static: |-
+ <table dir="auto"><tr><td>&#x000A;<pre>&#x000A;**Hello**,&#x000A;<p data-sourcepos="5:1-6:6"><em>world</em>.&#x000A;</p></pre>&#x000A;</td></tr></table>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__02:
+ canonical: |
+ <table>
+ <tr>
+ <td>
+ hi
+ </td>
+ </tr>
+ </table>
+ <p>okay.</p>
+ static: |-
+ <table dir="auto">&#x000A; <tr>&#x000A; <td>&#x000A; hi&#x000A; </td>&#x000A; </tr>&#x000A;</table>&#x000A;<p data-sourcepos="9:1-9:5" dir="auto">okay.</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__03:
+ canonical: |2
+ <div>
+ *hello*
+ <foo><a>
+ static: |2-
+ <div>&#x000A; *hello*&#x000A; <a></a>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__04:
+ canonical: |
+ </div>
+ *foo*
+ static: |-
+ &#x000A;*foo*
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
+04_06__leaf_blocks__html_blocks__05:
+ canonical: |
+ <DIV CLASS="foo">
+ <p><em>Markdown</em></p>
+ </DIV>
+ static: |-
+ <div>&#x000A;<p data-sourcepos="3:1-3:10"><em>Markdown</em></p>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__06:
+ canonical: |
+ <div id="foo"
+ class="bar">
+ </div>
+ static: |-
+ <div>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__07:
+ canonical: |
+ <div id="foo" class="bar
+ baz">
+ </div>
+ static: |-
+ <div>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__08:
+ canonical: |
+ <div>
+ *foo*
+ <p><em>bar</em></p>
+ static: |-
+ <div>&#x000A;*foo*&#x000A;<p data-sourcepos="4:1-4:5"><em>bar</em></p>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__09:
+ canonical: |
+ <div id="foo"
+ *hi*
+ static: |-
+ <div></div>
+ wysiwyg: |-
+ <p></p>
+04_06__leaf_blocks__html_blocks__10:
+ canonical: |
+ <div class
+ foo
+ static: |-
+ <div></div>
+ wysiwyg: |-
+ <p></p>
+04_06__leaf_blocks__html_blocks__11:
+ canonical: |
+ <div *???-&&&-<---
+ *foo*
+ static: |-
+ <div></div>
+ wysiwyg: |-
+ <p></p>
+04_06__leaf_blocks__html_blocks__12:
+ canonical: |
+ <div><a href="bar">*foo*</a></div>
+ static: |-
+ <div><a href="bar">*foo*</a></div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__13:
+ canonical: |
+ <table><tr><td>
+ foo
+ </td></tr></table>
+ static: |-
+ <table dir="auto"><tr><td>&#x000A;foo&#x000A;</td></tr></table>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__14:
+ canonical: |
+ <div></div>
+ ``` c
+ int x = 33;
+ ```
+ static: |-
+ <div></div>&#x000A;``` c&#x000A;int x = 33;&#x000A;```
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__15:
+ canonical: |
+ <a href="foo">
+ *bar*
+ </a>
+ static: |-
+ <a href="foo">&#x000A;*bar*&#x000A;</a>
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
+04_06__leaf_blocks__html_blocks__16:
+ canonical: |
+ <Warning>
+ *bar*
+ </Warning>
+ static: |-
+ &#x000A;*bar*
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "warning" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__17:
+ canonical: |
+ <i class="foo">
+ *bar*
+ </i>
+ static: |-
+ <i>&#x000A;*bar*&#x000A;</i>
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
+04_06__leaf_blocks__html_blocks__18:
+ canonical: |
+ </ins>
+ *bar*
+ static: |-
+ &#x000A;*bar*
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
+04_06__leaf_blocks__html_blocks__19:
+ canonical: |
+ <del>
+ *foo*
+ </del>
+ static: |-
+ <del>&#x000A;*foo*&#x000A;</del>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "del" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__20:
+ canonical: |
+ <del>
+ <p><em>foo</em></p>
+ </del>
+ static: |-
+ <del>&#x000A;<p data-sourcepos="3:1-3:5"><em>foo</em></p>&#x000A;</del>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "del" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__21:
+ canonical: |
+ <p><del><em>foo</em></del></p>
+ static: |-
+ <p data-sourcepos="1:1-1:16" dir="auto"><del><em>foo</em></del></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "del" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__22:
+ canonical: |
+ <pre language="haskell"><code>
+ import Text.HTML.TagSoup
+
+ main :: IO ()
+ main = print $ parseTags tags
+ </code></pre>
+ <p>okay</p>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"></span>&#x000A;<span id="LC2" class="line" lang="plaintext">import Text.HTML.TagSoup</span>&#x000A;<span id="LC3" class="line" lang="plaintext"></span>&#x000A;<span id="LC4" class="line" lang="plaintext">main :: IO ()</span>&#x000A;<span id="LC5" class="line" lang="plaintext">main = print $ parseTags tags</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="7:1-7:4" dir="auto">okay</p>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>
+ import Text.HTML.TagSoup
+
+ main :: IO ()
+ main = print $ parseTags tags</code></pre>
+04_06__leaf_blocks__html_blocks__23:
+ canonical: |
+ <script type="text/javascript">
+ // JavaScript example
+
+ document.getElementById("demo").innerHTML = "Hello JavaScript!";
+ </script>
+ <p>okay</p>
+ static: |-
+ &#x000A;<p data-sourcepos="6:1-6:4" dir="auto">okay</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "script" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__24:
+ canonical: |
+ <style
+ type="text/css">
+ h1 {color:red;}
+
+ p {color:blue;}
+ </style>
+ <p>okay</p>
+ static: |-
+ &#x000A;h1 {color:red;}&#x000A;&#x000A;p {color:blue;}&#x000A;&#x000A;<p data-sourcepos="7:1-7:4" dir="auto">okay</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "style" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__25:
+ canonical: |
+ <style
+ type="text/css">
+
+ foo
+ static: |-
+ &#x000A;&#x000A;foo
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "style" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__26:
+ canonical: |
+ <blockquote>
+ <div>
+ foo
+ </blockquote>
+ <p>bar</p>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:5" dir="auto">&#x000A;<div>&#x000A;foo&#x000A;&#x000A;<p data-sourcepos="4:1-4:3">bar</p>&#x000A;</div>&#x000A;</blockquote>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__27:
+ canonical: |
+ <ul>
+ <li>
+ <div>
+ </li>
+ <li>foo</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-2:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:7">&#x000A;<div>&#x000A;&#x000A;<li data-sourcepos="2:1-2:5">foo</li>&#x000A;</div>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__28:
+ canonical: |
+ <style>p{color:red;}</style>
+ <p><em>foo</em></p>
+ static: |-
+ p{color:red;}&#x000A;<p data-sourcepos="2:1-2:5" dir="auto"><em>foo</em></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "style" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__29:
+ canonical: |
+ <!-- foo -->*bar*
+ <p><em>baz</em></p>
+ static: |-
+ *bar*&#x000A;<p data-sourcepos="2:1-2:5" dir="auto"><em>baz</em></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__30:
+ canonical: |
+ <script>
+ foo
+ </script>1. *bar*
+ static: |-
+ 1. *bar*
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "script" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__31:
+ canonical: |
+ <!-- Foo
+
+ bar
+ baz -->
+ <p>okay</p>
+ static: |-
+ &#x000A;<p data-sourcepos="5:1-5:4" dir="auto">okay</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__32:
+ canonical: |
+ <?php
+
+ echo '>';
+
+ ?>
+ <p>okay</p>
+ static: |-
+ <?php echo '>';&#x000A;&#x000A;?&gt;&#x000A;<p data-sourcepos="6:1-6:4" dir="auto">okay</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__33:
+ canonical: |
+ <!DOCTYPE html>
+ static: ""
+ wysiwyg: |-
+ <p></p>
+04_06__leaf_blocks__html_blocks__34:
+ canonical: |
+ <![CDATA[
+ function matchwo(a,b)
+ {
+ if (a < b && a < 0) then {
+ return 1;
+
+ } else {
+
+ return 0;
+ }
+ }
+ ]]>
+ <p>okay</p>
+ static: |-
+ &#x000A;<p data-sourcepos="13:1-13:4" dir="auto">okay</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__35:
+ canonical: |2
+ <!-- foo -->
+ <pre><code>&lt;!-- foo --&gt;
+ </code></pre>
+ static: |2-
+ &#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:5-3:16" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">&lt;!-- foo --&gt;</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__36:
+ canonical: |2
+ <div>
+ <pre><code>&lt;div&gt;
+ </code></pre>
+ static: |2-
+ <div>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:5-3:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">&lt;div&gt;</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__37:
+ canonical: |
+ <p>Foo</p>
+ <div>
+ bar
+ </div>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">Foo</p>&#x000A;<div>&#x000A;bar&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__38:
+ canonical: |
+ <div>
+ bar
+ </div>
+ *foo*
+ static: |-
+ <div>&#x000A;bar&#x000A;</div>&#x000A;*foo*
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__39:
+ canonical: |
+ <p>Foo
+ <a href="bar">
+ baz</p>
+ static: |-
+ <p data-sourcepos="1:1-3:3" dir="auto">Foo&#x000A;<a href="bar">&#x000A;baz</a></p>
+ wysiwyg: |-
+ <p>Foo
+ <a target="_blank" rel="noopener noreferrer nofollow" href="bar">
+ baz</a></p>
+04_06__leaf_blocks__html_blocks__40:
+ canonical: |
+ <div>
+ <p><em>Emphasized</em> text.</p>
+ </div>
+ static: |-
+ <div>&#x000A;<p data-sourcepos="3:1-3:18"><em>Emphasized</em> text.</p>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__41:
+ canonical: |
+ <div>
+ *Emphasized* text.
+ </div>
+ static: |-
+ <div>&#x000A;*Emphasized* text.&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__42:
+ canonical: |
+ <table>
+ <tr>
+ <td>
+ Hi
+ </td>
+ </tr>
+ </table>
+ static: |-
+ <table dir="auto">&#x000A;<tr>&#x000A;<td>&#x000A;Hi&#x000A;</td>&#x000A;</tr>&#x000A;</table>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__43:
+ canonical: |
+ <table>
+ <tr>
+ <pre><code>&lt;td&gt;
+ Hi
+ &lt;/td&gt;
+ </code></pre>
+ </tr>
+ </table>
+ static: |-
+ <table dir="auto">&#x000A; <tr>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="5:5-8:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">&lt;td&gt;</span>&#x000A;<span id="LC2" class="line" lang="plaintext"> Hi</span>&#x000A;<span id="LC3" class="line" lang="plaintext">&lt;/td&gt;</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A; </tr>&#x000A;</table>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_07__leaf_blocks__link_reference_definitions__01:
+ canonical: |
+ <p><a href="/url" title="title">foo</a></p>
+ static: |-
+ <p data-sourcepos="3:1-3:5" dir="auto"><a href="/url" title="title">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__02:
+ canonical: |
+ <p><a href="/url" title="the title">foo</a></p>
+ static: |-
+ <p data-sourcepos="5:1-5:5" dir="auto"><a href="/url" title="the title">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="the title">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__03:
+ canonical: |
+ <p><a href="my_(url)" title="title (with parens)">Foo*bar]</a></p>
+ static: |-
+ <p data-sourcepos="3:1-3:11" dir="auto"><a href="my_(url)" title="title (with parens)">Foo*bar]</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="my_(url)" title="title (with parens)">Foo*bar]</a></p>
+04_07__leaf_blocks__link_reference_definitions__04:
+ canonical: |
+ <p><a href="my%20url" title="title">Foo bar</a></p>
+ static: |-
+ <p data-sourcepos="5:1-5:9" dir="auto"><a href="my%20url" title="title">Foo bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="my%20url" title="title">Foo bar</a></p>
+04_07__leaf_blocks__link_reference_definitions__05:
+ canonical: |
+ <p><a href="/url" title="
+ title
+ line1
+ line2
+ ">foo</a></p>
+ static: |-
+ <p data-sourcepos="7:1-7:5" dir="auto"><a href="/url" title="&#x000A;title&#x000A;line1&#x000A;line2&#x000A;">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="
+ title
+ line1
+ line2
+ ">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__06:
+ canonical: |
+ <p>[foo]: /url 'title</p>
+ <p>with blank line'</p>
+ <p>[foo]</p>
+ static: |-
+ <p data-sourcepos="1:1-1:18" dir="auto">[foo]: /url 'title</p>&#x000A;<p data-sourcepos="3:1-3:16" dir="auto">with blank line'</p>&#x000A;<p data-sourcepos="5:1-5:5" dir="auto">[foo]</p>
+ wysiwyg: |-
+ <p>[foo]: /url 'title</p>
+04_07__leaf_blocks__link_reference_definitions__07:
+ canonical: |
+ <p><a href="/url">foo</a></p>
+ static: |-
+ <p data-sourcepos="4:1-4:5" dir="auto"><a href="/url">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__08:
+ canonical: |
+ <p>[foo]:</p>
+ <p>[foo]</p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto">[foo]:</p>&#x000A;<p data-sourcepos="3:1-3:5" dir="auto">[foo]</p>
+ wysiwyg: |-
+ <p>[foo]:</p>
+04_07__leaf_blocks__link_reference_definitions__09:
+ canonical: |
+ <p><a href="">foo</a></p>
+ static: |-
+ <p data-sourcepos="3:1-3:5" dir="auto"><a href="">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__10:
+ canonical: |
+ <p>[foo]: <bar>(baz)</p>
+ <p>[foo]</p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto">[foo]: (baz)</p>&#x000A;<p data-sourcepos="3:1-3:5" dir="auto">[foo]</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "bar" not supported by this converter. Please, provide an specification.
+04_07__leaf_blocks__link_reference_definitions__11:
+ canonical: |
+ <p><a href="/url%5Cbar*baz" title="foo&quot;bar\baz">foo</a></p>
+ static: |-
+ <p data-sourcepos="3:1-3:5" dir="auto"><a href="/url%5Cbar*baz" title='foo"bar\baz'>foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url%5Cbar*baz" title="foo&quot;bar\baz">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__12:
+ canonical: |
+ <p><a href="url">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto"><a href="url">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="url">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__13:
+ canonical: |
+ <p><a href="first">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto"><a href="first">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="first">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__14:
+ canonical: |
+ <p><a href="/url">Foo</a></p>
+ static: |-
+ <p data-sourcepos="3:1-3:5" dir="auto"><a href="/url">Foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url">Foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__15:
+ canonical: |
+ <p><a href="/%CF%86%CE%BF%CF%85">αγω</a></p>
+ static: |-
+ <p data-sourcepos="3:1-3:8" dir="auto"><a href="/%CF%86%CE%BF%CF%85">αγω</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/%CF%86%CE%BF%CF%85">αγω</a></p>
+04_07__leaf_blocks__link_reference_definitions__16:
+ canonical: ""
+ static: ""
+ wysiwyg: |-
+ <p></p>
+04_07__leaf_blocks__link_reference_definitions__17:
+ canonical: |
+ <p>bar</p>
+ static: |-
+ <p data-sourcepos="1:1-4:3" dir="auto">bar</p>
+ wysiwyg: |-
+ <p>bar</p>
+04_07__leaf_blocks__link_reference_definitions__18:
+ canonical: |
+ <p>[foo]: /url &quot;title&quot; ok</p>
+ static: |-
+ <p data-sourcepos="1:1-1:22" dir="auto">[foo]: /url "title" ok</p>
+ wysiwyg: |-
+ <p>[foo]: /url "title" ok</p>
+04_07__leaf_blocks__link_reference_definitions__19:
+ canonical: |
+ <p>&quot;title&quot; ok</p>
+ static: |-
+ <p data-sourcepos="1:1-2:10" dir="auto">"title" ok</p>
+ wysiwyg: |-
+ <p>"title" ok</p>
+04_07__leaf_blocks__link_reference_definitions__20:
+ canonical: |
+ <pre><code>[foo]: /url &quot;title&quot;
+ </code></pre>
+ <p>[foo]</p>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-2:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">[foo]: /url "title"</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="3:1-3:5" dir="auto">[foo]</p>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>[foo]: /url "title"</code></pre>
+04_07__leaf_blocks__link_reference_definitions__21:
+ canonical: |
+ <pre><code>[foo]: /url
+ </code></pre>
+ <p>[foo]</p>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">[foo]: /url</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="5:1-5:5" dir="auto">[foo]</p>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>[foo]: /url</code></pre>
+04_07__leaf_blocks__link_reference_definitions__22:
+ canonical: |
+ <p>Foo
+ [bar]: /baz</p>
+ <p>[bar]</p>
+ static: |-
+ <p data-sourcepos="1:1-2:11" dir="auto">Foo&#x000A;[bar]: /baz</p>&#x000A;<p data-sourcepos="4:1-4:5" dir="auto">[bar]</p>
+ wysiwyg: |-
+ <p>Foo
+ [bar]: /baz</p>
+04_07__leaf_blocks__link_reference_definitions__23:
+ canonical: |
+ <h1><a href="/url">Foo</a></h1>
+ <blockquote>
+ <p>bar</p>
+ </blockquote>
+ static: |-
+ <h1 data-sourcepos="1:1-1:7" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a><a href="/url">Foo</a>&#x000A;</h1>&#x000A;<blockquote data-sourcepos="3:1-3:5" dir="auto">&#x000A;<p data-sourcepos="3:3-3:5">bar</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <h1><a target="_blank" rel="noopener noreferrer nofollow" href="/url">Foo</a></h1>
+04_07__leaf_blocks__link_reference_definitions__24:
+ canonical: |
+ <h1>bar</h1>
+ <p><a href="/url">foo</a></p>
+ static: |-
+ <h1 data-sourcepos="1:1-4:5" dir="auto">&#x000A;<a id="user-content-bar" class="anchor" href="#bar" aria-hidden="true"></a>bar</h1>&#x000A;<p data-sourcepos="4:1-4:5" dir="auto"><a href="/url">foo</a></p>
+ wysiwyg: |-
+ <h1>bar</h1>
+04_07__leaf_blocks__link_reference_definitions__25:
+ canonical: |
+ <p>===
+ <a href="/url">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-3:5" dir="auto">===&#x000A;<a href="/url">foo</a></p>
+ wysiwyg: |-
+ <p>===
+ <a target="_blank" rel="noopener noreferrer nofollow" href="/url">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__26:
+ canonical: |
+ <p><a href="/foo-url" title="foo">foo</a>,
+ <a href="/bar-url" title="bar">bar</a>,
+ <a href="/baz-url">baz</a></p>
+ static: |-
+ <p data-sourcepos="6:1-8:5" dir="auto"><a href="/foo-url" title="foo">foo</a>,&#x000A;<a href="/bar-url" title="bar">bar</a>,&#x000A;<a href="/baz-url">baz</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/foo-url" title="foo">foo</a>,
+ <a target="_blank" rel="noopener noreferrer nofollow" href="/bar-url" title="bar">bar</a>,
+ <a target="_blank" rel="noopener noreferrer nofollow" href="/baz-url">baz</a></p>
+04_07__leaf_blocks__link_reference_definitions__27:
+ canonical: |
+ <p><a href="/url">foo</a></p>
+ <blockquote>
+ </blockquote>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto"><a href="/url">foo</a></p>&#x000A;<blockquote data-sourcepos="3:1-3:13" dir="auto">&#x000A;</blockquote>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url">foo</a></p>
+04_07__leaf_blocks__link_reference_definitions__28:
+ canonical: ""
+ static: ""
+ wysiwyg: |-
+ <p></p>
+04_08__leaf_blocks__paragraphs__01:
+ canonical: |
+ <p>aaa</p>
+ <p>bbb</p>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">aaa</p>&#x000A;<p data-sourcepos="3:1-3:3" dir="auto">bbb</p>
+ wysiwyg: |-
+ <p>aaa</p>
+04_08__leaf_blocks__paragraphs__02:
+ canonical: |
+ <p>aaa
+ bbb</p>
+ <p>ccc
+ ddd</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">aaa&#x000A;bbb</p>&#x000A;<p data-sourcepos="4:1-5:3" dir="auto">ccc&#x000A;ddd</p>
+ wysiwyg: |-
+ <p>aaa
+ bbb</p>
+04_08__leaf_blocks__paragraphs__03:
+ canonical: |
+ <p>aaa</p>
+ <p>bbb</p>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">aaa</p>&#x000A;<p data-sourcepos="4:1-4:3" dir="auto">bbb</p>
+ wysiwyg: |-
+ <p>aaa</p>
+04_08__leaf_blocks__paragraphs__04:
+ canonical: |
+ <p>aaa
+ bbb</p>
+ static: |-
+ <p data-sourcepos="1:3-2:4" dir="auto">aaa&#x000A;bbb</p>
+ wysiwyg: |-
+ <p>aaa
+ bbb</p>
+04_08__leaf_blocks__paragraphs__05:
+ canonical: |
+ <p>aaa
+ bbb
+ ccc</p>
+ static: |-
+ <p data-sourcepos="1:1-3:42" dir="auto">aaa&#x000A;bbb&#x000A;ccc</p>
+ wysiwyg: |-
+ <p>aaa
+ bbb
+ ccc</p>
+04_08__leaf_blocks__paragraphs__06:
+ canonical: |
+ <p>aaa
+ bbb</p>
+ static: |-
+ <p data-sourcepos="1:4-2:3" dir="auto">aaa&#x000A;bbb</p>
+ wysiwyg: |-
+ <p>aaa
+ bbb</p>
+04_08__leaf_blocks__paragraphs__07:
+ canonical: |
+ <pre><code>aaa
+ </code></pre>
+ <p>bbb</p>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-1:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">aaa</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="2:1-2:3" dir="auto">bbb</p>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>aaa</code></pre>
+04_08__leaf_blocks__paragraphs__08:
+ canonical: |
+ <p>aaa<br />
+ bbb</p>
+ static: |-
+ <p data-sourcepos="1:1-2:8" dir="auto">aaa<br>&#x000A;bbb</p>
+ wysiwyg: |-
+ <p>aaa<br>
+ bbb</p>
+04_09__leaf_blocks__blank_lines__01:
+ canonical: |
+ <p>aaa</p>
+ <h1>aaa</h1>
+ static: |-
+ <p data-sourcepos="3:1-3:3" dir="auto">aaa</p>&#x000A;<h1 data-sourcepos="6:1-6:5" dir="auto">&#x000A;<a id="user-content-aaa" class="anchor" href="#aaa" aria-hidden="true"></a>aaa</h1>
+ wysiwyg: |-
+ <p>aaa</p>
+04_10__leaf_blocks__tables_extension__01:
+ canonical: |
+ <table>
+ <thead>
+ <tr>
+ <th>foo</th>
+ <th>bar</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>baz</td>
+ <td>bim</td>
+ </tr>
+ </tbody>
+ </table>
+ static: |-
+ <table data-sourcepos="1:1-3:13" dir="auto">&#x000A;<thead>&#x000A;<tr data-sourcepos="1:1-1:13">&#x000A;<th data-sourcepos="1:2-1:6">foo</th>&#x000A;<th data-sourcepos="1:8-1:12">bar</th>&#x000A;</tr>&#x000A;</thead>&#x000A;<tbody>&#x000A;<tr data-sourcepos="3:1-3:13">&#x000A;<td data-sourcepos="3:2-3:6">baz</td>&#x000A;<td data-sourcepos="3:8-3:12">bim</td>&#x000A;</tr>&#x000A;</tbody>&#x000A;</table>
+ wysiwyg: |-
+ <p>| foo | bar |
+ | --- | --- |
+ | baz | bim |</p>
+04_10__leaf_blocks__tables_extension__02:
+ canonical: |
+ <table>
+ <thead>
+ <tr>
+ <th align="center">abc</th>
+ <th align="right">defghi</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td align="center">bar</td>
+ <td align="right">baz</td>
+ </tr>
+ </tbody>
+ </table>
+ static: |-
+ <table data-sourcepos="1:1-3:9" dir="auto">&#x000A;<thead>&#x000A;<tr data-sourcepos="1:1-1:16">&#x000A;<th align="center" data-sourcepos="1:2-1:6">abc</th>&#x000A;<th align="right" data-sourcepos="1:8-1:15">defghi</th>&#x000A;</tr>&#x000A;</thead>&#x000A;<tbody>&#x000A;<tr data-sourcepos="3:1-3:9">&#x000A;<td align="center" data-sourcepos="3:1-3:4">bar</td>&#x000A;<td align="right" data-sourcepos="3:6-3:9">baz</td>&#x000A;</tr>&#x000A;</tbody>&#x000A;</table>
+ wysiwyg: |-
+ <p>| abc | defghi |
+ :-: | -----------:
+ bar | baz</p>
+04_10__leaf_blocks__tables_extension__03:
+ canonical: |
+ <table>
+ <thead>
+ <tr>
+ <th>f|oo</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>b <code>|</code> az</td>
+ </tr>
+ <tr>
+ <td>b <strong>|</strong> im</td>
+ </tr>
+ </tbody>
+ </table>
+ static: |-
+ <table data-sourcepos="1:1-4:15" dir="auto">&#x000A;<thead>&#x000A;<tr data-sourcepos="1:1-1:10">&#x000A;<th data-sourcepos="1:2-1:9">f|oo</th>&#x000A;</tr>&#x000A;</thead>&#x000A;<tbody>&#x000A;<tr data-sourcepos="3:1-3:13">&#x000A;<td data-sourcepos="3:2-3:12">b <code>|</code> az</td>&#x000A;</tr>&#x000A;<tr data-sourcepos="4:1-4:15">&#x000A;<td data-sourcepos="4:2-4:14">b <strong>|</strong> im</td>&#x000A;</tr>&#x000A;</tbody>&#x000A;</table>
+ wysiwyg: |-
+ <p>| f|oo |
+ | ------ |
+ | b <code>\|</code> az |
+ | b <strong>|</strong> im |</p>
+04_10__leaf_blocks__tables_extension__04:
+ canonical: |
+ <table>
+ <thead>
+ <tr>
+ <th>abc</th>
+ <th>def</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>bar</td>
+ <td>baz</td>
+ </tr>
+ </tbody>
+ </table>
+ <blockquote>
+ <p>bar</p>
+ </blockquote>
+ static: |-
+ <table data-sourcepos="1:1-3:13" dir="auto">&#x000A;<thead>&#x000A;<tr data-sourcepos="1:1-1:13">&#x000A;<th data-sourcepos="1:2-1:6">abc</th>&#x000A;<th data-sourcepos="1:8-1:12">def</th>&#x000A;</tr>&#x000A;</thead>&#x000A;<tbody>&#x000A;<tr data-sourcepos="3:1-3:13">&#x000A;<td data-sourcepos="3:2-3:6">bar</td>&#x000A;<td data-sourcepos="3:8-3:12">baz</td>&#x000A;</tr>&#x000A;</tbody>&#x000A;</table>&#x000A;<blockquote data-sourcepos="4:1-4:5" dir="auto">&#x000A;<p data-sourcepos="4:3-4:5">bar</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <p>| abc | def |
+ | --- | --- |
+ | bar | baz |</p>
+04_10__leaf_blocks__tables_extension__05:
+ canonical: |
+ <table>
+ <thead>
+ <tr>
+ <th>abc</th>
+ <th>def</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>bar</td>
+ <td>baz</td>
+ </tr>
+ <tr>
+ <td>bar</td>
+ <td></td>
+ </tr>
+ </tbody>
+ </table>
+ <p>bar</p>
+ static: |-
+ <table data-sourcepos="1:1-4:3" dir="auto">&#x000A;<thead>&#x000A;<tr data-sourcepos="1:1-1:13">&#x000A;<th data-sourcepos="1:2-1:6">abc</th>&#x000A;<th data-sourcepos="1:8-1:12">def</th>&#x000A;</tr>&#x000A;</thead>&#x000A;<tbody>&#x000A;<tr data-sourcepos="3:1-3:13">&#x000A;<td data-sourcepos="3:2-3:6">bar</td>&#x000A;<td data-sourcepos="3:8-3:12">baz</td>&#x000A;</tr>&#x000A;<tr data-sourcepos="4:1-4:3">&#x000A;<td data-sourcepos="4:1-4:3">bar</td>&#x000A;<td data-sourcepos="4:0-4:0"></td>&#x000A;</tr>&#x000A;</tbody>&#x000A;</table>&#x000A;<p data-sourcepos="6:1-6:3" dir="auto">bar</p>
+ wysiwyg: |-
+ <p>| abc | def |
+ | --- | --- |
+ | bar | baz |
+ bar</p>
+04_10__leaf_blocks__tables_extension__06:
+ canonical: |
+ <p>| abc | def |
+ | --- |
+ | bar |</p>
+ static: |-
+ <p data-sourcepos="1:1-3:7" dir="auto">| abc | def |&#x000A;| --- |&#x000A;| bar |</p>
+ wysiwyg: |-
+ <p>| abc | def |
+ | --- |
+ | bar |</p>
+04_10__leaf_blocks__tables_extension__07:
+ canonical: |
+ <table>
+ <thead>
+ <tr>
+ <th>abc</th>
+ <th>def</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>bar</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>bar</td>
+ <td>baz</td>
+ </tr>
+ </tbody>
+ </table>
+ static: |-
+ <table data-sourcepos="1:1-4:19" dir="auto">&#x000A;<thead>&#x000A;<tr data-sourcepos="1:1-1:13">&#x000A;<th data-sourcepos="1:2-1:6">abc</th>&#x000A;<th data-sourcepos="1:8-1:12">def</th>&#x000A;</tr>&#x000A;</thead>&#x000A;<tbody>&#x000A;<tr data-sourcepos="3:1-3:7">&#x000A;<td data-sourcepos="3:2-3:6">bar</td>&#x000A;<td data-sourcepos="3:0-3:0"></td>&#x000A;</tr>&#x000A;<tr data-sourcepos="4:1-4:19">&#x000A;<td data-sourcepos="4:2-4:6">bar</td>&#x000A;<td data-sourcepos="4:8-4:12">baz</td>&#x000A;</tr>&#x000A;</tbody>&#x000A;</table>
+ wysiwyg: |-
+ <p>| abc | def |
+ | --- | --- |
+ | bar |
+ | bar | baz | boo |</p>
+04_10__leaf_blocks__tables_extension__08:
+ canonical: |
+ <table>
+ <thead>
+ <tr>
+ <th>abc</th>
+ <th>def</th>
+ </tr>
+ </thead>
+ </table>
+ static: |-
+ <table data-sourcepos="1:1-2:13" dir="auto">&#x000A;<thead>&#x000A;<tr data-sourcepos="1:1-1:13">&#x000A;<th data-sourcepos="1:2-1:6">abc</th>&#x000A;<th data-sourcepos="1:8-1:12">def</th>&#x000A;</tr>&#x000A;</thead>&#x000A;</table>
+ wysiwyg: |-
+ <p>| abc | def |
+ | --- | --- |</p>
+05_01__container_blocks__block_quotes__01:
+ canonical: |
+ <blockquote>
+ <h1>Foo</h1>
+ <p>bar
+ baz</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:5" dir="auto">&#x000A;<h1 data-sourcepos="1:3-1:7">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h1>&#x000A;<p data-sourcepos="2:3-3:5">bar&#x000A;baz</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><h1>Foo</h1><p>bar
+ baz</p></blockquote>
+05_01__container_blocks__block_quotes__02:
+ canonical: |
+ <blockquote>
+ <h1>Foo</h1>
+ <p>bar
+ baz</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:5" dir="auto">&#x000A;<h1 data-sourcepos="1:2-1:6">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h1>&#x000A;<p data-sourcepos="2:2-3:5">bar&#x000A;baz</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><h1>Foo</h1><p>bar
+ baz</p></blockquote>
+05_01__container_blocks__block_quotes__03:
+ canonical: |
+ <blockquote>
+ <h1>Foo</h1>
+ <p>bar
+ baz</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:4-3:6" dir="auto">&#x000A;<h1 data-sourcepos="1:6-1:10">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h1>&#x000A;<p data-sourcepos="2:6-3:6">bar&#x000A;baz</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><h1>Foo</h1><p>bar
+ baz</p></blockquote>
+05_01__container_blocks__block_quotes__04:
+ canonical: |
+ <pre><code>&gt; # Foo
+ &gt; bar
+ &gt; baz
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-3:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">&gt; # Foo</span>&#x000A;<span id="LC2" class="line" lang="plaintext">&gt; bar</span>&#x000A;<span id="LC3" class="line" lang="plaintext">&gt; baz</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>&gt; # Foo
+ &gt; bar
+ &gt; baz</code></pre>
+05_01__container_blocks__block_quotes__05:
+ canonical: |
+ <blockquote>
+ <h1>Foo</h1>
+ <p>bar
+ baz</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:3" dir="auto">&#x000A;<h1 data-sourcepos="1:3-1:7">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h1>&#x000A;<p data-sourcepos="2:3-3:3">bar&#x000A;baz</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><h1>Foo</h1><p>bar
+ baz</p></blockquote>
+05_01__container_blocks__block_quotes__06:
+ canonical: |
+ <blockquote>
+ <p>bar
+ baz
+ foo</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:5" dir="auto">&#x000A;<p data-sourcepos="1:3-3:5">bar&#x000A;baz&#x000A;foo</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>bar
+ baz
+ foo</p></blockquote>
+05_01__container_blocks__block_quotes__07:
+ canonical: |
+ <blockquote>
+ <p>foo</p>
+ </blockquote>
+ <hr />
+ static: |-
+ <blockquote data-sourcepos="1:1-1:5" dir="auto">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;</blockquote>&#x000A;<hr data-sourcepos="2:1-2:3">
+ wysiwyg: |-
+ <blockquote multiline="false"><p>foo</p></blockquote>
+05_01__container_blocks__block_quotes__08:
+ canonical: |
+ <blockquote>
+ <ul>
+ <li>foo</li>
+ </ul>
+ </blockquote>
+ <ul>
+ <li>bar</li>
+ </ul>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:7" dir="auto">&#x000A;<ul data-sourcepos="1:3-1:7">&#x000A;<li data-sourcepos="1:3-1:7">foo</li>&#x000A;</ul>&#x000A;</blockquote>&#x000A;<ul data-sourcepos="2:1-2:5" dir="auto">&#x000A;<li data-sourcepos="2:1-2:5">bar</li>&#x000A;</ul>
+ wysiwyg: |-
+ <blockquote multiline="false"><ul bullet="*"><li><p>foo</p></li></ul></blockquote>
+05_01__container_blocks__block_quotes__09:
+ canonical: |
+ <blockquote>
+ <pre><code>foo
+ </code></pre>
+ </blockquote>
+ <pre><code>bar
+ </code></pre>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:9" dir="auto">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:7-1:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</blockquote>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="2:5-2:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <blockquote multiline="false"><pre class="content-editor-code-block undefined code highlight"><code>foo</code></pre></blockquote>
+05_01__container_blocks__block_quotes__10:
+ canonical: |
+ <blockquote>
+ <pre><code></code></pre>
+ </blockquote>
+ <p>foo</p>
+ <pre><code></code></pre>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:5" dir="auto">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:3-2:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</blockquote>&#x000A;<p data-sourcepos="2:1-2:3" dir="auto">foo</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <blockquote multiline="false"><pre class="content-editor-code-block undefined code highlight"><code></code></pre></blockquote>
+05_01__container_blocks__block_quotes__11:
+ canonical: |
+ <blockquote>
+ <p>foo
+ - bar</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:9" dir="auto">&#x000A;<p data-sourcepos="1:3-2:9">foo&#x000A;- bar</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>foo
+ - bar</p></blockquote>
+05_01__container_blocks__block_quotes__12:
+ canonical: |
+ <blockquote>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:1" dir="auto">&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p></p></blockquote>
+05_01__container_blocks__block_quotes__13:
+ canonical: |
+ <blockquote>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:2" dir="auto">&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p></p></blockquote>
+05_01__container_blocks__block_quotes__14:
+ canonical: |
+ <blockquote>
+ <p>foo</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:3" dir="auto">&#x000A;<p data-sourcepos="2:3-2:5">foo</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>foo</p></blockquote>
+05_01__container_blocks__block_quotes__15:
+ canonical: |
+ <blockquote>
+ <p>foo</p>
+ </blockquote>
+ <blockquote>
+ <p>bar</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:5" dir="auto">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;</blockquote>&#x000A;<blockquote data-sourcepos="3:1-3:5" dir="auto">&#x000A;<p data-sourcepos="3:3-3:5">bar</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>foo</p></blockquote>
+05_01__container_blocks__block_quotes__16:
+ canonical: |
+ <blockquote>
+ <p>foo
+ bar</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:5" dir="auto">&#x000A;<p data-sourcepos="1:3-2:5">foo&#x000A;bar</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>foo
+ bar</p></blockquote>
+05_01__container_blocks__block_quotes__17:
+ canonical: |
+ <blockquote>
+ <p>foo</p>
+ <p>bar</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:5" dir="auto">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;<p data-sourcepos="3:3-3:5">bar</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>foo</p><p>bar</p></blockquote>
+05_01__container_blocks__block_quotes__18:
+ canonical: |
+ <p>foo</p>
+ <blockquote>
+ <p>bar</p>
+ </blockquote>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">foo</p>&#x000A;<blockquote data-sourcepos="2:1-2:5" dir="auto">&#x000A;<p data-sourcepos="2:3-2:5">bar</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <p>foo</p>
+05_01__container_blocks__block_quotes__19:
+ canonical: |
+ <blockquote>
+ <p>aaa</p>
+ </blockquote>
+ <hr />
+ <blockquote>
+ <p>bbb</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:5" dir="auto">&#x000A;<p data-sourcepos="1:3-1:5">aaa</p>&#x000A;</blockquote>&#x000A;<hr data-sourcepos="2:1-2:3">&#x000A;<blockquote data-sourcepos="3:1-3:5" dir="auto">&#x000A;<p data-sourcepos="3:3-3:5">bbb</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>aaa</p></blockquote>
+05_01__container_blocks__block_quotes__20:
+ canonical: |
+ <blockquote>
+ <p>bar
+ baz</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:3" dir="auto">&#x000A;<p data-sourcepos="1:3-2:3">bar&#x000A;baz</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>bar
+ baz</p></blockquote>
+05_01__container_blocks__block_quotes__21:
+ canonical: |
+ <blockquote>
+ <p>bar</p>
+ </blockquote>
+ <p>baz</p>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:5" dir="auto">&#x000A;<p data-sourcepos="1:3-1:5">bar</p>&#x000A;</blockquote>&#x000A;<p data-sourcepos="3:1-3:3" dir="auto">baz</p>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>bar</p></blockquote>
+05_01__container_blocks__block_quotes__22:
+ canonical: |
+ <blockquote>
+ <p>bar</p>
+ </blockquote>
+ <p>baz</p>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:1" dir="auto">&#x000A;<p data-sourcepos="1:3-1:5">bar</p>&#x000A;</blockquote>&#x000A;<p data-sourcepos="3:1-3:3" dir="auto">baz</p>
+ wysiwyg: |-
+ <blockquote multiline="false"><p>bar</p></blockquote>
+05_01__container_blocks__block_quotes__23:
+ canonical: |
+ <blockquote>
+ <blockquote>
+ <blockquote>
+ <p>foo
+ bar</p>
+ </blockquote>
+ </blockquote>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:3" dir="auto">&#x000A;<blockquote data-sourcepos="1:3-2:3">&#x000A;<blockquote data-sourcepos="1:5-2:3">&#x000A;<p data-sourcepos="1:7-2:3">foo&#x000A;bar</p>&#x000A;</blockquote>&#x000A;</blockquote>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><blockquote multiline="false"><blockquote multiline="false"><p>foo
+ bar</p></blockquote></blockquote></blockquote>
+05_01__container_blocks__block_quotes__24:
+ canonical: |
+ <blockquote>
+ <blockquote>
+ <blockquote>
+ <p>foo
+ bar
+ baz</p>
+ </blockquote>
+ </blockquote>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:5" dir="auto">&#x000A;<blockquote data-sourcepos="1:2-3:5">&#x000A;<blockquote data-sourcepos="1:3-3:5">&#x000A;<p data-sourcepos="1:5-3:5">foo&#x000A;bar&#x000A;baz</p>&#x000A;</blockquote>&#x000A;</blockquote>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><blockquote multiline="false"><blockquote multiline="false"><p>foo
+ bar
+ baz</p></blockquote></blockquote></blockquote>
+05_01__container_blocks__block_quotes__25:
+ canonical: |
+ <blockquote>
+ <pre><code>code
+ </code></pre>
+ </blockquote>
+ <blockquote>
+ <p>not code</p>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-1:10" dir="auto">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:7-1:10" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</blockquote>&#x000A;<blockquote data-sourcepos="3:1-3:13" dir="auto">&#x000A;<p data-sourcepos="3:6-3:13">not code</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><pre class="content-editor-code-block undefined code highlight"><code>code</code></pre></blockquote>
+05_02__container_blocks__list_items__01:
+ canonical: |
+ <p>A paragraph
+ with two lines.</p>
+ <pre><code>indented code
+ </code></pre>
+ <blockquote>
+ <p>A block quote.</p>
+ </blockquote>
+ static: |-
+ <p data-sourcepos="1:1-2:15" dir="auto">A paragraph&#x000A;with two lines.</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="4:5-5:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<blockquote data-sourcepos="6:1-6:16" dir="auto">&#x000A;<p data-sourcepos="6:3-6:16">A block quote.</p>&#x000A;</blockquote>
+ wysiwyg: |-
+ <p>A paragraph
+ with two lines.</p>
+05_02__container_blocks__list_items__02:
+ canonical: |
+ <ol>
+ <li>
+ <p>A paragraph
+ with two lines.</p>
+ <pre><code>indented code
+ </code></pre>
+ <blockquote>
+ <p>A block quote.</p>
+ </blockquote>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-6:20" dir="auto">&#x000A;<li data-sourcepos="1:1-6:20">&#x000A;<p data-sourcepos="1:5-2:19">A paragraph&#x000A;with two lines.</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="4:9-5:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<blockquote data-sourcepos="6:5-6:20">&#x000A;<p data-sourcepos="6:7-6:20">A block quote.</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>A paragraph
+ with two lines.</p><pre class="content-editor-code-block undefined code highlight"><code>indented code</code></pre><blockquote multiline="false"><p>A block quote.</p></blockquote></li></ol>
+05_02__container_blocks__list_items__03:
+ canonical: |
+ <ul>
+ <li>one</li>
+ </ul>
+ <p>two</p>
+ static: |-
+ <ul data-sourcepos="1:1-2:0" dir="auto">&#x000A;<li data-sourcepos="1:1-2:0">one</li>&#x000A;</ul>&#x000A;<p data-sourcepos="3:2-3:4" dir="auto">two</p>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>one</p></li></ul>
+05_02__container_blocks__list_items__04:
+ canonical: |
+ <ul>
+ <li>
+ <p>one</p>
+ <p>two</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-3:5" dir="auto">&#x000A;<li data-sourcepos="1:1-3:5">&#x000A;<p data-sourcepos="1:3-1:5">one</p>&#x000A;<p data-sourcepos="3:3-3:5">two</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>one</p><p>two</p></li></ul>
+05_02__container_blocks__list_items__05:
+ canonical: |
+ <ul>
+ <li>one</li>
+ </ul>
+ <pre><code> two
+ </code></pre>
+ static: |-
+ <ul data-sourcepos="1:2-2:0" dir="auto">&#x000A;<li data-sourcepos="1:2-2:0">one</li>&#x000A;</ul>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:5-3:8" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"> two</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>one</p></li></ul>
+05_02__container_blocks__list_items__06:
+ canonical: |
+ <ul>
+ <li>
+ <p>one</p>
+ <p>two</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:2-3:9" dir="auto">&#x000A;<li data-sourcepos="1:2-3:9">&#x000A;<p data-sourcepos="1:7-1:9">one</p>&#x000A;<p data-sourcepos="3:7-3:9">two</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>one</p><p>two</p></li></ul>
+05_02__container_blocks__list_items__07:
+ canonical: |
+ <blockquote>
+ <blockquote>
+ <ol>
+ <li>
+ <p>one</p>
+ <p>two</p>
+ </li>
+ </ol>
+ </blockquote>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:4-3:10" dir="auto">&#x000A;<blockquote data-sourcepos="1:6-3:10">&#x000A;<ol data-sourcepos="1:8-3:10">&#x000A;<li data-sourcepos="1:8-3:10">&#x000A;<p data-sourcepos="1:12-1:14">one</p>&#x000A;<p data-sourcepos="3:8-3:10">two</p>&#x000A;</li>&#x000A;</ol>&#x000A;</blockquote>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><blockquote multiline="false"><ol parens="false"><li><p>one</p><p>two</p></li></ol></blockquote></blockquote>
+05_02__container_blocks__list_items__08:
+ canonical: |
+ <blockquote>
+ <blockquote>
+ <ul>
+ <li>one</li>
+ </ul>
+ <p>two</p>
+ </blockquote>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-3:10" dir="auto">&#x000A;<blockquote data-sourcepos="1:2-3:10">&#x000A;<ul data-sourcepos="1:3-2:2">&#x000A;<li data-sourcepos="1:3-2:2">one</li>&#x000A;</ul>&#x000A;<p data-sourcepos="3:8-3:10">two</p>&#x000A;</blockquote>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><blockquote multiline="false"><ul bullet="*"><li><p>one</p></li></ul><p>two</p></blockquote></blockquote>
+05_02__container_blocks__list_items__09:
+ canonical: |
+ <p>-one</p>
+ <p>2.two</p>
+ static: |-
+ <p data-sourcepos="1:1-1:4" dir="auto">-one</p>&#x000A;<p data-sourcepos="3:1-3:5" dir="auto">2.two</p>
+ wysiwyg: |-
+ <p>-one</p>
+05_02__container_blocks__list_items__10:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ <p>bar</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-4:5" dir="auto">&#x000A;<li data-sourcepos="1:1-4:5">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;<p data-sourcepos="4:3-4:5">bar</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p><p>bar</p></li></ul>
+05_02__container_blocks__list_items__11:
+ canonical: |
+ <ol>
+ <li>
+ <p>foo</p>
+ <pre><code>bar
+ </code></pre>
+ <p>baz</p>
+ <blockquote>
+ <p>bam</p>
+ </blockquote>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-9:9" dir="auto">&#x000A;<li data-sourcepos="1:1-9:9">&#x000A;<p data-sourcepos="1:5-1:7">foo</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:5-5:7" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="7:5-7:7">baz</p>&#x000A;<blockquote data-sourcepos="9:5-9:9">&#x000A;<p data-sourcepos="9:7-9:9">bam</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>foo</p><pre class="content-editor-code-block undefined code highlight"><code>bar</code></pre><p>baz</p><blockquote multiline="false"><p>bam</p></blockquote></li></ol>
+05_02__container_blocks__list_items__12:
+ canonical: |
+ <ul>
+ <li>
+ <p>Foo</p>
+ <pre><code>bar
+
+
+ baz
+ </code></pre>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-6:9" dir="auto">&#x000A;<li data-sourcepos="1:1-6:9">&#x000A;<p data-sourcepos="1:3-1:5">Foo</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:7-6:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">bar</span>&#x000A;<span id="LC2" class="line" lang="plaintext"></span>&#x000A;<span id="LC3" class="line" lang="plaintext"></span>&#x000A;<span id="LC4" class="line" lang="plaintext">baz</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>Foo</p><pre class="content-editor-code-block undefined code highlight"><code>bar
+
+
+ baz</code></pre></li></ul>
+05_02__container_blocks__list_items__13:
+ canonical: |
+ <ol start="123456789">
+ <li>ok</li>
+ </ol>
+ static: |-
+ <ol start="123456789" data-sourcepos="1:1-1:13" dir="auto">&#x000A;<li data-sourcepos="1:1-1:13">ok</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>ok</p></li></ol>
+05_02__container_blocks__list_items__14:
+ canonical: |
+ <p>1234567890. not ok</p>
+ static: |-
+ <p data-sourcepos="1:1-1:18" dir="auto">1234567890. not ok</p>
+ wysiwyg: |-
+ <p>1234567890. not ok</p>
+05_02__container_blocks__list_items__15:
+ canonical: |
+ <ol start="0">
+ <li>ok</li>
+ </ol>
+ static: |-
+ <ol start="0" data-sourcepos="1:1-1:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">ok</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>ok</p></li></ol>
+05_02__container_blocks__list_items__16:
+ canonical: |
+ <ol start="3">
+ <li>ok</li>
+ </ol>
+ static: |-
+ <ol start="3" data-sourcepos="1:1-1:7" dir="auto">&#x000A;<li data-sourcepos="1:1-1:7">ok</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>ok</p></li></ol>
+05_02__container_blocks__list_items__17:
+ canonical: |
+ <p>-1. not ok</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">-1. not ok</p>
+ wysiwyg: |-
+ <p>-1. not ok</p>
+05_02__container_blocks__list_items__18:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ <pre><code>bar
+ </code></pre>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-3:9" dir="auto">&#x000A;<li data-sourcepos="1:1-3:9">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:7-3:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p><pre class="content-editor-code-block undefined code highlight"><code>bar</code></pre></li></ul>
+05_02__container_blocks__list_items__19:
+ canonical: |
+ <ol start="10">
+ <li>
+ <p>foo</p>
+ <pre><code>bar
+ </code></pre>
+ </li>
+ </ol>
+ static: |-
+ <ol start="10" data-sourcepos="1:3-3:14" dir="auto">&#x000A;<li data-sourcepos="1:3-3:14">&#x000A;<p data-sourcepos="1:8-1:10">foo</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:12-3:14" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>foo</p><pre class="content-editor-code-block undefined code highlight"><code>bar</code></pre></li></ol>
+05_02__container_blocks__list_items__20:
+ canonical: |
+ <pre><code>indented code
+ </code></pre>
+ <p>paragraph</p>
+ <pre><code>more code
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-2:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="3:1-3:9" dir="auto">paragraph</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="5:5-5:13" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">more code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>indented code</code></pre>
+05_02__container_blocks__list_items__21:
+ canonical: |
+ <ol>
+ <li>
+ <pre><code>indented code
+ </code></pre>
+ <p>paragraph</p>
+ <pre><code>more code
+ </code></pre>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-5:16" dir="auto">&#x000A;<li data-sourcepos="1:1-5:16">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:8-2:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="3:4-3:12">paragraph</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="5:8-5:16" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">more code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p></p><pre class="content-editor-code-block undefined code highlight"><code>indented code</code></pre><p>paragraph</p><pre class="content-editor-code-block undefined code highlight"><code>more code</code></pre></li></ol>
+05_02__container_blocks__list_items__22:
+ canonical: |
+ <ol>
+ <li>
+ <pre><code> indented code
+ </code></pre>
+ <p>paragraph</p>
+ <pre><code>more code
+ </code></pre>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-5:16" dir="auto">&#x000A;<li data-sourcepos="1:1-5:16">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:8-2:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext"> indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="3:4-3:12">paragraph</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="5:8-5:16" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">more code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p></p><pre class="content-editor-code-block undefined code highlight"><code> indented code</code></pre><p>paragraph</p><pre class="content-editor-code-block undefined code highlight"><code>more code</code></pre></li></ol>
+05_02__container_blocks__list_items__23:
+ canonical: |
+ <p>foo</p>
+ <p>bar</p>
+ static: |-
+ <p data-sourcepos="1:4-1:6" dir="auto">foo</p>&#x000A;<p data-sourcepos="3:1-3:3" dir="auto">bar</p>
+ wysiwyg: |-
+ <p>foo</p>
+05_02__container_blocks__list_items__24:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ </ul>
+ <p>bar</p>
+ static: |-
+ <ul data-sourcepos="1:1-2:0" dir="auto">&#x000A;<li data-sourcepos="1:1-2:0">foo</li>&#x000A;</ul>&#x000A;<p data-sourcepos="3:3-3:5" dir="auto">bar</p>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li></ul>
+05_02__container_blocks__list_items__25:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ <p>bar</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-3:6" dir="auto">&#x000A;<li data-sourcepos="1:1-3:6">&#x000A;<p data-sourcepos="1:4-1:6">foo</p>&#x000A;<p data-sourcepos="3:4-3:6">bar</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p><p>bar</p></li></ul>
+05_02__container_blocks__list_items__26:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ <li>
+ <pre><code>bar
+ </code></pre>
+ </li>
+ <li>
+ <pre><code>baz
+ </code></pre>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-8:9" dir="auto">&#x000A;<li data-sourcepos="1:1-2:5">foo</li>&#x000A;<li data-sourcepos="3:1-6:5">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="4:3-6:5" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">bar</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;<li data-sourcepos="7:1-8:9">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="8:7-8:9" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">baz</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li><li><p></p><pre class="content-editor-code-block undefined code highlight"><code>bar</code></pre></li><li><p></p><pre class="content-editor-code-block undefined code highlight"><code>baz</code></pre></li></ul>
+05_02__container_blocks__list_items__27:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-2:5" dir="auto">&#x000A;<li data-sourcepos="1:1-2:5">foo</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li></ul>
+05_02__container_blocks__list_items__28:
+ canonical: |
+ <ul>
+ <li></li>
+ </ul>
+ <p>foo</p>
+ static: |-
+ <ul data-sourcepos="1:1-2:0" dir="auto">&#x000A;<li data-sourcepos="1:1-1:1">&#x000A;</li>&#x000A;</ul>&#x000A;<p data-sourcepos="3:3-3:5" dir="auto">foo</p>
+ wysiwyg: |-
+ <ul bullet="*"><li><p></p></li></ul>
+05_02__container_blocks__list_items__29:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ <li></li>
+ <li>bar</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-3:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">foo</li>&#x000A;<li data-sourcepos="2:1-2:1">&#x000A;</li>&#x000A;<li data-sourcepos="3:1-3:5">bar</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li><li><p></p></li><li><p>bar</p></li></ul>
+05_02__container_blocks__list_items__30:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ <li></li>
+ <li>bar</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-3:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">foo</li>&#x000A;<li data-sourcepos="2:1-2:4">&#x000A;</li>&#x000A;<li data-sourcepos="3:1-3:5">bar</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li><li><p></p></li><li><p>bar</p></li></ul>
+05_02__container_blocks__list_items__31:
+ canonical: |
+ <ol>
+ <li>foo</li>
+ <li></li>
+ <li>bar</li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-3:6" dir="auto">&#x000A;<li data-sourcepos="1:1-1:6">foo</li>&#x000A;<li data-sourcepos="2:1-2:2">&#x000A;</li>&#x000A;<li data-sourcepos="3:1-3:6">bar</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>foo</p></li><li><p></p></li><li><p>bar</p></li></ol>
+05_02__container_blocks__list_items__32:
+ canonical: |
+ <ul>
+ <li></li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-1:1" dir="auto">&#x000A;<li data-sourcepos="1:1-1:1">&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p></p></li></ul>
+05_02__container_blocks__list_items__33:
+ canonical: |
+ <p>foo
+ *</p>
+ <p>foo
+ 1.</p>
+ static: |-
+ <p data-sourcepos="1:1-2:1" dir="auto">foo&#x000A;*</p>&#x000A;<p data-sourcepos="4:1-5:2" dir="auto">foo&#x000A;1.</p>
+ wysiwyg: |-
+ <p>foo
+ *</p>
+05_02__container_blocks__list_items__34:
+ canonical: |
+ <ol>
+ <li>
+ <p>A paragraph
+ with two lines.</p>
+ <pre><code>indented code
+ </code></pre>
+ <blockquote>
+ <p>A block quote.</p>
+ </blockquote>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:2-6:21" dir="auto">&#x000A;<li data-sourcepos="1:2-6:21">&#x000A;<p data-sourcepos="1:6-2:20">A paragraph&#x000A;with two lines.</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="4:10-5:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<blockquote data-sourcepos="6:6-6:21">&#x000A;<p data-sourcepos="6:8-6:21">A block quote.</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>A paragraph
+ with two lines.</p><pre class="content-editor-code-block undefined code highlight"><code>indented code</code></pre><blockquote multiline="false"><p>A block quote.</p></blockquote></li></ol>
+05_02__container_blocks__list_items__35:
+ canonical: |
+ <ol>
+ <li>
+ <p>A paragraph
+ with two lines.</p>
+ <pre><code>indented code
+ </code></pre>
+ <blockquote>
+ <p>A block quote.</p>
+ </blockquote>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:3-6:22" dir="auto">&#x000A;<li data-sourcepos="1:3-6:22">&#x000A;<p data-sourcepos="1:7-2:21">A paragraph&#x000A;with two lines.</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="4:11-5:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<blockquote data-sourcepos="6:7-6:22">&#x000A;<p data-sourcepos="6:9-6:22">A block quote.</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>A paragraph
+ with two lines.</p><pre class="content-editor-code-block undefined code highlight"><code>indented code</code></pre><blockquote multiline="false"><p>A block quote.</p></blockquote></li></ol>
+05_02__container_blocks__list_items__36:
+ canonical: |
+ <ol>
+ <li>
+ <p>A paragraph
+ with two lines.</p>
+ <pre><code>indented code
+ </code></pre>
+ <blockquote>
+ <p>A block quote.</p>
+ </blockquote>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:4-6:23" dir="auto">&#x000A;<li data-sourcepos="1:4-6:23">&#x000A;<p data-sourcepos="1:8-2:22">A paragraph&#x000A;with two lines.</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="4:12-5:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<blockquote data-sourcepos="6:8-6:23">&#x000A;<p data-sourcepos="6:10-6:23">A block quote.</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>A paragraph
+ with two lines.</p><pre class="content-editor-code-block undefined code highlight"><code>indented code</code></pre><blockquote multiline="false"><p>A block quote.</p></blockquote></li></ol>
+05_02__container_blocks__list_items__37:
+ canonical: |
+ <pre><code>1. A paragraph
+ with two lines.
+
+ indented code
+
+ &gt; A block quote.
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-6:24" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">1. A paragraph</span>&#x000A;<span id="LC2" class="line" lang="plaintext"> with two lines.</span>&#x000A;<span id="LC3" class="line" lang="plaintext"></span>&#x000A;<span id="LC4" class="line" lang="plaintext"> indented code</span>&#x000A;<span id="LC5" class="line" lang="plaintext"></span>&#x000A;<span id="LC6" class="line" lang="plaintext"> &gt; A block quote.</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>1. A paragraph
+ with two lines.
+
+ indented code
+
+ &gt; A block quote.</code></pre>
+05_02__container_blocks__list_items__38:
+ canonical: |
+ <ol>
+ <li>
+ <p>A paragraph
+ with two lines.</p>
+ <pre><code>indented code
+ </code></pre>
+ <blockquote>
+ <p>A block quote.</p>
+ </blockquote>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:3-6:22" dir="auto">&#x000A;<li data-sourcepos="1:3-6:22">&#x000A;<p data-sourcepos="1:7-2:15">A paragraph&#x000A;with two lines.</p>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="4:11-5:0" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">indented code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<blockquote data-sourcepos="6:7-6:22">&#x000A;<p data-sourcepos="6:9-6:22">A block quote.</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>A paragraph
+ with two lines.</p><pre class="content-editor-code-block undefined code highlight"><code>indented code</code></pre><blockquote multiline="false"><p>A block quote.</p></blockquote></li></ol>
+05_02__container_blocks__list_items__39:
+ canonical: |
+ <ol>
+ <li>A paragraph
+ with two lines.</li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:3-2:19" dir="auto">&#x000A;<li data-sourcepos="1:3-2:19">A paragraph&#x000A;with two lines.</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>A paragraph
+ with two lines.</p></li></ol>
+05_02__container_blocks__list_items__40:
+ canonical: |
+ <blockquote>
+ <ol>
+ <li>
+ <blockquote>
+ <p>Blockquote
+ continued here.</p>
+ </blockquote>
+ </li>
+ </ol>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:15" dir="auto">&#x000A;<ol data-sourcepos="1:3-2:15">&#x000A;<li data-sourcepos="1:3-2:15">&#x000A;<blockquote data-sourcepos="1:6-2:15">&#x000A;<p data-sourcepos="1:8-2:15">Blockquote&#x000A;continued here.</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;</ol>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><ol parens="false"><li><p></p><blockquote multiline="false"><p>Blockquote
+ continued here.</p></blockquote></li></ol></blockquote>
+05_02__container_blocks__list_items__41:
+ canonical: |
+ <blockquote>
+ <ol>
+ <li>
+ <blockquote>
+ <p>Blockquote
+ continued here.</p>
+ </blockquote>
+ </li>
+ </ol>
+ </blockquote>
+ static: |-
+ <blockquote data-sourcepos="1:1-2:17" dir="auto">&#x000A;<ol data-sourcepos="1:3-2:17">&#x000A;<li data-sourcepos="1:3-2:17">&#x000A;<blockquote data-sourcepos="1:6-2:17">&#x000A;<p data-sourcepos="1:8-2:17">Blockquote&#x000A;continued here.</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;</ol>&#x000A;</blockquote>
+ wysiwyg: |-
+ <blockquote multiline="false"><ol parens="false"><li><p></p><blockquote multiline="false"><p>Blockquote
+ continued here.</p></blockquote></li></ol></blockquote>
+05_02__container_blocks__list_items__42:
+ canonical: |
+ <ul>
+ <li>foo
+ <ul>
+ <li>bar
+ <ul>
+ <li>baz
+ <ul>
+ <li>boo</li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-4:11" dir="auto">&#x000A;<li data-sourcepos="1:1-4:11">foo&#x000A;<ul data-sourcepos="2:3-4:11">&#x000A;<li data-sourcepos="2:3-4:11">bar&#x000A;<ul data-sourcepos="3:5-4:11">&#x000A;<li data-sourcepos="3:5-4:11">baz&#x000A;<ul data-sourcepos="4:7-4:11">&#x000A;<li data-sourcepos="4:7-4:11">boo</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo
+ </p><ul bullet="*"><li><p>bar
+ </p><ul bullet="*"><li><p>baz
+ </p><ul bullet="*"><li><p>boo</p></li></ul></li></ul></li></ul></li></ul>
+05_02__container_blocks__list_items__43:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ <li>bar</li>
+ <li>baz</li>
+ <li>boo</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-4:8" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">foo</li>&#x000A;<li data-sourcepos="2:2-2:6">bar</li>&#x000A;<li data-sourcepos="3:3-3:7">baz</li>&#x000A;<li data-sourcepos="4:4-4:8">boo</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li><li><p>bar</p></li><li><p>baz</p></li><li><p>boo</p></li></ul>
+05_02__container_blocks__list_items__44:
+ canonical: |
+ <ol start="10">
+ <li>foo
+ <ul>
+ <li>bar</li>
+ </ul>
+ </li>
+ </ol>
+ static: |-
+ <ol start="10" data-sourcepos="1:1-2:9" dir="auto">&#x000A;<li data-sourcepos="1:1-2:9">foo&#x000A;<ul data-sourcepos="2:5-2:9">&#x000A;<li data-sourcepos="2:5-2:9">bar</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>foo
+ </p><ul bullet="*"><li><p>bar</p></li></ul></li></ol>
+05_02__container_blocks__list_items__45:
+ canonical: |
+ <ol start="10">
+ <li>foo</li>
+ </ol>
+ <ul>
+ <li>bar</li>
+ </ul>
+ static: |-
+ <ol start="10" data-sourcepos="1:1-1:7" dir="auto">&#x000A;<li data-sourcepos="1:1-1:7">foo</li>&#x000A;</ol>&#x000A;<ul data-sourcepos="2:4-2:8" dir="auto">&#x000A;<li data-sourcepos="2:4-2:8">bar</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ol parens="false"><li><p>foo</p></li></ol>
+05_02__container_blocks__list_items__46:
+ canonical: |
+ <ul>
+ <li>
+ <ul>
+ <li>foo</li>
+ </ul>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-1:7" dir="auto">&#x000A;<li data-sourcepos="1:1-1:7">&#x000A;<ul data-sourcepos="1:3-1:7">&#x000A;<li data-sourcepos="1:3-1:7">foo</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p></p><ul bullet="*"><li><p>foo</p></li></ul></li></ul>
+05_02__container_blocks__list_items__47:
+ canonical: |
+ <ol>
+ <li>
+ <ul>
+ <li>
+ <ol start="2">
+ <li>foo</li>
+ </ol>
+ </li>
+ </ul>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-1:11" dir="auto">&#x000A;<li data-sourcepos="1:1-1:11">&#x000A;<ul data-sourcepos="1:4-1:11">&#x000A;<li data-sourcepos="1:4-1:11">&#x000A;<ol start="2" data-sourcepos="1:6-1:11">&#x000A;<li data-sourcepos="1:6-1:11">foo</li>&#x000A;</ol>&#x000A;</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p></p><ul bullet="*"><li><p></p><ol parens="false"><li><p>foo</p></li></ol></li></ul></li></ol>
+05_02__container_blocks__list_items__48:
+ canonical: |
+ <ul>
+ <li>
+ <h1>Foo</h1>
+ </li>
+ <li>
+ <h2>Bar</h2>
+ baz</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-4:5" dir="auto">&#x000A;<li data-sourcepos="1:1-1:7">&#x000A;<h1 data-sourcepos="1:3-1:7">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>Foo</h1>&#x000A;</li>&#x000A;<li data-sourcepos="2:1-4:5">&#x000A;<h2 data-sourcepos="2:3-4:5">&#x000A;<a id="user-content-bar" class="anchor" href="#bar" aria-hidden="true"></a>Bar</h2>&#x000A;baz</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p></p><h1>Foo</h1></li><li><p></p><h2>Bar
+ baz</h2></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__49:
+ canonical: |
+ <ul>
+ <li><input disabled="" type="checkbox"> foo</li>
+ <li><input checked="" disabled="" type="checkbox"> bar</li>
+ </ul>
+ <ul>
+ <li><input checked="" disabled="" type="checkbox"> foo
+ <ul>
+ <li><input disabled="" type="checkbox"> bar</li>
+ <li><input checked="" disabled="" type="checkbox"> baz</li>
+ </ul>
+ </li>
+ <li><input disabled="" type="checkbox"> bim</li>
+ </ul>
+ <ul>
+ <li>foo</li>
+ <li>bar</li>
+ </ul>
+ <ul>
+ <li>baz</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-8:5" class="task-list" dir="auto">&#x000A;<li data-sourcepos="1:1-1:9" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> foo</li>&#x000A;<li data-sourcepos="2:1-2:9" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" checked disabled> bar</li>&#x000A;<li data-sourcepos="3:1-5:11" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" checked disabled> foo&#x000A;<ul data-sourcepos="4:3-5:11" class="task-list">&#x000A;<li data-sourcepos="4:3-4:11" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> bar</li>&#x000A;<li data-sourcepos="5:3-5:11" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" checked disabled> baz</li>&#x000A;</ul>&#x000A;</li>&#x000A;<li data-sourcepos="6:1-6:9" class="task-list-item">&#x000A;<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> bim</li>&#x000A;<li data-sourcepos="7:1-7:5">foo</li>&#x000A;<li data-sourcepos="8:1-8:5">bar</li>&#x000A;</ul>&#x000A;<ul data-sourcepos="9:1-9:5" dir="auto">&#x000A;<li data-sourcepos="9:1-9:5">baz</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>[ ] foo</p></li><li><p>[x] bar</p></li><li><p>[x] foo
+ </p><ul bullet="*"><li><p>[ ] bar</p></li><li><p>[x] baz</p></li></ul></li><li><p>[ ] bim</p></li><li><p>foo</p></li><li><p>bar</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__50:
+ canonical: |
+ <ol>
+ <li>foo</li>
+ <li>bar</li>
+ </ol>
+ <ol start="3">
+ <li>baz</li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-2:6" dir="auto">&#x000A;<li data-sourcepos="1:1-1:6">foo</li>&#x000A;<li data-sourcepos="2:1-2:6">bar</li>&#x000A;</ol>&#x000A;<ol start="3" data-sourcepos="3:1-3:6" dir="auto">&#x000A;<li data-sourcepos="3:1-3:6">baz</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>foo</p></li><li><p>bar</p></li></ol>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__51:
+ canonical: |
+ <p>Foo</p>
+ <ul>
+ <li>bar</li>
+ <li>baz</li>
+ </ul>
+ static: |-
+ <p data-sourcepos="1:1-1:3" dir="auto">Foo</p>&#x000A;<ul data-sourcepos="2:1-3:5" dir="auto">&#x000A;<li data-sourcepos="2:1-2:5">bar</li>&#x000A;<li data-sourcepos="3:1-3:5">baz</li>&#x000A;</ul>
+ wysiwyg: |-
+ <p>Foo</p>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__52:
+ canonical: |
+ <p>The number of windows in my house is
+ 14. The number of doors is 6.</p>
+ static: |-
+ <p data-sourcepos="1:1-2:30" dir="auto">The number of windows in my house is&#x000A;14. The number of doors is 6.</p>
+ wysiwyg: |-
+ <p>The number of windows in my house is
+ 14. The number of doors is 6.</p>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__53:
+ canonical: |
+ <p>The number of windows in my house is</p>
+ <ol>
+ <li>The number of doors is 6.</li>
+ </ol>
+ static: |-
+ <p data-sourcepos="1:1-1:36" dir="auto">The number of windows in my house is</p>&#x000A;<ol data-sourcepos="2:1-2:29" dir="auto">&#x000A;<li data-sourcepos="2:1-2:29">The number of doors is 6.</li>&#x000A;</ol>
+ wysiwyg: |-
+ <p>The number of windows in my house is</p>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__54:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ </li>
+ <li>
+ <p>bar</p>
+ </li>
+ <li>
+ <p>baz</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-6:5" dir="auto">&#x000A;<li data-sourcepos="1:1-2:0">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;</li>&#x000A;<li data-sourcepos="3:1-5:0">&#x000A;<p data-sourcepos="3:3-3:5">bar</p>&#x000A;</li>&#x000A;<li data-sourcepos="6:1-6:5">&#x000A;<p data-sourcepos="6:3-6:5">baz</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p></li><li><p>bar</p></li><li><p>baz</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__55:
+ canonical: |
+ <ul>
+ <li>foo
+ <ul>
+ <li>bar
+ <ul>
+ <li>
+ <p>baz</p>
+ <p>bim</p>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-6:9" dir="auto">&#x000A;<li data-sourcepos="1:1-6:9">foo&#x000A;<ul data-sourcepos="2:3-6:9">&#x000A;<li data-sourcepos="2:3-6:9">bar&#x000A;<ul data-sourcepos="3:5-6:9">&#x000A;<li data-sourcepos="3:5-6:9">&#x000A;<p data-sourcepos="3:7-3:9">baz</p>&#x000A;<p data-sourcepos="6:7-6:9">bim</p>&#x000A;</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo
+ </p><ul bullet="*"><li><p>bar
+ </p><ul bullet="*"><li><p>baz</p><p>bim</p></li></ul></li></ul></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__56:
+ canonical: |
+ <ul>
+ <li>foo</li>
+ <li>bar</li>
+ </ul>
+ <!-- -->
+ <ul>
+ <li>baz</li>
+ <li>bim</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-3:0" dir="auto">&#x000A;<li data-sourcepos="1:1-1:5">foo</li>&#x000A;<li data-sourcepos="2:1-3:0">bar</li>&#x000A;</ul>&#x000A;&#x000A;<ul data-sourcepos="6:1-7:5" dir="auto">&#x000A;<li data-sourcepos="6:1-6:5">baz</li>&#x000A;<li data-sourcepos="7:1-7:5">bim</li>&#x000A;</ul>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__57:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ <p>notcode</p>
+ </li>
+ <li>
+ <p>foo</p>
+ </li>
+ </ul>
+ <!-- -->
+ <pre><code>code
+ </code></pre>
+ static: |-
+ <ul data-sourcepos="1:1-6:0" dir="auto">&#x000A;<li data-sourcepos="1:1-4:0">&#x000A;<p data-sourcepos="1:5-1:7">foo</p>&#x000A;<p data-sourcepos="3:5-3:11">notcode</p>&#x000A;</li>&#x000A;<li data-sourcepos="5:1-6:0">&#x000A;<p data-sourcepos="5:5-5:7">foo</p>&#x000A;</li>&#x000A;</ul>&#x000A;&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="9:5-9:8" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">code</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__58:
+ canonical: |
+ <ul>
+ <li>a</li>
+ <li>b</li>
+ <li>c</li>
+ <li>d</li>
+ <li>e</li>
+ <li>f</li>
+ <li>g</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-7:3" dir="auto">&#x000A;<li data-sourcepos="1:1-1:3">a</li>&#x000A;<li data-sourcepos="2:2-2:4">b</li>&#x000A;<li data-sourcepos="3:3-3:5">c</li>&#x000A;<li data-sourcepos="4:4-4:6">d</li>&#x000A;<li data-sourcepos="5:3-5:5">e</li>&#x000A;<li data-sourcepos="6:2-6:4">f</li>&#x000A;<li data-sourcepos="7:1-7:3">g</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p></li><li><p>b</p></li><li><p>c</p></li><li><p>d</p></li><li><p>e</p></li><li><p>f</p></li><li><p>g</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__59:
+ canonical: |
+ <ol>
+ <li>
+ <p>a</p>
+ </li>
+ <li>
+ <p>b</p>
+ </li>
+ <li>
+ <p>c</p>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-5:7" dir="auto">&#x000A;<li data-sourcepos="1:1-2:0">&#x000A;<p data-sourcepos="1:4-1:4">a</p>&#x000A;</li>&#x000A;<li data-sourcepos="3:3-4:0">&#x000A;<p data-sourcepos="3:6-3:6">b</p>&#x000A;</li>&#x000A;<li data-sourcepos="5:4-5:7">&#x000A;<p data-sourcepos="5:7-5:7">c</p>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p>a</p></li><li><p>b</p></li><li><p>c</p></li></ol>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__60:
+ canonical: |
+ <ul>
+ <li>a</li>
+ <li>b</li>
+ <li>c</li>
+ <li>d
+ - e</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-5:7" dir="auto">&#x000A;<li data-sourcepos="1:1-1:3">a</li>&#x000A;<li data-sourcepos="2:2-2:4">b</li>&#x000A;<li data-sourcepos="3:3-3:5">c</li>&#x000A;<li data-sourcepos="4:4-5:7">d&#x000A;- e</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p></li><li><p>b</p></li><li><p>c</p></li><li><p>d
+ - e</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__61:
+ canonical: |
+ <ol>
+ <li>
+ <p>a</p>
+ </li>
+ <li>
+ <p>b</p>
+ </li>
+ </ol>
+ <pre><code>3. c
+ </code></pre>
+ static: |-
+ <ol data-sourcepos="1:1-4:0" dir="auto">&#x000A;<li data-sourcepos="1:1-2:0">&#x000A;<p data-sourcepos="1:4-1:4">a</p>&#x000A;</li>&#x000A;<li data-sourcepos="3:3-4:0">&#x000A;<p data-sourcepos="3:6-3:6">b</p>&#x000A;</li>&#x000A;</ol>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="5:5-5:8" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">3. c</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <ol parens="false"><li><p>a</p></li><li><p>b</p></li></ol>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__62:
+ canonical: |
+ <ul>
+ <li>
+ <p>a</p>
+ </li>
+ <li>
+ <p>b</p>
+ </li>
+ <li>
+ <p>c</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-4:3" dir="auto">&#x000A;<li data-sourcepos="1:1-1:3">&#x000A;<p data-sourcepos="1:3-1:3">a</p>&#x000A;</li>&#x000A;<li data-sourcepos="2:1-3:0">&#x000A;<p data-sourcepos="2:3-2:3">b</p>&#x000A;</li>&#x000A;<li data-sourcepos="4:1-4:3">&#x000A;<p data-sourcepos="4:3-4:3">c</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p></li><li><p>b</p></li><li><p>c</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__63:
+ canonical: |
+ <ul>
+ <li>
+ <p>a</p>
+ </li>
+ <li></li>
+ <li>
+ <p>c</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-4:3" dir="auto">&#x000A;<li data-sourcepos="1:1-1:3">&#x000A;<p data-sourcepos="1:3-1:3">a</p>&#x000A;</li>&#x000A;<li data-sourcepos="2:1-2:1">&#x000A;</li>&#x000A;<li data-sourcepos="4:1-4:3">&#x000A;<p data-sourcepos="4:3-4:3">c</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p></li><li><p></p></li><li><p>c</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__64:
+ canonical: |
+ <ul>
+ <li>
+ <p>a</p>
+ </li>
+ <li>
+ <p>b</p>
+ <p>c</p>
+ </li>
+ <li>
+ <p>d</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-5:3" dir="auto">&#x000A;<li data-sourcepos="1:1-1:3">&#x000A;<p data-sourcepos="1:3-1:3">a</p>&#x000A;</li>&#x000A;<li data-sourcepos="2:1-4:3">&#x000A;<p data-sourcepos="2:3-2:3">b</p>&#x000A;<p data-sourcepos="4:3-4:3">c</p>&#x000A;</li>&#x000A;<li data-sourcepos="5:1-5:3">&#x000A;<p data-sourcepos="5:3-5:3">d</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p></li><li><p>b</p><p>c</p></li><li><p>d</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__65:
+ canonical: |
+ <ul>
+ <li>
+ <p>a</p>
+ </li>
+ <li>
+ <p>b</p>
+ </li>
+ <li>
+ <p>d</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-5:3" dir="auto">&#x000A;<li data-sourcepos="1:1-1:3">&#x000A;<p data-sourcepos="1:3-1:3">a</p>&#x000A;</li>&#x000A;<li data-sourcepos="2:1-4:13">&#x000A;<p data-sourcepos="2:3-2:3">b</p>&#x000A;</li>&#x000A;<li data-sourcepos="5:1-5:3">&#x000A;<p data-sourcepos="5:3-5:3">d</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p></li><li><p>b</p></li><li><p>d</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__66:
+ canonical: |
+ <ul>
+ <li>a</li>
+ <li>
+ <pre><code>b
+
+
+ </code></pre>
+ </li>
+ <li>c</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-7:3" dir="auto">&#x000A;<li data-sourcepos="1:1-1:3">a</li>&#x000A;<li data-sourcepos="2:1-6:5">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="2:3-6:5" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">b</span>&#x000A;<span id="LC2" class="line" lang="plaintext"></span>&#x000A;<span id="LC3" class="line" lang="plaintext"></span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;<li data-sourcepos="7:1-7:3">c</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p></li><li><p></p><pre class="content-editor-code-block undefined code highlight"><code>b
+
+ </code></pre></li><li><p>c</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__67:
+ canonical: |
+ <ul>
+ <li>a
+ <ul>
+ <li>
+ <p>b</p>
+ <p>c</p>
+ </li>
+ </ul>
+ </li>
+ <li>d</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-5:3" dir="auto">&#x000A;<li data-sourcepos="1:1-4:5">a&#x000A;<ul data-sourcepos="2:3-4:5">&#x000A;<li data-sourcepos="2:3-4:5">&#x000A;<p data-sourcepos="2:5-2:5">b</p>&#x000A;<p data-sourcepos="4:5-4:5">c</p>&#x000A;</li>&#x000A;</ul>&#x000A;</li>&#x000A;<li data-sourcepos="5:1-5:3">d</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a
+ </p><ul bullet="*"><li><p>b</p><p>c</p></li></ul></li><li><p>d</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__68:
+ canonical: |
+ <ul>
+ <li>a
+ <blockquote>
+ <p>b</p>
+ </blockquote>
+ </li>
+ <li>c</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-4:3" dir="auto">&#x000A;<li data-sourcepos="1:1-3:3">a&#x000A;<blockquote data-sourcepos="2:3-3:3">&#x000A;<p data-sourcepos="2:5-2:5">b</p>&#x000A;</blockquote>&#x000A;</li>&#x000A;<li data-sourcepos="4:1-4:3">c</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a
+ </p><blockquote multiline="false"><p>b</p></blockquote></li><li><p>c</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__69:
+ canonical: |
+ <ul>
+ <li>a
+ <blockquote>
+ <p>b</p>
+ </blockquote>
+ <pre><code>c
+ </code></pre>
+ </li>
+ <li>d</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-6:3" dir="auto">&#x000A;<li data-sourcepos="1:1-5:5">a&#x000A;<blockquote data-sourcepos="2:3-2:5">&#x000A;<p data-sourcepos="2:5-2:5">b</p>&#x000A;</blockquote>&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="3:3-5:5" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">c</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;</li>&#x000A;<li data-sourcepos="6:1-6:3">d</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a
+ </p><blockquote multiline="false"><p>b</p></blockquote><pre class="content-editor-code-block undefined code highlight"><code>c</code></pre></li><li><p>d</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__70:
+ canonical: |
+ <ul>
+ <li>a</li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-1:3" dir="auto">&#x000A;<li data-sourcepos="1:1-1:3">a</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__71:
+ canonical: |
+ <ul>
+ <li>a
+ <ul>
+ <li>b</li>
+ </ul>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-2:5" dir="auto">&#x000A;<li data-sourcepos="1:1-2:5">a&#x000A;<ul data-sourcepos="2:3-2:5">&#x000A;<li data-sourcepos="2:3-2:5">b</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a
+ </p><ul bullet="*"><li><p>b</p></li></ul></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__72:
+ canonical: |
+ <ol>
+ <li>
+ <pre><code>foo
+ </code></pre>
+ <p>bar</p>
+ </li>
+ </ol>
+ static: |-
+ <ol data-sourcepos="1:1-5:6" dir="auto">&#x000A;<li data-sourcepos="1:1-5:6">&#x000A;<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:4-3:6" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>&#x000A;<p data-sourcepos="5:4-5:6">bar</p>&#x000A;</li>&#x000A;</ol>
+ wysiwyg: |-
+ <ol parens="false"><li><p></p><pre class="content-editor-code-block undefined code highlight"><code>foo</code></pre><p>bar</p></li></ol>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__73:
+ canonical: |
+ <ul>
+ <li>
+ <p>foo</p>
+ <ul>
+ <li>bar</li>
+ </ul>
+ <p>baz</p>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-4:5" dir="auto">&#x000A;<li data-sourcepos="1:1-4:5">&#x000A;<p data-sourcepos="1:3-1:5">foo</p>&#x000A;<ul data-sourcepos="2:3-3:0">&#x000A;<li data-sourcepos="2:3-3:0">bar</li>&#x000A;</ul>&#x000A;<p data-sourcepos="4:3-4:5">baz</p>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>foo</p><ul bullet="*"><li><p>bar</p></li></ul><p>baz</p></li></ul>
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__74:
+ canonical: |
+ <ul>
+ <li>
+ <p>a</p>
+ <ul>
+ <li>b</li>
+ <li>c</li>
+ </ul>
+ </li>
+ <li>
+ <p>d</p>
+ <ul>
+ <li>e</li>
+ <li>f</li>
+ </ul>
+ </li>
+ </ul>
+ static: |-
+ <ul data-sourcepos="1:1-7:5" dir="auto">&#x000A;<li data-sourcepos="1:1-4:0">&#x000A;<p data-sourcepos="1:3-1:3">a</p>&#x000A;<ul data-sourcepos="2:3-4:0">&#x000A;<li data-sourcepos="2:3-2:5">b</li>&#x000A;<li data-sourcepos="3:3-4:0">c</li>&#x000A;</ul>&#x000A;</li>&#x000A;<li data-sourcepos="5:1-7:5">&#x000A;<p data-sourcepos="5:3-5:3">d</p>&#x000A;<ul data-sourcepos="6:3-7:5">&#x000A;<li data-sourcepos="6:3-6:5">e</li>&#x000A;<li data-sourcepos="7:3-7:5">f</li>&#x000A;</ul>&#x000A;</li>&#x000A;</ul>
+ wysiwyg: |-
+ <ul bullet="*"><li><p>a</p><ul bullet="*"><li><p>b</p></li><li><p>c</p></li></ul></li><li><p>d</p><ul bullet="*"><li><p>e</p></li><li><p>f</p></li></ul></li></ul>
+06_01__inlines__01:
+ canonical: |
+ <p><code>hi</code>lo`</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><code>hi</code>lo`</p>
+ wysiwyg: |-
+ <p><code>hi</code>lo`</p>
+06_02__inlines__backslash_escapes__01:
+ canonical: |
+ <p>!&quot;#$%&amp;'()*+,-./:;&lt;=&gt;?@[\]^_`{|}~</p>
+ static: |-
+ <p data-sourcepos="1:1-1:224" dir="auto"><span>!</span>"<span>#</span><span>$</span><span>%</span><span>&amp;</span>'()*+,-./:;&lt;=&gt;?<span>@</span>[\]<span>^</span>_`{|}<span>~</span></p>
+ wysiwyg: |-
+ <p>!"#$%&amp;'()*+,-./:;&lt;=&gt;?@[\]^_`{|}~</p>
+06_02__inlines__backslash_escapes__02:
+ canonical: "<p>\\\t\\A\\a\\ \\3\\φ\\«</p>\n"
+ static: "<p data-sourcepos=\"1:1-1:16\" dir=\"auto\">\\\t\\A\\a\\ \\3\\φ\\«</p>"
+ wysiwyg: "<p>\\\t\\A\\a\\ \\3\\φ\\«</p>"
+06_02__inlines__backslash_escapes__03:
+ canonical: |
+ <p>*not emphasized*
+ &lt;br/&gt; not a tag
+ [not a link](/foo)
+ `not code`
+ 1. not a list
+ * not a list
+ # not a heading
+ [foo]: /url &quot;not a reference&quot;
+ &amp;ouml; not a character entity</p>
+ static: |-
+ <p data-sourcepos="1:1-9:50" dir="auto">*not emphasized*&#x000A;&lt;br/&gt; not a tag&#x000A;<a href="/foo">not a link</a>&#x000A;`not code`&#x000A;1. not a list&#x000A;* not a list&#x000A;<span>#</span> not a heading&#x000A;[foo]: /url "not a reference"&#x000A;<span>&amp;</span>ouml; not a character entity</p>
+ wysiwyg: |-
+ <p>*not emphasized*
+ &lt;br/&gt; not a tag
+ [not a link](/foo)
+ `not code`
+ 1. not a list
+ * not a list
+ # not a heading
+ [foo]: /url "not a reference"
+ &amp;ouml; not a character entity</p>
+06_02__inlines__backslash_escapes__04:
+ canonical: |
+ <p>\<em>emphasis</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">\<em>emphasis</em></p>
+ wysiwyg: |-
+ <p>\<em>emphasis</em></p>
+06_02__inlines__backslash_escapes__05:
+ canonical: |
+ <p>foo<br />
+ bar</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">foo<br>&#x000A;bar</p>
+ wysiwyg: |-
+ <p>foo<br>
+ bar</p>
+06_02__inlines__backslash_escapes__06:
+ canonical: |
+ <p><code>\[\`</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><code>\[\`</code></p>
+ wysiwyg: |-
+ <p><code>\[\`</code></p>
+06_02__inlines__backslash_escapes__07:
+ canonical: |
+ <pre><code>\[\]
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-1:8" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">\[\]</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>\[\]</code></pre>
+06_02__inlines__backslash_escapes__08:
+ canonical: |
+ <pre><code>\[\]
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">\[\]</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>\[\]</code></pre>
+06_02__inlines__backslash_escapes__09:
+ canonical: |
+ <p><a href="http://example.com?find=%5C*">http://example.com?find=\*</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:28" dir="auto"><a href="http://example.com?find=%5C*" rel="nofollow noreferrer noopener" target="_blank">http://example.com?find=\*</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="http://example.com?find=%5C*">http://example.com?find=\*</a></p>
+06_02__inlines__backslash_escapes__10:
+ canonical: |
+ <a href="/bar\/)">
+ static: |-
+ <a href="/bar%5C/)" rel="nofollow noreferrer noopener" target="_blank"></a>
+ wysiwyg: |-
+ <p></p>
+06_02__inlines__backslash_escapes__11:
+ canonical: |
+ <p><a href="/bar*" title="ti*tle">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:23" dir="auto"><a href="/bar*" title="ti*tle">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/bar*" title="ti*tle">foo</a></p>
+06_02__inlines__backslash_escapes__12:
+ canonical: |
+ <p><a href="/bar*" title="ti*tle">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto"><a href="/bar*" title="ti*tle">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/bar*" title="ti*tle">foo</a></p>
+06_02__inlines__backslash_escapes__13:
+ canonical: |
+ <pre><code class="language-foo+bar">foo
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre language="foo+bar" class="content-editor-code-block undefined code highlight"><code>foo</code></pre>
+06_03__inlines__entity_and_numeric_character_references__01:
+ canonical: |
+ <p>  &amp; © Æ Ď
+ ¾ ℋ ⅆ
+ ∲ ≧̸</p>
+ static: |-
+ <p data-sourcepos="1:1-3:32" dir="auto">  &amp; © Æ Ď&#x000A;¾ ℋ ⅆ&#x000A;∲ ≧̸</p>
+ wysiwyg: |-
+ <p>&nbsp; &amp; © Æ Ď
+ ¾ ℋ ⅆ
+ ∲ ≧̸</p>
+06_03__inlines__entity_and_numeric_character_references__02:
+ canonical: |
+ <p># Ӓ Ϡ �</p>
+ static: |-
+ <p data-sourcepos="1:1-1:25" dir="auto"># Ӓ Ϡ �</p>
+ wysiwyg: |-
+ <p># Ӓ Ϡ �</p>
+06_03__inlines__entity_and_numeric_character_references__03:
+ canonical: |
+ <p>&quot; ആ ಫ</p>
+ static: |-
+ <p data-sourcepos="1:1-1:22" dir="auto">" ആ ಫ</p>
+ wysiwyg: |-
+ <p>" ആ ಫ</p>
+06_03__inlines__entity_and_numeric_character_references__04:
+ canonical: |
+ <p>&amp;nbsp &amp;x; &amp;#; &amp;#x;
+ &amp;#987654321;
+ &amp;#abcdef0;
+ &amp;ThisIsNotDefined; &amp;hi?;</p>
+ static: |-
+ <p data-sourcepos="1:1-4:24" dir="auto">&amp;nbsp &amp;x; &amp;#; &amp;#x;&#x000A;&amp;#987654321;&#x000A;&amp;#abcdef0;&#x000A;&amp;ThisIsNotDefined; &amp;hi?;</p>
+ wysiwyg: |-
+ <p>&amp;nbsp &amp;x; &amp;#; &amp;#x;
+ &amp;#987654321;
+ &amp;#abcdef0;
+ &amp;ThisIsNotDefined; &amp;hi?;</p>
+06_03__inlines__entity_and_numeric_character_references__05:
+ canonical: |
+ <p>&amp;copy</p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto">&amp;copy</p>
+ wysiwyg: |-
+ <p>&amp;copy</p>
+06_03__inlines__entity_and_numeric_character_references__06:
+ canonical: |
+ <p>&amp;MadeUpEntity;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:14" dir="auto">&amp;MadeUpEntity;</p>
+ wysiwyg: |-
+ <p>&amp;MadeUpEntity;</p>
+06_03__inlines__entity_and_numeric_character_references__07:
+ canonical: |
+ <a href="&ouml;&ouml;.html">
+ static: |-
+ <a href="%C3%B6%C3%B6.html" rel="nofollow noreferrer noopener" target="_blank"></a>
+ wysiwyg: |-
+ <p></p>
+06_03__inlines__entity_and_numeric_character_references__08:
+ canonical: |
+ <p><a href="/f%C3%B6%C3%B6" title="föö">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:37" dir="auto"><a href="/f%C3%B6%C3%B6" title="föö">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/f%C3%B6%C3%B6" title="föö">foo</a></p>
+06_03__inlines__entity_and_numeric_character_references__09:
+ canonical: |
+ <p><a href="/f%C3%B6%C3%B6" title="föö">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto"><a href="/f%C3%B6%C3%B6" title="föö">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/f%C3%B6%C3%B6" title="föö">foo</a></p>
+06_03__inlines__entity_and_numeric_character_references__10:
+ canonical: |
+ <pre><code class="language-föö">foo
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">foo</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre language="föö" class="content-editor-code-block undefined code highlight"><code>foo</code></pre>
+06_03__inlines__entity_and_numeric_character_references__11:
+ canonical: |
+ <p><code>f&amp;ouml;&amp;ouml;</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><code>f&amp;ouml;&amp;ouml;</code></p>
+ wysiwyg: |-
+ <p><code>f&amp;ouml;&amp;ouml;</code></p>
+06_03__inlines__entity_and_numeric_character_references__12:
+ canonical: |
+ <pre><code>f&amp;ouml;f&amp;ouml;
+ </code></pre>
+ static: |-
+ <div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:5-1:18" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">f&amp;ouml;f&amp;ouml;</span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>
+ wysiwyg: |-
+ <pre class="content-editor-code-block undefined code highlight"><code>f&amp;ouml;f&amp;ouml;</code></pre>
+06_03__inlines__entity_and_numeric_character_references__13:
+ canonical: |
+ <p>*foo*
+ <em>foo</em></p>
+ static: |-
+ <p data-sourcepos="1:1-2:5" dir="auto">*foo*&#x000A;<em>foo</em></p>
+ wysiwyg: |-
+ <p>*foo*
+ <em>foo</em></p>
+06_03__inlines__entity_and_numeric_character_references__14:
+ canonical: |
+ <p>* foo</p>
+ <ul>
+ <li>foo</li>
+ </ul>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">* foo</p>&#x000A;<ul data-sourcepos="3:1-3:5" dir="auto">&#x000A;<li data-sourcepos="3:1-3:5">foo</li>&#x000A;</ul>
+ wysiwyg: |-
+ <p>* foo</p>
+06_03__inlines__entity_and_numeric_character_references__15:
+ canonical: |
+ <p>foo
+
+ bar</p>
+ static: |-
+ <p data-sourcepos="1:1-1:16" dir="auto">foo&#x000A;&#x000A;bar</p>
+ wysiwyg: |-
+ <p>foo
+
+ bar</p>
+06_03__inlines__entity_and_numeric_character_references__16:
+ canonical: "<p>\tfoo</p>\n"
+ static: "<p data-sourcepos=\"1:1-1:7\" dir=\"auto\">\tfoo</p>"
+ wysiwyg: "<p>\tfoo</p>"
+06_03__inlines__entity_and_numeric_character_references__17:
+ canonical: |
+ <p>[a](url &quot;tit&quot;)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto"><a href="url" title="tit">a</a></p>
+ wysiwyg: |-
+ <p>[a](url "tit")</p>
+06_04__inlines__code_spans__01:
+ canonical: |
+ <p><code>foo</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto"><code>foo</code></p>
+ wysiwyg: |-
+ <p><code>foo</code></p>
+06_04__inlines__code_spans__02:
+ canonical: |
+ <p><code>foo ` bar</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><code>foo ` bar</code></p>
+ wysiwyg: |-
+ <p><code>foo ` bar</code></p>
+06_04__inlines__code_spans__03:
+ canonical: |
+ <p><code>``</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto"><code>``</code></p>
+ wysiwyg: |-
+ <p><code>``</code></p>
+06_04__inlines__code_spans__04:
+ canonical: |
+ <p><code> `` </code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><code> `` </code></p>
+ wysiwyg: |-
+ <p><code> `` </code></p>
+06_04__inlines__code_spans__05:
+ canonical: |
+ <p><code> a</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:4" dir="auto"><code> a</code></p>
+ wysiwyg: |-
+ <p><code> a</code></p>
+06_04__inlines__code_spans__06:
+ canonical: |
+ <p><code> b </code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><code> b </code></p>
+ wysiwyg: |-
+ <p><code>&nbsp;b&nbsp;</code></p>
+06_04__inlines__code_spans__07:
+ canonical: |
+ <p><code> </code>
+ <code> </code></p>
+ static: |-
+ <p data-sourcepos="1:1-2:4" dir="auto"><code> </code>&#x000A;<code> </code></p>
+ wysiwyg: |-
+ <p></p>
+06_04__inlines__code_spans__08:
+ canonical: |
+ <p><code>foo bar baz</code></p>
+ static: |-
+ <p data-sourcepos="1:1-5:2" dir="auto"><code>foo bar baz</code></p>
+ wysiwyg: |-
+ <p><code>foo bar baz</code></p>
+06_04__inlines__code_spans__09:
+ canonical: |
+ <p><code>foo </code></p>
+ static: |-
+ <p data-sourcepos="1:1-3:2" dir="auto"><code>foo </code></p>
+ wysiwyg: |-
+ <p><code>foo </code></p>
+06_04__inlines__code_spans__10:
+ canonical: |
+ <p><code>foo bar baz</code></p>
+ static: |-
+ <p data-sourcepos="1:1-2:4" dir="auto"><code>foo bar baz</code></p>
+ wysiwyg: |-
+ <p><code>foo bar baz</code></p>
+06_04__inlines__code_spans__11:
+ canonical: |
+ <p><code>foo\</code>bar`</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><code>foo\</code>bar`</p>
+ wysiwyg: |-
+ <p><code>foo\</code>bar`</p>
+06_04__inlines__code_spans__12:
+ canonical: |
+ <p><code>foo`bar</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><code>foo`bar</code></p>
+ wysiwyg: |-
+ <p><code>foo`bar</code></p>
+06_04__inlines__code_spans__13:
+ canonical: |
+ <p><code>foo `` bar</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:14" dir="auto"><code>foo `` bar</code></p>
+ wysiwyg: |-
+ <p><code>foo `` bar</code></p>
+06_04__inlines__code_spans__14:
+ canonical: |
+ <p>*foo<code>*</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">*foo<code>*</code></p>
+ wysiwyg: |-
+ <p>*foo<code>*</code></p>
+06_04__inlines__code_spans__15:
+ canonical: |
+ <p>[not a <code>link](/foo</code>)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:20" dir="auto">[not a <code>link](/foo</code>)</p>
+ wysiwyg: |-
+ <p>[not a <code>link](/foo</code>)</p>
+06_04__inlines__code_spans__16:
+ canonical: |
+ <p><code>&lt;a href=&quot;</code>&quot;&gt;`</p>
+ static: |-
+ <p data-sourcepos="1:1-1:14" dir="auto"><code>&lt;a href="</code>"&gt;`</p>
+ wysiwyg: |-
+ <p><code>&lt;a href="</code>"&gt;`</p>
+06_04__inlines__code_spans__17:
+ canonical: |
+ <p><a href="`">`</p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><a href="%60" rel="nofollow noreferrer noopener" target="_blank">`</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="`">`</a></p>
+06_04__inlines__code_spans__18:
+ canonical: |
+ <p><code>&lt;http://foo.bar.</code>baz&gt;`</p>
+ static: |-
+ <p data-sourcepos="1:1-1:23" dir="auto"><code>&lt;http://foo.bar.</code>baz&gt;`</p>
+ wysiwyg: |-
+ <p><code>&lt;http://foo.bar.</code>baz&gt;`</p>
+06_04__inlines__code_spans__19:
+ canonical: |
+ <p><a href="http://foo.bar.%60baz">http://foo.bar.`baz</a>`</p>
+ static: |-
+ <p data-sourcepos="1:1-1:22" dir="auto"><a href="http://foo.bar.%60baz" rel="nofollow noreferrer noopener" target="_blank">http://foo.bar.`baz</a>`</p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="http://foo.bar.%60baz">http://foo.bar.`baz</a>`</p>
+06_04__inlines__code_spans__20:
+ canonical: |
+ <p>```foo``</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">```foo``</p>
+ wysiwyg: |-
+ <p>```foo``</p>
+06_04__inlines__code_spans__21:
+ canonical: |
+ <p>`foo</p>
+ static: |-
+ <p data-sourcepos="1:1-1:4" dir="auto">`foo</p>
+ wysiwyg: |-
+ <p>`foo</p>
+06_04__inlines__code_spans__22:
+ canonical: |
+ <p>`foo<code>bar</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto">`foo<code>bar</code></p>
+ wysiwyg: |-
+ <p>`foo<code>bar</code></p>
+06_05__inlines__emphasis_and_strong_emphasis__01:
+ canonical: |
+ <p><em>foo bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><em>foo bar</em></p>
+ wysiwyg: |-
+ <p><em>foo bar</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__02:
+ canonical: |
+ <p>a * foo bar*</p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">a * foo bar*</p>
+ wysiwyg: |-
+ <p>a * foo bar*</p>
+06_05__inlines__emphasis_and_strong_emphasis__03:
+ canonical: |
+ <p>a*&quot;foo&quot;*</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">a*"foo"*</p>
+ wysiwyg: |-
+ <p>a*"foo"*</p>
+06_05__inlines__emphasis_and_strong_emphasis__04:
+ canonical: |
+ <p>* a *</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">* a *</p>
+ wysiwyg: |-
+ <p>*&nbsp;a&nbsp;*</p>
+06_05__inlines__emphasis_and_strong_emphasis__05:
+ canonical: |
+ <p>foo<em>bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">foo<em>bar</em></p>
+ wysiwyg: |-
+ <p>foo<em>bar</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__06:
+ canonical: |
+ <p>5<em>6</em>78</p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto">5<em>6</em>78</p>
+ wysiwyg: |-
+ <p>5<em>6</em>78</p>
+06_05__inlines__emphasis_and_strong_emphasis__07:
+ canonical: |
+ <p><em>foo bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><em>foo bar</em></p>
+ wysiwyg: |-
+ <p><em>foo bar</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__08:
+ canonical: |
+ <p>_ foo bar_</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">_ foo bar_</p>
+ wysiwyg: |-
+ <p>_ foo bar_</p>
+06_05__inlines__emphasis_and_strong_emphasis__09:
+ canonical: |
+ <p>a_&quot;foo&quot;_</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">a_"foo"_</p>
+ wysiwyg: |-
+ <p>a_"foo"_</p>
+06_05__inlines__emphasis_and_strong_emphasis__10:
+ canonical: |
+ <p>foo_bar_</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">foo_bar_</p>
+ wysiwyg: |-
+ <p>foo_bar_</p>
+06_05__inlines__emphasis_and_strong_emphasis__11:
+ canonical: |
+ <p>5_6_78</p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto">5_6_78</p>
+ wysiwyg: |-
+ <p>5_6_78</p>
+06_05__inlines__emphasis_and_strong_emphasis__12:
+ canonical: |
+ <p>пристаням_стремятся_</p>
+ static: |-
+ <p data-sourcepos="1:1-1:38" dir="auto">пристаням_стремятся_</p>
+ wysiwyg: |-
+ <p>пристаням_стремятся_</p>
+06_05__inlines__emphasis_and_strong_emphasis__13:
+ canonical: |
+ <p>aa_&quot;bb&quot;_cc</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">aa_"bb"_cc</p>
+ wysiwyg: |-
+ <p>aa_"bb"_cc</p>
+06_05__inlines__emphasis_and_strong_emphasis__14:
+ canonical: |
+ <p>foo-<em>(bar)</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto">foo-<em>(bar)</em></p>
+ wysiwyg: |-
+ <p>foo-<em>(bar)</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__15:
+ canonical: |
+ <p>_foo*</p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto">_foo*</p>
+ wysiwyg: |-
+ <p>_foo*</p>
+06_05__inlines__emphasis_and_strong_emphasis__16:
+ canonical: |
+ <p>*foo bar *</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">*foo bar *</p>
+ wysiwyg: |-
+ <p>*foo bar *</p>
+06_05__inlines__emphasis_and_strong_emphasis__17:
+ canonical: |
+ <p>*foo bar
+ *</p>
+ static: |-
+ <p data-sourcepos="1:1-2:1" dir="auto">*foo bar&#x000A;*</p>
+ wysiwyg: |-
+ <p>*foo bar
+ *</p>
+06_05__inlines__emphasis_and_strong_emphasis__18:
+ canonical: |
+ <p>*(*foo)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">*(*foo)</p>
+ wysiwyg: |-
+ <p>*(*foo)</p>
+06_05__inlines__emphasis_and_strong_emphasis__19:
+ canonical: |
+ <p><em>(<em>foo</em>)</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><em>(<em>foo</em>)</em></p>
+ wysiwyg: |-
+ <p><em>(foo</em>)</p>
+06_05__inlines__emphasis_and_strong_emphasis__20:
+ canonical: |
+ <p><em>foo</em>bar</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><em>foo</em>bar</p>
+ wysiwyg: |-
+ <p><em>foo</em>bar</p>
+06_05__inlines__emphasis_and_strong_emphasis__21:
+ canonical: |
+ <p>_foo bar _</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">_foo bar _</p>
+ wysiwyg: |-
+ <p>_foo bar _</p>
+06_05__inlines__emphasis_and_strong_emphasis__22:
+ canonical: |
+ <p>_(_foo)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">_(_foo)</p>
+ wysiwyg: |-
+ <p>_(_foo)</p>
+06_05__inlines__emphasis_and_strong_emphasis__23:
+ canonical: |
+ <p><em>(<em>foo</em>)</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><em>(<em>foo</em>)</em></p>
+ wysiwyg: |-
+ <p><em>(foo</em>)</p>
+06_05__inlines__emphasis_and_strong_emphasis__24:
+ canonical: |
+ <p>_foo_bar</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">_foo_bar</p>
+ wysiwyg: |-
+ <p>_foo_bar</p>
+06_05__inlines__emphasis_and_strong_emphasis__25:
+ canonical: |
+ <p>_пристаням_стремятся</p>
+ static: |-
+ <p data-sourcepos="1:1-1:38" dir="auto">_пристаням_стремятся</p>
+ wysiwyg: |-
+ <p>_пристаням_стремятся</p>
+06_05__inlines__emphasis_and_strong_emphasis__26:
+ canonical: |
+ <p><em>foo_bar_baz</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><em>foo_bar_baz</em></p>
+ wysiwyg: |-
+ <p><em>foo_bar_baz</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__27:
+ canonical: |
+ <p><em>(bar)</em>.</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><em>(bar)</em>.</p>
+ wysiwyg: |-
+ <p><em>(bar)</em>.</p>
+06_05__inlines__emphasis_and_strong_emphasis__28:
+ canonical: |
+ <p><strong>foo bar</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><strong>foo bar</strong></p>
+ wysiwyg: |-
+ <p><strong>foo bar</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__29:
+ canonical: |
+ <p>** foo bar**</p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">** foo bar**</p>
+ wysiwyg: |-
+ <p>** foo bar**</p>
+06_05__inlines__emphasis_and_strong_emphasis__30:
+ canonical: |
+ <p>a**&quot;foo&quot;**</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">a**"foo"**</p>
+ wysiwyg: |-
+ <p>a**"foo"**</p>
+06_05__inlines__emphasis_and_strong_emphasis__31:
+ canonical: |
+ <p>foo<strong>bar</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">foo<strong>bar</strong></p>
+ wysiwyg: |-
+ <p>foo<strong>bar</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__32:
+ canonical: |
+ <p><strong>foo bar</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><strong>foo bar</strong></p>
+ wysiwyg: |-
+ <p><strong>foo bar</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__33:
+ canonical: |
+ <p>__ foo bar__</p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">__ foo bar__</p>
+ wysiwyg: |-
+ <p>__ foo bar__</p>
+06_05__inlines__emphasis_and_strong_emphasis__34:
+ canonical: |
+ <p>__
+ foo bar__</p>
+ static: |-
+ <p data-sourcepos="1:1-2:9" dir="auto">__&#x000A;foo bar__</p>
+ wysiwyg: |-
+ <p>__
+ foo bar__</p>
+06_05__inlines__emphasis_and_strong_emphasis__35:
+ canonical: |
+ <p>a__&quot;foo&quot;__</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">a__"foo"__</p>
+ wysiwyg: |-
+ <p>a__"foo"__</p>
+06_05__inlines__emphasis_and_strong_emphasis__36:
+ canonical: |
+ <p>foo__bar__</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">foo__bar__</p>
+ wysiwyg: |-
+ <p>foo__bar__</p>
+06_05__inlines__emphasis_and_strong_emphasis__37:
+ canonical: |
+ <p>5__6__78</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">5__6__78</p>
+ wysiwyg: |-
+ <p>5__6__78</p>
+06_05__inlines__emphasis_and_strong_emphasis__38:
+ canonical: |
+ <p>пристаням__стремятся__</p>
+ static: |-
+ <p data-sourcepos="1:1-1:40" dir="auto">пристаням__стремятся__</p>
+ wysiwyg: |-
+ <p>пристаням__стремятся__</p>
+06_05__inlines__emphasis_and_strong_emphasis__39:
+ canonical: |
+ <p><strong>foo, <strong>bar</strong>, baz</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto"><strong>foo, <strong>bar</strong>, baz</strong></p>
+ wysiwyg: |-
+ <p><strong>foo, bar</strong>, baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__40:
+ canonical: |
+ <p>foo-<strong>(bar)</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">foo-<strong>(bar)</strong></p>
+ wysiwyg: |-
+ <p>foo-<strong>(bar)</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__41:
+ canonical: |
+ <p>**foo bar **</p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">**foo bar **</p>
+ wysiwyg: |-
+ <p>**foo bar **</p>
+06_05__inlines__emphasis_and_strong_emphasis__42:
+ canonical: |
+ <p>**(**foo)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">**(**foo)</p>
+ wysiwyg: |-
+ <p>**(**foo)</p>
+06_05__inlines__emphasis_and_strong_emphasis__43:
+ canonical: |
+ <p><em>(<strong>foo</strong>)</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><em>(<strong>foo</strong>)</em></p>
+ wysiwyg: |-
+ <p><em>(</em><strong>foo</strong>)</p>
+06_05__inlines__emphasis_and_strong_emphasis__44:
+ canonical: |
+ <p><strong>Gomphocarpus (<em>Gomphocarpus physocarpus</em>, syn.
+ <em>Asclepias physocarpa</em>)</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-2:25" dir="auto"><strong>Gomphocarpus (<em>Gomphocarpus physocarpus</em>, syn.&#x000A;<em>Asclepias physocarpa</em>)</strong></p>
+ wysiwyg: |-
+ <p><strong>Gomphocarpus (</strong><em>Gomphocarpus physocarpus</em>, syn.
+ <em>Asclepias physocarpa</em>)</p>
+06_05__inlines__emphasis_and_strong_emphasis__45:
+ canonical: |
+ <p><strong>foo &quot;<em>bar</em>&quot; foo</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:19" dir="auto"><strong>foo "<em>bar</em>" foo</strong></p>
+ wysiwyg: |-
+ <p><strong>foo "</strong><em>bar</em>" foo</p>
+06_05__inlines__emphasis_and_strong_emphasis__46:
+ canonical: |
+ <p><strong>foo</strong>bar</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><strong>foo</strong>bar</p>
+ wysiwyg: |-
+ <p><strong>foo</strong>bar</p>
+06_05__inlines__emphasis_and_strong_emphasis__47:
+ canonical: |
+ <p>__foo bar __</p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">__foo bar __</p>
+ wysiwyg: |-
+ <p>__foo bar __</p>
+06_05__inlines__emphasis_and_strong_emphasis__48:
+ canonical: |
+ <p>__(__foo)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">__(__foo)</p>
+ wysiwyg: |-
+ <p>__(__foo)</p>
+06_05__inlines__emphasis_and_strong_emphasis__49:
+ canonical: |
+ <p><em>(<strong>foo</strong>)</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><em>(<strong>foo</strong>)</em></p>
+ wysiwyg: |-
+ <p><em>(</em><strong>foo</strong>)</p>
+06_05__inlines__emphasis_and_strong_emphasis__50:
+ canonical: |
+ <p>__foo__bar</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">__foo__bar</p>
+ wysiwyg: |-
+ <p>__foo__bar</p>
+06_05__inlines__emphasis_and_strong_emphasis__51:
+ canonical: |
+ <p>__пристаням__стремятся</p>
+ static: |-
+ <p data-sourcepos="1:1-1:40" dir="auto">__пристаням__стремятся</p>
+ wysiwyg: |-
+ <p>__пристаням__стремятся</p>
+06_05__inlines__emphasis_and_strong_emphasis__52:
+ canonical: |
+ <p><strong>foo__bar__baz</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><strong>foo__bar__baz</strong></p>
+ wysiwyg: |-
+ <p><strong>foo__bar__baz</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__53:
+ canonical: |
+ <p><strong>(bar)</strong>.</p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><strong>(bar)</strong>.</p>
+ wysiwyg: |-
+ <p><strong>(bar)</strong>.</p>
+06_05__inlines__emphasis_and_strong_emphasis__54:
+ canonical: |
+ <p><em>foo <a href="/url">bar</a></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><em>foo <a href="/url">bar</a></em></p>
+ wysiwyg: |-
+ <p><em>foo </em><a target="_blank" rel="noopener noreferrer nofollow" href="/url">bar</a></p>
+06_05__inlines__emphasis_and_strong_emphasis__55:
+ canonical: |
+ <p><em>foo
+ bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-2:4" dir="auto"><em>foo&#x000A;bar</em></p>
+ wysiwyg: |-
+ <p><em>foo
+ bar</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__56:
+ canonical: |
+ <p><em>foo <strong>bar</strong> baz</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><em>foo <strong>bar</strong> baz</em></p>
+ wysiwyg: |-
+ <p><em>foo </em><strong>bar</strong> baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__57:
+ canonical: |
+ <p><em>foo <em>bar</em> baz</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><em>foo <em>bar</em> baz</em></p>
+ wysiwyg: |-
+ <p><em>foo bar</em> baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__58:
+ canonical: |
+ <p><em><em>foo</em> bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><em><em>foo</em> bar</em></p>
+ wysiwyg: |-
+ <p><em>foo</em> bar</p>
+06_05__inlines__emphasis_and_strong_emphasis__59:
+ canonical: |
+ <p><em>foo <em>bar</em></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><em>foo <em>bar</em></em></p>
+ wysiwyg: |-
+ <p><em>foo bar</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__60:
+ canonical: |
+ <p><em>foo <strong>bar</strong> baz</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><em>foo <strong>bar</strong> baz</em></p>
+ wysiwyg: |-
+ <p><em>foo </em><strong>bar</strong> baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__61:
+ canonical: |
+ <p><em>foo<strong>bar</strong>baz</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><em>foo<strong>bar</strong>baz</em></p>
+ wysiwyg: |-
+ <p><em>foo</em><strong>bar</strong>baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__62:
+ canonical: |
+ <p><em>foo**bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><em>foo**bar</em></p>
+ wysiwyg: |-
+ <p><em>foo**bar</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__63:
+ canonical: |
+ <p><em><strong>foo</strong> bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><em><strong>foo</strong> bar</em></p>
+ wysiwyg: |-
+ <p><strong><em>foo</em></strong> bar</p>
+06_05__inlines__emphasis_and_strong_emphasis__64:
+ canonical: |
+ <p><em>foo <strong>bar</strong></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><em>foo <strong>bar</strong></em></p>
+ wysiwyg: |-
+ <p><em>foo </em><strong>bar</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__65:
+ canonical: |
+ <p><em>foo<strong>bar</strong></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto"><em>foo<strong>bar</strong></em></p>
+ wysiwyg: |-
+ <p><em>foo</em><strong>bar</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__66:
+ canonical: |
+ <p>foo<em><strong>bar</strong></em>baz</p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">foo<em><strong>bar</strong></em>baz</p>
+ wysiwyg: |-
+ <p>foo<strong><em>bar</em></strong>baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__67:
+ canonical: |
+ <p>foo<strong><strong><strong>bar</strong></strong></strong>***baz</p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto">foo<strong><strong><strong>bar</strong></strong></strong>***baz</p>
+ wysiwyg: |-
+ <p>foo<strong>bar</strong>***baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__68:
+ canonical: |
+ <p><em>foo <strong>bar <em>baz</em> bim</strong> bop</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:27" dir="auto"><em>foo <strong>bar <em>baz</em> bim</strong> bop</em></p>
+ wysiwyg: |-
+ <p><em>foo </em><strong>bar </strong><em>baz</em> bim bop</p>
+06_05__inlines__emphasis_and_strong_emphasis__69:
+ canonical: |
+ <p><em>foo <a href="/url"><em>bar</em></a></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:19" dir="auto"><em>foo <a href="/url"><em>bar</em></a></em></p>
+ wysiwyg: |-
+ <p><em>foo </em><a target="_blank" rel="noopener noreferrer nofollow" href="/url"><em>bar</em></a></p>
+06_05__inlines__emphasis_and_strong_emphasis__70:
+ canonical: |
+ <p>** is not an empty emphasis</p>
+ static: |-
+ <p data-sourcepos="1:1-1:27" dir="auto">** is not an empty emphasis</p>
+ wysiwyg: |-
+ <p>** is not an empty emphasis</p>
+06_05__inlines__emphasis_and_strong_emphasis__71:
+ canonical: |
+ <p>**** is not an empty strong emphasis</p>
+ static: |-
+ <p data-sourcepos="1:1-1:36" dir="auto">**** is not an empty strong emphasis</p>
+ wysiwyg: |-
+ <p>**** is not an empty strong emphasis</p>
+06_05__inlines__emphasis_and_strong_emphasis__72:
+ canonical: |
+ <p><strong>foo <a href="/url">bar</a></strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:19" dir="auto"><strong>foo <a href="/url">bar</a></strong></p>
+ wysiwyg: |-
+ <p><strong>foo </strong><a target="_blank" rel="noopener noreferrer nofollow" href="/url">bar</a></p>
+06_05__inlines__emphasis_and_strong_emphasis__73:
+ canonical: |
+ <p><strong>foo
+ bar</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-2:5" dir="auto"><strong>foo&#x000A;bar</strong></p>
+ wysiwyg: |-
+ <p><strong>foo
+ bar</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__74:
+ canonical: |
+ <p><strong>foo <em>bar</em> baz</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><strong>foo <em>bar</em> baz</strong></p>
+ wysiwyg: |-
+ <p><strong>foo </strong><em>bar</em> baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__75:
+ canonical: |
+ <p><strong>foo <strong>bar</strong> baz</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:19" dir="auto"><strong>foo <strong>bar</strong> baz</strong></p>
+ wysiwyg: |-
+ <p><strong>foo bar</strong> baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__76:
+ canonical: |
+ <p><strong><strong>foo</strong> bar</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><strong><strong>foo</strong> bar</strong></p>
+ wysiwyg: |-
+ <p><strong>foo</strong> bar</p>
+06_05__inlines__emphasis_and_strong_emphasis__77:
+ canonical: |
+ <p><strong>foo <strong>bar</strong></strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><strong>foo <strong>bar</strong></strong></p>
+ wysiwyg: |-
+ <p><strong>foo bar</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__78:
+ canonical: |
+ <p><strong>foo <em>bar</em> baz</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><strong>foo <em>bar</em> baz</strong></p>
+ wysiwyg: |-
+ <p><strong>foo </strong><em>bar</em> baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__79:
+ canonical: |
+ <p><strong>foo<em>bar</em>baz</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><strong>foo<em>bar</em>baz</strong></p>
+ wysiwyg: |-
+ <p><strong>foo</strong><em>bar</em>baz</p>
+06_05__inlines__emphasis_and_strong_emphasis__80:
+ canonical: |
+ <p><strong><em>foo</em> bar</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><strong><em>foo</em> bar</strong></p>
+ wysiwyg: |-
+ <p><strong><em>foo</em></strong> bar</p>
+06_05__inlines__emphasis_and_strong_emphasis__81:
+ canonical: |
+ <p><strong>foo <em>bar</em></strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><strong>foo <em>bar</em></strong></p>
+ wysiwyg: |-
+ <p><strong>foo </strong><em>bar</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__82:
+ canonical: |
+ <p><strong>foo <em>bar <strong>baz</strong>
+ bim</em> bop</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-2:10" dir="auto"><strong>foo <em>bar <strong>baz</strong>&#x000A;bim</em> bop</strong></p>
+ wysiwyg: |-
+ <p><strong>foo </strong><em>bar </em><strong>baz</strong>
+ bim bop</p>
+06_05__inlines__emphasis_and_strong_emphasis__83:
+ canonical: |
+ <p><strong>foo <a href="/url"><em>bar</em></a></strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto"><strong>foo <a href="/url"><em>bar</em></a></strong></p>
+ wysiwyg: |-
+ <p><strong>foo </strong><a target="_blank" rel="noopener noreferrer nofollow" href="/url"><em>bar</em></a></p>
+06_05__inlines__emphasis_and_strong_emphasis__84:
+ canonical: |
+ <p>__ is not an empty emphasis</p>
+ static: |-
+ <p data-sourcepos="1:1-1:27" dir="auto">__ is not an empty emphasis</p>
+ wysiwyg: |-
+ <p>__ is not an empty emphasis</p>
+06_05__inlines__emphasis_and_strong_emphasis__85:
+ canonical: |
+ <p>____ is not an empty strong emphasis</p>
+ static: |-
+ <p data-sourcepos="1:1-1:36" dir="auto">____ is not an empty strong emphasis</p>
+ wysiwyg: |-
+ <p>____ is not an empty strong emphasis</p>
+06_05__inlines__emphasis_and_strong_emphasis__86:
+ canonical: |
+ <p>foo ***</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">foo ***</p>
+ wysiwyg: |-
+ <p>foo ***</p>
+06_05__inlines__emphasis_and_strong_emphasis__87:
+ canonical: |
+ <p>foo <em>*</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">foo <em>*</em></p>
+ wysiwyg: |-
+ <p>foo <em>*</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__88:
+ canonical: |
+ <p>foo <em>_</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">foo <em>_</em></p>
+ wysiwyg: |-
+ <p>foo <em>_</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__89:
+ canonical: |
+ <p>foo *****</p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">foo *****</p>
+ wysiwyg: |-
+ <p>foo *****</p>
+06_05__inlines__emphasis_and_strong_emphasis__90:
+ canonical: |
+ <p>foo <strong>*</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">foo <strong>*</strong></p>
+ wysiwyg: |-
+ <p>foo <strong>*</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__91:
+ canonical: |
+ <p>foo <strong>_</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">foo <strong>_</strong></p>
+ wysiwyg: |-
+ <p>foo <strong>_</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__92:
+ canonical: |
+ <p>*<em>foo</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto">*<em>foo</em></p>
+ wysiwyg: |-
+ <p>*<em>foo</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__93:
+ canonical: |
+ <p><em>foo</em>*</p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto"><em>foo</em>*</p>
+ wysiwyg: |-
+ <p><em>foo</em>*</p>
+06_05__inlines__emphasis_and_strong_emphasis__94:
+ canonical: |
+ <p>*<strong>foo</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">*<strong>foo</strong></p>
+ wysiwyg: |-
+ <p>*<strong>foo</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__95:
+ canonical: |
+ <p>***<em>foo</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">***<em>foo</em></p>
+ wysiwyg: |-
+ <p>***<em>foo</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__96:
+ canonical: |
+ <p><strong>foo</strong>*</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><strong>foo</strong>*</p>
+ wysiwyg: |-
+ <p><strong>foo</strong>*</p>
+06_05__inlines__emphasis_and_strong_emphasis__97:
+ canonical: |
+ <p><em>foo</em>***</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><em>foo</em>***</p>
+ wysiwyg: |-
+ <p><em>foo</em>***</p>
+06_05__inlines__emphasis_and_strong_emphasis__98:
+ canonical: |
+ <p>foo ___</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">foo ___</p>
+ wysiwyg: |-
+ <p>foo ___</p>
+06_05__inlines__emphasis_and_strong_emphasis__99:
+ canonical: |
+ <p>foo <em>_</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">foo <em>_</em></p>
+ wysiwyg: |-
+ <p>foo <em>_</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__100:
+ canonical: |
+ <p>foo <em>*</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">foo <em>*</em></p>
+ wysiwyg: |-
+ <p>foo <em>*</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__101:
+ canonical: |
+ <p>foo _____</p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">foo _____</p>
+ wysiwyg: |-
+ <p>foo _____</p>
+06_05__inlines__emphasis_and_strong_emphasis__102:
+ canonical: |
+ <p>foo <strong>_</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto">foo <strong>_</strong></p>
+ wysiwyg: |-
+ <p>foo <strong>_</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__103:
+ canonical: |
+ <p>foo <strong>*</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">foo <strong>*</strong></p>
+ wysiwyg: |-
+ <p>foo <strong>*</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__104:
+ canonical: |
+ <p>_<em>foo</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto">_<em>foo</em></p>
+ wysiwyg: |-
+ <p>_<em>foo</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__105:
+ canonical: |
+ <p><em>foo</em>_</p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto"><em>foo</em>_</p>
+ wysiwyg: |-
+ <p><em>foo</em>_</p>
+06_05__inlines__emphasis_and_strong_emphasis__106:
+ canonical: |
+ <p>_<strong>foo</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">_<strong>foo</strong></p>
+ wysiwyg: |-
+ <p>_<strong>foo</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__107:
+ canonical: |
+ <p>___<em>foo</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">___<em>foo</em></p>
+ wysiwyg: |-
+ <p>___<em>foo</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__108:
+ canonical: |
+ <p><strong>foo</strong>_</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><strong>foo</strong>_</p>
+ wysiwyg: |-
+ <p><strong>foo</strong>_</p>
+06_05__inlines__emphasis_and_strong_emphasis__109:
+ canonical: |
+ <p><em>foo</em>___</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><em>foo</em>___</p>
+ wysiwyg: |-
+ <p><em>foo</em>___</p>
+06_05__inlines__emphasis_and_strong_emphasis__110:
+ canonical: |
+ <p><strong>foo</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><strong>foo</strong></p>
+ wysiwyg: |-
+ <p><strong>foo</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__111:
+ canonical: |
+ <p><em><em>foo</em></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><em><em>foo</em></em></p>
+ wysiwyg: |-
+ <p><em>foo</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__112:
+ canonical: |
+ <p><strong>foo</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><strong>foo</strong></p>
+ wysiwyg: |-
+ <p><strong>foo</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__113:
+ canonical: |
+ <p><em><em>foo</em></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><em><em>foo</em></em></p>
+ wysiwyg: |-
+ <p><em>foo</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__114:
+ canonical: |
+ <p><strong><strong>foo</strong></strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><strong><strong>foo</strong></strong></p>
+ wysiwyg: |-
+ <p><strong>foo</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__115:
+ canonical: |
+ <p><strong><strong>foo</strong></strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><strong><strong>foo</strong></strong></p>
+ wysiwyg: |-
+ <p><strong>foo</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__116:
+ canonical: |
+ <p><strong><strong><strong>foo</strong></strong></strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><strong><strong><strong>foo</strong></strong></strong></p>
+ wysiwyg: |-
+ <p><strong>foo</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__117:
+ canonical: |
+ <p><em><strong>foo</strong></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><em><strong>foo</strong></em></p>
+ wysiwyg: |-
+ <p><strong><em>foo</em></strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__118:
+ canonical: |
+ <p><em><strong><strong>foo</strong></strong></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><em><strong><strong>foo</strong></strong></em></p>
+ wysiwyg: |-
+ <p><strong><em>foo</em></strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__119:
+ canonical: |
+ <p><em>foo _bar</em> baz_</p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><em>foo _bar</em> baz_</p>
+ wysiwyg: |-
+ <p><em>foo _bar</em> baz_</p>
+06_05__inlines__emphasis_and_strong_emphasis__120:
+ canonical: |
+ <p><em>foo <strong>bar *baz bim</strong> bam</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:26" dir="auto"><em>foo <strong>bar *baz bim</strong> bam</em></p>
+ wysiwyg: |-
+ <p><em>foo </em><strong>bar *baz bim</strong> bam</p>
+06_05__inlines__emphasis_and_strong_emphasis__121:
+ canonical: |
+ <p>**foo <strong>bar baz</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto">**foo <strong>bar baz</strong></p>
+ wysiwyg: |-
+ <p>**foo <strong>bar baz</strong></p>
+06_05__inlines__emphasis_and_strong_emphasis__122:
+ canonical: |
+ <p>*foo <em>bar baz</em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:14" dir="auto">*foo <em>bar baz</em></p>
+ wysiwyg: |-
+ <p>*foo <em>bar baz</em></p>
+06_05__inlines__emphasis_and_strong_emphasis__123:
+ canonical: |
+ <p>*<a href="/url">bar*</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">*<a href="/url">bar*</a></p>
+ wysiwyg: |-
+ <p>*<a target="_blank" rel="noopener noreferrer nofollow" href="/url">bar*</a></p>
+06_05__inlines__emphasis_and_strong_emphasis__124:
+ canonical: |
+ <p>_foo <a href="/url">bar_</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto">_foo <a href="/url">bar_</a></p>
+ wysiwyg: |-
+ <p>_foo <a target="_blank" rel="noopener noreferrer nofollow" href="/url">bar_</a></p>
+06_05__inlines__emphasis_and_strong_emphasis__125:
+ canonical: |
+ <p>*<img src="foo" title="*"/></p>
+ static: |-
+ <p data-sourcepos="1:1-1:27" dir="auto">*<a class="no-attachment-icon" href="foo" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" title="*" decoding="async" class="lazy" data-src="foo"></a></p>
+ wysiwyg: |-
+ <p>*<img src="foo" title="*"></p>
+06_05__inlines__emphasis_and_strong_emphasis__126:
+ canonical: |
+ <p>**<a href="**"></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">**<a href="**"></a></p>
+ wysiwyg: |-
+ <p>**</p>
+06_05__inlines__emphasis_and_strong_emphasis__127:
+ canonical: |
+ <p>__<a href="__"></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">__<a href="__"></a></p>
+ wysiwyg: |-
+ <p>__</p>
+06_05__inlines__emphasis_and_strong_emphasis__128:
+ canonical: |
+ <p><em>a <code>*</code></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><em>a <code>*</code></em></p>
+ wysiwyg: |-
+ <p><em>a </em><code>*</code></p>
+06_05__inlines__emphasis_and_strong_emphasis__129:
+ canonical: |
+ <p><em>a <code>_</code></em></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><em>a <code>_</code></em></p>
+ wysiwyg: |-
+ <p><em>a </em><code>_</code></p>
+06_05__inlines__emphasis_and_strong_emphasis__130:
+ canonical: |
+ <p>**a<a href="http://foo.bar/?q=**">http://foo.bar/?q=**</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:25" dir="auto">**a<a href="http://foo.bar/?q=**" rel="nofollow noreferrer noopener" target="_blank">http://foo.bar/?q=**</a></p>
+ wysiwyg: |-
+ <p>**a<a target="_blank" rel="noopener noreferrer nofollow" href="http://foo.bar/?q=**">http://foo.bar/?q=**</a></p>
+06_05__inlines__emphasis_and_strong_emphasis__131:
+ canonical: |
+ <p>__a<a href="http://foo.bar/?q=__">http://foo.bar/?q=__</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:25" dir="auto">__a<a href="http://foo.bar/?q=__" rel="nofollow noreferrer noopener" target="_blank">http://foo.bar/?q=__</a></p>
+ wysiwyg: |-
+ <p>__a<a target="_blank" rel="noopener noreferrer nofollow" href="http://foo.bar/?q=__">http://foo.bar/?q=__</a></p>
+06_06__inlines__strikethrough_extension__01:
+ canonical: |
+ <p><del>Hi</del> Hello, world!</p>
+ static: |-
+ <p data-sourcepos="1:1-1:20" dir="auto"><del>Hi</del> Hello, world!</p>
+ wysiwyg: |-
+ <p>~~Hi~~ Hello, world!</p>
+06_06__inlines__strikethrough_extension__02:
+ canonical: |
+ <p>This ~~has a</p>
+ <p>new paragraph~~.</p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">This ~~has a</p>&#x000A;<p data-sourcepos="3:1-3:16" dir="auto">new paragraph~~.</p>
+ wysiwyg: |-
+ <p>This ~~has a</p>
+06_07__inlines__links__01:
+ canonical: |
+ <p><a href="/uri" title="title">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:20" dir="auto"><a href="/uri" title="title">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri" title="title">link</a></p>
+06_07__inlines__links__02:
+ canonical: |
+ <p><a href="/uri">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto"><a href="/uri">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">link</a></p>
+06_07__inlines__links__03:
+ canonical: |
+ <p><a href="">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><a href="">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="">link</a></p>
+06_07__inlines__links__04:
+ canonical: |
+ <p><a href="">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><a href="">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="">link</a></p>
+06_07__inlines__links__05:
+ canonical: |
+ <p>[link](/my uri)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><a href="/my%20uri">link</a></p>
+ wysiwyg: |-
+ <p>[link](/my uri)</p>
+06_07__inlines__links__06:
+ canonical: |
+ <p><a href="/my%20uri">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><a href="/my%20uri">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/my%20uri">link</a></p>
+06_07__inlines__links__07:
+ canonical: |
+ <p>[link](foo
+ bar)</p>
+ static: |-
+ <p data-sourcepos="1:1-2:4" dir="auto">[link](foo&#x000A;bar)</p>
+ wysiwyg: |-
+ <p>[link](foo
+ bar)</p>
+06_07__inlines__links__08:
+ canonical: |
+ <p>[link](<foo
+ bar>)</p>
+ static: |-
+ <p data-sourcepos="1:1-2:5" dir="auto">[link]()</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "foo" not supported by this converter. Please, provide an specification.
+06_07__inlines__links__09:
+ canonical: |
+ <p><a href="b)c">a</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><a href="b)c">a</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="b)c">a</a></p>
+06_07__inlines__links__10:
+ canonical: |
+ <p>[link](&lt;foo&gt;)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:14" dir="auto"><a href="%3Cfoo%3E">link</a></p>
+ wysiwyg: |-
+ <p>[link](&lt;foo&gt;)</p>
+06_07__inlines__links__11:
+ canonical: |
+ <p>[a](&lt;b)c
+ [a](&lt;b)c&gt;
+ [a](<b>c)</p>
+ static: |-
+ <p data-sourcepos="1:1-3:9" dir="auto"><a href="%3Cb">a</a>c&#x000A;<a href="%3Cb">a</a>c&gt;&#x000A;[a](<b>c)</b></p>
+ wysiwyg: |-
+ <p>[a](&lt;b)c
+ [a](&lt;b)c&gt;
+ [a](<strong>c)</strong></p>
+06_07__inlines__links__12:
+ canonical: |
+ <p><a href="(foo)">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><a href="(foo)">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="(foo)">link</a></p>
+06_07__inlines__links__13:
+ canonical: |
+ <p><a href="foo(and(bar))">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto"><a href="foo(and(bar))">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="foo(and(bar))">link</a></p>
+06_07__inlines__links__14:
+ canonical: |
+ <p><a href="foo(and(bar)">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:23" dir="auto"><a href="foo(and(bar)">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="foo(and(bar)">link</a></p>
+06_07__inlines__links__15:
+ canonical: |
+ <p><a href="foo(and(bar)">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:22" dir="auto"><a href="foo(and(bar)">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="foo(and(bar)">link</a></p>
+06_07__inlines__links__16:
+ canonical: |
+ <p><a href="foo):">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><a>link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="foo):">link</a></p>
+06_07__inlines__links__17:
+ canonical: |
+ <p><a href="#fragment">link</a></p>
+ <p><a href="http://example.com#fragment">link</a></p>
+ <p><a href="http://example.com?foo=3#frag">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><a href="#fragment">link</a></p>&#x000A;<p data-sourcepos="3:1-3:35" dir="auto"><a href="http://example.com#fragment" rel="nofollow noreferrer noopener" target="_blank">link</a></p>&#x000A;<p data-sourcepos="5:1-5:37" dir="auto"><a href="http://example.com?foo=3#frag" rel="nofollow noreferrer noopener" target="_blank">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="#fragment">link</a></p>
+06_07__inlines__links__18:
+ canonical: |
+ <p><a href="foo%5Cbar">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><a href="foo%5Cbar">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="foo%5Cbar">link</a></p>
+06_07__inlines__links__19:
+ canonical: |
+ <p><a href="foo%20b%C3%A4">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto"><a href="foo%20b%C3%A4">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="foo%20b%C3%A4">link</a></p>
+06_07__inlines__links__20:
+ canonical: |
+ <p><a href="%22title%22">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><a href="%22title%22">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="%22title%22">link</a></p>
+06_07__inlines__links__21:
+ canonical: |
+ <p><a href="/url" title="title">link</a>
+ <a href="/url" title="title">link</a>
+ <a href="/url" title="title">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-3:20" dir="auto"><a href="/url" title="title">link</a>&#x000A;<a href="/url" title="title">link</a>&#x000A;<a href="/url" title="title">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">linklinklink</a></p>
+06_07__inlines__links__22:
+ canonical: |
+ <p><a href="/url" title="title &quot;&quot;">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:29" dir="auto"><a href="/url" title='title ""'>link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title &quot;&quot;">link</a></p>
+06_07__inlines__links__23:
+ canonical: |
+ <p><a href="/url%C2%A0%22title%22">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto"><a href="/url%C2%A0%22title%22">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url%C2%A0%22title%22">link</a></p>
+06_07__inlines__links__24:
+ canonical: |
+ <p>[link](/url &quot;title &quot;and&quot; title&quot;)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:32" dir="auto">[link](/url "title "and" title")</p>
+ wysiwyg: |-
+ <p>[link](/url "title "and" title")</p>
+06_07__inlines__links__25:
+ canonical: |
+ <p><a href="/url" title="title &quot;and&quot; title">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:32" dir="auto"><a href="/url" title='title "and" title'>link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title &quot;and&quot; title">link</a></p>
+06_07__inlines__links__26:
+ canonical: |
+ <p><a href="/uri" title="title">link</a></p>
+ static: |-
+ <p data-sourcepos="1:1-2:12" dir="auto"><a href="/uri" title="title">link</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri" title="title">link</a></p>
+06_07__inlines__links__27:
+ canonical: |
+ <p>[link] (/uri)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">[link] (/uri)</p>
+ wysiwyg: |-
+ <p>[link] (/uri)</p>
+06_07__inlines__links__28:
+ canonical: |
+ <p><a href="/uri">link [foo [bar]]</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto"><a href="/uri">link [foo [bar]]</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">link [foo [bar]]</a></p>
+06_07__inlines__links__29:
+ canonical: |
+ <p>[link] bar](/uri)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto">[link] bar](/uri)</p>
+ wysiwyg: |-
+ <p>[link] bar](/uri)</p>
+06_07__inlines__links__30:
+ canonical: |
+ <p>[link <a href="/uri">bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto">[link <a href="/uri">bar</a></p>
+ wysiwyg: |-
+ <p>[link <a target="_blank" rel="noopener noreferrer nofollow" href="/uri">bar</a></p>
+06_07__inlines__links__31:
+ canonical: |
+ <p><a href="/uri">link [bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:18" dir="auto"><a href="/uri">link [bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">link [bar</a></p>
+06_07__inlines__links__32:
+ canonical: |
+ <p><a href="/uri">link <em>foo <strong>bar</strong> <code>#</code></em></a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:30" dir="auto"><a href="/uri">link <em>foo <strong>bar</strong> <code>#</code></em></a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">link </a><em>foo </em><strong>bar</strong><code>#</code></p>
+06_07__inlines__links__33:
+ canonical: |
+ <p><a href="/uri"><img src="moon.jpg" alt="moon" /></a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:25" dir="auto"><a href="/uri"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="moon" decoding="async" class="lazy" data-src="moon.jpg"></a></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot destructure property 'type' of 'this.stack.pop(...)' as it is undefined.
+06_07__inlines__links__34:
+ canonical: |
+ <p>[foo <a href="/uri">bar</a>](/uri)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:23" dir="auto">[foo <a href="/uri">bar</a>](/uri)</p>
+ wysiwyg: |-
+ <p>[foo <a target="_blank" rel="noopener noreferrer nofollow" href="/uri">bar</a>](/uri)</p>
+06_07__inlines__links__35:
+ canonical: |
+ <p>[foo <em>[bar <a href="/uri">baz</a>](/uri)</em>](/uri)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:37" dir="auto">[foo <em>[bar <a href="/uri">baz</a>](/uri)</em>](/uri)</p>
+ wysiwyg: |-
+ <p>[foo <em>[bar </em><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">baz</a>](/uri)](/uri)</p>
+06_07__inlines__links__36:
+ canonical: |
+ <p><img src="uri3" alt="[foo](uri2)" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:28" dir="auto"><a class="no-attachment-icon" href="uri3" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="[foo](uri2)" decoding="async" class="lazy" data-src="uri3"></a></p>
+ wysiwyg: |-
+ <p><img src="uri3" alt="[foo](uri2)"></p>
+06_07__inlines__links__37:
+ canonical: |
+ <p>*<a href="/uri">foo*</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">*<a href="/uri">foo*</a></p>
+ wysiwyg: |-
+ <p>*<a target="_blank" rel="noopener noreferrer nofollow" href="/uri">foo*</a></p>
+06_07__inlines__links__38:
+ canonical: |
+ <p><a href="baz*">foo *bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:16" dir="auto"><a href="baz*">foo *bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="baz*">foo *bar</a></p>
+06_07__inlines__links__39:
+ canonical: |
+ <p><em>foo [bar</em> baz]</p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><em>foo [bar</em> baz]</p>
+ wysiwyg: |-
+ <p><em>foo [bar</em> baz]</p>
+06_07__inlines__links__40:
+ canonical: |
+ <p>[foo <bar attr="](baz)"></p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto">[foo </p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "bar" not supported by this converter. Please, provide an specification.
+06_07__inlines__links__41:
+ canonical: |
+ <p>[foo<code>](/uri)</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">[foo<code>](/uri)</code></p>
+ wysiwyg: |-
+ <p>[foo<code>](/uri)</code></p>
+06_07__inlines__links__42:
+ canonical: |
+ <p>[foo<a href="http://example.com/?search=%5D(uri)">http://example.com/?search=](uri)</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:39" dir="auto">[foo<a href="http://example.com/?search=%5D(uri)" rel="nofollow noreferrer noopener" target="_blank">http://example.com/?search=](uri)</a></p>
+ wysiwyg: |-
+ <p>[foo<a target="_blank" rel="noopener noreferrer nofollow" href="http://example.com/?search=%5D(uri)">http://example.com/?search=](uri)</a></p>
+06_07__inlines__links__43:
+ canonical: |
+ <p><a href="/url" title="title">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><a href="/url" title="title">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">foo</a></p>
+06_07__inlines__links__44:
+ canonical: |
+ <p><a href="/uri">link [foo [bar]]</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:23" dir="auto"><a href="/uri">link [foo [bar]]</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">link [foo [bar]]</a></p>
+06_07__inlines__links__45:
+ canonical: |
+ <p><a href="/uri">link [bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><a href="/uri">link [bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">link [bar</a></p>
+06_07__inlines__links__46:
+ canonical: |
+ <p><a href="/uri">link <em>foo <strong>bar</strong> <code>#</code></em></a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:29" dir="auto"><a href="/uri">link <em>foo <strong>bar</strong> <code>#</code></em></a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">link </a><em>foo </em><strong>bar</strong><code>#</code></p>
+06_07__inlines__links__47:
+ canonical: |
+ <p><a href="/uri"><img src="moon.jpg" alt="moon" /></a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto"><a href="/uri"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="moon" decoding="async" class="lazy" data-src="moon.jpg"></a></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot destructure property 'type' of 'this.stack.pop(...)' as it is undefined.
+06_07__inlines__links__48:
+ canonical: |
+ <p>[foo <a href="/uri">bar</a>]<a href="/uri">ref</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:22" dir="auto">[foo <a href="/uri">bar</a>]<a href="/uri">ref</a></p>
+ wysiwyg: |-
+ <p>[foo <a target="_blank" rel="noopener noreferrer nofollow" href="/uri">bar</a>]<a target="_blank" rel="noopener noreferrer nofollow" href="/uri">ref</a></p>
+06_07__inlines__links__49:
+ canonical: |
+ <p>[foo <em>bar <a href="/uri">baz</a></em>]<a href="/uri">ref</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:27" dir="auto">[foo <em>bar <a href="/uri">baz</a></em>]<a href="/uri">ref</a></p>
+ wysiwyg: |-
+ <p>[foo <em>bar </em><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">baz</a>]<a target="_blank" rel="noopener noreferrer nofollow" href="/uri">ref</a></p>
+06_07__inlines__links__50:
+ canonical: |
+ <p>*<a href="/uri">foo*</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">*<a href="/uri">foo*</a></p>
+ wysiwyg: |-
+ <p>*<a target="_blank" rel="noopener noreferrer nofollow" href="/uri">foo*</a></p>
+06_07__inlines__links__51:
+ canonical: |
+ <p><a href="/uri">foo *bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><a href="/uri">foo *bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">foo *bar</a></p>
+06_07__inlines__links__52:
+ canonical: |
+ <p>[foo <bar attr="][ref]"></p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto">[foo </p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "bar" not supported by this converter. Please, provide an specification.
+06_07__inlines__links__53:
+ canonical: |
+ <p>[foo<code>][ref]</code></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto">[foo<code>][ref]</code></p>
+ wysiwyg: |-
+ <p>[foo<code>][ref]</code></p>
+06_07__inlines__links__54:
+ canonical: |
+ <p>[foo<a href="http://example.com/?search=%5D%5Bref%5D">http://example.com/?search=][ref]</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:39" dir="auto">[foo<a href="http://example.com/?search=%5D%5Bref%5D" rel="nofollow noreferrer noopener" target="_blank">http://example.com/?search=][ref]</a></p>
+ wysiwyg: |-
+ <p>[foo<a target="_blank" rel="noopener noreferrer nofollow" href="http://example.com/?search=%5D%5Bref%5D">http://example.com/?search=][ref]</a></p>
+06_07__inlines__links__55:
+ canonical: |
+ <p><a href="/url" title="title">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><a href="/url" title="title">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">foo</a></p>
+06_07__inlines__links__56:
+ canonical: |
+ <p><a href="/url">Толпой</a> is a Russian word.</p>
+ static: |-
+ <p data-sourcepos="1:1-1:47" dir="auto"><a href="/url">Толпой</a> is a Russian word.</p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url">Толпой</a> is a Russian word.</p>
+06_07__inlines__links__57:
+ canonical: |
+ <p><a href="/url">Baz</a></p>
+ static: |-
+ <p data-sourcepos="4:1-4:14" dir="auto"><a href="/url">Baz</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url">Baz</a></p>
+06_07__inlines__links__58:
+ canonical: |
+ <p>[foo] <a href="/url" title="title">bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto">[foo] <a href="/url" title="title">bar</a></p>
+ wysiwyg: |-
+ <p>[foo] <a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">bar</a></p>
+06_07__inlines__links__59:
+ canonical: |
+ <p>[foo]
+ <a href="/url" title="title">bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-2:5" dir="auto">[foo]&#x000A;<a href="/url" title="title">bar</a></p>
+ wysiwyg: |-
+ <p>[foo]
+ <a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">bar</a></p>
+06_07__inlines__links__60:
+ canonical: |
+ <p><a href="/url1">bar</a></p>
+ static: |-
+ <p data-sourcepos="5:1-5:10" dir="auto"><a href="/url1">bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url1">bar</a></p>
+06_07__inlines__links__61:
+ canonical: |
+ <p>[bar][foo!]</p>
+ static: |-
+ <p data-sourcepos="1:1-1:32" dir="auto">[bar][foo<span>!</span>]</p>
+ wysiwyg: |-
+ <p>[bar][foo!]</p>
+06_07__inlines__links__62:
+ canonical: |
+ <p>[foo][ref[]</p>
+ <p>[ref[]: /uri</p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto">[foo][ref[]</p>&#x000A;<p data-sourcepos="3:1-3:12" dir="auto">[ref[]: /uri</p>
+ wysiwyg: |-
+ <p>[foo][ref[]</p>
+06_07__inlines__links__63:
+ canonical: |
+ <p>[foo][ref[bar]]</p>
+ <p>[ref[bar]]: /uri</p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">[foo][ref[bar]]</p>&#x000A;<p data-sourcepos="3:1-3:16" dir="auto">[ref[bar]]: /uri</p>
+ wysiwyg: |-
+ <p>[foo][ref[bar]]</p>
+06_07__inlines__links__64:
+ canonical: |
+ <p>[[[foo]]]</p>
+ <p>[[[foo]]]: /url</p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">[[[foo]]]</p>&#x000A;<p data-sourcepos="3:1-3:15" dir="auto">[[[foo]]]: /url</p>
+ wysiwyg: |-
+ <p>[[[foo]]]</p>
+06_07__inlines__links__65:
+ canonical: |
+ <p><a href="/uri">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto"><a href="/uri">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">foo</a></p>
+06_07__inlines__links__66:
+ canonical: |
+ <p><a href="/uri">bar\</a></p>
+ static: |-
+ <p data-sourcepos="3:1-3:7" dir="auto"><a href="/uri">bar\</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/uri">bar\</a></p>
+06_07__inlines__links__67:
+ canonical: |
+ <p>[]</p>
+ <p>[]: /uri</p>
+ static: |-
+ <p data-sourcepos="1:1-1:2" dir="auto">[]</p>&#x000A;<p data-sourcepos="3:1-3:8" dir="auto">[]: /uri</p>
+ wysiwyg: |-
+ <p>[]</p>
+06_07__inlines__links__68:
+ canonical: |
+ <p>[
+ ]</p>
+ <p>[
+ ]: /uri</p>
+ static: |-
+ <p data-sourcepos="1:1-2:2" dir="auto">[&#x000A;]</p>&#x000A;<p data-sourcepos="4:1-5:8" dir="auto">[&#x000A;]: /uri</p>
+ wysiwyg: |-
+ <p>[
+ ]</p>
+06_07__inlines__links__69:
+ canonical: |
+ <p><a href="/url" title="title">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><a href="/url" title="title">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">foo</a></p>
+06_07__inlines__links__70:
+ canonical: |
+ <p><a href="/url" title="title"><em>foo</em> bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><a href="/url" title="title"><em>foo</em> bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title"><em>foo</em></a> bar</p>
+06_07__inlines__links__71:
+ canonical: |
+ <p><a href="/url" title="title">Foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><a href="/url" title="title">Foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">Foo</a></p>
+06_07__inlines__links__72:
+ canonical: |
+ <p><a href="/url" title="title">foo</a>
+ []</p>
+ static: |-
+ <p data-sourcepos="1:1-2:2" dir="auto"><a href="/url" title="title">foo</a>&#x000A;[]</p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">foo</a>
+ []</p>
+06_07__inlines__links__73:
+ canonical: |
+ <p><a href="/url" title="title">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto"><a href="/url" title="title">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">foo</a></p>
+06_07__inlines__links__74:
+ canonical: |
+ <p><a href="/url" title="title"><em>foo</em> bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><a href="/url" title="title"><em>foo</em> bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title"><em>foo</em></a> bar</p>
+06_07__inlines__links__75:
+ canonical: |
+ <p>[<a href="/url" title="title"><em>foo</em> bar</a>]</p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">[<a href="/url" title="title"><em>foo</em> bar</a>]</p>
+ wysiwyg: |-
+ <p>[<a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title"><em>foo</em></a> bar]</p>
+06_07__inlines__links__76:
+ canonical: |
+ <p>[[bar <a href="/url">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto">[[bar <a href="/url">foo</a></p>
+ wysiwyg: |-
+ <p>[[bar <a target="_blank" rel="noopener noreferrer nofollow" href="/url">foo</a></p>
+06_07__inlines__links__77:
+ canonical: |
+ <p><a href="/url" title="title">Foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto"><a href="/url" title="title">Foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">Foo</a></p>
+06_07__inlines__links__78:
+ canonical: |
+ <p><a href="/url">foo</a> bar</p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><a href="/url">foo</a> bar</p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url">foo</a> bar</p>
+06_07__inlines__links__79:
+ canonical: |
+ <p>[foo]</p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto">[foo]</p>
+ wysiwyg: |-
+ <p>[foo]</p>
+06_07__inlines__links__80:
+ canonical: |
+ <p>*<a href="/url">foo*</a></p>
+ static: |-
+ <p data-sourcepos="3:1-3:7" dir="auto">*<a href="/url">foo*</a></p>
+ wysiwyg: |-
+ <p>*<a target="_blank" rel="noopener noreferrer nofollow" href="/url">foo*</a></p>
+06_07__inlines__links__81:
+ canonical: |
+ <p><a href="/url2">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:10" dir="auto"><a href="/url2">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url2">foo</a></p>
+06_07__inlines__links__82:
+ canonical: |
+ <p><a href="/url1">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><a href="/url1">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url1">foo</a></p>
+06_07__inlines__links__83:
+ canonical: |
+ <p><a href="">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto"><a href="">foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="">foo</a></p>
+06_07__inlines__links__84:
+ canonical: |
+ <p><a href="/url1">foo</a>(not a link)</p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><a href="/url1">foo</a>(not a link)</p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url1">foo</a>(not a link)</p>
+06_07__inlines__links__85:
+ canonical: |
+ <p>[foo]<a href="/url">bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">[foo]<a href="/url">bar</a></p>
+ wysiwyg: |-
+ <p>[foo]<a target="_blank" rel="noopener noreferrer nofollow" href="/url">bar</a></p>
+06_07__inlines__links__86:
+ canonical: |
+ <p><a href="/url2">foo</a><a href="/url1">baz</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto"><a href="/url2">foo</a><a href="/url1">baz</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="/url2">foo</a><a target="_blank" rel="noopener noreferrer nofollow" href="/url1">baz</a></p>
+06_07__inlines__links__87:
+ canonical: |
+ <p>[foo]<a href="/url1">bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">[foo]<a href="/url1">bar</a></p>
+ wysiwyg: |-
+ <p>[foo]<a target="_blank" rel="noopener noreferrer nofollow" href="/url1">bar</a></p>
+06_08__inlines__images__01:
+ canonical: |
+ <p><img src="/url" alt="foo" title="title" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:20" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo" title="title" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="foo" title="title"></p>
+06_08__inlines__images__02:
+ canonical: |
+ <p><img src="train.jpg" alt="foo bar" title="train &amp; tracks" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto"><a class="no-attachment-icon" href="train.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo bar" title="train &amp; tracks" decoding="async" class="lazy" data-src="train.jpg"></a></p>
+ wysiwyg: |-
+ <p><img src="train.jpg" alt="foo bar" title="train &amp; tracks"></p>
+06_08__inlines__images__03:
+ canonical: |
+ <p><img src="/url2" alt="foo bar" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:26" dir="auto"><a class="no-attachment-icon" href="/url2" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo bar" decoding="async" class="lazy" data-src="/url2"></a></p>
+ wysiwyg: |-
+ <p><img src="/url2" alt="foo bar"></p>
+06_08__inlines__images__04:
+ canonical: |
+ <p><img src="/url2" alt="foo bar" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:25" dir="auto"><a class="no-attachment-icon" href="/url2" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo bar" decoding="async" class="lazy" data-src="/url2"></a></p>
+ wysiwyg: |-
+ <p><img src="/url2" alt="foo bar"></p>
+06_08__inlines__images__05:
+ canonical: |
+ <p><img src="train.jpg" alt="foo bar" title="train &amp; tracks" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:14" dir="auto"><a class="no-attachment-icon" href="train.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo bar" title="train &amp; tracks" decoding="async" class="lazy" data-src="train.jpg"></a></p>
+ wysiwyg: |-
+ <p><img src="train.jpg" alt="foo bar" title="train &amp; tracks"></p>
+06_08__inlines__images__06:
+ canonical: |
+ <p><img src="train.jpg" alt="foo bar" title="train &amp; tracks" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:20" dir="auto"><a class="no-attachment-icon" href="train.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo bar" title="train &amp; tracks" decoding="async" class="lazy" data-src="train.jpg"></a></p>
+ wysiwyg: |-
+ <p><img src="train.jpg" alt="foo bar" title="train &amp; tracks"></p>
+06_08__inlines__images__07:
+ canonical: |
+ <p><img src="train.jpg" alt="foo" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto"><a class="no-attachment-icon" href="train.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo" decoding="async" class="lazy" data-src="train.jpg"></a></p>
+ wysiwyg: |-
+ <p><img src="train.jpg" alt="foo"></p>
+06_08__inlines__images__08:
+ canonical: |
+ <p>My <img src="/path/to/train.jpg" alt="foo bar" title="title" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:45" dir="auto">My <a class="no-attachment-icon" href="/path/to/train.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo bar" title="title" decoding="async" class="lazy" data-src="/path/to/train.jpg"></a></p>
+ wysiwyg: |-
+ <p>My <img src="/path/to/train.jpg" alt="foo bar" title="title"></p>
+06_08__inlines__images__09:
+ canonical: |
+ <p><img src="url" alt="foo" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><a class="no-attachment-icon" href="url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo" decoding="async" class="lazy" data-src="url"></a></p>
+ wysiwyg: |-
+ <p><img src="url" alt="foo"></p>
+06_08__inlines__images__10:
+ canonical: |
+ <p><img src="/url" alt="" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt=""></p>
+06_08__inlines__images__11:
+ canonical: |
+ <p><img src="/url" alt="foo" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="foo"></p>
+06_08__inlines__images__12:
+ canonical: |
+ <p><img src="/url" alt="foo" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="foo"></p>
+06_08__inlines__images__13:
+ canonical: |
+ <p><img src="/url" alt="foo" title="title" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo" title="title" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="foo" title="title"></p>
+06_08__inlines__images__14:
+ canonical: |
+ <p><img src="/url" alt="foo bar" title="title" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:14" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo bar" title="title" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="foo bar" title="title"></p>
+06_08__inlines__images__15:
+ canonical: |
+ <p><img src="/url" alt="Foo" title="title" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Foo" title="title" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="Foo" title="title"></p>
+06_08__inlines__images__16:
+ canonical: |
+ <p><img src="/url" alt="foo" title="title" />
+ []</p>
+ static: |-
+ <p data-sourcepos="1:1-2:2" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo" title="title" decoding="async" class="lazy" data-src="/url"></a>&#x000A;[]</p>
+ wysiwyg: |-
+ <p><img src="/url" alt="foo" title="title">
+ []</p>
+06_08__inlines__images__17:
+ canonical: |
+ <p><img src="/url" alt="foo" title="title" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo" title="title" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="foo" title="title"></p>
+06_08__inlines__images__18:
+ canonical: |
+ <p><img src="/url" alt="foo bar" title="title" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="foo bar" title="title" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="foo bar" title="title"></p>
+06_08__inlines__images__19:
+ canonical: |
+ <p>![[foo]]</p>
+ <p>[[foo]]: /url &quot;title&quot;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto">![[foo]]</p>&#x000A;<p data-sourcepos="3:1-3:21" dir="auto">[[foo]]: /url "title"</p>
+ wysiwyg: |-
+ <p>![[foo]]</p>
+06_08__inlines__images__20:
+ canonical: |
+ <p><img src="/url" alt="Foo" title="title" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:6" dir="auto"><a class="no-attachment-icon" href="/url" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Foo" title="title" decoding="async" class="lazy" data-src="/url"></a></p>
+ wysiwyg: |-
+ <p><img src="/url" alt="Foo" title="title"></p>
+06_08__inlines__images__21:
+ canonical: |
+ <p>![foo]</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">![foo]</p>
+ wysiwyg: |-
+ <p>![foo]</p>
+06_08__inlines__images__22:
+ canonical: |
+ <p>!<a href="/url" title="title">foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:27" dir="auto"><span>!</span><a href="/url" title="title">foo</a></p>
+ wysiwyg: |-
+ <p>!<a target="_blank" rel="noopener noreferrer nofollow" href="/url" title="title">foo</a></p>
+06_09__inlines__autolinks__01:
+ canonical: |
+ <p><a href="http://foo.bar.baz">http://foo.bar.baz</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:20" dir="auto"><a href="http://foo.bar.baz" rel="nofollow noreferrer noopener" target="_blank">http://foo.bar.baz</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="http://foo.bar.baz">http://foo.bar.baz</a></p>
+06_09__inlines__autolinks__02:
+ canonical: |
+ <p><a href="http://foo.bar.baz/test?q=hello&amp;id=22&amp;boolean">http://foo.bar.baz/test?q=hello&amp;id=22&amp;boolean</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:47" dir="auto"><a href="http://foo.bar.baz/test?q=hello&amp;id=22&amp;boolean" rel="nofollow noreferrer noopener" target="_blank">http://foo.bar.baz/test?q=hello&amp;id=22&amp;boolean</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="http://foo.bar.baz/test?q=hello&amp;id=22&amp;boolean">http://foo.bar.baz/test?q=hello&amp;id=22&amp;boolean</a></p>
+06_09__inlines__autolinks__03:
+ canonical: |
+ <p><a href="irc://foo.bar:2233/baz">irc://foo.bar:2233/baz</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto"><a href="irc://foo.bar:2233/baz">irc://foo.bar:2233/baz</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="irc://foo.bar:2233/baz">irc://foo.bar:2233/baz</a></p>
+06_09__inlines__autolinks__04:
+ canonical: |
+ <p><a href="MAILTO:FOO@BAR.BAZ">MAILTO:FOO@BAR.BAZ</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:20" dir="auto"><a href="mailto:FOO@BAR.BAZ">MAILTO:FOO@BAR.BAZ</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="MAILTO:FOO@BAR.BAZ">MAILTO:FOO@BAR.BAZ</a></p>
+06_09__inlines__autolinks__05:
+ canonical: |
+ <p><a href="a+b+c:d">a+b+c:d</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><a href="a+b+c:d">a+b+c:d</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="a+b+c:d">a+b+c:d</a></p>
+06_09__inlines__autolinks__06:
+ canonical: |
+ <p><a href="made-up-scheme://foo,bar">made-up-scheme://foo,bar</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:26" dir="auto"><a href="made-up-scheme://foo,bar">made-up-scheme://foo,bar</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="made-up-scheme://foo,bar">made-up-scheme://foo,bar</a></p>
+06_09__inlines__autolinks__07:
+ canonical: |
+ <p><a href="http://../">http://../</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:12" dir="auto"><a href="http://../" rel="nofollow noreferrer noopener" target="_blank">http://../</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="http://../">http://../</a></p>
+06_09__inlines__autolinks__08:
+ canonical: |
+ <p><a href="localhost:5001/foo">localhost:5001/foo</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:20" dir="auto"><a href="localhost:5001/foo">localhost:5001/foo</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="localhost:5001/foo">localhost:5001/foo</a></p>
+06_09__inlines__autolinks__09:
+ canonical: |
+ <p>&lt;http://foo.bar/baz bim&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto">&lt;<a href="http://foo.bar/baz" rel="nofollow noreferrer noopener" target="_blank">http://foo.bar/baz</a> bim&gt;</p>
+ wysiwyg: |-
+ <p>&lt;http://foo.bar/baz bim&gt;</p>
+06_09__inlines__autolinks__10:
+ canonical: |
+ <p><a href="http://example.com/%5C%5B%5C">http://example.com/\[\</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto"><a href="http://example.com/%5C%5B%5C" rel="nofollow noreferrer noopener" target="_blank">http://example.com/\[\</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="http://example.com/%5C%5B%5C">http://example.com/\[\</a></p>
+06_09__inlines__autolinks__11:
+ canonical: |
+ <p><a href="mailto:foo@bar.example.com">foo@bar.example.com</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto"><a href="mailto:foo@bar.example.com">foo@bar.example.com</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="mailto:foo@bar.example.com">foo@bar.example.com</a></p>
+06_09__inlines__autolinks__12:
+ canonical: |
+ <p><a href="mailto:foo+special@Bar.baz-bar0.com">foo+special@Bar.baz-bar0.com</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:30" dir="auto"><a href="mailto:foo+special@Bar.baz-bar0.com">foo+special@Bar.baz-bar0.com</a></p>
+ wysiwyg: |-
+ <p><a target="_blank" rel="noopener noreferrer nofollow" href="mailto:foo+special@Bar.baz-bar0.com">foo+special@Bar.baz-bar0.com</a></p>
+06_09__inlines__autolinks__13:
+ canonical: |
+ <p>&lt;foo+@bar.example.com&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:23" dir="auto">&lt;<a href="mailto:foo+@bar.example.com">foo+@bar.example.com</a>&gt;</p>
+ wysiwyg: |-
+ <p>&lt;foo+@bar.example.com&gt;</p>
+06_09__inlines__autolinks__14:
+ canonical: |
+ <p>&lt;&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:2" dir="auto">&lt;&gt;</p>
+ wysiwyg: |-
+ <p>&lt;&gt;</p>
+06_09__inlines__autolinks__15:
+ canonical: |
+ <p>&lt; http://foo.bar &gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:18" dir="auto">&lt; <a href="http://foo.bar" rel="nofollow noreferrer noopener" target="_blank">http://foo.bar</a> &gt;</p>
+ wysiwyg: |-
+ <p>&lt; http://foo.bar &gt;</p>
+06_09__inlines__autolinks__16:
+ canonical: |
+ <p>&lt;m:abc&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:7" dir="auto">&lt;m:abc&gt;</p>
+ wysiwyg: |-
+ <p>&lt;m:abc&gt;</p>
+06_09__inlines__autolinks__17:
+ canonical: |
+ <p>&lt;foo.bar.baz&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">&lt;foo.bar.baz&gt;</p>
+ wysiwyg: |-
+ <p>&lt;foo.bar.baz&gt;</p>
+06_09__inlines__autolinks__18:
+ canonical: |
+ <p>http://example.com</p>
+ static: |-
+ <p data-sourcepos="1:1-1:18" dir="auto"><a href="http://example.com" rel="nofollow noreferrer noopener" target="_blank">http://example.com</a></p>
+ wysiwyg: |-
+ <p>http://example.com</p>
+06_09__inlines__autolinks__19:
+ canonical: |
+ <p>foo@bar.example.com</p>
+ static: |-
+ <p data-sourcepos="1:1-1:19" dir="auto"><a href="mailto:foo@bar.example.com">foo@bar.example.com</a></p>
+ wysiwyg: |-
+ <p>foo@bar.example.com</p>
+06_10__inlines__autolinks_extension__01:
+ canonical: |
+ <p><a href="http://www.commonmark.org">www.commonmark.org</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:18" dir="auto"><a href="http://www.commonmark.org" rel="nofollow noreferrer noopener" target="_blank">www.commonmark.org</a></p>
+ wysiwyg: |-
+ <p>www.commonmark.org</p>
+06_10__inlines__autolinks_extension__02:
+ canonical: |
+ <p>Visit <a href="http://www.commonmark.org/help">www.commonmark.org/help</a> for more information.</p>
+ static: |-
+ <p data-sourcepos="1:1-1:51" dir="auto">Visit <a href="http://www.commonmark.org/help" rel="nofollow noreferrer noopener" target="_blank">www.commonmark.org/help</a> for more information.</p>
+ wysiwyg: |-
+ <p>Visit www.commonmark.org/help for more information.</p>
+06_10__inlines__autolinks_extension__03:
+ canonical: |
+ <p>Visit <a href="http://www.commonmark.org">www.commonmark.org</a>.</p>
+ <p>Visit <a href="http://www.commonmark.org/a.b">www.commonmark.org/a.b</a>.</p>
+ static: |-
+ <p data-sourcepos="1:1-1:25" dir="auto">Visit <a href="http://www.commonmark.org" rel="nofollow noreferrer noopener" target="_blank">www.commonmark.org</a>.</p>&#x000A;<p data-sourcepos="3:1-3:29" dir="auto">Visit <a href="http://www.commonmark.org/a.b" rel="nofollow noreferrer noopener" target="_blank">www.commonmark.org/a.b</a>.</p>
+ wysiwyg: |-
+ <p>Visit www.commonmark.org.</p>
+06_10__inlines__autolinks_extension__04:
+ canonical: |
+ <p><a href="http://www.google.com/search?q=Markup+(business)">www.google.com/search?q=Markup+(business)</a></p>
+ <p><a href="http://www.google.com/search?q=Markup+(business)">www.google.com/search?q=Markup+(business)</a>))</p>
+ <p>(<a href="http://www.google.com/search?q=Markup+(business)">www.google.com/search?q=Markup+(business)</a>)</p>
+ <p>(<a href="http://www.google.com/search?q=Markup+(business)">www.google.com/search?q=Markup+(business)</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:41" dir="auto"><a href="http://www.google.com/search?q=Markup+(business)" rel="nofollow noreferrer noopener" target="_blank">www.google.com/search?q=Markup+(business)</a></p>&#x000A;<p data-sourcepos="3:1-3:43" dir="auto"><a href="http://www.google.com/search?q=Markup+(business)" rel="nofollow noreferrer noopener" target="_blank">www.google.com/search?q=Markup+(business)</a>))</p>&#x000A;<p data-sourcepos="5:1-5:43" dir="auto">(<a href="http://www.google.com/search?q=Markup+(business)" rel="nofollow noreferrer noopener" target="_blank">www.google.com/search?q=Markup+(business)</a>)</p>&#x000A;<p data-sourcepos="7:1-7:42" dir="auto">(<a href="http://www.google.com/search?q=Markup+(business)" rel="nofollow noreferrer noopener" target="_blank">www.google.com/search?q=Markup+(business)</a></p>
+ wysiwyg: |-
+ <p>www.google.com/search?q=Markup+(business)</p>
+06_10__inlines__autolinks_extension__05:
+ canonical: |
+ <p><a href="http://www.google.com/search?q=(business))+ok">www.google.com/search?q=(business))+ok</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:38" dir="auto"><a href="http://www.google.com/search?q=(business))+ok" rel="nofollow noreferrer noopener" target="_blank">www.google.com/search?q=(business))+ok</a></p>
+ wysiwyg: |-
+ <p>www.google.com/search?q=(business))+ok</p>
+06_10__inlines__autolinks_extension__06:
+ canonical: |
+ <p><a href="http://www.google.com/search?q=commonmark&amp;hl=en">www.google.com/search?q=commonmark&amp;hl=en</a></p>
+ <p><a href="http://www.google.com/search?q=commonmark">www.google.com/search?q=commonmark</a>&amp;hl;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:40" dir="auto"><a href="http://www.google.com/search?q=commonmark&amp;hl=en" rel="nofollow noreferrer noopener" target="_blank">www.google.com/search?q=commonmark&amp;hl=en</a></p>&#x000A;<p data-sourcepos="3:1-3:38" dir="auto"><a href="http://www.google.com/search?q=commonmark" rel="nofollow noreferrer noopener" target="_blank">www.google.com/search?q=commonmark</a>&amp;hl;</p>
+ wysiwyg: |-
+ <p>www.google.com/search?q=commonmark&amp;hl=en</p>
+06_10__inlines__autolinks_extension__07:
+ canonical: |
+ <p><a href="http://www.commonmark.org/he">www.commonmark.org/he</a>&lt;lp</p>
+ static: |-
+ <p data-sourcepos="1:1-1:24" dir="auto"><a href="http://www.commonmark.org/he" rel="nofollow noreferrer noopener" target="_blank">www.commonmark.org/he</a>&lt;lp</p>
+ wysiwyg: |-
+ <p>www.commonmark.org/he&lt;lp</p>
+06_10__inlines__autolinks_extension__08:
+ canonical: |
+ <p><a href="http://commonmark.org">http://commonmark.org</a></p>
+ <p>(Visit <a href="https://encrypted.google.com/search?q=Markup+(business)">https://encrypted.google.com/search?q=Markup+(business)</a>)</p>
+ <p>Anonymous FTP is available at <a href="ftp://foo.bar.baz">ftp://foo.bar.baz</a>.</p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto"><a href="http://commonmark.org" rel="nofollow noreferrer noopener" target="_blank">http://commonmark.org</a></p>&#x000A;<p data-sourcepos="3:1-3:63" dir="auto">(Visit <a href="https://encrypted.google.com/search?q=Markup+(business)" rel="nofollow noreferrer noopener" target="_blank">https://encrypted.google.com/search?q=Markup+(business)</a>)</p>&#x000A;<p data-sourcepos="5:1-5:48" dir="auto">Anonymous FTP is available at <a href="ftp://foo.bar.baz/">ftp://foo.bar.baz</a>.</p>
+ wysiwyg: |-
+ <p>http://commonmark.org</p>
+06_10__inlines__autolinks_extension__09:
+ canonical: |
+ <p><a href="mailto:foo@bar.baz">foo@bar.baz</a></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><a href="mailto:foo@bar.baz">foo@bar.baz</a></p>
+ wysiwyg: |-
+ <p>foo@bar.baz</p>
+06_10__inlines__autolinks_extension__10:
+ canonical: |
+ <p>hello@mail+xyz.example isn't valid, but <a href="mailto:hello+xyz@mail.example">hello+xyz@mail.example</a> is.</p>
+ static: |-
+ <p data-sourcepos="1:1-1:66" dir="auto">hello@mail+xyz.example isn't valid, but <a href="mailto:hello+xyz@mail.example">hello+xyz@mail.example</a> is.</p>
+ wysiwyg: |-
+ <p>hello@mail+xyz.example isn't valid, but hello+xyz@mail.example is.</p>
+06_10__inlines__autolinks_extension__11:
+ canonical: |
+ <p><a href="mailto:a.b-c_d@a.b">a.b-c_d@a.b</a></p>
+ <p><a href="mailto:a.b-c_d@a.b">a.b-c_d@a.b</a>.</p>
+ <p>a.b-c_d@a.b-</p>
+ <p>a.b-c_d@a.b_</p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"><a href="mailto:a.b-c_d@a.b">a.b-c_d@a.b</a></p>&#x000A;<p data-sourcepos="3:1-3:12" dir="auto"><a href="mailto:a.b-c_d@a.b">a.b-c_d@a.b</a>.</p>&#x000A;<p data-sourcepos="5:1-5:12" dir="auto">a.b-c_d@a.b-</p>&#x000A;<p data-sourcepos="7:1-7:12" dir="auto">a.b-c_d@a.b_</p>
+ wysiwyg: |-
+ <p>a.b-c_d@a.b</p>
+06_11__inlines__raw_html__01:
+ canonical: |
+ <p><a><bab><c2c></p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto"><a></a></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "bab" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__02:
+ canonical: |
+ <p><a/><b2/></p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto"><a></a></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "b2" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__03:
+ canonical: |
+ <p><a /><b2
+ data="foo" ></p>
+ static: |-
+ <p data-sourcepos="1:1-2:12" dir="auto"><a></a></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "b2" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__04:
+ canonical: |
+ <p><a foo="bar" bam = 'baz <em>"</em>'
+ _boolean zoop:33=zoop:33 /></p>
+ static: |-
+ <p data-sourcepos="1:1-2:27" dir="auto"><a></a></p>
+ wysiwyg: |-
+ <p></p>
+06_11__inlines__raw_html__05:
+ canonical: |
+ <p>Foo <responsive-image src="foo.jpg" /></p>
+ static: |-
+ <p data-sourcepos="1:1-1:38" dir="auto">Foo </p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "responsive-image" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__06:
+ canonical: |
+ <p>&lt;33&gt; &lt;__&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:9" dir="auto">&lt;33&gt; &lt;__&gt;</p>
+ wysiwyg: |-
+ <p>&lt;33&gt; &lt;__&gt;</p>
+06_11__inlines__raw_html__07:
+ canonical: |
+ <p>&lt;a h*#ref=&quot;hi&quot;&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">&lt;a h*#ref="hi"&gt;</p>
+ wysiwyg: |-
+ <p>&lt;a h*#ref="hi"&gt;</p>
+06_11__inlines__raw_html__08:
+ canonical: |
+ <p>&lt;a href=&quot;hi'&gt; &lt;a href=hi'&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:26" dir="auto">&lt;a href="hi'&gt; &lt;a href=hi'&gt;</p>
+ wysiwyg: |-
+ <p>&lt;a href="hi'&gt; &lt;a href=hi'&gt;</p>
+06_11__inlines__raw_html__09:
+ canonical: |
+ <p>&lt; a&gt;&lt;
+ foo&gt;&lt;bar/ &gt;
+ &lt;foo bar=baz
+ bim!bop /&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-4:10" dir="auto">&lt; a&gt;&lt;&#x000A;foo&gt;&lt;bar/ &gt;&#x000A;&lt;foo bar=baz&#x000A;bim!bop /&gt;</p>
+ wysiwyg: |-
+ <p>&lt; a&gt;&lt;
+ foo&gt;&lt;bar/ &gt;
+ &lt;foo bar=baz
+ bim!bop /&gt;</p>
+06_11__inlines__raw_html__10:
+ canonical: |
+ <p>&lt;a href='bar'title=title&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:25" dir="auto">&lt;a href='bar'title=title&gt;</p>
+ wysiwyg: |-
+ <p>&lt;a href='bar'title=title&gt;</p>
+06_11__inlines__raw_html__11:
+ canonical: |
+ <p></a></foo ></p>
+ static: |-
+ <p data-sourcepos="1:1-1:11" dir="auto"></p>
+ wysiwyg: |-
+ <p></p>
+06_11__inlines__raw_html__12:
+ canonical: |
+ <p>&lt;/a href=&quot;foo&quot;&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">&lt;/a href="foo"&gt;</p>
+ wysiwyg: |-
+ <p>&lt;/a href="foo"&gt;</p>
+06_11__inlines__raw_html__13:
+ canonical: |
+ <p>foo <!-- this is a
+ comment - with hyphen --></p>
+ static: |-
+ <p data-sourcepos="1:1-2:25" dir="auto">foo </p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__14:
+ canonical: |
+ <p>foo &lt;!-- not a comment -- two hyphens --&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:41" dir="auto">foo &lt;!-- not a comment -- two hyphens --&gt;</p>
+ wysiwyg: |-
+ <p>foo &lt;!-- not a comment -- two hyphens --&gt;</p>
+06_11__inlines__raw_html__15:
+ canonical: |
+ <p>foo &lt;!--&gt; foo --&gt;</p>
+ <p>foo &lt;!-- foo---&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto">foo &lt;!--&gt; foo --&gt;</p>&#x000A;<p data-sourcepos="3:1-3:16" dir="auto">foo &lt;!-- foo---&gt;</p>
+ wysiwyg: |-
+ <p>foo &lt;!--&gt; foo --&gt;</p>
+06_11__inlines__raw_html__16:
+ canonical: |
+ <p>foo <?php echo $a; ?></p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto">foo <?php echo $a; ?></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__17:
+ canonical: |
+ <p>foo <!ELEMENT br EMPTY></p>
+ static: |-
+ <p data-sourcepos="1:1-1:23" dir="auto">foo </p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__18:
+ canonical: |
+ <p>foo <![CDATA[>&<]]></p>
+ static: |-
+ <p data-sourcepos="1:1-1:19" dir="auto">foo &amp;</p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__19:
+ canonical: |
+ <p>foo <a href="&ouml;"></p>
+ static: |-
+ <p data-sourcepos="1:1-1:21" dir="auto">foo <a href="%C3%B6" rel="nofollow noreferrer noopener" target="_blank"></a></p>
+ wysiwyg: |-
+ <p>foo </p>
+06_11__inlines__raw_html__20:
+ canonical: |
+ <p>foo <a href="\*"></p>
+ static: |-
+ <p data-sourcepos="1:1-1:17" dir="auto">foo <a href="%5C*" rel="nofollow noreferrer noopener" target="_blank"></a></p>
+ wysiwyg: |-
+ <p>foo </p>
+06_11__inlines__raw_html__21:
+ canonical: |
+ <p>&lt;a href=&quot;&quot;&quot;&gt;</p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">&lt;a href="""&gt;</p>
+ wysiwyg: |-
+ <p>&lt;a href="""&gt;</p>
+06_12__inlines__disallowed_raw_html_extension__01:
+ canonical: |
+ <p><strong> &lt;title> &lt;style> <em></p>
+ <blockquote>
+ &lt;xmp> is disallowed. &lt;XMP> is also disallowed.
+ </blockquote>
+ static: |-
+ <p data-sourcepos="1:1-1:29" dir="auto"><strong> &lt;em&gt;&lt;/p&gt;&#x000A;&lt;blockquote&gt;&#x000A; &lt;xmp&gt; is disallowed. &lt;XMP&gt; is also disallowed.&#x000A;&lt;/blockquote&gt;</strong></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Hast node of type "title" not supported by this converter. Please, provide an specification.
+06_13__inlines__hard_line_breaks__01:
+ canonical: |
+ <p>foo<br />
+ baz</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">foo<br>&#x000A;baz</p>
+ wysiwyg: |-
+ <p>foo<br>
+ baz</p>
+06_13__inlines__hard_line_breaks__02:
+ canonical: |
+ <p>foo<br />
+ baz</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">foo<br>&#x000A;baz</p>
+ wysiwyg: |-
+ <p>foo<br>
+ baz</p>
+06_13__inlines__hard_line_breaks__03:
+ canonical: |
+ <p>foo<br />
+ baz</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">foo<br>&#x000A;baz</p>
+ wysiwyg: |-
+ <p>foo<br>
+ baz</p>
+06_13__inlines__hard_line_breaks__04:
+ canonical: |
+ <p>foo<br />
+ bar</p>
+ static: |-
+ <p data-sourcepos="1:1-2:8" dir="auto">foo<br>&#x000A;bar</p>
+ wysiwyg: |-
+ <p>foo<br>
+ bar</p>
+06_13__inlines__hard_line_breaks__05:
+ canonical: |
+ <p>foo<br />
+ bar</p>
+ static: |-
+ <p data-sourcepos="1:1-2:8" dir="auto">foo<br>&#x000A;bar</p>
+ wysiwyg: |-
+ <p>foo<br>
+ bar</p>
+06_13__inlines__hard_line_breaks__06:
+ canonical: |
+ <p><em>foo<br />
+ bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-2:4" dir="auto"><em>foo<br>&#x000A;bar</em></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot destructure property 'type' of 'this.stack.pop(...)' as it is undefined.
+06_13__inlines__hard_line_breaks__07:
+ canonical: |
+ <p><em>foo<br />
+ bar</em></p>
+ static: |-
+ <p data-sourcepos="1:1-2:4" dir="auto"><em>foo<br>&#x000A;bar</em></p>
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot destructure property 'type' of 'this.stack.pop(...)' as it is undefined.
+06_13__inlines__hard_line_breaks__08:
+ canonical: |
+ <p><code>code span</code></p>
+ static: |-
+ <p data-sourcepos="1:1-2:5" dir="auto"><code>code span</code></p>
+ wysiwyg: |-
+ <p><code>code span</code></p>
+06_13__inlines__hard_line_breaks__09:
+ canonical: |
+ <p><code>code\ span</code></p>
+ static: |-
+ <p data-sourcepos="1:1-2:5" dir="auto"><code>code\ span</code></p>
+ wysiwyg: |-
+ <p><code>code\ span</code></p>
+06_13__inlines__hard_line_breaks__10:
+ canonical: "<p><a href=\"foo \nbar\"></p>\n"
+ static: |-
+ <p data-sourcepos="1:1-2:5" dir="auto"><a href="foo%20%20%0Abar" rel="nofollow noreferrer noopener" target="_blank"></a></p>
+ wysiwyg: |-
+ <p></p>
+06_13__inlines__hard_line_breaks__11:
+ canonical: |
+ <p><a href="foo\
+ bar"></p>
+ static: |-
+ <p data-sourcepos="1:1-2:5" dir="auto"><a href="foo%5C%0Abar" rel="nofollow noreferrer noopener" target="_blank"></a></p>
+ wysiwyg: |-
+ <p></p>
+06_13__inlines__hard_line_breaks__12:
+ canonical: |
+ <p>foo\</p>
+ static: |-
+ <p data-sourcepos="1:1-1:4" dir="auto">foo\</p>
+ wysiwyg: |-
+ <p>foo\</p>
+06_13__inlines__hard_line_breaks__13:
+ canonical: |
+ <p>foo</p>
+ static: |-
+ <p data-sourcepos="1:1-1:5" dir="auto">foo</p>
+ wysiwyg: |-
+ <p>foo</p>
+06_13__inlines__hard_line_breaks__14:
+ canonical: |
+ <h3>foo\</h3>
+ static: |-
+ <h3 data-sourcepos="1:1-1:8" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo\</h3>
+ wysiwyg: |-
+ <h3>foo\</h3>
+06_13__inlines__hard_line_breaks__15:
+ canonical: |
+ <h3>foo</h3>
+ static: |-
+ <h3 data-sourcepos="1:1-1:7" dir="auto">&#x000A;<a id="user-content-foo" class="anchor" href="#foo" aria-hidden="true"></a>foo</h3>
+ wysiwyg: |-
+ <h3>foo</h3>
+06_14__inlines__soft_line_breaks__01:
+ canonical: |
+ <p>foo
+ baz</p>
+ static: |-
+ <p data-sourcepos="1:1-2:3" dir="auto">foo&#x000A;baz</p>
+ wysiwyg: |-
+ <p>foo
+ baz</p>
+06_14__inlines__soft_line_breaks__02:
+ canonical: |
+ <p>foo
+ baz</p>
+ static: |-
+ <p data-sourcepos="1:1-2:4" dir="auto">foo&#x000A;baz</p>
+ wysiwyg: |-
+ <p>foo
+ baz</p>
+06_15__inlines__textual_content__01:
+ canonical: |
+ <p>hello $.;'there</p>
+ static: |-
+ <p data-sourcepos="1:1-1:15" dir="auto">hello $.;'there</p>
+ wysiwyg: |-
+ <p>hello $.;'there</p>
+06_15__inlines__textual_content__02:
+ canonical: |
+ <p>Foo χρῆν</p>
+ static: |-
+ <p data-sourcepos="1:1-1:13" dir="auto">Foo χρῆν</p>
+ wysiwyg: |-
+ <p>Foo χρῆν</p>
+06_15__inlines__textual_content__03:
+ canonical: |
+ <p>Multiple spaces</p>
+ static: |-
+ <p data-sourcepos="1:1-1:19" dir="auto">Multiple spaces</p>
+ wysiwyg: |-
+ <p>Multiple spaces</p>
+07_01__first_gitlab_specific_section_with_examples__strong_but_with_two_asterisks__01:
+ canonical: |
+ <p><strong>bold</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><strong>bold</strong></p>
+ wysiwyg: |-
+ <p><strong>bold</strong></p>
+08_01__second_gitlab_specific_section_with_examples__strong_but_with_html__01:
+ canonical: |
+ <p><strong>
+ bold
+ </strong></p>
+ static: |-
+ <strong>&#x000A;bold&#x000A;</strong>
+ wysiwyg: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
diff --git a/spec/fixtures/glfm/example_snapshots/markdown.yml b/spec/fixtures/glfm/example_snapshots/markdown.yml
new file mode 100644
index 00000000000..8232b158050
--- /dev/null
+++ b/spec/fixtures/glfm/example_snapshots/markdown.yml
@@ -0,0 +1,2203 @@
+---
+02_01__preliminaries__tabs__01: "\tfoo\tbaz\t\tbim\n"
+02_01__preliminaries__tabs__02: " \tfoo\tbaz\t\tbim\n"
+02_01__preliminaries__tabs__03: " a\ta\n ὐ\ta\n"
+02_01__preliminaries__tabs__04: " - foo\n\n\tbar\n"
+02_01__preliminaries__tabs__05: "- foo\n\n\t\tbar\n"
+02_01__preliminaries__tabs__06: ">\t\tfoo\n"
+02_01__preliminaries__tabs__07: "-\t\tfoo\n"
+02_01__preliminaries__tabs__08: " foo\n\tbar\n"
+02_01__preliminaries__tabs__09: " - foo\n - bar\n\t - baz\n"
+02_01__preliminaries__tabs__10: "#\tFoo\n"
+02_01__preliminaries__tabs__11: "*\t*\t*\t\n"
+03_01__blocks_and_inlines__precedence__01: |
+ - `one
+ - two`
+04_01__leaf_blocks__thematic_breaks__01: |
+ ***
+ ---
+ ___
+04_01__leaf_blocks__thematic_breaks__02: |
+ +++
+04_01__leaf_blocks__thematic_breaks__03: |
+ ===
+04_01__leaf_blocks__thematic_breaks__04: |
+ --
+ **
+ __
+04_01__leaf_blocks__thematic_breaks__05: |2
+ ***
+ ***
+ ***
+04_01__leaf_blocks__thematic_breaks__06: |2
+ ***
+04_01__leaf_blocks__thematic_breaks__07: |
+ Foo
+ ***
+04_01__leaf_blocks__thematic_breaks__08: |
+ _____________________________________
+04_01__leaf_blocks__thematic_breaks__09: |2
+ - - -
+04_01__leaf_blocks__thematic_breaks__10: |2
+ ** * ** * ** * **
+04_01__leaf_blocks__thematic_breaks__11: |
+ - - - -
+04_01__leaf_blocks__thematic_breaks__12: "- - - - \n"
+04_01__leaf_blocks__thematic_breaks__13: |
+ _ _ _ _ a
+
+ a------
+
+ ---a---
+04_01__leaf_blocks__thematic_breaks__14: |2
+ *-*
+04_01__leaf_blocks__thematic_breaks__15: |
+ - foo
+ ***
+ - bar
+04_01__leaf_blocks__thematic_breaks__16: |
+ Foo
+ ***
+ bar
+04_01__leaf_blocks__thematic_breaks__17: |
+ Foo
+ ---
+ bar
+04_01__leaf_blocks__thematic_breaks__18: |
+ * Foo
+ * * *
+ * Bar
+04_01__leaf_blocks__thematic_breaks__19: |
+ - Foo
+ - * * *
+04_02__leaf_blocks__atx_headings__01: |
+ # foo
+ ## foo
+ ### foo
+ #### foo
+ ##### foo
+ ###### foo
+04_02__leaf_blocks__atx_headings__02: |
+ ####### foo
+04_02__leaf_blocks__atx_headings__03: |
+ #5 bolt
+
+ #hashtag
+04_02__leaf_blocks__atx_headings__04: |
+ \## foo
+04_02__leaf_blocks__atx_headings__05: |
+ # foo *bar* \*baz\*
+04_02__leaf_blocks__atx_headings__06: "# foo \n"
+04_02__leaf_blocks__atx_headings__07: |2
+ ### foo
+ ## foo
+ # foo
+04_02__leaf_blocks__atx_headings__08: |2
+ # foo
+04_02__leaf_blocks__atx_headings__09: |
+ foo
+ # bar
+04_02__leaf_blocks__atx_headings__10: |
+ ## foo ##
+ ### bar ###
+04_02__leaf_blocks__atx_headings__11: |
+ # foo ##################################
+ ##### foo ##
+04_02__leaf_blocks__atx_headings__12: "### foo ### \n"
+04_02__leaf_blocks__atx_headings__13: |
+ ### foo ### b
+04_02__leaf_blocks__atx_headings__14: |
+ # foo#
+04_02__leaf_blocks__atx_headings__15: |
+ ### foo \###
+ ## foo #\##
+ # foo \#
+04_02__leaf_blocks__atx_headings__16: |
+ ****
+ ## foo
+ ****
+04_02__leaf_blocks__atx_headings__17: |
+ Foo bar
+ # baz
+ Bar foo
+04_02__leaf_blocks__atx_headings__18: "## \n#\n### ###\n"
+04_03__leaf_blocks__setext_headings__01: |
+ Foo *bar*
+ =========
+
+ Foo *bar*
+ ---------
+04_03__leaf_blocks__setext_headings__02: |
+ Foo *bar
+ baz*
+ ====
+04_03__leaf_blocks__setext_headings__03: " Foo *bar\nbaz*\t\n====\n"
+04_03__leaf_blocks__setext_headings__04: |
+ Foo
+ -------------------------
+
+ Foo
+ =
+04_03__leaf_blocks__setext_headings__05: |2
+ Foo
+ ---
+
+ Foo
+ -----
+
+ Foo
+ ===
+04_03__leaf_blocks__setext_headings__06: |2
+ Foo
+ ---
+
+ Foo
+ ---
+04_03__leaf_blocks__setext_headings__07: "Foo\n ---- \n"
+04_03__leaf_blocks__setext_headings__08: |
+ Foo
+ ---
+04_03__leaf_blocks__setext_headings__09: |
+ Foo
+ = =
+
+ Foo
+ --- -
+04_03__leaf_blocks__setext_headings__10: "Foo \n-----\n"
+04_03__leaf_blocks__setext_headings__11: |
+ Foo\
+ ----
+04_03__leaf_blocks__setext_headings__12: |
+ `Foo
+ ----
+ `
+
+ <a title="a lot
+ ---
+ of dashes"/>
+04_03__leaf_blocks__setext_headings__13: |
+ > Foo
+ ---
+04_03__leaf_blocks__setext_headings__14: |
+ > foo
+ bar
+ ===
+04_03__leaf_blocks__setext_headings__15: |
+ - Foo
+ ---
+04_03__leaf_blocks__setext_headings__16: |
+ Foo
+ Bar
+ ---
+04_03__leaf_blocks__setext_headings__17: |
+ ---
+ Foo
+ ---
+ Bar
+ ---
+ Baz
+04_03__leaf_blocks__setext_headings__18: |2
+
+ ====
+04_03__leaf_blocks__setext_headings__19: |
+ ---
+ ---
+04_03__leaf_blocks__setext_headings__20: |
+ - foo
+ -----
+04_03__leaf_blocks__setext_headings__21: |2
+ foo
+ ---
+04_03__leaf_blocks__setext_headings__22: |
+ > foo
+ -----
+04_03__leaf_blocks__setext_headings__23: |
+ \> foo
+ ------
+04_03__leaf_blocks__setext_headings__24: |
+ Foo
+
+ bar
+ ---
+ baz
+04_03__leaf_blocks__setext_headings__25: |
+ Foo
+ bar
+
+ ---
+
+ baz
+04_03__leaf_blocks__setext_headings__26: |
+ Foo
+ bar
+ * * *
+ baz
+04_03__leaf_blocks__setext_headings__27: |
+ Foo
+ bar
+ \---
+ baz
+04_04__leaf_blocks__indented_code_blocks__01: |2
+ a simple
+ indented code block
+04_04__leaf_blocks__indented_code_blocks__02: |2
+ - foo
+
+ bar
+04_04__leaf_blocks__indented_code_blocks__03: |
+ 1. foo
+
+ - bar
+04_04__leaf_blocks__indented_code_blocks__04: |2
+ <a/>
+ *hi*
+
+ - one
+04_04__leaf_blocks__indented_code_blocks__05: " chunk1\n\n chunk2\n \n \n \n
+ \ chunk3\n"
+04_04__leaf_blocks__indented_code_blocks__06: " chunk1\n \n chunk2\n"
+04_04__leaf_blocks__indented_code_blocks__07: |+
+ Foo
+ bar
+
+04_04__leaf_blocks__indented_code_blocks__08: |2
+ foo
+ bar
+04_04__leaf_blocks__indented_code_blocks__09: |
+ # Heading
+ foo
+ Heading
+ ------
+ foo
+ ----
+04_04__leaf_blocks__indented_code_blocks__10: |2
+ foo
+ bar
+04_04__leaf_blocks__indented_code_blocks__11: "\n \n foo\n \n\n"
+04_04__leaf_blocks__indented_code_blocks__12: " foo \n"
+04_05__leaf_blocks__fenced_code_blocks__01: |
+ ```
+ <
+ >
+ ```
+04_05__leaf_blocks__fenced_code_blocks__02: |
+ ~~~
+ <
+ >
+ ~~~
+04_05__leaf_blocks__fenced_code_blocks__03: |
+ ``
+ foo
+ ``
+04_05__leaf_blocks__fenced_code_blocks__04: |
+ ```
+ aaa
+ ~~~
+ ```
+04_05__leaf_blocks__fenced_code_blocks__05: |
+ ~~~
+ aaa
+ ```
+ ~~~
+04_05__leaf_blocks__fenced_code_blocks__06: |
+ ````
+ aaa
+ ```
+ ``````
+04_05__leaf_blocks__fenced_code_blocks__07: |
+ ~~~~
+ aaa
+ ~~~
+ ~~~~
+04_05__leaf_blocks__fenced_code_blocks__08: |
+ ```
+04_05__leaf_blocks__fenced_code_blocks__09: |
+ `````
+
+ ```
+ aaa
+04_05__leaf_blocks__fenced_code_blocks__10: |
+ > ```
+ > aaa
+
+ bbb
+04_05__leaf_blocks__fenced_code_blocks__11: "```\n\n \n```\n"
+04_05__leaf_blocks__fenced_code_blocks__12: |
+ ```
+ ```
+04_05__leaf_blocks__fenced_code_blocks__13: |2
+ ```
+ aaa
+ aaa
+ ```
+04_05__leaf_blocks__fenced_code_blocks__14: |2
+ ```
+ aaa
+ aaa
+ aaa
+ ```
+04_05__leaf_blocks__fenced_code_blocks__15: |2
+ ```
+ aaa
+ aaa
+ aaa
+ ```
+04_05__leaf_blocks__fenced_code_blocks__16: |2
+ ```
+ aaa
+ ```
+04_05__leaf_blocks__fenced_code_blocks__17: |
+ ```
+ aaa
+ ```
+04_05__leaf_blocks__fenced_code_blocks__18: |2
+ ```
+ aaa
+ ```
+04_05__leaf_blocks__fenced_code_blocks__19: |
+ ```
+ aaa
+ ```
+04_05__leaf_blocks__fenced_code_blocks__20: |
+ ``` ```
+ aaa
+04_05__leaf_blocks__fenced_code_blocks__21: |
+ ~~~~~~
+ aaa
+ ~~~ ~~
+04_05__leaf_blocks__fenced_code_blocks__22: |
+ foo
+ ```
+ bar
+ ```
+ baz
+04_05__leaf_blocks__fenced_code_blocks__23: |
+ foo
+ ---
+ ~~~
+ bar
+ ~~~
+ # baz
+04_05__leaf_blocks__fenced_code_blocks__24: |
+ ```ruby
+ def foo(x)
+ return 3
+ end
+ ```
+04_05__leaf_blocks__fenced_code_blocks__25: |
+ ~~~~ ruby startline=3 $%@#$
+ def foo(x)
+ return 3
+ end
+ ~~~~~~~
+04_05__leaf_blocks__fenced_code_blocks__26: |
+ ````;
+ ````
+04_05__leaf_blocks__fenced_code_blocks__27: |
+ ``` aa ```
+ foo
+04_05__leaf_blocks__fenced_code_blocks__28: |
+ ~~~ aa ``` ~~~
+ foo
+ ~~~
+04_05__leaf_blocks__fenced_code_blocks__29: |
+ ```
+ ``` aaa
+ ```
+04_06__leaf_blocks__html_blocks__01: |
+ <table><tr><td>
+ <pre>
+ **Hello**,
+
+ _world_.
+ </pre>
+ </td></tr></table>
+04_06__leaf_blocks__html_blocks__02: |
+ <table>
+ <tr>
+ <td>
+ hi
+ </td>
+ </tr>
+ </table>
+
+ okay.
+04_06__leaf_blocks__html_blocks__03: |2
+ <div>
+ *hello*
+ <foo><a>
+04_06__leaf_blocks__html_blocks__04: |
+ </div>
+ *foo*
+04_06__leaf_blocks__html_blocks__05: |
+ <DIV CLASS="foo">
+
+ *Markdown*
+
+ </DIV>
+04_06__leaf_blocks__html_blocks__06: |
+ <div id="foo"
+ class="bar">
+ </div>
+04_06__leaf_blocks__html_blocks__07: |
+ <div id="foo" class="bar
+ baz">
+ </div>
+04_06__leaf_blocks__html_blocks__08: |
+ <div>
+ *foo*
+
+ *bar*
+04_06__leaf_blocks__html_blocks__09: |
+ <div id="foo"
+ *hi*
+04_06__leaf_blocks__html_blocks__10: |
+ <div class
+ foo
+04_06__leaf_blocks__html_blocks__11: |
+ <div *???-&&&-<---
+ *foo*
+04_06__leaf_blocks__html_blocks__12: |
+ <div><a href="bar">*foo*</a></div>
+04_06__leaf_blocks__html_blocks__13: |
+ <table><tr><td>
+ foo
+ </td></tr></table>
+04_06__leaf_blocks__html_blocks__14: |
+ <div></div>
+ ``` c
+ int x = 33;
+ ```
+04_06__leaf_blocks__html_blocks__15: |
+ <a href="foo">
+ *bar*
+ </a>
+04_06__leaf_blocks__html_blocks__16: |
+ <Warning>
+ *bar*
+ </Warning>
+04_06__leaf_blocks__html_blocks__17: |
+ <i class="foo">
+ *bar*
+ </i>
+04_06__leaf_blocks__html_blocks__18: |
+ </ins>
+ *bar*
+04_06__leaf_blocks__html_blocks__19: |
+ <del>
+ *foo*
+ </del>
+04_06__leaf_blocks__html_blocks__20: |
+ <del>
+
+ *foo*
+
+ </del>
+04_06__leaf_blocks__html_blocks__21: |
+ <del>*foo*</del>
+04_06__leaf_blocks__html_blocks__22: |
+ <pre language="haskell"><code>
+ import Text.HTML.TagSoup
+
+ main :: IO ()
+ main = print $ parseTags tags
+ </code></pre>
+ okay
+04_06__leaf_blocks__html_blocks__23: |
+ <script type="text/javascript">
+ // JavaScript example
+
+ document.getElementById("demo").innerHTML = "Hello JavaScript!";
+ </script>
+ okay
+04_06__leaf_blocks__html_blocks__24: |
+ <style
+ type="text/css">
+ h1 {color:red;}
+
+ p {color:blue;}
+ </style>
+ okay
+04_06__leaf_blocks__html_blocks__25: |
+ <style
+ type="text/css">
+
+ foo
+04_06__leaf_blocks__html_blocks__26: |
+ > <div>
+ > foo
+
+ bar
+04_06__leaf_blocks__html_blocks__27: |
+ - <div>
+ - foo
+04_06__leaf_blocks__html_blocks__28: |
+ <style>p{color:red;}</style>
+ *foo*
+04_06__leaf_blocks__html_blocks__29: |
+ <!-- foo -->*bar*
+ *baz*
+04_06__leaf_blocks__html_blocks__30: |
+ <script>
+ foo
+ </script>1. *bar*
+04_06__leaf_blocks__html_blocks__31: |
+ <!-- Foo
+
+ bar
+ baz -->
+ okay
+04_06__leaf_blocks__html_blocks__32: |
+ <?php
+
+ echo '>';
+
+ ?>
+ okay
+04_06__leaf_blocks__html_blocks__33: |
+ <!DOCTYPE html>
+04_06__leaf_blocks__html_blocks__34: |
+ <![CDATA[
+ function matchwo(a,b)
+ {
+ if (a < b && a < 0) then {
+ return 1;
+
+ } else {
+
+ return 0;
+ }
+ }
+ ]]>
+ okay
+04_06__leaf_blocks__html_blocks__35: |2
+ <!-- foo -->
+
+ <!-- foo -->
+04_06__leaf_blocks__html_blocks__36: |2
+ <div>
+
+ <div>
+04_06__leaf_blocks__html_blocks__37: |
+ Foo
+ <div>
+ bar
+ </div>
+04_06__leaf_blocks__html_blocks__38: |
+ <div>
+ bar
+ </div>
+ *foo*
+04_06__leaf_blocks__html_blocks__39: |
+ Foo
+ <a href="bar">
+ baz
+04_06__leaf_blocks__html_blocks__40: |
+ <div>
+
+ *Emphasized* text.
+
+ </div>
+04_06__leaf_blocks__html_blocks__41: |
+ <div>
+ *Emphasized* text.
+ </div>
+04_06__leaf_blocks__html_blocks__42: |
+ <table>
+
+ <tr>
+
+ <td>
+ Hi
+ </td>
+
+ </tr>
+
+ </table>
+04_06__leaf_blocks__html_blocks__43: |
+ <table>
+
+ <tr>
+
+ <td>
+ Hi
+ </td>
+
+ </tr>
+
+ </table>
+04_07__leaf_blocks__link_reference_definitions__01: |
+ [foo]: /url "title"
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__02: " [foo]: \n /url \n 'the
+ title' \n\n[foo]\n"
+04_07__leaf_blocks__link_reference_definitions__03: |
+ [Foo*bar\]]:my_(url) 'title (with parens)'
+
+ [Foo*bar\]]
+04_07__leaf_blocks__link_reference_definitions__04: |
+ [Foo bar]:
+ <my url>
+ 'title'
+
+ [Foo bar]
+04_07__leaf_blocks__link_reference_definitions__05: |
+ [foo]: /url '
+ title
+ line1
+ line2
+ '
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__06: |
+ [foo]: /url 'title
+
+ with blank line'
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__07: |
+ [foo]:
+ /url
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__08: |
+ [foo]:
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__09: |
+ [foo]: <>
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__10: |
+ [foo]: <bar>(baz)
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__11: |
+ [foo]: /url\bar\*baz "foo\"bar\baz"
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__12: |
+ [foo]
+
+ [foo]: url
+04_07__leaf_blocks__link_reference_definitions__13: |
+ [foo]
+
+ [foo]: first
+ [foo]: second
+04_07__leaf_blocks__link_reference_definitions__14: |
+ [FOO]: /url
+
+ [Foo]
+04_07__leaf_blocks__link_reference_definitions__15: |
+ [ΑΓΩ]: /φου
+
+ [αγω]
+04_07__leaf_blocks__link_reference_definitions__16: |
+ [foo]: /url
+04_07__leaf_blocks__link_reference_definitions__17: |
+ [
+ foo
+ ]: /url
+ bar
+04_07__leaf_blocks__link_reference_definitions__18: |
+ [foo]: /url "title" ok
+04_07__leaf_blocks__link_reference_definitions__19: |
+ [foo]: /url
+ "title" ok
+04_07__leaf_blocks__link_reference_definitions__20: |2
+ [foo]: /url "title"
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__21: |
+ ```
+ [foo]: /url
+ ```
+
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__22: |
+ Foo
+ [bar]: /baz
+
+ [bar]
+04_07__leaf_blocks__link_reference_definitions__23: |
+ # [Foo]
+ [foo]: /url
+ > bar
+04_07__leaf_blocks__link_reference_definitions__24: |
+ [foo]: /url
+ bar
+ ===
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__25: |
+ [foo]: /url
+ ===
+ [foo]
+04_07__leaf_blocks__link_reference_definitions__26: |
+ [foo]: /foo-url "foo"
+ [bar]: /bar-url
+ "bar"
+ [baz]: /baz-url
+
+ [foo],
+ [bar],
+ [baz]
+04_07__leaf_blocks__link_reference_definitions__27: |
+ [foo]
+
+ > [foo]: /url
+04_07__leaf_blocks__link_reference_definitions__28: |
+ [foo]: /url
+04_08__leaf_blocks__paragraphs__01: |
+ aaa
+
+ bbb
+04_08__leaf_blocks__paragraphs__02: |
+ aaa
+ bbb
+
+ ccc
+ ddd
+04_08__leaf_blocks__paragraphs__03: |
+ aaa
+
+
+ bbb
+04_08__leaf_blocks__paragraphs__04: |2
+ aaa
+ bbb
+04_08__leaf_blocks__paragraphs__05: |
+ aaa
+ bbb
+ ccc
+04_08__leaf_blocks__paragraphs__06: |2
+ aaa
+ bbb
+04_08__leaf_blocks__paragraphs__07: |2
+ aaa
+ bbb
+04_08__leaf_blocks__paragraphs__08: "aaa \nbbb \n"
+04_09__leaf_blocks__blank_lines__01: " \n\naaa\n \n\n# aaa\n\n \n"
+04_10__leaf_blocks__tables_extension__01: |
+ | foo | bar |
+ | --- | --- |
+ | baz | bim |
+04_10__leaf_blocks__tables_extension__02: |
+ | abc | defghi |
+ :-: | -----------:
+ bar | baz
+04_10__leaf_blocks__tables_extension__03: |
+ | f\|oo |
+ | ------ |
+ | b `\|` az |
+ | b **\|** im |
+04_10__leaf_blocks__tables_extension__04: |
+ | abc | def |
+ | --- | --- |
+ | bar | baz |
+ > bar
+04_10__leaf_blocks__tables_extension__05: |
+ | abc | def |
+ | --- | --- |
+ | bar | baz |
+ bar
+
+ bar
+04_10__leaf_blocks__tables_extension__06: |
+ | abc | def |
+ | --- |
+ | bar |
+04_10__leaf_blocks__tables_extension__07: |
+ | abc | def |
+ | --- | --- |
+ | bar |
+ | bar | baz | boo |
+04_10__leaf_blocks__tables_extension__08: |
+ | abc | def |
+ | --- | --- |
+05_01__container_blocks__block_quotes__01: |
+ > # Foo
+ > bar
+ > baz
+05_01__container_blocks__block_quotes__02: |
+ ># Foo
+ >bar
+ > baz
+05_01__container_blocks__block_quotes__03: |2
+ > # Foo
+ > bar
+ > baz
+05_01__container_blocks__block_quotes__04: |2
+ > # Foo
+ > bar
+ > baz
+05_01__container_blocks__block_quotes__05: |
+ > # Foo
+ > bar
+ baz
+05_01__container_blocks__block_quotes__06: |
+ > bar
+ baz
+ > foo
+05_01__container_blocks__block_quotes__07: |
+ > foo
+ ---
+05_01__container_blocks__block_quotes__08: |
+ > - foo
+ - bar
+05_01__container_blocks__block_quotes__09: |
+ > foo
+ bar
+05_01__container_blocks__block_quotes__10: |
+ > ```
+ foo
+ ```
+05_01__container_blocks__block_quotes__11: |
+ > foo
+ - bar
+05_01__container_blocks__block_quotes__12: |
+ >
+05_01__container_blocks__block_quotes__13: ">\n> \n> \n"
+05_01__container_blocks__block_quotes__14: ">\n> foo\n> \n"
+05_01__container_blocks__block_quotes__15: |
+ > foo
+
+ > bar
+05_01__container_blocks__block_quotes__16: |
+ > foo
+ > bar
+05_01__container_blocks__block_quotes__17: |
+ > foo
+ >
+ > bar
+05_01__container_blocks__block_quotes__18: |
+ foo
+ > bar
+05_01__container_blocks__block_quotes__19: |
+ > aaa
+ ***
+ > bbb
+05_01__container_blocks__block_quotes__20: |
+ > bar
+ baz
+05_01__container_blocks__block_quotes__21: |
+ > bar
+
+ baz
+05_01__container_blocks__block_quotes__22: |
+ > bar
+ >
+ baz
+05_01__container_blocks__block_quotes__23: |
+ > > > foo
+ bar
+05_01__container_blocks__block_quotes__24: |
+ >>> foo
+ > bar
+ >>baz
+05_01__container_blocks__block_quotes__25: |
+ > code
+
+ > not code
+05_02__container_blocks__list_items__01: |
+ A paragraph
+ with two lines.
+
+ indented code
+
+ > A block quote.
+05_02__container_blocks__list_items__02: |
+ 1. A paragraph
+ with two lines.
+
+ indented code
+
+ > A block quote.
+05_02__container_blocks__list_items__03: |
+ - one
+
+ two
+05_02__container_blocks__list_items__04: |
+ - one
+
+ two
+05_02__container_blocks__list_items__05: |2
+ - one
+
+ two
+05_02__container_blocks__list_items__06: |2
+ - one
+
+ two
+05_02__container_blocks__list_items__07: |2
+ > > 1. one
+ >>
+ >> two
+05_02__container_blocks__list_items__08: |
+ >>- one
+ >>
+ > > two
+05_02__container_blocks__list_items__09: |
+ -one
+
+ 2.two
+05_02__container_blocks__list_items__10: |
+ - foo
+
+
+ bar
+05_02__container_blocks__list_items__11: |
+ 1. foo
+
+ ```
+ bar
+ ```
+
+ baz
+
+ > bam
+05_02__container_blocks__list_items__12: |
+ - Foo
+
+ bar
+
+
+ baz
+05_02__container_blocks__list_items__13: |
+ 123456789. ok
+05_02__container_blocks__list_items__14: |
+ 1234567890. not ok
+05_02__container_blocks__list_items__15: |
+ 0. ok
+05_02__container_blocks__list_items__16: |
+ 003. ok
+05_02__container_blocks__list_items__17: |
+ -1. not ok
+05_02__container_blocks__list_items__18: |
+ - foo
+
+ bar
+05_02__container_blocks__list_items__19: |2
+ 10. foo
+
+ bar
+05_02__container_blocks__list_items__20: |2
+ indented code
+
+ paragraph
+
+ more code
+05_02__container_blocks__list_items__21: |
+ 1. indented code
+
+ paragraph
+
+ more code
+05_02__container_blocks__list_items__22: |
+ 1. indented code
+
+ paragraph
+
+ more code
+05_02__container_blocks__list_items__23: |2
+ foo
+
+ bar
+05_02__container_blocks__list_items__24: |
+ - foo
+
+ bar
+05_02__container_blocks__list_items__25: |
+ - foo
+
+ bar
+05_02__container_blocks__list_items__26: |
+ -
+ foo
+ -
+ ```
+ bar
+ ```
+ -
+ baz
+05_02__container_blocks__list_items__27: "- \n foo\n"
+05_02__container_blocks__list_items__28: |
+ -
+
+ foo
+05_02__container_blocks__list_items__29: |
+ - foo
+ -
+ - bar
+05_02__container_blocks__list_items__30: "- foo\n- \n- bar\n"
+05_02__container_blocks__list_items__31: |
+ 1. foo
+ 2.
+ 3. bar
+05_02__container_blocks__list_items__32: |
+ *
+05_02__container_blocks__list_items__33: |
+ foo
+ *
+
+ foo
+ 1.
+05_02__container_blocks__list_items__34: |2
+ 1. A paragraph
+ with two lines.
+
+ indented code
+
+ > A block quote.
+05_02__container_blocks__list_items__35: |2
+ 1. A paragraph
+ with two lines.
+
+ indented code
+
+ > A block quote.
+05_02__container_blocks__list_items__36: |2
+ 1. A paragraph
+ with two lines.
+
+ indented code
+
+ > A block quote.
+05_02__container_blocks__list_items__37: |2
+ 1. A paragraph
+ with two lines.
+
+ indented code
+
+ > A block quote.
+05_02__container_blocks__list_items__38: |2
+ 1. A paragraph
+ with two lines.
+
+ indented code
+
+ > A block quote.
+05_02__container_blocks__list_items__39: |2
+ 1. A paragraph
+ with two lines.
+05_02__container_blocks__list_items__40: |
+ > 1. > Blockquote
+ continued here.
+05_02__container_blocks__list_items__41: |
+ > 1. > Blockquote
+ > continued here.
+05_02__container_blocks__list_items__42: |
+ - foo
+ - bar
+ - baz
+ - boo
+05_02__container_blocks__list_items__43: |
+ - foo
+ - bar
+ - baz
+ - boo
+05_02__container_blocks__list_items__44: |
+ 10) foo
+ - bar
+05_02__container_blocks__list_items__45: |
+ 10) foo
+ - bar
+05_02__container_blocks__list_items__46: |
+ - - foo
+05_02__container_blocks__list_items__47: |
+ 1. - 2. foo
+05_02__container_blocks__list_items__48: |
+ - # Foo
+ - Bar
+ ---
+ baz
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__49: |
+ - [ ] foo
+ - [x] bar
+ - [x] foo
+ - [ ] bar
+ - [x] baz
+ - [ ] bim
+ - foo
+ - bar
+ + baz
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__50: |
+ 1. foo
+ 2. bar
+ 3) baz
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__51: |
+ Foo
+ - bar
+ - baz
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__52: |
+ The number of windows in my house is
+ 14. The number of doors is 6.
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__53: |
+ The number of windows in my house is
+ 1. The number of doors is 6.
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__54: |
+ - foo
+
+ - bar
+
+
+ - baz
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__55: |
+ - foo
+ - bar
+ - baz
+
+
+ bim
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__56: |
+ - foo
+ - bar
+
+ <!-- -->
+
+ - baz
+ - bim
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__57: |
+ - foo
+
+ notcode
+
+ - foo
+
+ <!-- -->
+
+ code
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__58: |
+ - a
+ - b
+ - c
+ - d
+ - e
+ - f
+ - g
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__59: |
+ 1. a
+
+ 2. b
+
+ 3. c
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__60: |
+ - a
+ - b
+ - c
+ - d
+ - e
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__61: |
+ 1. a
+
+ 2. b
+
+ 3. c
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__62: |
+ - a
+ - b
+
+ - c
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__63: |
+ * a
+ *
+
+ * c
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__64: |
+ - a
+ - b
+
+ c
+ - d
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__65: |
+ - a
+ - b
+
+ [ref]: /url
+ - d
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__66: |
+ - a
+ - ```
+ b
+
+
+ ```
+ - c
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__67: |
+ - a
+ - b
+
+ c
+ - d
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__68: |
+ * a
+ > b
+ >
+ * c
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__69: |
+ - a
+ > b
+ ```
+ c
+ ```
+ - d
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__70: |
+ - a
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__71: |
+ - a
+ - b
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__72: |
+ 1. ```
+ foo
+ ```
+
+ bar
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__73: |
+ * foo
+ * bar
+
+ baz
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__74: |
+ - a
+ - b
+ - c
+
+ - d
+ - e
+ - f
+06_01__inlines__01: |
+ `hi`lo`
+06_02__inlines__backslash_escapes__01: |
+ \!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~
+06_02__inlines__backslash_escapes__02: "\\\t\\A\\a\\ \\3\\φ\\«\n"
+06_02__inlines__backslash_escapes__03: |
+ \*not emphasized*
+ \<br/> not a tag
+ \[not a link](/foo)
+ \`not code`
+ 1\. not a list
+ \* not a list
+ \# not a heading
+ \[foo]: /url "not a reference"
+ \&ouml; not a character entity
+06_02__inlines__backslash_escapes__04: |
+ \\*emphasis*
+06_02__inlines__backslash_escapes__05: |
+ foo\
+ bar
+06_02__inlines__backslash_escapes__06: |
+ `` \[\` ``
+06_02__inlines__backslash_escapes__07: |2
+ \[\]
+06_02__inlines__backslash_escapes__08: |
+ ~~~
+ \[\]
+ ~~~
+06_02__inlines__backslash_escapes__09: |
+ <http://example.com?find=\*>
+06_02__inlines__backslash_escapes__10: |
+ <a href="/bar\/)">
+06_02__inlines__backslash_escapes__11: |
+ [foo](/bar\* "ti\*tle")
+06_02__inlines__backslash_escapes__12: |
+ [foo]
+
+ [foo]: /bar\* "ti\*tle"
+06_02__inlines__backslash_escapes__13: |
+ ``` foo\+bar
+ foo
+ ```
+06_03__inlines__entity_and_numeric_character_references__01: |
+ &nbsp; &amp; &copy; &AElig; &Dcaron;
+ &frac34; &HilbertSpace; &DifferentialD;
+ &ClockwiseContourIntegral; &ngE;
+06_03__inlines__entity_and_numeric_character_references__02: |
+ &#35; &#1234; &#992; &#0;
+06_03__inlines__entity_and_numeric_character_references__03: |
+ &#X22; &#XD06; &#xcab;
+06_03__inlines__entity_and_numeric_character_references__04: |
+ &nbsp &x; &#; &#x;
+ &#987654321;
+ &#abcdef0;
+ &ThisIsNotDefined; &hi?;
+06_03__inlines__entity_and_numeric_character_references__05: |
+ &copy
+06_03__inlines__entity_and_numeric_character_references__06: |
+ &MadeUpEntity;
+06_03__inlines__entity_and_numeric_character_references__07: |
+ <a href="&ouml;&ouml;.html">
+06_03__inlines__entity_and_numeric_character_references__08: |
+ [foo](/f&ouml;&ouml; "f&ouml;&ouml;")
+06_03__inlines__entity_and_numeric_character_references__09: |
+ [foo]
+
+ [foo]: /f&ouml;&ouml; "f&ouml;&ouml;"
+06_03__inlines__entity_and_numeric_character_references__10: |
+ ``` f&ouml;&ouml;
+ foo
+ ```
+06_03__inlines__entity_and_numeric_character_references__11: |
+ `f&ouml;&ouml;`
+06_03__inlines__entity_and_numeric_character_references__12: |2
+ f&ouml;f&ouml;
+06_03__inlines__entity_and_numeric_character_references__13: |
+ &#42;foo&#42;
+ *foo*
+06_03__inlines__entity_and_numeric_character_references__14: |
+ &#42; foo
+
+ * foo
+06_03__inlines__entity_and_numeric_character_references__15: |
+ foo&#10;&#10;bar
+06_03__inlines__entity_and_numeric_character_references__16: |
+ &#9;foo
+06_03__inlines__entity_and_numeric_character_references__17: |
+ [a](url &quot;tit&quot;)
+06_04__inlines__code_spans__01: |
+ `foo`
+06_04__inlines__code_spans__02: |
+ `` foo ` bar ``
+06_04__inlines__code_spans__03: |
+ ` `` `
+06_04__inlines__code_spans__04: |
+ ` `` `
+06_04__inlines__code_spans__05: |
+ ` a`
+06_04__inlines__code_spans__06: |
+ ` b `
+06_04__inlines__code_spans__07: |
+ ` `
+ ` `
+06_04__inlines__code_spans__08: "``\nfoo\nbar \nbaz\n``\n"
+06_04__inlines__code_spans__09: "``\nfoo \n``\n"
+06_04__inlines__code_spans__10: "`foo bar \nbaz`\n"
+06_04__inlines__code_spans__11: |
+ `foo\`bar`
+06_04__inlines__code_spans__12: |
+ ``foo`bar``
+06_04__inlines__code_spans__13: |
+ ` foo `` bar `
+06_04__inlines__code_spans__14: |
+ *foo`*`
+06_04__inlines__code_spans__15: |
+ [not a `link](/foo`)
+06_04__inlines__code_spans__16: |
+ `<a href="`">`
+06_04__inlines__code_spans__17: |
+ <a href="`">`
+06_04__inlines__code_spans__18: |
+ `<http://foo.bar.`baz>`
+06_04__inlines__code_spans__19: |
+ <http://foo.bar.`baz>`
+06_04__inlines__code_spans__20: |
+ ```foo``
+06_04__inlines__code_spans__21: |
+ `foo
+06_04__inlines__code_spans__22: |
+ `foo``bar``
+06_05__inlines__emphasis_and_strong_emphasis__01: |
+ *foo bar*
+06_05__inlines__emphasis_and_strong_emphasis__02: |
+ a * foo bar*
+06_05__inlines__emphasis_and_strong_emphasis__03: |
+ a*"foo"*
+06_05__inlines__emphasis_and_strong_emphasis__04: |
+ * a *
+06_05__inlines__emphasis_and_strong_emphasis__05: |
+ foo*bar*
+06_05__inlines__emphasis_and_strong_emphasis__06: |
+ 5*6*78
+06_05__inlines__emphasis_and_strong_emphasis__07: |
+ _foo bar_
+06_05__inlines__emphasis_and_strong_emphasis__08: |
+ _ foo bar_
+06_05__inlines__emphasis_and_strong_emphasis__09: |
+ a_"foo"_
+06_05__inlines__emphasis_and_strong_emphasis__10: |
+ foo_bar_
+06_05__inlines__emphasis_and_strong_emphasis__11: |
+ 5_6_78
+06_05__inlines__emphasis_and_strong_emphasis__12: |
+ пристаням_стремятся_
+06_05__inlines__emphasis_and_strong_emphasis__13: |
+ aa_"bb"_cc
+06_05__inlines__emphasis_and_strong_emphasis__14: |
+ foo-_(bar)_
+06_05__inlines__emphasis_and_strong_emphasis__15: |
+ _foo*
+06_05__inlines__emphasis_and_strong_emphasis__16: |
+ *foo bar *
+06_05__inlines__emphasis_and_strong_emphasis__17: |
+ *foo bar
+ *
+06_05__inlines__emphasis_and_strong_emphasis__18: |
+ *(*foo)
+06_05__inlines__emphasis_and_strong_emphasis__19: |
+ *(*foo*)*
+06_05__inlines__emphasis_and_strong_emphasis__20: |
+ *foo*bar
+06_05__inlines__emphasis_and_strong_emphasis__21: |
+ _foo bar _
+06_05__inlines__emphasis_and_strong_emphasis__22: |
+ _(_foo)
+06_05__inlines__emphasis_and_strong_emphasis__23: |
+ _(_foo_)_
+06_05__inlines__emphasis_and_strong_emphasis__24: |
+ _foo_bar
+06_05__inlines__emphasis_and_strong_emphasis__25: |
+ _пристаням_стремятся
+06_05__inlines__emphasis_and_strong_emphasis__26: |
+ _foo_bar_baz_
+06_05__inlines__emphasis_and_strong_emphasis__27: |
+ _(bar)_.
+06_05__inlines__emphasis_and_strong_emphasis__28: |
+ **foo bar**
+06_05__inlines__emphasis_and_strong_emphasis__29: |
+ ** foo bar**
+06_05__inlines__emphasis_and_strong_emphasis__30: |
+ a**"foo"**
+06_05__inlines__emphasis_and_strong_emphasis__31: |
+ foo**bar**
+06_05__inlines__emphasis_and_strong_emphasis__32: |
+ __foo bar__
+06_05__inlines__emphasis_and_strong_emphasis__33: |
+ __ foo bar__
+06_05__inlines__emphasis_and_strong_emphasis__34: |
+ __
+ foo bar__
+06_05__inlines__emphasis_and_strong_emphasis__35: |
+ a__"foo"__
+06_05__inlines__emphasis_and_strong_emphasis__36: |
+ foo__bar__
+06_05__inlines__emphasis_and_strong_emphasis__37: |
+ 5__6__78
+06_05__inlines__emphasis_and_strong_emphasis__38: |
+ пристаням__стремятся__
+06_05__inlines__emphasis_and_strong_emphasis__39: |
+ __foo, __bar__, baz__
+06_05__inlines__emphasis_and_strong_emphasis__40: |
+ foo-__(bar)__
+06_05__inlines__emphasis_and_strong_emphasis__41: |
+ **foo bar **
+06_05__inlines__emphasis_and_strong_emphasis__42: |
+ **(**foo)
+06_05__inlines__emphasis_and_strong_emphasis__43: |
+ *(**foo**)*
+06_05__inlines__emphasis_and_strong_emphasis__44: |
+ **Gomphocarpus (*Gomphocarpus physocarpus*, syn.
+ *Asclepias physocarpa*)**
+06_05__inlines__emphasis_and_strong_emphasis__45: |
+ **foo "*bar*" foo**
+06_05__inlines__emphasis_and_strong_emphasis__46: |
+ **foo**bar
+06_05__inlines__emphasis_and_strong_emphasis__47: |
+ __foo bar __
+06_05__inlines__emphasis_and_strong_emphasis__48: |
+ __(__foo)
+06_05__inlines__emphasis_and_strong_emphasis__49: |
+ _(__foo__)_
+06_05__inlines__emphasis_and_strong_emphasis__50: |
+ __foo__bar
+06_05__inlines__emphasis_and_strong_emphasis__51: |
+ __пристаням__стремятся
+06_05__inlines__emphasis_and_strong_emphasis__52: |
+ __foo__bar__baz__
+06_05__inlines__emphasis_and_strong_emphasis__53: |
+ __(bar)__.
+06_05__inlines__emphasis_and_strong_emphasis__54: |
+ *foo [bar](/url)*
+06_05__inlines__emphasis_and_strong_emphasis__55: |
+ *foo
+ bar*
+06_05__inlines__emphasis_and_strong_emphasis__56: |
+ _foo __bar__ baz_
+06_05__inlines__emphasis_and_strong_emphasis__57: |
+ _foo _bar_ baz_
+06_05__inlines__emphasis_and_strong_emphasis__58: |
+ __foo_ bar_
+06_05__inlines__emphasis_and_strong_emphasis__59: |
+ *foo *bar**
+06_05__inlines__emphasis_and_strong_emphasis__60: |
+ *foo **bar** baz*
+06_05__inlines__emphasis_and_strong_emphasis__61: |
+ *foo**bar**baz*
+06_05__inlines__emphasis_and_strong_emphasis__62: |
+ *foo**bar*
+06_05__inlines__emphasis_and_strong_emphasis__63: |
+ ***foo** bar*
+06_05__inlines__emphasis_and_strong_emphasis__64: |
+ *foo **bar***
+06_05__inlines__emphasis_and_strong_emphasis__65: |
+ *foo**bar***
+06_05__inlines__emphasis_and_strong_emphasis__66: |
+ foo***bar***baz
+06_05__inlines__emphasis_and_strong_emphasis__67: |
+ foo******bar*********baz
+06_05__inlines__emphasis_and_strong_emphasis__68: |
+ *foo **bar *baz* bim** bop*
+06_05__inlines__emphasis_and_strong_emphasis__69: |
+ *foo [*bar*](/url)*
+06_05__inlines__emphasis_and_strong_emphasis__70: |
+ ** is not an empty emphasis
+06_05__inlines__emphasis_and_strong_emphasis__71: |
+ **** is not an empty strong emphasis
+06_05__inlines__emphasis_and_strong_emphasis__72: |
+ **foo [bar](/url)**
+06_05__inlines__emphasis_and_strong_emphasis__73: |
+ **foo
+ bar**
+06_05__inlines__emphasis_and_strong_emphasis__74: |
+ __foo _bar_ baz__
+06_05__inlines__emphasis_and_strong_emphasis__75: |
+ __foo __bar__ baz__
+06_05__inlines__emphasis_and_strong_emphasis__76: |
+ ____foo__ bar__
+06_05__inlines__emphasis_and_strong_emphasis__77: |
+ **foo **bar****
+06_05__inlines__emphasis_and_strong_emphasis__78: |
+ **foo *bar* baz**
+06_05__inlines__emphasis_and_strong_emphasis__79: |
+ **foo*bar*baz**
+06_05__inlines__emphasis_and_strong_emphasis__80: |
+ ***foo* bar**
+06_05__inlines__emphasis_and_strong_emphasis__81: |
+ **foo *bar***
+06_05__inlines__emphasis_and_strong_emphasis__82: |
+ **foo *bar **baz**
+ bim* bop**
+06_05__inlines__emphasis_and_strong_emphasis__83: |
+ **foo [*bar*](/url)**
+06_05__inlines__emphasis_and_strong_emphasis__84: |
+ __ is not an empty emphasis
+06_05__inlines__emphasis_and_strong_emphasis__85: |
+ ____ is not an empty strong emphasis
+06_05__inlines__emphasis_and_strong_emphasis__86: |
+ foo ***
+06_05__inlines__emphasis_and_strong_emphasis__87: |
+ foo *\**
+06_05__inlines__emphasis_and_strong_emphasis__88: |
+ foo *_*
+06_05__inlines__emphasis_and_strong_emphasis__89: |
+ foo *****
+06_05__inlines__emphasis_and_strong_emphasis__90: |
+ foo **\***
+06_05__inlines__emphasis_and_strong_emphasis__91: |
+ foo **_**
+06_05__inlines__emphasis_and_strong_emphasis__92: |
+ **foo*
+06_05__inlines__emphasis_and_strong_emphasis__93: |
+ *foo**
+06_05__inlines__emphasis_and_strong_emphasis__94: |
+ ***foo**
+06_05__inlines__emphasis_and_strong_emphasis__95: |
+ ****foo*
+06_05__inlines__emphasis_and_strong_emphasis__96: |
+ **foo***
+06_05__inlines__emphasis_and_strong_emphasis__97: |
+ *foo****
+06_05__inlines__emphasis_and_strong_emphasis__98: |
+ foo ___
+06_05__inlines__emphasis_and_strong_emphasis__99: |
+ foo _\__
+06_05__inlines__emphasis_and_strong_emphasis__100: |
+ foo _*_
+06_05__inlines__emphasis_and_strong_emphasis__101: |
+ foo _____
+06_05__inlines__emphasis_and_strong_emphasis__102: |
+ foo __\___
+06_05__inlines__emphasis_and_strong_emphasis__103: |
+ foo __*__
+06_05__inlines__emphasis_and_strong_emphasis__104: |
+ __foo_
+06_05__inlines__emphasis_and_strong_emphasis__105: |
+ _foo__
+06_05__inlines__emphasis_and_strong_emphasis__106: |
+ ___foo__
+06_05__inlines__emphasis_and_strong_emphasis__107: |
+ ____foo_
+06_05__inlines__emphasis_and_strong_emphasis__108: |
+ __foo___
+06_05__inlines__emphasis_and_strong_emphasis__109: |
+ _foo____
+06_05__inlines__emphasis_and_strong_emphasis__110: |
+ **foo**
+06_05__inlines__emphasis_and_strong_emphasis__111: |
+ *_foo_*
+06_05__inlines__emphasis_and_strong_emphasis__112: |
+ __foo__
+06_05__inlines__emphasis_and_strong_emphasis__113: |
+ _*foo*_
+06_05__inlines__emphasis_and_strong_emphasis__114: |
+ ****foo****
+06_05__inlines__emphasis_and_strong_emphasis__115: |
+ ____foo____
+06_05__inlines__emphasis_and_strong_emphasis__116: |
+ ******foo******
+06_05__inlines__emphasis_and_strong_emphasis__117: |
+ ***foo***
+06_05__inlines__emphasis_and_strong_emphasis__118: |
+ _____foo_____
+06_05__inlines__emphasis_and_strong_emphasis__119: |
+ *foo _bar* baz_
+06_05__inlines__emphasis_and_strong_emphasis__120: |
+ *foo __bar *baz bim__ bam*
+06_05__inlines__emphasis_and_strong_emphasis__121: |
+ **foo **bar baz**
+06_05__inlines__emphasis_and_strong_emphasis__122: |
+ *foo *bar baz*
+06_05__inlines__emphasis_and_strong_emphasis__123: |
+ *[bar*](/url)
+06_05__inlines__emphasis_and_strong_emphasis__124: |
+ _foo [bar_](/url)
+06_05__inlines__emphasis_and_strong_emphasis__125: |
+ *<img src="foo" title="*"/>
+06_05__inlines__emphasis_and_strong_emphasis__126: |
+ **<a href="**">
+06_05__inlines__emphasis_and_strong_emphasis__127: |
+ __<a href="__">
+06_05__inlines__emphasis_and_strong_emphasis__128: |
+ *a `*`*
+06_05__inlines__emphasis_and_strong_emphasis__129: |
+ _a `_`_
+06_05__inlines__emphasis_and_strong_emphasis__130: |
+ **a<http://foo.bar/?q=**>
+06_05__inlines__emphasis_and_strong_emphasis__131: |
+ __a<http://foo.bar/?q=__>
+06_06__inlines__strikethrough_extension__01: |
+ ~~Hi~~ Hello, world!
+06_06__inlines__strikethrough_extension__02: |
+ This ~~has a
+
+ new paragraph~~.
+06_07__inlines__links__01: |
+ [link](/uri "title")
+06_07__inlines__links__02: |
+ [link](/uri)
+06_07__inlines__links__03: |
+ [link]()
+06_07__inlines__links__04: |
+ [link](<>)
+06_07__inlines__links__05: |
+ [link](/my uri)
+06_07__inlines__links__06: |
+ [link](</my uri>)
+06_07__inlines__links__07: |
+ [link](foo
+ bar)
+06_07__inlines__links__08: |
+ [link](<foo
+ bar>)
+06_07__inlines__links__09: |
+ [a](<b)c>)
+06_07__inlines__links__10: |
+ [link](<foo\>)
+06_07__inlines__links__11: |
+ [a](<b)c
+ [a](<b)c>
+ [a](<b>c)
+06_07__inlines__links__12: |
+ [link](\(foo\))
+06_07__inlines__links__13: |
+ [link](foo(and(bar)))
+06_07__inlines__links__14: |
+ [link](foo\(and\(bar\))
+06_07__inlines__links__15: |
+ [link](<foo(and(bar)>)
+06_07__inlines__links__16: |
+ [link](foo\)\:)
+06_07__inlines__links__17: |
+ [link](#fragment)
+
+ [link](http://example.com#fragment)
+
+ [link](http://example.com?foo=3#frag)
+06_07__inlines__links__18: |
+ [link](foo\bar)
+06_07__inlines__links__19: |
+ [link](foo%20b&auml;)
+06_07__inlines__links__20: |
+ [link]("title")
+06_07__inlines__links__21: |
+ [link](/url "title")
+ [link](/url 'title')
+ [link](/url (title))
+06_07__inlines__links__22: |
+ [link](/url "title \"&quot;")
+06_07__inlines__links__23: |
+ [link](/url "title")
+06_07__inlines__links__24: |
+ [link](/url "title "and" title")
+06_07__inlines__links__25: |
+ [link](/url 'title "and" title')
+06_07__inlines__links__26: |
+ [link]( /uri
+ "title" )
+06_07__inlines__links__27: |
+ [link] (/uri)
+06_07__inlines__links__28: |
+ [link [foo [bar]]](/uri)
+06_07__inlines__links__29: |
+ [link] bar](/uri)
+06_07__inlines__links__30: |
+ [link [bar](/uri)
+06_07__inlines__links__31: |
+ [link \[bar](/uri)
+06_07__inlines__links__32: |
+ [link *foo **bar** `#`*](/uri)
+06_07__inlines__links__33: |
+ [![moon](moon.jpg)](/uri)
+06_07__inlines__links__34: |
+ [foo [bar](/uri)](/uri)
+06_07__inlines__links__35: |
+ [foo *[bar [baz](/uri)](/uri)*](/uri)
+06_07__inlines__links__36: |
+ ![[[foo](uri1)](uri2)](uri3)
+06_07__inlines__links__37: |
+ *[foo*](/uri)
+06_07__inlines__links__38: |
+ [foo *bar](baz*)
+06_07__inlines__links__39: |
+ *foo [bar* baz]
+06_07__inlines__links__40: |
+ [foo <bar attr="](baz)">
+06_07__inlines__links__41: |
+ [foo`](/uri)`
+06_07__inlines__links__42: |
+ [foo<http://example.com/?search=](uri)>
+06_07__inlines__links__43: |
+ [foo][bar]
+
+ [bar]: /url "title"
+06_07__inlines__links__44: |
+ [link [foo [bar]]][ref]
+
+ [ref]: /uri
+06_07__inlines__links__45: |
+ [link \[bar][ref]
+
+ [ref]: /uri
+06_07__inlines__links__46: |
+ [link *foo **bar** `#`*][ref]
+
+ [ref]: /uri
+06_07__inlines__links__47: |
+ [![moon](moon.jpg)][ref]
+
+ [ref]: /uri
+06_07__inlines__links__48: |
+ [foo [bar](/uri)][ref]
+
+ [ref]: /uri
+06_07__inlines__links__49: |
+ [foo *bar [baz][ref]*][ref]
+
+ [ref]: /uri
+06_07__inlines__links__50: |
+ *[foo*][ref]
+
+ [ref]: /uri
+06_07__inlines__links__51: |
+ [foo *bar][ref]
+
+ [ref]: /uri
+06_07__inlines__links__52: |
+ [foo <bar attr="][ref]">
+
+ [ref]: /uri
+06_07__inlines__links__53: |
+ [foo`][ref]`
+
+ [ref]: /uri
+06_07__inlines__links__54: |
+ [foo<http://example.com/?search=][ref]>
+
+ [ref]: /uri
+06_07__inlines__links__55: |
+ [foo][BaR]
+
+ [bar]: /url "title"
+06_07__inlines__links__56: |
+ [Толпой][Толпой] is a Russian word.
+
+ [ТОЛПОЙ]: /url
+06_07__inlines__links__57: |
+ [Foo
+ bar]: /url
+
+ [Baz][Foo bar]
+06_07__inlines__links__58: |
+ [foo] [bar]
+
+ [bar]: /url "title"
+06_07__inlines__links__59: |
+ [foo]
+ [bar]
+
+ [bar]: /url "title"
+06_07__inlines__links__60: |
+ [foo]: /url1
+
+ [foo]: /url2
+
+ [bar][foo]
+06_07__inlines__links__61: |
+ [bar][foo\!]
+
+ [foo!]: /url
+06_07__inlines__links__62: |
+ [foo][ref[]
+
+ [ref[]: /uri
+06_07__inlines__links__63: |
+ [foo][ref[bar]]
+
+ [ref[bar]]: /uri
+06_07__inlines__links__64: |
+ [[[foo]]]
+
+ [[[foo]]]: /url
+06_07__inlines__links__65: |
+ [foo][ref\[]
+
+ [ref\[]: /uri
+06_07__inlines__links__66: |
+ [bar\\]: /uri
+
+ [bar\\]
+06_07__inlines__links__67: |
+ []
+
+ []: /uri
+06_07__inlines__links__68: |
+ [
+ ]
+
+ [
+ ]: /uri
+06_07__inlines__links__69: |
+ [foo][]
+
+ [foo]: /url "title"
+06_07__inlines__links__70: |
+ [*foo* bar][]
+
+ [*foo* bar]: /url "title"
+06_07__inlines__links__71: |
+ [Foo][]
+
+ [foo]: /url "title"
+06_07__inlines__links__72: "[foo] \n[]\n\n[foo]: /url \"title\"\n"
+06_07__inlines__links__73: |
+ [foo]
+
+ [foo]: /url "title"
+06_07__inlines__links__74: |
+ [*foo* bar]
+
+ [*foo* bar]: /url "title"
+06_07__inlines__links__75: |
+ [[*foo* bar]]
+
+ [*foo* bar]: /url "title"
+06_07__inlines__links__76: |
+ [[bar [foo]
+
+ [foo]: /url
+06_07__inlines__links__77: |
+ [Foo]
+
+ [foo]: /url "title"
+06_07__inlines__links__78: |
+ [foo] bar
+
+ [foo]: /url
+06_07__inlines__links__79: |
+ \[foo]
+
+ [foo]: /url "title"
+06_07__inlines__links__80: |
+ [foo*]: /url
+
+ *[foo*]
+06_07__inlines__links__81: |
+ [foo][bar]
+
+ [foo]: /url1
+ [bar]: /url2
+06_07__inlines__links__82: |
+ [foo][]
+
+ [foo]: /url1
+06_07__inlines__links__83: |
+ [foo]()
+
+ [foo]: /url1
+06_07__inlines__links__84: |
+ [foo](not a link)
+
+ [foo]: /url1
+06_07__inlines__links__85: |
+ [foo][bar][baz]
+
+ [baz]: /url
+06_07__inlines__links__86: |
+ [foo][bar][baz]
+
+ [baz]: /url1
+ [bar]: /url2
+06_07__inlines__links__87: |
+ [foo][bar][baz]
+
+ [baz]: /url1
+ [foo]: /url2
+06_08__inlines__images__01: |
+ ![foo](/url "title")
+06_08__inlines__images__02: |
+ ![foo *bar*]
+
+ [foo *bar*]: train.jpg "train & tracks"
+06_08__inlines__images__03: |
+ ![foo ![bar](/url)](/url2)
+06_08__inlines__images__04: |
+ ![foo [bar](/url)](/url2)
+06_08__inlines__images__05: |
+ ![foo *bar*][]
+
+ [foo *bar*]: train.jpg "train & tracks"
+06_08__inlines__images__06: |
+ ![foo *bar*][foobar]
+
+ [FOOBAR]: train.jpg "train & tracks"
+06_08__inlines__images__07: |
+ ![foo](train.jpg)
+06_08__inlines__images__08: |
+ My ![foo bar](/path/to/train.jpg "title" )
+06_08__inlines__images__09: |
+ ![foo](<url>)
+06_08__inlines__images__10: |
+ ![](/url)
+06_08__inlines__images__11: |
+ ![foo][bar]
+
+ [bar]: /url
+06_08__inlines__images__12: |
+ ![foo][bar]
+
+ [BAR]: /url
+06_08__inlines__images__13: |
+ ![foo][]
+
+ [foo]: /url "title"
+06_08__inlines__images__14: |
+ ![*foo* bar][]
+
+ [*foo* bar]: /url "title"
+06_08__inlines__images__15: |
+ ![Foo][]
+
+ [foo]: /url "title"
+06_08__inlines__images__16: "![foo] \n[]\n\n[foo]: /url \"title\"\n"
+06_08__inlines__images__17: |
+ ![foo]
+
+ [foo]: /url "title"
+06_08__inlines__images__18: |
+ ![*foo* bar]
+
+ [*foo* bar]: /url "title"
+06_08__inlines__images__19: |
+ ![[foo]]
+
+ [[foo]]: /url "title"
+06_08__inlines__images__20: |
+ ![Foo]
+
+ [foo]: /url "title"
+06_08__inlines__images__21: |
+ !\[foo]
+
+ [foo]: /url "title"
+06_08__inlines__images__22: |
+ \![foo]
+
+ [foo]: /url "title"
+06_09__inlines__autolinks__01: |
+ <http://foo.bar.baz>
+06_09__inlines__autolinks__02: |
+ <http://foo.bar.baz/test?q=hello&id=22&boolean>
+06_09__inlines__autolinks__03: |
+ <irc://foo.bar:2233/baz>
+06_09__inlines__autolinks__04: |
+ <MAILTO:FOO@BAR.BAZ>
+06_09__inlines__autolinks__05: |
+ <a+b+c:d>
+06_09__inlines__autolinks__06: |
+ <made-up-scheme://foo,bar>
+06_09__inlines__autolinks__07: |
+ <http://../>
+06_09__inlines__autolinks__08: |
+ <localhost:5001/foo>
+06_09__inlines__autolinks__09: |
+ <http://foo.bar/baz bim>
+06_09__inlines__autolinks__10: |
+ <http://example.com/\[\>
+06_09__inlines__autolinks__11: |
+ <foo@bar.example.com>
+06_09__inlines__autolinks__12: |
+ <foo+special@Bar.baz-bar0.com>
+06_09__inlines__autolinks__13: |
+ <foo\+@bar.example.com>
+06_09__inlines__autolinks__14: |
+ <>
+06_09__inlines__autolinks__15: |
+ < http://foo.bar >
+06_09__inlines__autolinks__16: |
+ <m:abc>
+06_09__inlines__autolinks__17: |
+ <foo.bar.baz>
+06_09__inlines__autolinks__18: |
+ http://example.com
+06_09__inlines__autolinks__19: |
+ foo@bar.example.com
+06_10__inlines__autolinks_extension__01: |
+ www.commonmark.org
+06_10__inlines__autolinks_extension__02: |
+ Visit www.commonmark.org/help for more information.
+06_10__inlines__autolinks_extension__03: |
+ Visit www.commonmark.org.
+
+ Visit www.commonmark.org/a.b.
+06_10__inlines__autolinks_extension__04: |
+ www.google.com/search?q=Markup+(business)
+
+ www.google.com/search?q=Markup+(business)))
+
+ (www.google.com/search?q=Markup+(business))
+
+ (www.google.com/search?q=Markup+(business)
+06_10__inlines__autolinks_extension__05: |
+ www.google.com/search?q=(business))+ok
+06_10__inlines__autolinks_extension__06: |
+ www.google.com/search?q=commonmark&hl=en
+
+ www.google.com/search?q=commonmark&hl;
+06_10__inlines__autolinks_extension__07: |
+ www.commonmark.org/he<lp
+06_10__inlines__autolinks_extension__08: |
+ http://commonmark.org
+
+ (Visit https://encrypted.google.com/search?q=Markup+(business))
+
+ Anonymous FTP is available at ftp://foo.bar.baz.
+06_10__inlines__autolinks_extension__09: |
+ foo@bar.baz
+06_10__inlines__autolinks_extension__10: |
+ hello@mail+xyz.example isn't valid, but hello+xyz@mail.example is.
+06_10__inlines__autolinks_extension__11: |
+ a.b-c_d@a.b
+
+ a.b-c_d@a.b.
+
+ a.b-c_d@a.b-
+
+ a.b-c_d@a.b_
+06_11__inlines__raw_html__01: |
+ <a><bab><c2c>
+06_11__inlines__raw_html__02: |
+ <a/><b2/>
+06_11__inlines__raw_html__03: |
+ <a /><b2
+ data="foo" >
+06_11__inlines__raw_html__04: |
+ <a foo="bar" bam = 'baz <em>"</em>'
+ _boolean zoop:33=zoop:33 />
+06_11__inlines__raw_html__05: |
+ Foo <responsive-image src="foo.jpg" />
+06_11__inlines__raw_html__06: |
+ <33> <__>
+06_11__inlines__raw_html__07: |
+ <a h*#ref="hi">
+06_11__inlines__raw_html__08: |
+ <a href="hi'> <a href=hi'>
+06_11__inlines__raw_html__09: |
+ < a><
+ foo><bar/ >
+ <foo bar=baz
+ bim!bop />
+06_11__inlines__raw_html__10: |
+ <a href='bar'title=title>
+06_11__inlines__raw_html__11: |
+ </a></foo >
+06_11__inlines__raw_html__12: |
+ </a href="foo">
+06_11__inlines__raw_html__13: |
+ foo <!-- this is a
+ comment - with hyphen -->
+06_11__inlines__raw_html__14: |
+ foo <!-- not a comment -- two hyphens -->
+06_11__inlines__raw_html__15: |
+ foo <!--> foo -->
+
+ foo <!-- foo--->
+06_11__inlines__raw_html__16: |
+ foo <?php echo $a; ?>
+06_11__inlines__raw_html__17: |
+ foo <!ELEMENT br EMPTY>
+06_11__inlines__raw_html__18: |
+ foo <![CDATA[>&<]]>
+06_11__inlines__raw_html__19: |
+ foo <a href="&ouml;">
+06_11__inlines__raw_html__20: |
+ foo <a href="\*">
+06_11__inlines__raw_html__21: |
+ <a href="\"">
+06_12__inlines__disallowed_raw_html_extension__01: |
+ <strong> <title> <style> <em>
+
+ <blockquote>
+ <xmp> is disallowed. <XMP> is also disallowed.
+ </blockquote>
+06_13__inlines__hard_line_breaks__01: "foo \nbaz\n"
+06_13__inlines__hard_line_breaks__02: |
+ foo\
+ baz
+06_13__inlines__hard_line_breaks__03: "foo \nbaz\n"
+06_13__inlines__hard_line_breaks__04: "foo \n bar\n"
+06_13__inlines__hard_line_breaks__05: |
+ foo\
+ bar
+06_13__inlines__hard_line_breaks__06: "*foo \nbar*\n"
+06_13__inlines__hard_line_breaks__07: |
+ *foo\
+ bar*
+06_13__inlines__hard_line_breaks__08: "`code \nspan`\n"
+06_13__inlines__hard_line_breaks__09: |
+ `code\
+ span`
+06_13__inlines__hard_line_breaks__10: "<a href=\"foo \nbar\">\n"
+06_13__inlines__hard_line_breaks__11: |
+ <a href="foo\
+ bar">
+06_13__inlines__hard_line_breaks__12: |
+ foo\
+06_13__inlines__hard_line_breaks__13: "foo \n"
+06_13__inlines__hard_line_breaks__14: |
+ ### foo\
+06_13__inlines__hard_line_breaks__15: "### foo \n"
+06_14__inlines__soft_line_breaks__01: |
+ foo
+ baz
+06_14__inlines__soft_line_breaks__02: "foo \n baz\n"
+06_15__inlines__textual_content__01: |
+ hello $.;'there
+06_15__inlines__textual_content__02: |
+ Foo χρῆν
+06_15__inlines__textual_content__03: |
+ Multiple spaces
+07_01__first_gitlab_specific_section_with_examples__strong_but_with_two_asterisks__01: |
+ **bold**
+08_01__second_gitlab_specific_section_with_examples__strong_but_with_html__01: |
+ <strong>
+ bold
+ </strong>
diff --git a/spec/fixtures/glfm/example_snapshots/prosemirror_json.yml b/spec/fixtures/glfm/example_snapshots/prosemirror_json.yml
new file mode 100644
index 00000000000..07d0235d22a
--- /dev/null
+++ b/spec/fixtures/glfm/example_snapshots/prosemirror_json.yml
@@ -0,0 +1,16739 @@
+---
+02_01__preliminaries__tabs__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\tbaz\t\tbim"
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\tbaz\t\tbim"
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "a\ta\nὐ\ta"
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+02_01__preliminaries__tabs__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+03_01__blocks_and_inlines__precedence__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "`one"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "two`"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "+++"
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "==="
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "--\n**\n__"
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "***"
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\n***"
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_ _ _ _ a"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a------"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "---a---"
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "-"
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_01__leaf_blocks__thematic_breaks__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 4
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 5
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 6
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "####### foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "#5 bolt"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "#hashtag"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "## foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": " *baz*"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "# foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n# bar"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 5
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo ### b"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo#"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo ###"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo ###"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo #"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo bar"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Bar foo"
+ }
+ ]
+ }
+ ]
+ }
+04_02__leaf_blocks__atx_headings__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ }
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ }
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ }
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\n---\n\nFoo"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\n---"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\n= ="
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\\"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "`Foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "`"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "<a title=\"a lot"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "of dashes\"/>"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\nbar\n==="
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\nBar"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Bar"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Baz"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "===="
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__23: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "> foo"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__24: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__25: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\nbar"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__26: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\nbar"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+04_03__leaf_blocks__setext_headings__27: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\nbar\n---\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "a simple\n indented code block"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "<a/>\n*hi*\n\n- one"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "chunk1\n\nchunk2\n\n\n\nchunk3"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "chunk1\n \n chunk2"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Heading"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Heading"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_04__leaf_blocks__indented_code_blocks__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "<\n >"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "<\n >"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\n~~~"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\n```"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\n```"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\n~~~"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ }
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "\n```\naaa"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bbb"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "\n "
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ }
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\naaa"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\naaa\naaa"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\n aaa\naaa"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "```\naaa\n```"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\n ```"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "\naaa"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\n~~~ ~~"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__23: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__24: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": "ruby",
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "def foo(x)\n return 3\nend"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__25: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": "ruby",
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "def foo(x)\n return 3\nend"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__26: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": ";",
+ "class": "code highlight"
+ }
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__27: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "aa"
+ },
+ {
+ "type": "text",
+ "text": "\nfoo"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__28: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": "aa",
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_05__leaf_blocks__fenced_code_blocks__29: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "``` aaa"
+ }
+ ]
+ }
+ ]
+ }
+04_06__leaf_blocks__html_blocks__01: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__02: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__03: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__04: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
+04_06__leaf_blocks__html_blocks__05: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__06: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__07: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__08: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+04_06__leaf_blocks__html_blocks__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+04_06__leaf_blocks__html_blocks__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+04_06__leaf_blocks__html_blocks__12: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__13: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__14: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__15: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
+04_06__leaf_blocks__html_blocks__16: |-
+ Error - check implementation:
+ Hast node of type "warning" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__17: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
+04_06__leaf_blocks__html_blocks__18: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
+04_06__leaf_blocks__html_blocks__19: |-
+ Error - check implementation:
+ Hast node of type "del" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__20: |-
+ Error - check implementation:
+ Hast node of type "del" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__21: |-
+ Error - check implementation:
+ Hast node of type "del" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "\nimport Text.HTML.TagSoup\n\nmain :: IO ()\nmain = print $ parseTags tags"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "okay"
+ }
+ ]
+ }
+ ]
+ }
+04_06__leaf_blocks__html_blocks__23: |-
+ Error - check implementation:
+ Hast node of type "script" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__24: |-
+ Error - check implementation:
+ Hast node of type "style" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__25: |-
+ Error - check implementation:
+ Hast node of type "style" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__26: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__27: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__28: |-
+ Error - check implementation:
+ Hast node of type "style" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__29: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__30: |-
+ Error - check implementation:
+ Hast node of type "script" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__31: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__32: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__33: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+04_06__leaf_blocks__html_blocks__34: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__35: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__36: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__37: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__38: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__39: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\n"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "bar",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+04_06__leaf_blocks__html_blocks__40: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__41: |-
+ Error - check implementation:
+ Hast node of type "div" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__42: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_06__leaf_blocks__html_blocks__43: |-
+ Error - check implementation:
+ Hast node of type "table" not supported by this converter. Please, provide an specification.
+04_07__leaf_blocks__link_reference_definitions__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "the title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "my_(url)",
+ "target": "_blank",
+ "title": "title (with parens)",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "Foo*bar]"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "my%20url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "Foo bar"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "\ntitle\nline1\nline2\n",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]: /url 'title"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "with blank line'"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]:"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__10: |-
+ Error - check implementation:
+ Hast node of type "bar" not supported by this converter. Please, provide an specification.
+04_07__leaf_blocks__link_reference_definitions__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url%5Cbar*baz",
+ "target": "_blank",
+ "title": "foo\"bar\\baz",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "first",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/%CF%86%CE%BF%CF%85",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "αγω"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]: /url \"title\" ok"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "\"title\" ok"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]: /url \"title\""
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]: /url"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo\n[bar]: /baz"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[bar]"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__23: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__24: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__25: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "===\n"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__26: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/foo-url",
+ "target": "_blank",
+ "title": "foo",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": ",\n"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/bar-url",
+ "target": "_blank",
+ "title": "bar",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": ",\n"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/baz-url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__27: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+ ]
+ }
+04_07__leaf_blocks__link_reference_definitions__28: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+04_08__leaf_blocks__paragraphs__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bbb"
+ }
+ ]
+ }
+ ]
+ }
+04_08__leaf_blocks__paragraphs__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\nbbb"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "ccc\nddd"
+ }
+ ]
+ }
+ ]
+ }
+04_08__leaf_blocks__paragraphs__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bbb"
+ }
+ ]
+ }
+ ]
+ }
+04_08__leaf_blocks__paragraphs__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\nbbb"
+ }
+ ]
+ }
+ ]
+ }
+04_08__leaf_blocks__paragraphs__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\nbbb\nccc"
+ }
+ ]
+ }
+ ]
+ }
+04_08__leaf_blocks__paragraphs__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa\nbbb"
+ }
+ ]
+ }
+ ]
+ }
+04_08__leaf_blocks__paragraphs__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bbb"
+ }
+ ]
+ }
+ ]
+ }
+04_08__leaf_blocks__paragraphs__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ },
+ {
+ "type": "hardBreak"
+ },
+ {
+ "type": "text",
+ "text": "\nbbb"
+ }
+ ]
+ }
+ ]
+ }
+04_09__leaf_blocks__blank_lines__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ }
+ ]
+ }
+04_10__leaf_blocks__tables_extension__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "| foo | bar |\n| --- | --- |\n| baz | bim |"
+ }
+ ]
+ }
+ ]
+ }
+04_10__leaf_blocks__tables_extension__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "| abc | defghi |\n:-: | -----------:\nbar | baz"
+ }
+ ]
+ }
+ ]
+ }
+04_10__leaf_blocks__tables_extension__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "| f|oo |\n| ------ |\n| b "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "\\|"
+ },
+ {
+ "type": "text",
+ "text": " az |\n| b "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "|"
+ },
+ {
+ "type": "text",
+ "text": " im |"
+ }
+ ]
+ }
+ ]
+ }
+04_10__leaf_blocks__tables_extension__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "| abc | def |\n| --- | --- |\n| bar | baz |"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+04_10__leaf_blocks__tables_extension__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "| abc | def |\n| --- | --- |\n| bar | baz |\nbar"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+04_10__leaf_blocks__tables_extension__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "| abc | def |\n| --- |\n| bar |"
+ }
+ ]
+ }
+ ]
+ }
+04_10__leaf_blocks__tables_extension__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "| abc | def |\n| --- | --- |\n| bar |\n| bar | baz | boo |"
+ }
+ ]
+ }
+ ]
+ }
+04_10__leaf_blocks__tables_extension__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "| abc | def |\n| --- | --- |"
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "> # Foo\n> bar\n> baz"
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\nbaz\nfoo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ }
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ }
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n- bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aaa"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bbb"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__23: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__24: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\nbar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_01__container_blocks__block_quotes__25: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "code"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "not code"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A paragraph\nwith two lines."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "indented code"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A block quote."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A paragraph\nwith two lines."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "indented code"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A block quote."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "one"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "two"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "one"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "two"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "one"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " two"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "one"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "two"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "one"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "two"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "one"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "two"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "-one"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "2.two"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bam"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\n\n\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "ok"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "1234567890. not ok"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "ok"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "ok"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "-1. not ok"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "indented code"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "paragraph"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "more code"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "indented code"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "paragraph"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "more code"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": " indented code"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "paragraph"
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "more code"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__23: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__24: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__25: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__26: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__27: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__28: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__29: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__30: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__31: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__32: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__33: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n*"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n1."
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__34: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A paragraph\nwith two lines."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "indented code"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A block quote."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__35: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A paragraph\nwith two lines."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "indented code"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A block quote."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__36: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A paragraph\nwith two lines."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "indented code"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A block quote."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__37: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote."
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__38: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A paragraph\nwith two lines."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "indented code"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A block quote."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__39: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "A paragraph\nwith two lines."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__40: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Blockquote\ncontinued here."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__41: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Blockquote\ncontinued here."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__42: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "boo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__43: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "boo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__44: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__45: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__46: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__47: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__48: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Bar\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__49: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[ ] foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[x] bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[x] foo\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[ ] bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[x] baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[ ] bim"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__50: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__51: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__52: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "The number of windows in my house is\n14. The number of doors is 6."
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__53: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "The number of windows in my house is"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "The number of doors is 6."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__54: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__55: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bim"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__56: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__57: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__58: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "e"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "f"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "g"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__59: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__60: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "d\n- e"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__61: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "3. c"
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__62: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__63: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__64: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__65: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__66: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "b\n\n"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__67: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__68: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a\n"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__69: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a\n"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "attrs": {
+ "multiline": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "d"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__70: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__71: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a\n"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__72: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "orderedList",
+ "attrs": {
+ "start": 1,
+ "parens": false
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph"
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__73: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+05_02__container_blocks__list_items__motivation__task_list_items_extension__lists__74: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "b"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "c"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "d"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "e"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "f"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+06_01__inlines__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "hi"
+ },
+ {
+ "type": "text",
+ "text": "lo`"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "\\\t\\A\\a\\ \\3\\φ\\«"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*not emphasized*\n<br/> not a tag\n[not a link](/foo)\n`not code`\n1. not a list\n* not a list\n# not a heading\n[foo]: /url \"not a reference\"\n&ouml; not a character entity"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "\\"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "emphasis"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "hardBreak"
+ },
+ {
+ "type": "text",
+ "text": "\nbar"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "\\[\\`"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "\\[\\]"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "\\[\\]"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://example.com?find=%5C*",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://example.com?find=\\*"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/bar*",
+ "target": "_blank",
+ "title": "ti*tle",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/bar*",
+ "target": "_blank",
+ "title": "ti*tle",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_02__inlines__backslash_escapes__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": "foo+bar",
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "  & © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "# Ӓ Ϡ �"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "\" ആ ಫ"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "&nbsp &x; &#; &#x;\n&#987654321;\n&#abcdef0;\n&ThisIsNotDefined; &hi?;"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "&copy"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "&MadeUpEntity;"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/f%C3%B6%C3%B6",
+ "target": "_blank",
+ "title": "föö",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/f%C3%B6%C3%B6",
+ "target": "_blank",
+ "title": "föö",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": "föö",
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "f&ouml;&ouml;"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null,
+ "class": "code highlight"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "f&ouml;f&ouml;"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*foo*\n"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "* foo"
+ }
+ ]
+ },
+ {
+ "type": "bulletList",
+ "attrs": {
+ "bullet": "*"
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\n\nbar"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "\tfoo"
+ }
+ ]
+ }
+ ]
+ }
+06_03__inlines__entity_and_numeric_character_references__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[a](url \"tit\")"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo ` bar"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "``"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": " `` "
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": " a"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": " b "
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+06_04__inlines__code_spans__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo bar baz"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo "
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo bar baz"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo\\"
+ },
+ {
+ "type": "text",
+ "text": "bar`"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo`bar"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "foo `` bar"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "*"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[not a "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "link](/foo"
+ },
+ {
+ "type": "text",
+ "text": ")"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "<a href=\""
+ },
+ {
+ "type": "text",
+ "text": "\">`"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "`",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "`"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "<http://foo.bar."
+ },
+ {
+ "type": "text",
+ "text": "baz>`"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://foo.bar.%60baz",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://foo.bar.`baz"
+ },
+ {
+ "type": "text",
+ "text": "`"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "```foo``"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "`foo"
+ }
+ ]
+ }
+ ]
+ }
+06_04__inlines__code_spans__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "`foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a * foo bar*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a*\"foo\"*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "* a *"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "5"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "6"
+ },
+ {
+ "type": "text",
+ "text": "78"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_ foo bar_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a_\"foo\"_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo_bar_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "5_6_78"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "пристаням_стремятся_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "aa_\"bb\"_cc"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo-"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "(bar)"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_foo*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*foo bar *"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*foo bar\n*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*(*foo)"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "(foo"
+ },
+ {
+ "type": "text",
+ "text": ")"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_foo bar _"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_(_foo)"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__23: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "(foo"
+ },
+ {
+ "type": "text",
+ "text": ")"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__24: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_foo_bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__25: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_пристаням_стремятся"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__26: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo_bar_baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__27: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "(bar)"
+ },
+ {
+ "type": "text",
+ "text": "."
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__28: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__29: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "** foo bar**"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__30: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a**\"foo\"**"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__31: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__32: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__33: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__ foo bar__"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__34: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__\nfoo bar__"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__35: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a__\"foo\"__"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__36: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo__bar__"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__37: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "5__6__78"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__38: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "пристаням__стремятся__"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__39: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo, bar"
+ },
+ {
+ "type": "text",
+ "text": ", baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__40: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo-"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "(bar)"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__41: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "**foo bar **"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__42: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "**(**foo)"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__43: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "("
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": ")"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__44: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Gomphocarpus ("
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "Gomphocarpus physocarpus"
+ },
+ {
+ "type": "text",
+ "text": ", syn.\n"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "Asclepias physocarpa"
+ },
+ {
+ "type": "text",
+ "text": ")"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__45: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo \""
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": "\" foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__46: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__47: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__foo bar __"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__48: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__(__foo)"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__49: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "("
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": ")"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__50: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__foo__bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__51: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__пристаням__стремятся"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__52: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo__bar__baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__53: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "(bar)"
+ },
+ {
+ "type": "text",
+ "text": "."
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__54: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__55: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__56: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": " baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__57: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo bar"
+ },
+ {
+ "type": "text",
+ "text": " baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__58: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": " bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__59: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__60: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": " baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__61: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__62: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo**bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__63: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": " bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__64: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__65: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__66: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__67: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": "***baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__68: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "baz"
+ },
+ {
+ "type": "text",
+ "text": " bim bop"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__69: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__70: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "** is not an empty emphasis"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__71: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "**** is not an empty strong emphasis"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__72: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__73: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo\nbar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__74: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": " baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__75: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo bar"
+ },
+ {
+ "type": "text",
+ "text": " baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__76: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": " bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__77: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__78: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": " baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__79: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__80: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": " bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__81: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__82: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "baz"
+ },
+ {
+ "type": "text",
+ "text": "\nbim bop"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__83: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__84: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__ is not an empty emphasis"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__85: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "____ is not an empty strong emphasis"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__86: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo ***"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__87: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__88: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__89: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo *****"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__90: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__91: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__92: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__93: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__94: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__95: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "***"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__96: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__97: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "***"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__98: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo ___"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__99: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__100: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__101: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo _____"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__102: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__103: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__104: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__105: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__106: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__107: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "___"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__108: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__109: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "___"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__110: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__111: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__112: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__113: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__114: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__115: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__116: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__117: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__118: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__119: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo _bar"
+ },
+ {
+ "type": "text",
+ "text": " baz_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__120: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar *baz bim"
+ },
+ {
+ "type": "text",
+ "text": " bam"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__121: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "**foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__122: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar baz"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__123: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__124: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "_foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__125: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*"
+ },
+ {
+ "type": "image",
+ "attrs": {
+ "src": "foo",
+ "alt": null,
+ "title": "*",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__126: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "**"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__127: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__128: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "a "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "*"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__129: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "a "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "_"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__130: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "**a"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://foo.bar/?q=**",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://foo.bar/?q=**"
+ }
+ ]
+ }
+ ]
+ }
+06_05__inlines__emphasis_and_strong_emphasis__131: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "__a"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://foo.bar/?q=__",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://foo.bar/?q=__"
+ }
+ ]
+ }
+ ]
+ }
+06_06__inlines__strikethrough_extension__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "~~Hi~~ Hello, world!"
+ }
+ ]
+ }
+ ]
+ }
+06_06__inlines__strikethrough_extension__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "This ~~has a"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "new paragraph~~."
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[link](/my uri)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/my%20uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[link](foo\nbar)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__08: |-
+ Error - check implementation:
+ Hast node of type "foo" not supported by this converter. Please, provide an specification.
+06_07__inlines__links__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "b)c",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "a"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[link](<foo>)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[a](<b)c\n[a](<b)c>\n[a]("
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "c)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "(foo)",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "foo(and(bar))",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "foo(and(bar)",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "foo(and(bar)",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "foo):",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "#fragment",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://example.com#fragment",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://example.com?foo=3#frag",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "foo%5Cbar",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "foo%20b%C3%A4",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "%22title%22",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "linklinklink"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title \"\"",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__23: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url%C2%A0%22title%22",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__24: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[link](/url \"title \"and\" title\")"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__25: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title \"and\" title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__26: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__27: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[link] (/uri)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__28: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link [foo [bar]]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__29: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[link] bar](/uri)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__30: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[link "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__31: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link [bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__32: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "#"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__33: |-
+ Error - check implementation:
+ Cannot destructure property 'type' of 'this.stack.pop(...)' as it is undefined.
+06_07__inlines__links__34: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": "](/uri)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__35: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "[bar "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "baz"
+ },
+ {
+ "type": "text",
+ "text": "](/uri)](/uri)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__36: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "uri3",
+ "alt": "[foo](uri2)",
+ "title": null,
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__37: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo*"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__38: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "baz*",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo *bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__39: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo [bar"
+ },
+ {
+ "type": "text",
+ "text": " baz]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__40: |-
+ Error - check implementation:
+ Hast node of type "bar" not supported by this converter. Please, provide an specification.
+06_07__inlines__links__41: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "](/uri)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__42: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://example.com/?search=%5D(uri)",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://example.com/?search=](uri)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__43: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__44: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link [foo [bar]]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__45: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link [bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__46: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "link "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "#"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__47: |-
+ Error - check implementation:
+ Cannot destructure property 'type' of 'this.stack.pop(...)' as it is undefined.
+06_07__inlines__links__48: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ },
+ {
+ "type": "text",
+ "text": "]"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "ref"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__49: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "bar "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "baz"
+ },
+ {
+ "type": "text",
+ "text": "]"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "ref"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__50: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo*"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__51: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo *bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__52: |-
+ Error - check implementation:
+ Hast node of type "bar" not supported by this converter. Please, provide an specification.
+06_07__inlines__links__53: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "][ref]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__54: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://example.com/?search=%5D%5Bref%5D",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://example.com/?search=][ref]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__55: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__56: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "Толпой"
+ },
+ {
+ "type": "text",
+ "text": " is a Russian word."
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__57: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "Baz"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__58: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo] "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__59: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]\n"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__60: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url1",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__61: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[bar][foo!]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__62: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo][ref[]"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[ref[]: /uri"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__63: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo][ref[bar]]"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[ref[bar]]: /uri"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__64: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[[[foo]]]"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[[[foo]]]: /url"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__65: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__66: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/uri",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar\\"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__67: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[]"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[]: /uri"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__68: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[\n]"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[\n]: /uri"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__69: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__70: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": " bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__71: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__72: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "\n[]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__73: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__74: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": " bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__75: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "["
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ },
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": " bar]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__76: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[[bar "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__77: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "Foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__78: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": " bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__79: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__80: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "*"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo*"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__81: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url2",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__82: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url1",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__83: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__84: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url1",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "text": "(not a link)"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__85: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__86: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url2",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url1",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "baz"
+ }
+ ]
+ }
+ ]
+ }
+06_07__inlines__links__87: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[foo]"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url1",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "bar"
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "foo",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "train.jpg",
+ "alt": "foo bar",
+ "title": "train & tracks",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url2",
+ "alt": "foo bar",
+ "title": null,
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url2",
+ "alt": "foo bar",
+ "title": null,
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "train.jpg",
+ "alt": "foo bar",
+ "title": "train & tracks",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "train.jpg",
+ "alt": "foo bar",
+ "title": "train & tracks",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "train.jpg",
+ "alt": "foo",
+ "title": null,
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "My "
+ },
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/path/to/train.jpg",
+ "alt": "foo bar",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "url",
+ "alt": "foo",
+ "title": null,
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "",
+ "title": null,
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "foo",
+ "title": null,
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "foo",
+ "title": null,
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "foo",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "foo bar",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "Foo",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "foo",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ },
+ {
+ "type": "text",
+ "text": "\n[]"
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "foo",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "foo bar",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "![[foo]]"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "[[foo]]: /url \"title\""
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/url",
+ "alt": "Foo",
+ "title": "title",
+ "uploading": false,
+ "canonicalSrc": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "![foo]"
+ }
+ ]
+ }
+ ]
+ }
+06_08__inlines__images__22: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "!"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "/url",
+ "target": "_blank",
+ "title": "title",
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://foo.bar.baz",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://foo.bar.baz"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://foo.bar.baz/test?q=hello&id=22&boolean",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://foo.bar.baz/test?q=hello&id=22&boolean"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "irc://foo.bar:2233/baz",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "irc://foo.bar:2233/baz"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "MAILTO:FOO@BAR.BAZ",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "MAILTO:FOO@BAR.BAZ"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "a+b+c:d",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "a+b+c:d"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "made-up-scheme://foo,bar",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "made-up-scheme://foo,bar"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://../",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://../"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "localhost:5001/foo",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "localhost:5001/foo"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<http://foo.bar/baz bim>"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "http://example.com/%5C%5B%5C",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "http://example.com/\\[\\"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "mailto:foo@bar.example.com",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo@bar.example.com"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "mailto:foo+special@Bar.baz-bar0.com",
+ "target": "_blank",
+ "title": null,
+ "canonicalSrc": null
+ }
+ }
+ ],
+ "text": "foo+special@Bar.baz-bar0.com"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<foo+@bar.example.com>"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<>"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "< http://foo.bar >"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__16: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<m:abc>"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__17: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<foo.bar.baz>"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__18: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "http://example.com"
+ }
+ ]
+ }
+ ]
+ }
+06_09__inlines__autolinks__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo@bar.example.com"
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "www.commonmark.org"
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Visit www.commonmark.org/help for more information."
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Visit www.commonmark.org."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Visit www.commonmark.org/a.b."
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "www.google.com/search?q=Markup+(business)"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "www.google.com/search?q=Markup+(business)))"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "(www.google.com/search?q=Markup+(business))"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "(www.google.com/search?q=Markup+(business)"
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "www.google.com/search?q=(business))+ok"
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "www.google.com/search?q=commonmark&hl=en"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "www.google.com/search?q=commonmark&hl;"
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "www.commonmark.org/he<lp"
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "http://commonmark.org"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "(Visit https://encrypted.google.com/search?q=Markup+(business))"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Anonymous FTP is available at ftp://foo.bar.baz."
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo@bar.baz"
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "hello@mail+xyz.example isn't valid, but hello+xyz@mail.example is."
+ }
+ ]
+ }
+ ]
+ }
+06_10__inlines__autolinks_extension__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a.b-c_d@a.b"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a.b-c_d@a.b."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a.b-c_d@a.b-"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "a.b-c_d@a.b_"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__01: |-
+ Error - check implementation:
+ Hast node of type "bab" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__02: |-
+ Error - check implementation:
+ Hast node of type "b2" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__03: |-
+ Error - check implementation:
+ Hast node of type "b2" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+06_11__inlines__raw_html__05: |-
+ Error - check implementation:
+ Hast node of type "responsive-image" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__06: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<33> <__>"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__07: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<a h*#ref=\"hi\">"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<a href=\"hi'> <a href=hi'>"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "< a><\nfoo><bar/ >\n<foo bar=baz\nbim!bop />"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<a href='bar'title=title>"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+06_11__inlines__raw_html__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "</a href=\"foo\">"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__13: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo <!-- not a comment -- two hyphens -->"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo <!--> foo -->"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo <!-- foo--->"
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__16: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__17: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__18: |-
+ Error - check implementation:
+ Hast node of type "comment" not supported by this converter. Please, provide an specification.
+06_11__inlines__raw_html__19: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__20: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo "
+ }
+ ]
+ }
+ ]
+ }
+06_11__inlines__raw_html__21: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "<a href=\"\"\">"
+ }
+ ]
+ }
+ ]
+ }
+06_12__inlines__disallowed_raw_html_extension__01: |-
+ Error - check implementation:
+ Hast node of type "title" not supported by this converter. Please, provide an specification.
+06_13__inlines__hard_line_breaks__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "hardBreak"
+ },
+ {
+ "type": "text",
+ "text": "\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "hardBreak"
+ },
+ {
+ "type": "text",
+ "text": "\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "hardBreak"
+ },
+ {
+ "type": "text",
+ "text": "\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__04: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "hardBreak"
+ },
+ {
+ "type": "text",
+ "text": "\nbar"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__05: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ },
+ {
+ "type": "hardBreak"
+ },
+ {
+ "type": "text",
+ "text": "\nbar"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__06: |-
+ Error - check implementation:
+ Cannot destructure property 'type' of 'this.stack.pop(...)' as it is undefined.
+06_13__inlines__hard_line_breaks__07: |-
+ Error - check implementation:
+ Cannot destructure property 'type' of 'this.stack.pop(...)' as it is undefined.
+06_13__inlines__hard_line_breaks__08: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "code span"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__09: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "code\\ span"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__10: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__11: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph"
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__12: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\\"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__13: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__14: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\\"
+ }
+ ]
+ }
+ ]
+ }
+06_13__inlines__hard_line_breaks__15: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "level": 3
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "foo"
+ }
+ ]
+ }
+ ]
+ }
+06_14__inlines__soft_line_breaks__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+06_14__inlines__soft_line_breaks__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "foo\nbaz"
+ }
+ ]
+ }
+ ]
+ }
+06_15__inlines__textual_content__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "hello $.;'there"
+ }
+ ]
+ }
+ ]
+ }
+06_15__inlines__textual_content__02: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Foo χρῆν"
+ }
+ ]
+ }
+ ]
+ }
+06_15__inlines__textual_content__03: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "Multiple spaces"
+ }
+ ]
+ }
+ ]
+ }
+07_01__first_gitlab_specific_section_with_examples__strong_but_with_two_asterisks__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bold"
+ }
+ ]
+ }
+ ]
+ }
+08_01__second_gitlab_specific_section_with_examples__strong_but_with_html__01: |-
+ Error - check implementation:
+ Cannot read properties of undefined (reading 'wrapTextInParagraph')
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json
index ce0df4250d6..e721525f00c 100644
--- a/spec/fixtures/lib/gitlab/import_export/complex/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json
@@ -2379,6 +2379,23 @@
"created_at": "2019-12-26T10:17:14.621Z",
"updated_at": "2019-12-26T10:17:14.621Z"
}
+ ],
+ "milestone_releases": [
+ {
+ "milestone_id": 1349,
+ "release_id": 9172,
+ "milestone": {
+ "id": 1,
+ "title": "test milestone",
+ "project_id": 8,
+ "description": "test milestone",
+ "due_date": null,
+ "created_at": "2016-06-14T15:02:04.415Z",
+ "updated_at": "2016-06-14T15:02:04.415Z",
+ "state": "active",
+ "iid": 1
+ }
+ }
]
}
],
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson
index 0c14c023378..a194898cb5a 100644
--- a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson
+++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/releases.ndjson
@@ -1 +1 @@
-{"id":1,"tag":"release-1.1","description":"Some release notes","project_id":5,"created_at":"2019-12-26T10:17:14.621Z","updated_at":"2019-12-26T10:17:14.621Z","author_id":1,"name":"release-1.1","sha":"901de3a8bd5573f4a049b1457d28bc1592ba6bf9","released_at":"2019-12-26T10:17:14.615Z","links":[{"id":1,"release_id":1,"url":"http://localhost/namespace6/project6/-/jobs/140463678/artifacts/download","name":"release-1.1.dmg","created_at":"2019-12-26T10:17:14.621Z","updated_at":"2019-12-26T10:17:14.621Z"}]}
+{"id":1,"tag":"release-1.1","description":"Some release notes","project_id":5,"created_at":"2019-12-26T10:17:14.621Z","updated_at":"2019-12-26T10:17:14.621Z","author_id":1,"name":"release-1.1","sha":"901de3a8bd5573f4a049b1457d28bc1592ba6bf9","released_at":"2019-12-26T10:17:14.615Z","links":[{"id":1,"release_id":1,"url":"http://localhost/namespace6/project6/-/jobs/140463678/artifacts/download","name":"release-1.1.dmg","created_at":"2019-12-26T10:17:14.621Z","updated_at":"2019-12-26T10:17:14.621Z"}],"milestone_releases":[{"milestone_id":1349,"release_id":9172,"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1}}]}
diff --git a/spec/fixtures/lib/gitlab/import_export/designs/project.json b/spec/fixtures/lib/gitlab/import_export/designs/project.json
index 16dd805c132..6720139adeb 100644
--- a/spec/fixtures/lib/gitlab/import_export/designs/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/designs/project.json
@@ -9,7 +9,6 @@
"merge_requests_ff_only_enabled":false,
"issues_template":null,
"shared_runners_enabled":true,
- "build_coverage_regex":null,
"build_allow_git_fetch":true,
"build_timeout":3600,
"pending_delete":false,
diff --git a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/project.json b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/project.json
index 5ca803cc11f..d25371e10dd 100644
--- a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/project.json
@@ -5,7 +5,6 @@
"autoclose_referenced_issues": true,
"boards": [],
"build_allow_git_fetch": true,
- "build_coverage_regex": null,
"build_timeout": 3600,
"ci_cd_settings": {
"group_runners_enabled": true
diff --git a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json
index 5f7cf8128bc..4e08ae31f36 100644
--- a/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/multi_pipeline_ref_one_external_pr/tree/project.json
@@ -1 +1 @@
-{"id":5,"approvals_before_merge":0,"archived":false,"auto_cancel_pending_pipelines":"enabled","autoclose_referenced_issues":true,"build_allow_git_fetch":true,"build_coverage_regex":null,"build_timeout":3600,"ci_config_path":null,"delete_error":null,"description":"Vim, Tmux and others","disable_overriding_approvers_per_merge_request":null,"external_authorization_classification_label":"","external_webhook_token":"D3mVYFzZkgZ5kMfcW_wx","public_builds":true,"shared_runners_enabled":true,"visibility_level":20}
+{"id":5,"approvals_before_merge":0,"archived":false,"auto_cancel_pending_pipelines":"enabled","autoclose_referenced_issues":true,"build_allow_git_fetch":true,"build_timeout":3600,"ci_config_path":null,"delete_error":null,"description":"Vim, Tmux and others","disable_overriding_approvers_per_merge_request":null,"external_authorization_classification_label":"","external_webhook_token":"D3mVYFzZkgZ5kMfcW_wx","public_builds":true,"shared_runners_enabled":true,"visibility_level":20}
diff --git a/spec/fixtures/logo_sample.svg b/spec/fixtures/logo_sample.svg
index 883e7e6cf92..2feb64a9aa2 100644
--- a/spec/fixtures/logo_sample.svg
+++ b/spec/fixtures/logo_sample.svg
@@ -1,27 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<svg width="210px" height="210px" viewBox="0 0 210 210" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
- <!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
- <title>Slice 1</title>
- <desc>Created with Sketch.</desc>
- <script>alert('FAIL')</script>
- <defs></defs>
- <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
- <g id="logo" sketch:type="MSLayerGroup" transform="translate(0.000000, 10.000000)">
- <g id="Page-1" sketch:type="MSShapeGroup">
- <g id="Fill-1-+-Group-24">
- <g id="Group-24">
- <g id="Group">
- <path d="M105.0614,193.655 L105.0614,193.655 L143.7014,74.734 L66.4214,74.734 L105.0614,193.655 L105.0614,193.655 Z" id="Fill-4" fill="#E24329" class="tanuki-shape"></path>
- <path d="M105.0614,193.6548 L66.4214,74.7338 L12.2684,74.7338 L105.0614,193.6548 L105.0614,193.6548 Z" id="Fill-8" fill="#FC6D26" class="tanuki-shape"></path>
- <path d="M12.2685,74.7341 L12.2685,74.7341 L0.5265,110.8731 C-0.5445,114.1691 0.6285,117.7801 3.4325,119.8171 L105.0615,193.6551 L12.2685,74.7341 L12.2685,74.7341 Z" id="Fill-12" fill="#FCA326" class="tanuki-shape"></path>
- <path d="M12.2685,74.7342 L66.4215,74.7342 L43.1485,3.1092 C41.9515,-0.5768 36.7375,-0.5758 35.5405,3.1092 L12.2685,74.7342 L12.2685,74.7342 Z" id="Fill-16" fill="#E24329" class="tanuki-shape"></path>
- <path d="M105.0614,193.6548 L143.7014,74.7338 L197.8544,74.7338 L105.0614,193.6548 L105.0614,193.6548 Z" id="Fill-18" fill="#FC6D26" class="tanuki-shape"></path>
- <path d="M197.8544,74.7341 L197.8544,74.7341 L209.5964,110.8731 C210.6674,114.1691 209.4944,117.7801 206.6904,119.8171 L105.0614,193.6551 L197.8544,74.7341 L197.8544,74.7341 Z" id="Fill-20" fill="#FCA326" class="tanuki-shape"></path>
- <path d="M197.8544,74.7342 L143.7014,74.7342 L166.9744,3.1092 C168.1714,-0.5768 173.3854,-0.5758 174.5824,3.1092 L197.8544,74.7342 L197.8544,74.7342 Z" id="Fill-22" fill="#E24329" class="tanuki-shape"></path>
- </g>
- </g>
- </g>
- </g>
- </g>
- </g>
+<svg width="50" height="48" viewBox="0 0 50 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
+ <title>Slice 1</title>
+ <desc>Created with Sketch.</desc>
+ <script>alert('FAIL')</script>
+ <defs></defs>
+ <path d="m49.014 19-.067-.18-6.784-17.696a1.792 1.792 0 0 0-3.389.182l-4.579 14.02H15.651l-4.58-14.02a1.795 1.795 0 0 0-3.388-.182l-6.78 17.7-.071.175A12.595 12.595 0 0 0 5.01 33.556l.026.02.057.044 10.32 7.734 5.12 3.87 3.11 2.351a2.102 2.102 0 0 0 2.535 0l3.11-2.352 5.12-3.869 10.394-7.779.029-.022a12.595 12.595 0 0 0 4.182-14.554Z"
+ fill="#E24329"/>
+ <path d="m49.014 19-.067-.18a22.88 22.88 0 0 0-9.12 4.103L24.931 34.187l9.485 7.167 10.393-7.779.03-.022a12.595 12.595 0 0 0 4.175-14.554Z"
+ fill="#FC6D26"/>
+ <path d="m15.414 41.354 5.12 3.87 3.11 2.351a2.102 2.102 0 0 0 2.535 0l3.11-2.352 5.12-3.869-9.484-7.167-9.51 7.167Z"
+ fill="#FCA326"/>
+ <path d="M10.019 22.923a22.86 22.86 0 0 0-9.117-4.1L.832 19A12.595 12.595 0 0 0 5.01 33.556l.026.02.057.044 10.32 7.734 9.491-7.167L10.02 22.923Z"
+ fill="#FC6D26"/>
</svg>
+
diff --git a/spec/fixtures/markdown/markdown_golden_master_examples.yml b/spec/fixtures/markdown/markdown_golden_master_examples.yml
index bdd7c13c1a3..5847e9f2cdf 100644
--- a/spec/fixtures/markdown/markdown_golden_master_examples.yml
+++ b/spec/fixtures/markdown/markdown_golden_master_examples.yml
@@ -140,7 +140,7 @@
markdown: |-
![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)
html: |-
- <p data-sourcepos="1:1-1:69" dir="auto"><a class="no-attachment-icon gfm" href="/groups/group58/-/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" data-link="true"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="test-file" class="lazy gfm" data-src="/groups/group58/-/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" data-canonical-src="/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png"></a></p>
+ <p data-sourcepos="1:1-1:69" dir="auto"><a class="no-attachment-icon gfm" href="/groups/group58/-/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" data-link="true"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="test-file" decoding="async" class="lazy gfm" data-src="/groups/group58/-/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" data-canonical-src="/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png"></a></p>
- name: attachment_image_for_project
api_context: project
@@ -149,7 +149,7 @@
markdown: |-
![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)
html: |-
- <p data-sourcepos="1:1-1:69" dir="auto"><a class="no-attachment-icon gfm" href="/group58/project22/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" data-link="true"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="test-file" class="lazy gfm" data-src="/group58/project22/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" data-canonical-src="/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png"></a></p>
+ <p data-sourcepos="1:1-1:69" dir="auto"><a class="no-attachment-icon gfm" href="/group58/project22/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" data-link="true"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="test-file" decoding="async" class="lazy gfm" data-src="/group58/project22/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png" data-canonical-src="/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png"></a></p>
- name: attachment_image_for_project_wiki
api_context: project_wiki
@@ -158,7 +158,7 @@
markdown: |-
![test-file](test-file.png)
html: |-
- <p data-sourcepos="1:1-1:27" dir="auto"><a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png"><img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"></a></p>
+ <p data-sourcepos="1:1-1:27" dir="auto"><a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png"><img alt="test-file" decoding="async" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"></a></p>
- name: attachment_link_for_group
api_context: group
@@ -391,7 +391,7 @@
]
```
html: |-
- <a class="no-attachment-icon" href="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA==" target="_blank" rel="noopener noreferrer" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,ICAjc3Ryb2tlOiAjYTg2MTI4CiAgWzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybnwKICAgIFs8YWJzdHJhY3Q+Q29tcG9uZW50fHwrIG9wZXJhdGlvbigpXQogICAgW0NsaWVudF0gZGVwZW5kcyAtLT4gW0NvbXBvbmVudF0KICAgIFtEZWNvcmF0b3J8LSBuZXh0OiBDb21wb25lbnRdCiAgICBbRGVjb3JhdG9yXSBkZWNvcmF0ZXMgLS0gW0NvbmNyZXRlQ29tcG9uZW50XQogICAgW0NvbXBvbmVudF0gPDotIFtEZWNvcmF0b3JdCiAgICBbQ29tcG9uZW50XSA8Oi0gW0NvbmNyZXRlQ29tcG9uZW50XQogIF0K"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="js-render-kroki lazy" data-src="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA=="></a>
+ <a class="no-attachment-icon" href="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA==" target="_blank" rel="noopener noreferrer" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,ICAjc3Ryb2tlOiAjYTg2MTI4CiAgWzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybnwKICAgIFs8YWJzdHJhY3Q+Q29tcG9uZW50fHwrIG9wZXJhdGlvbigpXQogICAgW0NsaWVudF0gZGVwZW5kcyAtLT4gW0NvbXBvbmVudF0KICAgIFtEZWNvcmF0b3J8LSBuZXh0OiBDb21wb25lbnRdCiAgICBbRGVjb3JhdG9yXSBkZWNvcmF0ZXMgLS0gW0NvbmNyZXRlQ29tcG9uZW50XQogICAgW0NvbXBvbmVudF0gPDotIFtEZWNvcmF0b3JdCiAgICBbQ29tcG9uZW50XSA8Oi0gW0NvbmNyZXRlQ29tcG9uZW50XQogIF0K"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="js-render-kroki lazy" decoding="async" data-src="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA=="></a>
- name: diagram_plantuml
markdown: |-
@@ -403,7 +403,7 @@
Alice <-- Bob: Another authentication Response
```
html: |-
- <a class="no-attachment-icon" href="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00" target="_blank" rel="noopener noreferrer" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,ICBBbGljZSAtPiBCb2I6IEF1dGhlbnRpY2F0aW9uIFJlcXVlc3QKICBCb2IgLS0+IEFsaWNlOiBBdXRoZW50aWNhdGlvbiBSZXNwb25zZQoKICBBbGljZSAtPiBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVxdWVzdAogIEFsaWNlIDwtLSBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVzcG9uc2UK"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="lazy" data-src="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00"></a>
+ <a class="no-attachment-icon" href="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00" target="_blank" rel="noopener noreferrer" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,ICBBbGljZSAtPiBCb2I6IEF1dGhlbnRpY2F0aW9uIFJlcXVlc3QKICBCb2IgLS0+IEFsaWNlOiBBdXRoZW50aWNhdGlvbiBSZXNwb25zZQoKICBBbGljZSAtPiBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVxdWVzdAogIEFsaWNlIDwtLSBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVzcG9uc2UK"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" decoding="async" class="lazy" data-src="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00"></a>
- name: div
markdown: |-
@@ -449,11 +449,11 @@
</figure>
html: |-
<figure>
- <p data-sourcepos="3:1-3:42"><a class="no-attachment-icon" href="elephant-sunset.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Elephant at sunset" class="lazy" data-src="elephant-sunset.jpg"></a></p>
+ <p data-sourcepos="3:1-3:42"><a class="no-attachment-icon" href="elephant-sunset.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Elephant at sunset" decoding="async" class="lazy" data-src="elephant-sunset.jpg"></a></p>
<figcaption>An elephant at sunset</figcaption>
</figure>
<figure>
- <p data-sourcepos="9:1-9:44"><a class="no-attachment-icon" href="croc-crocs.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="A crocodile wearing crocs" class="lazy" data-src="croc-crocs.jpg"></a></p>
+ <p data-sourcepos="9:1-9:44"><a class="no-attachment-icon" href="croc-crocs.jpg" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="A crocodile wearing crocs" decoding="async" class="lazy" data-src="croc-crocs.jpg"></a></p>
<figcaption>
<p data-sourcepos="13:1-13:28">A crocodile wearing <em>crocs</em>!</p>
</figcaption>
@@ -613,7 +613,7 @@
markdown: |-
![alt text](https://gitlab.com/logo.png)
html: |-
- <p data-sourcepos="1:1-1:40" dir="auto"><a class="no-attachment-icon" href="https://gitlab.com/logo.png" target="_blank" rel="nofollow noreferrer noopener"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="alt text" class="lazy" data-src="https://gitlab.com/logo.png"></a></p>
+ <p data-sourcepos="1:1-1:40" dir="auto"><a class="no-attachment-icon" href="https://gitlab.com/logo.png" target="_blank" rel="nofollow noreferrer noopener"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="alt text" decoding="async" class="lazy" data-src="https://gitlab.com/logo.png"></a></p>
- name: inline_code
markdown: |-
@@ -750,7 +750,7 @@
markdown: |-
Hi @gfm_user - thank you for reporting this bug (#1) we hope to fix it in %1.1 as part of !1
html: |-
- <p data-sourcepos="1:1-1:92" dir="auto">Hi <a href="/gfm_user" data-user="1" data-reference-type="user" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="John Doe1">@gfm_user</a> - thank you for reporting this bug (<a href="/group1/project1/-/issues/1" data-original="#1" data-link="false" data-link-reference="false" data-project="11" data-issue="11" data-reference-type="issue" data-container="body" data-placement="top" title="My title 1" class="gfm gfm-issue has-tooltip">#1</a>) we hope to fix it in <a href="/group1/project1/-/milestones/1" data-original="%1.1" data-link="false" data-link-reference="false" data-project="11" data-milestone="11" data-reference-type="milestone" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%1.1</a> as part of <a href="/group1/project1/-/merge_requests/1" data-original="!1" data-link="false" data-link-reference="false" data-project="11" data-merge-request="11" data-project-path="group1/project1" data-iid="1" data-mr-title="My title 2" data-reference-type="merge_request" data-container="body" data-placement="top" title="" class="gfm gfm-merge_request">!1</a></p>
+ <p data-sourcepos="1:1-1:92" dir="auto">Hi <a href="/gfm_user" data-user="1" data-reference-type="user" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="John Doe1">@gfm_user</a> - thank you for reporting this bug (<a href="/group1/project1/-/issues/1" data-original="#1" data-link="false" data-link-reference="false" data-project="11" data-issue="11" data-issue-type="issue" data-reference-type="issue" data-container="body" data-placement="top" title="My title 1" class="gfm gfm-issue has-tooltip">#1</a>) we hope to fix it in <a href="/group1/project1/-/milestones/1" data-original="%1.1" data-link="false" data-link-reference="false" data-project="11" data-milestone="11" data-reference-type="milestone" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%1.1</a> as part of <a href="/group1/project1/-/merge_requests/1" data-original="!1" data-link="false" data-link-reference="false" data-project="11" data-merge-request="11" data-project-path="group1/project1" data-iid="1" data-mr-title="My title 2" data-reference-type="merge_request" data-container="body" data-placement="top" title="" class="gfm gfm-merge_request">!1</a></p>
- name: strike
markdown: |-
~~del~~
diff --git a/spec/fixtures/security_reports/master/gl-common-scanning-report.json b/spec/fixtures/security_reports/master/gl-common-scanning-report.json
index 1fb00b2ff3a..787573301bb 100644
--- a/spec/fixtures/security_reports/master/gl-common-scanning-report.json
+++ b/spec/fixtures/security_reports/master/gl-common-scanning-report.json
@@ -1,300 +1,422 @@
{
- "vulnerabilities": [
- {
- "category": "dependency_scanning",
- "name": "Vulnerabilities in libxml2",
- "message": "Vulnerabilities in libxml2 in nokogiri",
- "description": "",
- "cve": "CVE-1020",
- "severity": "High",
- "solution": "Upgrade to latest version.",
- "scanner": {
- "id": "gemnasium",
- "name": "Gemnasium"
- },
- "evidence": {
- "source": {
- "id": "assert:CORS - Bad 'Origin' value",
- "name": "CORS - Bad 'Origin' value"
- },
- "summary": "The Origin header was changed to an invalid value of http://peachapisecurity.com and the response contained an Access-Control-Allow-Origin header which included this invalid Origin, indicating that the CORS configuration on the server is overly permissive.\n\n\n",
- "request": {
- "headers": [
- {
- "name": "Host",
- "value": "127.0.0.1:7777"
- }
- ],
- "method": "GET",
- "url": "http://127.0.0.1:7777/api/users",
- "body": ""
- },
- "response": {
- "headers": [
- {
- "name": "Server",
- "value": "TwistedWeb/20.3.0"
- }
- ],
- "reason_phrase": "OK",
- "status_code": 200,
- "body": "[{\"user_id\":1,\"user\":\"admin\",\"first\":\"Joe\",\"last\":\"Smith\",\"password\":\"Password!\"}]"
- },
- "supporting_messages": [
- {
- "name": "Origional",
- "request": {
- "headers": [
- {
- "name": "Host",
- "value": "127.0.0.1:7777"
- }
- ],
- "method": "GET",
- "url": "http://127.0.0.1:7777/api/users",
- "body": ""
- }
- },
- {
- "name": "Recorded",
- "request": {
- "headers": [
- {
- "name": "Host",
- "value": "127.0.0.1:7777"
- }
- ],
- "method": "GET",
- "url": "http://127.0.0.1:7777/api/users",
- "body": ""
- },
- "response": {
- "headers": [
- {
- "name": "Server",
- "value": "TwistedWeb/20.3.0"
- }
- ],
- "reason_phrase": "OK",
- "status_code": 200,
- "body": "[{\"user_id\":1,\"user\":\"admin\",\"first\":\"Joe\",\"last\":\"Smith\",\"password\":\"Password!\"}]"
- }
- }
- ]
- },
- "location": {},
- "identifiers": [
- {
- "type": "GitLab",
- "name": "Foo vulnerability",
- "value": "foo"
- }
- ],
- "links": [
- {
- "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1020"
- }
- ],
- "details": {
- "commit": {
- "name": [
- {
- "lang": "en",
- "value": "The Commit"
- }
- ],
- "description": [
- {
- "lang": "en",
- "value": "Commit where the vulnerability was identified"
- }
- ],
- "type": "commit",
- "value": "41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19"
- }
- }
+ "vulnerabilities": [{
+ "category": "dependency_scanning",
+ "name": "Vulnerability for remediation testing 1",
+ "message": "This vulnerability should have ONE remediation",
+ "description": "",
+ "cve": "CVE-2137",
+ "severity": "High",
+ "solution": "Upgrade to latest version.",
+ "scanner": {
+ "id": "gemnasium",
+ "name": "Gemnasium"
+ },
+ "location": {},
+ "identifiers": [{
+ "type": "GitLab",
+ "name": "Foo vulnerability",
+ "value": "foo"
+ }],
+ "links": [{
+ "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-2137"
+ }],
+ "details": {
+ "commit": {
+ "name": [{
+ "lang": "en",
+ "value": "The Commit"
+ }],
+ "description": [{
+ "lang": "en",
+ "value": "Commit where the vulnerability was identified"
+ }],
+ "type": "commit",
+ "value": "41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19"
+ }
+ }
+ },
+ {
+ "category": "dependency_scanning",
+ "name": "Vulnerability for remediation testing 2",
+ "message": "This vulnerability should have ONE remediation",
+ "description": "",
+ "cve": "CVE-2138",
+ "severity": "High",
+ "solution": "Upgrade to latest version.",
+ "scanner": {
+ "id": "gemnasium",
+ "name": "Gemnasium"
+ },
+ "location": {},
+ "identifiers": [{
+ "type": "GitLab",
+ "name": "Foo vulnerability",
+ "value": "foo"
+ }],
+ "links": [{
+ "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-2138"
+ }],
+ "details": {
+ "commit": {
+ "name": [{
+ "lang": "en",
+ "value": "The Commit"
+ }],
+ "description": [{
+ "lang": "en",
+ "value": "Commit where the vulnerability was identified"
+ }],
+ "type": "commit",
+ "value": "41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19"
+ }
+ }
+ },
+ {
+ "category": "dependency_scanning",
+ "name": "Vulnerability for remediation testing 3",
+ "message": "Remediation for this vulnerability should remediate CVE-2140 as well",
+ "description": "",
+ "cve": "CVE-2139",
+ "severity": "High",
+ "solution": "Upgrade to latest version.",
+ "scanner": {
+ "id": "gemnasium",
+ "name": "Gemnasium"
+ },
+ "location": {},
+ "identifiers": [{
+ "type": "GitLab",
+ "name": "Foo vulnerability",
+ "value": "foo"
+ }],
+ "links": [{
+ "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-2139"
+ }],
+ "details": {
+ "commit": {
+ "name": [{
+ "lang": "en",
+ "value": "The Commit"
+ }],
+ "description": [{
+ "lang": "en",
+ "value": "Commit where the vulnerability was identified"
+ }],
+ "type": "commit",
+ "value": "41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19"
+ }
+ }
+ },
+ {
+ "category": "dependency_scanning",
+ "name": "Vulnerability for remediation testing 4",
+ "message": "Remediation for this vulnerability should remediate CVE-2139 as well",
+ "description": "",
+ "cve": "CVE-2140",
+ "severity": "High",
+ "solution": "Upgrade to latest version.",
+ "scanner": {
+ "id": "gemnasium",
+ "name": "Gemnasium"
+ },
+ "location": {},
+ "identifiers": [{
+ "type": "GitLab",
+ "name": "Foo vulnerability",
+ "value": "foo"
+ }],
+ "links": [{
+ "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-2140"
+ }],
+ "details": {
+ "commit": {
+ "name": [{
+ "lang": "en",
+ "value": "The Commit"
+ }],
+ "description": [{
+ "lang": "en",
+ "value": "Commit where the vulnerability was identified"
+ }],
+ "type": "commit",
+ "value": "41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19"
+ }
+ }
+ },
+ {
+ "category": "dependency_scanning",
+ "name": "Vulnerabilities in libxml2",
+ "message": "Vulnerabilities in libxml2 in nokogiri",
+ "description": "",
+ "cve": "CVE-1020",
+ "severity": "High",
+ "solution": "Upgrade to latest version.",
+ "scanner": {
+ "id": "gemnasium",
+ "name": "Gemnasium"
+ },
+ "evidence": {
+ "source": {
+ "id": "assert:CORS - Bad 'Origin' value",
+ "name": "CORS - Bad 'Origin' value"
},
- {
- "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3",
- "category": "dependency_scanning",
- "name": "Regular Expression Denial of Service",
- "message": "Regular Expression Denial of Service in debug",
- "description": "",
- "cve": "CVE-1030",
- "severity": "Unknown",
- "solution": "Upgrade to latest versions.",
- "scanner": {
- "id": "gemnasium",
- "name": "Gemnasium"
- },
- "evidence": {
- "source": {
- "id": "assert:CORS - Bad 'Origin' value",
- "name": "CORS - Bad 'Origin' value"
- },
- "summary": "The Origin header was changed to an invalid value of http://peachapisecurity.com and the response contained an Access-Control-Allow-Origin header which included this invalid Origin, indicating that the CORS configuration on the server is overly permissive.\n\n\n",
- "request": {
- "headers": [
- {
- "name": "Host",
- "value": "127.0.0.1:7777"
- }
- ],
- "method": "GET",
- "url": "http://127.0.0.1:7777/api/users",
- "body": ""
- },
- "response": {
- "headers": [
- {
- "name": "Server",
- "value": "TwistedWeb/20.3.0"
- }
- ],
- "reason_phrase": "OK",
- "status_code": 200,
- "body": "[{\"user_id\":1,\"user\":\"admin\",\"first\":\"Joe\",\"last\":\"Smith\",\"password\":\"Password!\"}]"
- },
- "supporting_messages": [
- {
- "name": "Origional",
- "request": {
- "headers": [
- {
- "name": "Host",
- "value": "127.0.0.1:7777"
- }
- ],
- "method": "GET",
- "url": "http://127.0.0.1:7777/api/users",
- "body": ""
- }
- },
- {
- "name": "Recorded",
- "request": {
- "headers": [
- {
- "name": "Host",
- "value": "127.0.0.1:7777"
- }
- ],
- "method": "GET",
- "url": "http://127.0.0.1:7777/api/users",
- "body": ""
- },
- "response": {
- "headers": [
- {
- "name": "Server",
- "value": "TwistedWeb/20.3.0"
- }
- ],
- "reason_phrase": "OK",
- "status_code": 200,
- "body": "[{\"user_id\":1,\"user\":\"admin\",\"first\":\"Joe\",\"last\":\"Smith\",\"password\":\"Password!\"}]"
- }
- }
- ]
- },
- "location": {},
- "identifiers": [
- {
- "type": "GitLab",
- "name": "Bar vulnerability",
- "value": "bar"
- }
- ],
- "links": [
- {
- "name": "CVE-1030",
- "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1030"
- }
- ]
+ "summary": "The Origin header was changed to an invalid value of http://peachapisecurity.com and the response contained an Access-Control-Allow-Origin header which included this invalid Origin, indicating that the CORS configuration on the server is overly permissive.\n\n\n",
+ "request": {
+ "headers": [{
+ "name": "Host",
+ "value": "127.0.0.1:7777"
+ }],
+ "method": "GET",
+ "url": "http://127.0.0.1:7777/api/users",
+ "body": ""
},
- {
- "category": "dependency_scanning",
- "name": "Authentication bypass via incorrect DOM traversal and canonicalization",
- "message": "Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js",
- "description": "",
- "cve": "yarn/yarn.lock:saml2-js:gemnasium:9952e574-7b5b-46fa-a270-aeb694198a98",
- "severity": "Unknown",
- "solution": "Upgrade to fixed version.\r\n",
- "scanner": {
- "id": "gemnasium",
- "name": "Gemnasium"
+ "response": {
+ "headers": [{
+ "name": "Server",
+ "value": "TwistedWeb/20.3.0"
+ }],
+ "reason_phrase": "OK",
+ "status_code": 200,
+ "body": "[{\"user_id\":1,\"user\":\"admin\",\"first\":\"Joe\",\"last\":\"Smith\",\"password\":\"Password!\"}]"
+ },
+ "supporting_messages": [{
+ "name": "Origional",
+ "request": {
+ "headers": [{
+ "name": "Host",
+ "value": "127.0.0.1:7777"
+ }],
+ "method": "GET",
+ "url": "http://127.0.0.1:7777/api/users",
+ "body": ""
+ }
+ },
+ {
+ "name": "Recorded",
+ "request": {
+ "headers": [{
+ "name": "Host",
+ "value": "127.0.0.1:7777"
+ }],
+ "method": "GET",
+ "url": "http://127.0.0.1:7777/api/users",
+ "body": ""
},
- "location": {},
- "identifiers": [],
- "links": [
- ]
+ "response": {
+ "headers": [{
+ "name": "Server",
+ "value": "TwistedWeb/20.3.0"
+ }],
+ "reason_phrase": "OK",
+ "status_code": 200,
+ "body": "[{\"user_id\":1,\"user\":\"admin\",\"first\":\"Joe\",\"last\":\"Smith\",\"password\":\"Password!\"}]"
+ }
+ }
+ ]
+ },
+ "location": {},
+ "identifiers": [{
+ "type": "GitLab",
+ "name": "Foo vulnerability",
+ "value": "foo"
+ }],
+ "links": [{
+ "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1020"
+ }],
+ "details": {
+ "commit": {
+ "name": [{
+ "lang": "en",
+ "value": "The Commit"
+ }],
+ "description": [{
+ "lang": "en",
+ "value": "Commit where the vulnerability was identified"
+ }],
+ "type": "commit",
+ "value": "41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19"
}
- ],
- "remediations": [
- {
- "fixes": [
- {
- "cve": "CVE-1020"
- }
- ],
- "summary": "",
- "diff": ""
- },
- {
- "fixes": [
- {
- "cve": "CVE",
- "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3"
- }
- ],
- "summary": "",
- "diff": ""
+ }
+ },
+ {
+ "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3",
+ "category": "dependency_scanning",
+ "name": "Regular Expression Denial of Service",
+ "message": "Regular Expression Denial of Service in debug",
+ "description": "",
+ "cve": "CVE-1030",
+ "severity": "Unknown",
+ "solution": "Upgrade to latest versions.",
+ "scanner": {
+ "id": "gemnasium",
+ "name": "Gemnasium"
+ },
+ "evidence": {
+ "source": {
+ "id": "assert:CORS - Bad 'Origin' value",
+ "name": "CORS - Bad 'Origin' value"
},
- {
- "fixes": [
- {
- "cve": "CVE",
- "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3"
- }
- ],
- "summary": "",
- "diff": ""
+ "summary": "The Origin header was changed to an invalid value of http://peachapisecurity.com and the response contained an Access-Control-Allow-Origin header which included this invalid Origin, indicating that the CORS configuration on the server is overly permissive.\n\n\n",
+ "request": {
+ "headers": [{
+ "name": "Host",
+ "value": "127.0.0.1:7777"
+ }],
+ "method": "GET",
+ "url": "http://127.0.0.1:7777/api/users",
+ "body": ""
},
- {
- "fixes": [
- {
- "id": "2134",
- "cve": "CVE-1"
- }
- ],
- "summary": "",
- "diff": ""
- }
- ],
- "dependency_files": [],
- "scan": {
- "analyzer": {
- "id": "common-analyzer",
- "name": "Common Analyzer",
- "url": "https://site.com/analyzer/common",
- "version": "2.0.1",
- "vendor": {
- "name": "Common"
- }
+ "response": {
+ "headers": [{
+ "name": "Server",
+ "value": "TwistedWeb/20.3.0"
+ }],
+ "reason_phrase": "OK",
+ "status_code": 200,
+ "body": "[{\"user_id\":1,\"user\":\"admin\",\"first\":\"Joe\",\"last\":\"Smith\",\"password\":\"Password!\"}]"
},
- "scanner": {
- "id": "gemnasium",
- "name": "Gemnasium",
- "url": "https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium-maven",
- "vendor": {
- "name": "GitLab"
+ "supporting_messages": [{
+ "name": "Origional",
+ "request": {
+ "headers": [{
+ "name": "Host",
+ "value": "127.0.0.1:7777"
+ }],
+ "method": "GET",
+ "url": "http://127.0.0.1:7777/api/users",
+ "body": ""
+ }
+ },
+ {
+ "name": "Recorded",
+ "request": {
+ "headers": [{
+ "name": "Host",
+ "value": "127.0.0.1:7777"
+ }],
+ "method": "GET",
+ "url": "http://127.0.0.1:7777/api/users",
+ "body": ""
},
- "version": "2.18.0"
- },
- "type": "dependency_scanning",
- "start_time": "placeholder-value",
- "end_time": "placeholder-value",
- "status": "success"
+ "response": {
+ "headers": [{
+ "name": "Server",
+ "value": "TwistedWeb/20.3.0"
+ }],
+ "reason_phrase": "OK",
+ "status_code": 200,
+ "body": "[{\"user_id\":1,\"user\":\"admin\",\"first\":\"Joe\",\"last\":\"Smith\",\"password\":\"Password!\"}]"
+ }
+ }
+ ]
+ },
+ "location": {},
+ "identifiers": [{
+ "type": "GitLab",
+ "name": "Bar vulnerability",
+ "value": "bar"
+ }],
+ "links": [{
+ "name": "CVE-1030",
+ "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1030"
+ }]
+ },
+ {
+ "category": "dependency_scanning",
+ "name": "Authentication bypass via incorrect DOM traversal and canonicalization",
+ "message": "Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js",
+ "description": "",
+ "cve": "yarn/yarn.lock:saml2-js:gemnasium:9952e574-7b5b-46fa-a270-aeb694198a98",
+ "severity": "Unknown",
+ "solution": "Upgrade to fixed version.\r\n",
+ "scanner": {
+ "id": "gemnasium",
+ "name": "Gemnasium"
+ },
+ "location": {},
+ "identifiers": [],
+ "links": []
+ }
+ ],
+ "remediations": [{
+ "fixes": [{
+ "cve": "CVE-2137"
+ }],
+ "summary": "this remediates CVE-2137",
+ "diff": "dG90YWxseSBsZWdpdCBkaWZm"
+ },
+ {
+ "fixes": [{
+ "cve": "CVE-2138"
+ }],
+ "summary": "this remediates CVE-2138",
+ "diff": "dG90YWxseSBsZWdpdCBkaWZm"
+ },
+ {
+ "fixes": [{
+ "cve": "CVE-2139"
+ }, {
+ "cve": "CVE-2140"
+ }],
+ "summary": "this remediates CVE-2139 and CVE-2140",
+ "diff": "dG90YWxseSBsZWdpdGltYXRlIGRpZmYsIDEwLzEwIHdvdWxkIGFwcGx5"
+ },
+ {
+ "fixes": [{
+ "cve": "CVE-1020"
+ }],
+ "summary": "",
+ "diff": ""
+ },
+ {
+ "fixes": [{
+ "cve": "CVE",
+ "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3"
+ }],
+ "summary": "",
+ "diff": ""
+ },
+ {
+ "fixes": [{
+ "cve": "CVE",
+ "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3"
+ }],
+ "summary": "",
+ "diff": ""
+ },
+ {
+ "fixes": [{
+ "id": "2134",
+ "cve": "CVE-1"
+ }],
+ "summary": "",
+ "diff": ""
+ }
+ ],
+ "dependency_files": [],
+ "scan": {
+ "analyzer": {
+ "id": "common-analyzer",
+ "name": "Common Analyzer",
+ "url": "https://site.com/analyzer/common",
+ "version": "2.0.1",
+ "vendor": {
+ "name": "Common"
+ }
+ },
+ "scanner": {
+ "id": "gemnasium",
+ "name": "Gemnasium",
+ "url": "https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium-maven",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "2.18.0"
},
- "version": "14.0.2"
+ "type": "dependency_scanning",
+ "start_time": "placeholder-value",
+ "end_time": "placeholder-value",
+ "status": "success"
+ },
+ "version": "14.0.2"
}
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml
index e12c4e5e820..45639f4c948 100644
--- a/spec/frontend/.eslintrc.yml
+++ b/spec/frontend/.eslintrc.yml
@@ -11,9 +11,6 @@ settings:
import/resolver:
jest:
jestConfigFile: 'jest.config.js'
-globals:
- loadFixtures: false
- setFixtures: false
rules:
jest/expect-expect:
- off
@@ -21,8 +18,6 @@ rules:
- 'expect*'
- 'assert*'
- 'testAction'
- jest/no-test-callback:
- - off
"@gitlab/no-global-event-off":
- off
import/no-unresolved:
diff --git a/spec/frontend/__helpers__/dom_shims/clipboard.js b/spec/frontend/__helpers__/dom_shims/clipboard.js
new file mode 100644
index 00000000000..3bc1b059272
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/clipboard.js
@@ -0,0 +1,5 @@
+Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ writeText: () => {},
+ },
+});
diff --git a/spec/frontend/__helpers__/dom_shims/index.js b/spec/frontend/__helpers__/dom_shims/index.js
index 9b70cb86b8b..742d55196b4 100644
--- a/spec/frontend/__helpers__/dom_shims/index.js
+++ b/spec/frontend/__helpers__/dom_shims/index.js
@@ -1,3 +1,4 @@
+import './clipboard';
import './create_object_url';
import './element_scroll_into_view';
import './element_scroll_by';
diff --git a/spec/frontend/__helpers__/fixtures.js b/spec/frontend/__helpers__/fixtures.js
index d8054d32fae..a6f7b37161e 100644
--- a/spec/frontend/__helpers__/fixtures.js
+++ b/spec/frontend/__helpers__/fixtures.js
@@ -20,24 +20,15 @@ Did you run bin/rake frontend:fixtures?`,
return fs.readFileSync(absolutePath, 'utf8');
}
-/**
- * @deprecated Use `import` to load a JSON fixture instead.
- * See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html#use-fixtures,
- * https://gitlab.com/gitlab-org/gitlab/-/issues/339346.
- */
-export const getJSONFixture = (relativePath) => JSON.parse(getFixture(relativePath));
-
export const resetHTMLFixture = () => {
document.head.innerHTML = '';
document.body.innerHTML = '';
};
-export const setHTMLFixture = (htmlContent, resetHook = afterEach) => {
+export const setHTMLFixture = (htmlContent) => {
document.body.innerHTML = htmlContent;
- resetHook(resetHTMLFixture);
};
-export const loadHTMLFixture = (relativePath, resetHook = afterEach) => {
- const fileContent = getFixture(relativePath);
- setHTMLFixture(fileContent, resetHook);
+export const loadHTMLFixture = (relativePath) => {
+ setHTMLFixture(getFixture(relativePath));
};
diff --git a/spec/frontend/__helpers__/flush_promises.js b/spec/frontend/__helpers__/flush_promises.js
deleted file mode 100644
index eefc2ed7c17..00000000000
--- a/spec/frontend/__helpers__/flush_promises.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export default function flushPromises() {
- // eslint-disable-next-line no-restricted-syntax
- return new Promise(setImmediate);
-}
diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js
index 7b5df18ee0f..011e1142c76 100644
--- a/spec/frontend/__helpers__/shared_test_setup.js
+++ b/spec/frontend/__helpers__/shared_test_setup.js
@@ -6,7 +6,6 @@ import 'jquery';
import Translate from '~/vue_shared/translate';
import setWindowLocation from './set_window_location_helper';
import { setGlobalDateToFakeDate } from './fake_date';
-import { loadHTMLFixture, setHTMLFixture } from './fixtures';
import { TEST_HOST } from './test_constants';
import * as customMatchers from './matchers';
@@ -28,12 +27,6 @@ Vue.config.productionTip = false;
Vue.use(Translate);
-// convenience wrapper for migration from Karma
-Object.assign(global, {
- loadFixtures: loadHTMLFixture,
- setFixtures: setHTMLFixture,
-});
-
const JQUERY_MATCHERS_TO_EXCLUDE = ['toHaveLength', 'toExist'];
// custom-jquery-matchers was written for an old Jest version, we need to make it compatible
diff --git a/spec/frontend/__helpers__/user_mock_data_helper.js b/spec/frontend/__helpers__/user_mock_data_helper.js
index db747283d9e..29ce95a88e2 100644
--- a/spec/frontend/__helpers__/user_mock_data_helper.js
+++ b/spec/frontend/__helpers__/user_mock_data_helper.js
@@ -15,7 +15,7 @@ export default {
id: id + 1,
name: getRandomString(),
username: getRandomString(),
- user_path: getRandomUrl(),
+ web_url: getRandomUrl(),
});
id += 1;
diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js
index 95a811d0385..ab2637d6024 100644
--- a/spec/frontend/__helpers__/vuex_action_helper.js
+++ b/spec/frontend/__helpers__/vuex_action_helper.js
@@ -1,5 +1,3 @@
-const noop = () => {};
-
/**
* Helper for testing action with expected mutations inspired in
* https://vuex.vuejs.org/en/testing.html
@@ -9,7 +7,6 @@ const noop = () => {};
* @param {Object} state will be provided to the action
* @param {Array} [expectedMutations=[]] mutations expected to be committed
* @param {Array} [expectedActions=[]] actions expected to be dispatched
- * @param {Function} [done=noop] to be executed after the tests
* @return {Promise}
*
* @example
@@ -27,20 +24,9 @@ const noop = () => {};
* { type: 'actionName', payload: {param: 'foobar'}},
* { type: 'actionName1'}
* ]
- * done,
* );
*
* @example
- * testAction(
- * actions.actionName, // action
- * { }, // mocked payload
- * state, //state
- * [ { type: types.MUTATION} ], // expected mutations
- * [], // expected actions
- * ).then(done)
- * .catch(done.fail);
- *
- * @example
* await testAction({
* action: actions.actionName,
* payload: { deleteListId: 1 },
@@ -56,24 +42,15 @@ export default (
stateArg,
expectedMutationsArg = [],
expectedActionsArg = [],
- doneArg = noop,
) => {
let action = actionArg;
let payload = payloadArg;
let state = stateArg;
let expectedMutations = expectedMutationsArg;
let expectedActions = expectedActionsArg;
- let done = doneArg;
if (typeof actionArg !== 'function') {
- ({
- action,
- payload,
- state,
- expectedMutations = [],
- expectedActions = [],
- done = noop,
- } = actionArg);
+ ({ action, payload, state, expectedMutations = [], expectedActions = [] } = actionArg);
}
const mutations = [];
@@ -109,7 +86,6 @@ export default (
mutations: expectedMutations,
actions: expectedActions,
});
- done();
};
const result = action(
@@ -117,8 +93,13 @@ export default (
payload,
);
- // eslint-disable-next-line no-restricted-syntax
- return (result || new Promise((resolve) => setImmediate(resolve)))
+ return (
+ result ||
+ new Promise((resolve) => {
+ // eslint-disable-next-line no-restricted-syntax
+ setImmediate(resolve);
+ })
+ )
.catch((error) => {
validateResults();
throw error;
diff --git a/spec/frontend/__helpers__/vuex_action_helper_spec.js b/spec/frontend/__helpers__/vuex_action_helper_spec.js
index b4f5a291774..5bb2b3b26e2 100644
--- a/spec/frontend/__helpers__/vuex_action_helper_spec.js
+++ b/spec/frontend/__helpers__/vuex_action_helper_spec.js
@@ -4,8 +4,8 @@ import axios from '~/lib/utils/axios_utils';
import testActionFn from './vuex_action_helper';
const testActionFnWithOptionsArg = (...args) => {
- const [action, payload, state, expectedMutations, expectedActions, done] = args;
- return testActionFn({ action, payload, state, expectedMutations, expectedActions, done });
+ const [action, payload, state, expectedMutations, expectedActions] = args;
+ return testActionFn({ action, payload, state, expectedMutations, expectedActions });
};
describe.each([testActionFn, testActionFnWithOptionsArg])(
@@ -14,7 +14,6 @@ describe.each([testActionFn, testActionFnWithOptionsArg])(
let originalExpect;
let assertion;
let mock;
- const noop = () => {};
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -48,7 +47,7 @@ describe.each([testActionFn, testActionFnWithOptionsArg])(
assertion = { mutations: [], actions: [] };
- testAction(action, examplePayload, exampleState);
+ return testAction(action, examplePayload, exampleState);
});
describe('given a sync action', () => {
@@ -59,7 +58,7 @@ describe.each([testActionFn, testActionFnWithOptionsArg])(
assertion = { mutations: [{ type: 'MUTATION' }], actions: [] };
- testAction(action, null, {}, assertion.mutations, assertion.actions, noop);
+ return testAction(action, null, {}, assertion.mutations, assertion.actions);
});
it('mocks dispatching actions', () => {
@@ -69,26 +68,21 @@ describe.each([testActionFn, testActionFnWithOptionsArg])(
assertion = { actions: [{ type: 'ACTION' }], mutations: [] };
- testAction(action, null, {}, assertion.mutations, assertion.actions, noop);
+ return testAction(action, null, {}, assertion.mutations, assertion.actions);
});
- it('works with done callback once finished', (done) => {
+ it('returns a promise', () => {
assertion = { mutations: [], actions: [] };
- testAction(noop, null, {}, assertion.mutations, assertion.actions, done);
- });
+ const promise = testAction(() => {}, null, {}, assertion.mutations, assertion.actions);
- it('returns a promise', (done) => {
- assertion = { mutations: [], actions: [] };
+ originalExpect(promise instanceof Promise).toBeTruthy();
- testAction(noop, null, {}, assertion.mutations, assertion.actions)
- .then(done)
- .catch(done.fail);
+ return promise;
});
});
describe('given an async action (returning a promise)', () => {
- let lastError;
const data = { FOO: 'BAR' };
const asyncAction = ({ commit, dispatch }) => {
@@ -98,7 +92,6 @@ describe.each([testActionFn, testActionFnWithOptionsArg])(
.get(TEST_HOST)
.catch((error) => {
commit('ERROR');
- lastError = error;
throw error;
})
.then(() => {
@@ -107,46 +100,26 @@ describe.each([testActionFn, testActionFnWithOptionsArg])(
});
};
- beforeEach(() => {
- lastError = null;
- });
-
- it('works with done callback once finished', (done) => {
+ it('returns original data of successful promise while checking actions/mutations', async () => {
mock.onGet(TEST_HOST).replyOnce(200, 42);
assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] };
- testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done);
+ const res = await testAction(asyncAction, null, {}, assertion.mutations, assertion.actions);
+ originalExpect(res).toEqual(data);
});
- it('returns original data of successful promise while checking actions/mutations', (done) => {
- mock.onGet(TEST_HOST).replyOnce(200, 42);
-
- assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] };
-
- testAction(asyncAction, null, {}, assertion.mutations, assertion.actions)
- .then((res) => {
- originalExpect(res).toEqual(data);
- done();
- })
- .catch(done.fail);
- });
-
- it('returns original error of rejected promise while checking actions/mutations', (done) => {
+ it('returns original error of rejected promise while checking actions/mutations', async () => {
mock.onGet(TEST_HOST).replyOnce(500, '');
assertion = { mutations: [{ type: 'ERROR' }], actions: [{ type: 'ACTION' }] };
- testAction(asyncAction, null, {}, assertion.mutations, assertion.actions)
- .then(done.fail)
- .catch((error) => {
- originalExpect(error).toBe(lastError);
- done();
- });
+ const err = testAction(asyncAction, null, {}, assertion.mutations, assertion.actions);
+ await originalExpect(err).rejects.toEqual(new Error('Request failed with status code 500'));
});
});
- it('works with async actions not returning promises', (done) => {
+ it('works with actions not returning promises', () => {
const data = { FOO: 'BAR' };
const asyncAction = ({ commit, dispatch }) => {
@@ -168,7 +141,7 @@ describe.each([testActionFn, testActionFnWithOptionsArg])(
assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] };
- testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done);
+ return testAction(asyncAction, null, {}, assertion.mutations, assertion.actions);
});
},
);
diff --git a/spec/frontend/__helpers__/wait_for_promises.js b/spec/frontend/__helpers__/wait_for_promises.js
index 2fd1cc6ba0d..753c3c5d92b 100644
--- a/spec/frontend/__helpers__/wait_for_promises.js
+++ b/spec/frontend/__helpers__/wait_for_promises.js
@@ -1 +1,4 @@
-export default () => new Promise((resolve) => requestAnimationFrame(resolve));
+export default () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(resolve);
+ });
diff --git a/spec/frontend/activities_spec.js b/spec/frontend/activities_spec.js
index 00519148b30..ebace21217a 100644
--- a/spec/frontend/activities_spec.js
+++ b/spec/frontend/activities_spec.js
@@ -1,6 +1,7 @@
/* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow */
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import Activities from '~/activities';
import Pager from '~/pager';
@@ -38,11 +39,15 @@ describe('Activities', () => {
}
beforeEach(() => {
- loadFixtures(fixtureTemplate);
+ loadHTMLFixture(fixtureTemplate);
jest.spyOn(Pager, 'init').mockImplementation(() => {});
new Activities();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
for (let i = 0; i < filters.length; i += 1) {
((i) => {
describe(`when selecting ${getEventName(i)}`, () => {
diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
index 82114077455..3fdbacb6efa 100644
--- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
+++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
@@ -2,6 +2,7 @@
exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = `
<gl-modal-stub
+ arialabel=""
body-class="add-review-item pt-0"
cancel-variant="light"
dismisslabel="Close"
diff --git a/spec/frontend/admin/applications/components/delete_application_spec.js b/spec/frontend/admin/applications/components/delete_application_spec.js
index 20119b64952..1a400a101b5 100644
--- a/spec/frontend/admin/applications/components/delete_application_spec.js
+++ b/spec/frontend/admin/applications/components/delete_application_spec.js
@@ -1,5 +1,6 @@
import { GlModal, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import DeleteApplication from '~/admin/applications/components/delete_application.vue';
const path = 'application/path/1';
@@ -22,7 +23,7 @@ describe('DeleteApplication', () => {
const findForm = () => wrapper.find('form');
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<button class="js-application-delete-button" data-path="${path}" data-name="${name}">Destroy</button>
`);
@@ -31,6 +32,7 @@ describe('DeleteApplication', () => {
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
describe('the modal component', () => {
diff --git a/spec/frontend/admin/background_migrations/components/database_listbox_spec.js b/spec/frontend/admin/background_migrations/components/database_listbox_spec.js
new file mode 100644
index 00000000000..3778943872e
--- /dev/null
+++ b/spec/frontend/admin/background_migrations/components/database_listbox_spec.js
@@ -0,0 +1,57 @@
+import { GlListbox } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import BackgroundMigrationsDatabaseListbox from '~/admin/background_migrations/components/database_listbox.vue';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import { MOCK_DATABASES, MOCK_SELECTED_DATABASE } from '../mock_data';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ setUrlParams: jest.fn(),
+}));
+
+describe('BackgroundMigrationsDatabaseListbox', () => {
+ let wrapper;
+
+ const defaultProps = {
+ databases: MOCK_DATABASES,
+ selectedDatabase: MOCK_SELECTED_DATABASE,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(BackgroundMigrationsDatabaseListbox, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findGlListbox = () => wrapper.findComponent(GlListbox);
+
+ describe('template always', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders GlListbox', () => {
+ expect(findGlListbox().exists()).toBe(true);
+ });
+ });
+
+ describe('actions', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('selecting a listbox item fires visitUrl with the database param', () => {
+ findGlListbox().vm.$emit('select', MOCK_DATABASES[1].value);
+
+ expect(setUrlParams).toHaveBeenCalledWith({ database: MOCK_DATABASES[1].value });
+ expect(visitUrl).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/admin/background_migrations/mock_data.js b/spec/frontend/admin/background_migrations/mock_data.js
new file mode 100644
index 00000000000..fbb1718f6b8
--- /dev/null
+++ b/spec/frontend/admin/background_migrations/mock_data.js
@@ -0,0 +1,6 @@
+export const MOCK_DATABASES = [
+ { value: 'main', text: 'main' },
+ { value: 'ci', text: 'ci' },
+];
+
+export const MOCK_SELECTED_DATABASE = 'main';
diff --git a/spec/frontend/admin/users/new_spec.js b/spec/frontend/admin/users/new_spec.js
index 692c583dca8..5e5763822a8 100644
--- a/spec/frontend/admin/users/new_spec.js
+++ b/spec/frontend/admin/users/new_spec.js
@@ -4,6 +4,7 @@ import {
ID_USER_EXTERNAL,
ID_WARNING,
} from '~/admin/users/new';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
describe('admin/users/new', () => {
const FIXTURE = 'admin/users/new_with_internal_user_regex.html';
@@ -13,7 +14,7 @@ describe('admin/users/new', () => {
let elWarningMessage;
beforeEach(() => {
- loadFixtures(FIXTURE);
+ loadHTMLFixture(FIXTURE);
setupInternalUserRegexHandler();
elExternal = document.getElementById(ID_USER_EXTERNAL);
@@ -23,6 +24,10 @@ describe('admin/users/new', () => {
elExternal.checked = true;
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
const changeEmail = (val) => {
elUserEmail.value = val;
elUserEmail.dispatchEvent(new Event('input'));
diff --git a/spec/frontend/alert_handler_spec.js b/spec/frontend/alert_handler_spec.js
index 228053b1b2b..ba8e5bcb202 100644
--- a/spec/frontend/alert_handler_spec.js
+++ b/spec/frontend/alert_handler_spec.js
@@ -1,4 +1,4 @@
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initAlertHandler from '~/alert_handler';
describe('Alert Handler', () => {
@@ -25,6 +25,10 @@ describe('Alert Handler', () => {
initAlertHandler();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should render the alert', () => {
expect(findFirstAlert()).not.toBe(null);
});
@@ -41,6 +45,10 @@ describe('Alert Handler', () => {
initAlertHandler();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should render two alerts', () => {
expect(findAllAlerts()).toHaveLength(2);
});
@@ -57,6 +65,10 @@ describe('Alert Handler', () => {
initAlertHandler();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should render the banner', () => {
expect(findFirstBanner()).not.toBe(null);
});
@@ -78,6 +90,10 @@ describe('Alert Handler', () => {
initAlertHandler();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should render the banner', () => {
expect(findFirstAlert()).not.toBe(null);
});
diff --git a/spec/frontend/analytics/shared/components/metric_popover_spec.js b/spec/frontend/analytics/shared/components/metric_popover_spec.js
index b799c911488..ffec77c2708 100644
--- a/spec/frontend/analytics/shared/components/metric_popover_spec.js
+++ b/spec/frontend/analytics/shared/components/metric_popover_spec.js
@@ -6,7 +6,7 @@ const MOCK_METRIC = {
key: 'deployment-frequency',
label: 'Deployment Frequency',
value: '10.0',
- unit: 'per day',
+ unit: '/day',
description: 'Average number of deployments to production per day.',
links: [],
};
diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
index 386fb4eb616..69918c1db65 100644
--- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
+++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlTruncate } from '@gitlab/ui';
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
@@ -76,8 +76,8 @@ describe('ProjectsDropdownFilter component', () => {
const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items');
const findUnhighlightedItems = () => wrapper.findByTestId('vsa-default-items');
- const findHighlightedItemsTitle = () => wrapper.findByText('Selected');
const findClearAllButton = () => wrapper.findByText('Clear all');
+ const findSelectedProjectsLabel = () => wrapper.findComponent(GlTruncate);
const findDropdown = () => wrapper.find(GlDropdown);
@@ -158,8 +158,8 @@ describe('ProjectsDropdownFilter component', () => {
expect(findSelectedDropdownItems().length).toBe(0);
});
- it('does not render the highlighted items title', () => {
- expect(findHighlightedItemsTitle().exists()).toBe(false);
+ it('renders the default project label text', () => {
+ expect(findSelectedProjectsLabel().text()).toBe('Select projects');
});
it('does not render the clear all button', () => {
@@ -180,7 +180,7 @@ describe('ProjectsDropdownFilter component', () => {
});
it('renders the highlighted items title', () => {
- expect(findHighlightedItemsTitle().exists()).toBe(true);
+ expect(findSelectedProjectsLabel().text()).toBe(projects[0].name);
});
it('renders the clear all button', () => {
@@ -190,13 +190,12 @@ describe('ProjectsDropdownFilter component', () => {
it('clears all selected items when the clear all button is clicked', async () => {
await selectDropdownItemAtIndex(1);
- expect(wrapper.text()).toContain('2 projects selected');
+ expect(findSelectedProjectsLabel().text()).toBe('2 projects selected');
findClearAllButton().trigger('click');
await nextTick();
- expect(wrapper.text()).not.toContain('2 projects selected');
- expect(wrapper.text()).toContain('Select projects');
+ expect(findSelectedProjectsLabel().text()).toBe('Select projects');
});
});
});
diff --git a/spec/frontend/api/tags_api_spec.js b/spec/frontend/api/tags_api_spec.js
new file mode 100644
index 00000000000..a7436bf6a50
--- /dev/null
+++ b/spec/frontend/api/tags_api_spec.js
@@ -0,0 +1,37 @@
+import MockAdapter from 'axios-mock-adapter';
+import * as tagsApi from '~/api/tags_api';
+import axios from '~/lib/utils/axios_utils';
+import httpStatus from '~/lib/utils/http_status';
+
+describe('~/api/tags_api.js', () => {
+ let mock;
+ let originalGon;
+
+ const projectId = 1;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ originalGon = window.gon;
+ window.gon = { api_version: 'v7' };
+ });
+
+ afterEach(() => {
+ mock.restore();
+ window.gon = originalGon;
+ });
+
+ describe('getTag', () => {
+ it('fetches a tag of a given tag name of a particular project', () => {
+ const tagName = 'tag-name';
+ const expectedUrl = `/api/v7/projects/${projectId}/repository/tags/${tagName}`;
+ mock.onGet(expectedUrl).reply(httpStatus.OK, {
+ name: tagName,
+ });
+
+ return tagsApi.getTag(projectId, tagName).then(({ data }) => {
+ expect(data.name).toBe(tagName);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js
new file mode 100644
index 00000000000..ee7194bdf5f
--- /dev/null
+++ b/spec/frontend/api/user_api_spec.js
@@ -0,0 +1,50 @@
+import MockAdapter from 'axios-mock-adapter';
+
+import { followUser, unfollowUser } from '~/api/user_api';
+import axios from '~/lib/utils/axios_utils';
+
+describe('~/api/user_api', () => {
+ let axiosMock;
+ let originalGon;
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+
+ originalGon = window.gon;
+ window.gon = { api_version: 'v4' };
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ axiosMock.resetHistory();
+ window.gon = originalGon;
+ });
+
+ describe('followUser', () => {
+ it('calls correct URL and returns expected response', async () => {
+ const expectedUrl = '/api/v4/users/1/follow';
+ const expectedResponse = { message: 'Success' };
+
+ axiosMock.onPost(expectedUrl).replyOnce(200, expectedResponse);
+
+ await expect(followUser(1)).resolves.toEqual(
+ expect.objectContaining({ data: expectedResponse }),
+ );
+ expect(axiosMock.history.post[0].url).toBe(expectedUrl);
+ });
+ });
+
+ describe('unfollowUser', () => {
+ it('calls correct URL and returns expected response', async () => {
+ const expectedUrl = '/api/v4/users/1/unfollow';
+ const expectedResponse = { message: 'Success' };
+
+ axiosMock.onPost(expectedUrl).replyOnce(200, expectedResponse);
+
+ await expect(unfollowUser(1)).resolves.toEqual(
+ expect.objectContaining({ data: expectedResponse }),
+ );
+ expect(axiosMock.history.post[0].url).toBe(expectedUrl);
+ });
+ });
+});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 85332bf21d8..5f162f498c4 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -1593,6 +1593,38 @@ describe('Api', () => {
});
});
+ describe('uploadProjectSecureFile', () => {
+ it('uploads a secure file to a project', async () => {
+ const projectId = 1;
+ const secureFile = {
+ id: projectId,
+ title: 'File Name',
+ permissions: 'read_only',
+ checksum: '12345',
+ checksum_algorithm: 'sha256',
+ created_at: '2022-02-21T15:27:18',
+ };
+
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files`;
+ mock.onPost(expectedUrl).reply(httpStatus.OK, secureFile);
+ const { data } = await Api.uploadProjectSecureFile(projectId, 'some data');
+
+ expect(data).toEqual(secureFile);
+ });
+ });
+
+ describe('deleteProjectSecureFile', () => {
+ it('removes a secure file from a project', async () => {
+ const projectId = 1;
+ const secureFileId = 2;
+
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files/${secureFileId}`;
+ mock.onDelete(expectedUrl).reply(httpStatus.NO_CONTENT, '');
+ const { data } = await Api.deleteProjectSecureFile(projectId, secureFileId);
+ expect(data).toEqual('');
+ });
+ });
+
describe('dependency proxy cache', () => {
it('schedules the cache list for deletion', async () => {
const groupId = 1;
diff --git a/spec/frontend/attention_requests/components/navigation_popover_spec.js b/spec/frontend/attention_requests/components/navigation_popover_spec.js
index d0231afbdc4..e4d53d5dbdb 100644
--- a/spec/frontend/attention_requests/components/navigation_popover_spec.js
+++ b/spec/frontend/attention_requests/components/navigation_popover_spec.js
@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlPopover, GlButton, GlSprintf, GlIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import NavigationPopover from '~/attention_requests/components/navigation_popover.vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
let wrapper;
@@ -29,13 +30,14 @@ function createComponent(provideData = {}, shouldShowCallout = true) {
describe('Attention requests navigation popover', () => {
beforeEach(() => {
- setFixtures('<div><div class="js-test-popover"></div><div class="js-test"></div></div>');
+ setHTMLFixture('<div><div class="js-test-popover"></div><div class="js-test"></div></div>');
dismiss = jest.fn();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
+ resetHTMLFixture();
});
it('hides popover if callout is disabled', () => {
diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js
index 31782899ce4..3ae7fcf1c49 100644
--- a/spec/frontend/authentication/u2f/authenticate_spec.js
+++ b/spec/frontend/authentication/u2f/authenticate_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import U2FAuthenticate from '~/authentication/u2f/authenticate';
import 'vendor/u2f';
import MockU2FDevice from './mock_u2f_device';
@@ -9,7 +10,7 @@ describe('U2FAuthenticate', () => {
let component;
beforeEach(() => {
- loadFixtures('u2f/authenticate.html');
+ loadHTMLFixture('u2f/authenticate.html');
u2fDevice = new MockU2FDevice();
container = $('#js-authenticate-token-2fa');
component = new U2FAuthenticate(
@@ -23,6 +24,10 @@ describe('U2FAuthenticate', () => {
);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('with u2f unavailable', () => {
let oldu2f;
diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js
index 810396aa9fd..7ae3a2734cb 100644
--- a/spec/frontend/authentication/u2f/register_spec.js
+++ b/spec/frontend/authentication/u2f/register_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import U2FRegister from '~/authentication/u2f/register';
import 'vendor/u2f';
import MockU2FDevice from './mock_u2f_device';
@@ -9,13 +10,17 @@ describe('U2FRegister', () => {
let component;
beforeEach(() => {
- loadFixtures('u2f/register.html');
+ loadHTMLFixture('u2f/register.html');
u2fDevice = new MockU2FDevice();
container = $('#js-register-token-2fa');
component = new U2FRegister(container, {});
return component.start();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('allows registering a U2F device', () => {
const setupButton = container.find('#js-setup-token-2fa-device');
diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js
index 8b27560bbbe..b1f4e43e56d 100644
--- a/spec/frontend/authentication/webauthn/authenticate_spec.js
+++ b/spec/frontend/authentication/webauthn/authenticate_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate';
import MockWebAuthnDevice from './mock_webauthn_device';
@@ -34,7 +35,7 @@ describe('WebAuthnAuthenticate', () => {
};
beforeEach(() => {
- loadFixtures('webauthn/authenticate.html');
+ loadHTMLFixture('webauthn/authenticate.html');
fallbackElement = document.createElement('div');
fallbackElement.classList.add('js-2fa-form');
webAuthnDevice = new MockWebAuthnDevice();
@@ -62,6 +63,10 @@ describe('WebAuthnAuthenticate', () => {
submitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('with webauthn unavailable', () => {
let oldGetCredentials;
diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js
index 0f8ea2b635f..95cb993fc70 100644
--- a/spec/frontend/authentication/webauthn/register_spec.js
+++ b/spec/frontend/authentication/webauthn/register_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import setWindowLocation from 'helpers/set_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WebAuthnRegister from '~/authentication/webauthn/register';
@@ -23,7 +24,7 @@ describe('WebAuthnRegister', () => {
let component;
beforeEach(() => {
- loadFixtures('webauthn/register.html');
+ loadHTMLFixture('webauthn/register.html');
webAuthnDevice = new MockWebAuthnDevice();
container = $('#js-register-token-2fa');
component = new WebAuthnRegister(container, {
@@ -41,6 +42,10 @@ describe('WebAuthnRegister', () => {
component.start();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
const findSetupButton = () => container.find('#js-setup-token-2fa-device');
const findMessage = () => container.find('p');
const findDeviceResponse = () => container.find('#js-device-response');
diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js
index c4002ec11f3..5d657745615 100644
--- a/spec/frontend/awards_handler_spec.js
+++ b/spec/frontend/awards_handler_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
-import Cookies from 'js-cookie';
+import Cookies from '~/lib/utils/cookies';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import loadAwardsHandler from '~/awards_handler';
@@ -75,7 +76,7 @@ describe('AwardsHandler', () => {
beforeEach(async () => {
await initEmojiMock(emojiData);
- loadFixtures('snippets/show.html');
+ loadHTMLFixture('snippets/show.html');
awardsHandler = await loadAwardsHandler(true);
jest.spyOn(awardsHandler, 'postEmoji').mockImplementation((button, url, emoji, cb) => cb());
@@ -91,6 +92,8 @@ describe('AwardsHandler', () => {
$('body').removeAttr('data-page');
awardsHandler.destroy();
+
+ resetHTMLFixture();
});
describe('::showEmojiMenu', () => {
diff --git a/spec/frontend/badges/components/badge_form_spec.js b/spec/frontend/badges/components/badge_form_spec.js
index ba2ec775b61..6d8a00eb50b 100644
--- a/spec/frontend/badges/components/badge_form_spec.js
+++ b/spec/frontend/badges/components/badge_form_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { DUMMY_IMAGE_URL, TEST_HOST } from 'helpers/test_constants';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import BadgeForm from '~/badges/components/badge_form.vue';
@@ -16,7 +17,7 @@ describe('BadgeForm component', () => {
let vm;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div id="dummy-element"></div>
`);
@@ -26,6 +27,7 @@ describe('BadgeForm component', () => {
afterEach(() => {
vm.$destroy();
axiosMock.restore();
+ resetHTMLFixture();
});
describe('methods', () => {
diff --git a/spec/frontend/badges/components/badge_list_row_spec.js b/spec/frontend/badges/components/badge_list_row_spec.js
index 0fb0fa86a02..ad8426f3168 100644
--- a/spec/frontend/badges/components/badge_list_row_spec.js
+++ b/spec/frontend/badges/components/badge_list_row_spec.js
@@ -1,4 +1,5 @@
import Vue, { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import BadgeListRow from '~/badges/components/badge_list_row.vue';
import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
@@ -11,7 +12,7 @@ describe('BadgeListRow component', () => {
let vm;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div id="delete-badge-modal" class="modal"></div>
<div id="dummy-element"></div>
`);
@@ -29,6 +30,7 @@ describe('BadgeListRow component', () => {
afterEach(() => {
vm.$destroy();
+ resetHTMLFixture();
});
it('renders the badge', () => {
diff --git a/spec/frontend/badges/components/badge_list_spec.js b/spec/frontend/badges/components/badge_list_spec.js
index 39fa502b207..32cd9483ef8 100644
--- a/spec/frontend/badges/components/badge_list_spec.js
+++ b/spec/frontend/badges/components/badge_list_spec.js
@@ -1,4 +1,5 @@
import Vue, { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import BadgeList from '~/badges/components/badge_list.vue';
import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
@@ -11,7 +12,7 @@ describe('BadgeList component', () => {
let vm;
beforeEach(() => {
- setFixtures('<div id="dummy-element"></div>');
+ setHTMLFixture('<div id="dummy-element"></div>');
const badges = [];
for (let id = 0; id < numberOfDummyBadges; id += 1) {
badges.push({ id, ...createDummyBadge() });
@@ -34,6 +35,7 @@ describe('BadgeList component', () => {
afterEach(() => {
vm.$destroy();
+ resetHTMLFixture();
});
it('renders a header with the badge count', () => {
diff --git a/spec/frontend/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js
index fe4cf8ce8eb..19b3a9f23a6 100644
--- a/spec/frontend/badges/components/badge_spec.js
+++ b/spec/frontend/badges/components/badge_spec.js
@@ -1,4 +1,5 @@
import Vue, { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import mountComponent from 'helpers/vue_mount_component_helper';
import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants';
import Badge from '~/badges/components/badge.vue';
@@ -90,10 +91,14 @@ describe('Badge component', () => {
describe('behavior', () => {
beforeEach(() => {
- setFixtures('<div id="dummy-element"></div>');
+ setHTMLFixture('<div id="dummy-element"></div>');
return createComponent({ ...dummyProps }, '#dummy-element');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('shows a badge image after loading', () => {
expect(vm.isLoading).toBe(false);
expect(vm.hasError).toBe(false);
diff --git a/spec/frontend/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js
index 02e1b8e65e4..b799273ff63 100644
--- a/spec/frontend/badges/store/actions_spec.js
+++ b/spec/frontend/badges/store/actions_spec.js
@@ -371,10 +371,8 @@ describe('Badges store actions', () => {
const url = axios.get.mock.calls[0][0];
expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`));
- expect(url).toMatch(
- new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'),
- );
- expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$'));
+ expect(url).toMatch(/\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&/);
+ expect(url).toMatch(/&image_url=%26make-sandwich%3Dtrue$/);
});
it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', async () => {
diff --git a/spec/frontend/behaviors/autosize_spec.js b/spec/frontend/behaviors/autosize_spec.js
index a9dbee7fd08..7008b7b2eb6 100644
--- a/spec/frontend/behaviors/autosize_spec.js
+++ b/spec/frontend/behaviors/autosize_spec.js
@@ -1,4 +1,5 @@
import '~/behaviors/autosize';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
jest.mock('~/helpers/startup_css_helper', () => {
return {
@@ -20,19 +21,22 @@ jest.mock('~/helpers/startup_css_helper', () => {
describe('Autosize behavior', () => {
beforeEach(() => {
- setFixtures('<textarea class="js-autosize"></textarea>');
+ setHTMLFixture('<textarea class="js-autosize"></textarea>');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('is applied to the textarea', () => {
// This is the second part of the Hack:
// Because we are forcing the mock for WaitForCSSLoaded and the very end of our callstack
// to call its callback. This querySelector needs to go to the very end of our callstack
- // as well, if we would not have this setTimeout Function here, the querySelector
- // would run before the mockImplementation called its callBack Function
- // the DOM Manipulation didn't happen yet and the test would fail.
- setTimeout(() => {
- const textarea = document.querySelector('textarea');
- expect(textarea.classList).toContain('js-autosize-initialized');
- }, 0);
+ // as well, if we would not have this jest.runOnlyPendingTimers here, the querySelector
+ // would not run and the test would fail.
+ jest.runOnlyPendingTimers();
+
+ const textarea = document.querySelector('textarea');
+ expect(textarea.classList).toContain('js-autosize-initialized');
});
});
diff --git a/spec/frontend/behaviors/copy_as_gfm_spec.js b/spec/frontend/behaviors/copy_as_gfm_spec.js
index c96db09cc76..2032faa1c33 100644
--- a/spec/frontend/behaviors/copy_as_gfm_spec.js
+++ b/spec/frontend/behaviors/copy_as_gfm_spec.js
@@ -8,6 +8,9 @@ describe('CopyAsGFM', () => {
beforeEach(() => {
target = document.createElement('input');
target.value = 'This is code: ';
+
+ // needed for the underlying insertText to work
+ document.execCommand = jest.fn(() => false);
});
// When GFM code is copied, we put the regular plain text
diff --git a/spec/frontend/behaviors/date_picker_spec.js b/spec/frontend/behaviors/date_picker_spec.js
index 9f7701a0366..363052ad7fb 100644
--- a/spec/frontend/behaviors/date_picker_spec.js
+++ b/spec/frontend/behaviors/date_picker_spec.js
@@ -1,4 +1,5 @@
import * as Pikaday from 'pikaday';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initDatePickers from '~/behaviors/date_picker';
import * as utils from '~/lib/utils/datetime_utility';
@@ -12,7 +13,7 @@ describe('date_picker behavior', () => {
beforeEach(() => {
pikadayMock = jest.spyOn(Pikaday, 'default');
parseMock = jest.spyOn(utils, 'parsePikadayDate');
- setFixtures(`
+ setHTMLFixture(`
<div>
<input class="datepicker" value="2020-10-01" />
</div>
@@ -21,6 +22,10 @@ describe('date_picker behavior', () => {
</div>`);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('Instantiates Pickaday for every instance of a .datepicker class', () => {
initDatePickers();
diff --git a/spec/frontend/behaviors/load_startup_css_spec.js b/spec/frontend/behaviors/load_startup_css_spec.js
index 59f49585645..e9e4c06732f 100644
--- a/spec/frontend/behaviors/load_startup_css_spec.js
+++ b/spec/frontend/behaviors/load_startup_css_spec.js
@@ -1,4 +1,4 @@
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { loadStartupCSS } from '~/behaviors/load_startup_css';
describe('behaviors/load_startup_css', () => {
@@ -25,6 +25,10 @@ describe('behaviors/load_startup_css', () => {
loadStartupCSS();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('does nothing at first', () => {
expect(loadListener).not.toHaveBeenCalled();
});
diff --git a/spec/frontend/behaviors/markdown/highlight_current_user_spec.js b/spec/frontend/behaviors/markdown/highlight_current_user_spec.js
index 3305ddc412d..38d19ac3808 100644
--- a/spec/frontend/behaviors/markdown/highlight_current_user_spec.js
+++ b/spec/frontend/behaviors/markdown/highlight_current_user_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
describe('highlightCurrentUser', () => {
@@ -5,7 +6,7 @@ describe('highlightCurrentUser', () => {
let elements;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div id="dummy-root-element">
<div data-user="1">@first</div>
<div data-user="2">@second</div>
@@ -15,6 +16,10 @@ describe('highlightCurrentUser', () => {
elements = rootElement.querySelectorAll('[data-user]');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('without current user', () => {
beforeEach(() => {
window.gon = window.gon || {};
diff --git a/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js b/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js
new file mode 100644
index 00000000000..2b9442162aa
--- /dev/null
+++ b/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js
@@ -0,0 +1,34 @@
+import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import renderMermaid from '~/behaviors/markdown/render_sandboxed_mermaid';
+
+describe('Render mermaid diagrams for Gitlab Flavoured Markdown', () => {
+ it('Does something', () => {
+ document.body.dataset.page = '';
+ setHTMLFixture(`
+ <div class="gl-relative markdown-code-block js-markdown-code">
+ <pre data-sourcepos="1:1-7:3" class="code highlight js-syntax-highlight language-mermaid white" lang="mermaid" id="code-4">
+ <code class="js-render-mermaid">
+ <span id="LC1" class="line" lang="mermaid">graph TD;</span>
+ <span id="LC2" class="line" lang="mermaid">A--&gt;B</span>
+ <span id="LC3" class="line" lang="mermaid">A--&gt;C</span>
+ <span id="LC4" class="line" lang="mermaid">B--&gt;D</span>
+ <span id="LC5" class="line" lang="mermaid">C--&gt;D</span>
+ </code>
+ </pre>
+ <copy-code>
+ <button type="button" class="btn btn-default btn-md gl-button btn-icon has-tooltip" data-title="Copy to clipboard" data-clipboard-target="pre#code-4">
+ <svg><use xlink:href="/assets/icons-7f1680a3670112fe4c8ef57b9dfb93f0f61b43a2a479d7abd6c83bcb724b9201.svg#copy-to-clipboard"></use></svg>
+ </button>
+ </copy-code>
+ </div>`);
+ const els = $('pre.js-syntax-highlight').find('.js-render-mermaid');
+
+ renderMermaid(els);
+
+ jest.runAllTimers();
+ expect(document.querySelector('pre.js-syntax-highlight').classList).toContain('gl-sr-only');
+
+ resetHTMLFixture();
+ });
+});
diff --git a/spec/frontend/behaviors/quick_submit_spec.js b/spec/frontend/behaviors/quick_submit_spec.js
index 86a85831c6b..317c671cd2b 100644
--- a/spec/frontend/behaviors/quick_submit_spec.js
+++ b/spec/frontend/behaviors/quick_submit_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import '~/behaviors/quick_submit';
describe('Quick Submit behavior', () => {
@@ -7,7 +8,7 @@ describe('Quick Submit behavior', () => {
const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options);
beforeEach(() => {
- loadFixtures('snippets/show.html');
+ loadHTMLFixture('snippets/show.html');
testContext = {};
@@ -24,6 +25,10 @@ describe('Quick Submit behavior', () => {
testContext.textarea = $('.js-quick-submit textarea').first();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('does not respond to other keyCodes', () => {
testContext.textarea.trigger(
keydownEvent({
diff --git a/spec/frontend/behaviors/requires_input_spec.js b/spec/frontend/behaviors/requires_input_spec.js
index bb22133ae44..f2f68f17d1c 100644
--- a/spec/frontend/behaviors/requires_input_spec.js
+++ b/spec/frontend/behaviors/requires_input_spec.js
@@ -1,14 +1,19 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import '~/behaviors/requires_input';
describe('requiresInput', () => {
let submitButton;
beforeEach(() => {
- loadFixtures('branches/new_branch.html');
+ loadHTMLFixture('branches/new_branch.html');
submitButton = $('button[type="submit"]');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('disables submit when any field is required', () => {
$('.js-requires-input').requiresInput();
diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
index e1811247124..e6e587ff44b 100644
--- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
+++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import Mousetrap from 'mousetrap';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
@@ -12,7 +12,6 @@ jest.mock('~/lib/utils/common_utils', () => ({
describe('ShortcutsIssuable', () => {
const snippetShowFixtureName = 'snippets/show.html';
- const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html';
beforeAll(() => {
initCopyAsGFM();
@@ -25,7 +24,7 @@ describe('ShortcutsIssuable', () => {
const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
beforeEach(() => {
- loadFixtures(snippetShowFixtureName);
+ loadHTMLFixture(snippetShowFixtureName);
$('body').append(
`<div class="js-main-target-form">
<textarea class="js-vue-comment-form"></textarea>
@@ -40,6 +39,7 @@ describe('ShortcutsIssuable', () => {
$(FORM_SELECTOR).remove();
delete window.shortcut;
+ resetHTMLFixture();
});
// Stub getSelectedFragment to return a node with the provided HTML.
@@ -280,55 +280,4 @@ describe('ShortcutsIssuable', () => {
});
});
});
-
- describe('copyBranchName', () => {
- let sidebarCollapsedBtn;
- let sidebarExpandedBtn;
-
- beforeEach(() => {
- loadFixtures(mrShowFixtureName);
-
- window.shortcut = new ShortcutsIssuable();
-
- [sidebarCollapsedBtn, sidebarExpandedBtn] = document.querySelectorAll(
- '.js-sidebar-source-branch button',
- );
-
- [sidebarCollapsedBtn, sidebarExpandedBtn].forEach((btn) => jest.spyOn(btn, 'click'));
- });
-
- afterEach(() => {
- delete window.shortcut;
- });
-
- describe('when the sidebar is expanded', () => {
- beforeEach(() => {
- // simulate the applied CSS styles when the
- // sidebar is expanded
- sidebarCollapsedBtn.style.display = 'none';
-
- Mousetrap.trigger('b');
- });
-
- it('clicks the "expanded" version of the copy source branch button', () => {
- expect(sidebarExpandedBtn.click).toHaveBeenCalled();
- expect(sidebarCollapsedBtn.click).not.toHaveBeenCalled();
- });
- });
-
- describe('when the sidebar is collapsed', () => {
- beforeEach(() => {
- // simulate the applied CSS styles when the
- // sidebar is collapsed
- sidebarExpandedBtn.style.display = 'none';
-
- Mousetrap.trigger('b');
- });
-
- it('clicks the "collapsed" version of the copy source branch button', () => {
- expect(sidebarCollapsedBtn.click).toHaveBeenCalled();
- expect(sidebarExpandedBtn.click).not.toHaveBeenCalled();
- });
- });
- });
});
diff --git a/spec/frontend/blob/blob_file_dropzone_spec.js b/spec/frontend/blob/blob_file_dropzone_spec.js
index 47c90030e18..d6fc824258b 100644
--- a/spec/frontend/blob/blob_file_dropzone_spec.js
+++ b/spec/frontend/blob/blob_file_dropzone_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import BlobFileDropzone from '~/blob/blob_file_dropzone';
describe('BlobFileDropzone', () => {
@@ -6,7 +7,7 @@ describe('BlobFileDropzone', () => {
let replaceFileButton;
beforeEach(() => {
- loadFixtures('blob/show.html');
+ loadHTMLFixture('blob/show.html');
const form = $('.js-upload-blob-form');
// eslint-disable-next-line no-new
new BlobFileDropzone(form, 'POST');
@@ -15,6 +16,10 @@ describe('BlobFileDropzone', () => {
replaceFileButton = $('#submit-all');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('submit button', () => {
it('requires file', () => {
jest.spyOn(window, 'alert').mockImplementation(() => {});
diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
index 5926836d9c1..b430dc15557 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
@@ -18,7 +18,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = `
</div>
<div
- class="gl-sm-display-flex file-actions"
+ class="gl-display-flex gl-flex-wrap file-actions"
>
<viewer-switcher-stub
docicon="document"
diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js
index ade35d39b4f..358ac31819c 100644
--- a/spec/frontend/blob/components/table_contents_spec.js
+++ b/spec/frontend/blob/components/table_contents_spec.js
@@ -1,6 +1,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import TableContents from '~/blob/components/table_contents.vue';
let wrapper;
@@ -17,7 +18,7 @@ async function setLoaded(loaded) {
describe('Markdown table of contents component', () => {
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class="blob-viewer" data-type="rich" data-loaded="false">
<h1><a href="#1"></a>Hello</h1>
<h2><a href="#2"></a>World</h2>
@@ -29,6 +30,7 @@ describe('Markdown table of contents component', () => {
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
describe('not loaded', () => {
diff --git a/spec/frontend/blob/file_template_mediator_spec.js b/spec/frontend/blob/file_template_mediator_spec.js
index 44e12deb564..907a3c97799 100644
--- a/spec/frontend/blob/file_template_mediator_spec.js
+++ b/spec/frontend/blob/file_template_mediator_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import TemplateSelectorMediator from '~/blob/file_template_mediator';
describe('Template Selector Mediator', () => {
@@ -11,7 +12,7 @@ describe('Template Selector Mediator', () => {
}))();
beforeEach(() => {
- setFixtures('<div class="file-editor"><input class="js-file-path-name-input" /></div>');
+ setHTMLFixture('<div class="file-editor"><input class="js-file-path-name-input" /></div>');
input = document.querySelector('.js-file-path-name-input');
mediator = new TemplateSelectorMediator({
editor,
@@ -20,6 +21,10 @@ describe('Template Selector Mediator', () => {
});
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('fills out the input field', () => {
expect(input.value).toBe('');
mediator.setFilename(newFileName);
diff --git a/spec/frontend/blob/file_template_selector_spec.js b/spec/frontend/blob/file_template_selector_spec.js
index 2ab3b3ebc82..65444e86efd 100644
--- a/spec/frontend/blob/file_template_selector_spec.js
+++ b/spec/frontend/blob/file_template_selector_spec.js
@@ -1,10 +1,11 @@
-import $ from 'jquery';
import FileTemplateSelector from '~/blob/file_template_selector';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
describe('FileTemplateSelector', () => {
let subject;
- let dropdown;
- let wrapper;
+
+ const dropdown = '.dropdown';
+ const wrapper = '.wrapper';
const createSubject = () => {
subject = new FileTemplateSelector({});
@@ -17,13 +18,16 @@ describe('FileTemplateSelector', () => {
afterEach(() => {
subject = null;
+ resetHTMLFixture();
});
describe('show method', () => {
beforeEach(() => {
- dropdown = document.createElement('div');
- wrapper = document.createElement('div');
- wrapper.classList.add('hidden');
+ setHTMLFixture(`
+ <div class="wrapper hidden">
+ <div class="dropdown"></div>
+ </div>
+ `);
createSubject();
});
@@ -37,25 +41,24 @@ describe('FileTemplateSelector', () => {
it('does not call init on subsequent calls', () => {
jest.spyOn(subject, 'init');
subject.show();
- subject.show();
expect(subject.init).toHaveBeenCalledTimes(1);
});
- it('removes hidden class from $wrapper', () => {
- expect($(wrapper).hasClass('hidden')).toBe(true);
+ it('removes hidden class from wrapper', () => {
+ subject.init();
+ expect(subject.wrapper.classList.contains('hidden')).toBe(true);
subject.show();
-
- expect($(wrapper).hasClass('hidden')).toBe(false);
+ expect(subject.wrapper.classList.contains('hidden')).toBe(false);
});
it('sets the focus on the dropdown', async () => {
subject.show();
- jest.spyOn(subject.$dropdown, 'focus');
+ jest.spyOn(subject.dropdown, 'focus');
jest.runAllTimers();
- expect(subject.$dropdown.focus).toHaveBeenCalled();
+ expect(subject.dropdown.focus).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/blob/line_highlighter_spec.js b/spec/frontend/blob/line_highlighter_spec.js
index 330f1f3137e..21d4e8db503 100644
--- a/spec/frontend/blob/line_highlighter_spec.js
+++ b/spec/frontend/blob/line_highlighter_spec.js
@@ -1,6 +1,7 @@
/* eslint-disable no-return-assign, no-new, no-underscore-dangle */
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import LineHighlighter from '~/blob/line_highlighter';
import * as utils from '~/lib/utils/common_utils';
@@ -14,8 +15,9 @@ describe('LineHighlighter', () => {
const e = $.Event('click', eventData);
return $(`#L${number}`).trigger(e);
};
+
beforeEach(() => {
- loadFixtures('static/line_highlighter.html');
+ loadHTMLFixture('static/line_highlighter.html');
testContext.class = new LineHighlighter();
testContext.css = testContext.class.highlightLineClass;
return (testContext.spies = {
@@ -25,6 +27,10 @@ describe('LineHighlighter', () => {
});
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('behavior', () => {
it('highlights one line given in the URL hash', () => {
new LineHighlighter({ hash: '#L13' });
diff --git a/spec/frontend/blob/openapi/index_spec.js b/spec/frontend/blob/openapi/index_spec.js
new file mode 100644
index 00000000000..53220809f80
--- /dev/null
+++ b/spec/frontend/blob/openapi/index_spec.js
@@ -0,0 +1,28 @@
+import { SwaggerUIBundle } from 'swagger-ui-dist';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import renderOpenApi from '~/blob/openapi';
+
+jest.mock('swagger-ui-dist');
+
+describe('OpenAPI blob viewer', () => {
+ const id = 'js-openapi-viewer';
+ const mockEndpoint = 'some/endpoint';
+
+ beforeEach(() => {
+ setHTMLFixture(`<div id="${id}" data-endpoint="${mockEndpoint}"></div>`);
+ renderOpenApi();
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ it('initializes SwaggerUI with the correct configuration', () => {
+ expect(SwaggerUIBundle).toHaveBeenCalledWith({
+ url: mockEndpoint,
+ dom_id: `#${id}`,
+ deepLinking: true,
+ displayOperationId: true,
+ });
+ });
+});
diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
index f4af57de41f..750dd8f0a72 100644
--- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js
+++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
@@ -1,6 +1,6 @@
import { GlSprintf, GlModal, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Cookies from 'js-cookie';
+import Cookies from '~/lib/utils/cookies';
import { stubComponent } from 'helpers/stub_component';
import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper';
import pipelineTourSuccess from '~/blob/pipeline_tour_success_modal.vue';
diff --git a/spec/frontend/blob/sketch/index_spec.js b/spec/frontend/blob/sketch/index_spec.js
index 7424897b22c..5e1922a24f4 100644
--- a/spec/frontend/blob/sketch/index_spec.js
+++ b/spec/frontend/blob/sketch/index_spec.js
@@ -1,20 +1,34 @@
-import JSZip from 'jszip';
import SketchLoader from '~/blob/sketch';
-
-jest.mock('jszip');
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('jszip', () => {
+ return {
+ loadAsync: jest.fn().mockResolvedValue({
+ files: {
+ 'previews/preview.png': {
+ async: jest.fn().mockResolvedValue('foo'),
+ },
+ },
+ }),
+ };
+});
describe('Sketch viewer', () => {
beforeEach(() => {
- loadFixtures('static/sketch_viewer.html');
+ loadHTMLFixture('static/sketch_viewer.html');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
describe('with error message', () => {
- beforeEach((done) => {
+ beforeEach(() => {
jest.spyOn(SketchLoader.prototype, 'getZipFile').mockImplementation(
() =>
new Promise((resolve, reject) => {
reject();
- done();
}),
);
@@ -35,26 +49,12 @@ describe('Sketch viewer', () => {
});
describe('success', () => {
- beforeEach((done) => {
- const loadAsyncMock = {
- files: {
- 'previews/preview.png': {
- async: jest.fn(),
- },
- },
- };
-
- loadAsyncMock.files['previews/preview.png'].async.mockImplementation(
- () =>
- new Promise((resolve) => {
- resolve('foo');
- done();
- }),
- );
-
+ beforeEach(() => {
jest.spyOn(SketchLoader.prototype, 'getZipFile').mockResolvedValue();
- jest.spyOn(JSZip, 'loadAsync').mockResolvedValue(loadAsyncMock);
- return new SketchLoader(document.getElementById('js-sketch-viewer'));
+ // eslint-disable-next-line no-new
+ new SketchLoader(document.getElementById('js-sketch-viewer'));
+
+ return waitForPromises();
});
it('does not render error message', () => {
diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js
index fe55a537b89..5f6baf3f63d 100644
--- a/spec/frontend/blob/viewer/index_spec.js
+++ b/spec/frontend/blob/viewer/index_spec.js
@@ -2,6 +2,7 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout';
import { BlobViewer } from '~/blob/viewer/index';
import axios from '~/lib/utils/axios_utils';
@@ -26,7 +27,7 @@ describe('Blob viewer', () => {
$.fn.extend(jQueryMock);
mock = new MockAdapter(axios);
- loadFixtures('blob/show_readme.html');
+ loadHTMLFixture('blob/show_readme.html');
$('#modal-upload-blob').remove();
mock.onGet(/blob\/.+\/README\.md/).reply(200, {
@@ -39,6 +40,8 @@ describe('Blob viewer', () => {
afterEach(() => {
mock.restore();
window.location.hash = '';
+
+ resetHTMLFixture();
});
it('loads source file after switching views', async () => {
diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js
index 2c9ddfaf867..644539308c2 100644
--- a/spec/frontend/blob_edit/blob_bundle_spec.js
+++ b/spec/frontend/blob_edit/blob_bundle_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
import blobBundle from '~/blob_edit/blob_bundle';
@@ -14,15 +15,17 @@ describe('BlobBundle', () => {
});
it('loads SourceEditor for the edit screen', async () => {
- setFixtures(`<div class="js-edit-blob-form"></div>`);
+ setHTMLFixture(`<div class="js-edit-blob-form"></div>`);
blobBundle();
await waitForPromises();
expect(SourceEditor).toHaveBeenCalled();
+
+ resetHTMLFixture();
});
describe('No Suggest Popover', () => {
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class="js-edit-blob-form" data-blob-filename="blah">
<button class="js-commit-button"></button>
<button id='cancel-changes'></button>
@@ -31,6 +34,10 @@ describe('BlobBundle', () => {
blobBundle();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('sets the window beforeunload listener to a function returning a string', () => {
expect(window.onbeforeunload()).toBe('');
});
@@ -52,7 +59,7 @@ describe('BlobBundle', () => {
let trackingSpy;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class="js-edit-blob-form" data-blob-filename="blah" id="target">
<div class="js-suggest-gitlab-ci-yml"
data-target="#target"
@@ -73,6 +80,7 @@ describe('BlobBundle', () => {
afterEach(() => {
unmockTracking();
+ resetHTMLFixture();
});
it('sends a tracking event when the commit button is clicked', () => {
diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js
index 9c974e79e6e..c031cae11df 100644
--- a/spec/frontend/blob_edit/edit_blob_spec.js
+++ b/spec/frontend/blob_edit/edit_blob_spec.js
@@ -1,9 +1,12 @@
+import { Emitter } from 'monaco-editor';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import EditBlob from '~/blob_edit/edit_blob';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
+import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext';
import SourceEditor from '~/editor/source_editor';
jest.mock('~/editor/source_editor');
@@ -11,11 +14,13 @@ jest.mock('~/editor/extensions/source_editor_extension_base');
jest.mock('~/editor/extensions/source_editor_file_template_ext');
jest.mock('~/editor/extensions/source_editor_markdown_ext');
jest.mock('~/editor/extensions/source_editor_markdown_livepreview_ext');
+jest.mock('~/editor/extensions/source_editor_toolbar_ext');
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
const defaultExtensions = [
{ definition: SourceEditorExtension },
{ definition: FileTemplateExtension },
+ { definition: ToolbarExtension },
];
const markdownExtensions = [
{ definition: EditorMarkdownExtension },
@@ -26,15 +31,20 @@ const markdownExtensions = [
];
describe('Blob Editing', () => {
- const useMock = jest.fn();
+ let blobInstance;
+ const useMock = jest.fn(() => markdownExtensions);
+ const unuseMock = jest.fn();
+ const emitter = new Emitter();
const mockInstance = {
use: useMock,
+ unuse: unuseMock,
setValue: jest.fn(),
getValue: jest.fn().mockReturnValue('test value'),
focus: jest.fn(),
+ onDidChangeModelLanguage: emitter.event,
};
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<form class="js-edit-blob-form">
<div id="file_path"></div>
<div id="editor"></div>
@@ -44,17 +54,18 @@ describe('Blob Editing', () => {
jest.spyOn(SourceEditor.prototype, 'createInstance').mockReturnValue(mockInstance);
});
afterEach(() => {
- SourceEditorExtension.mockClear();
- EditorMarkdownExtension.mockClear();
- EditorMarkdownPreviewExtension.mockClear();
- FileTemplateExtension.mockClear();
+ jest.clearAllMocks();
+ unuseMock.mockClear();
+ useMock.mockClear();
+ resetHTMLFixture();
});
const editorInst = (isMarkdown) => {
- return new EditBlob({
+ blobInstance = new EditBlob({
isMarkdown,
previewMarkdownPath: PREVIEW_MARKDOWN_PATH,
});
+ return blobInstance;
};
const initEditor = async (isMarkdown = false) => {
@@ -79,6 +90,22 @@ describe('Blob Editing', () => {
expect(useMock).toHaveBeenCalledTimes(2);
expect(useMock.mock.calls[1]).toEqual([markdownExtensions]);
});
+
+ it('correctly handles switching from markdown and un-uses markdown extensions', async () => {
+ await initEditor(true);
+ expect(unuseMock).not.toHaveBeenCalled();
+ await emitter.fire({ newLanguage: 'plaintext', oldLanguage: 'markdown' });
+ expect(unuseMock).toHaveBeenCalledWith(markdownExtensions);
+ });
+
+ it('correctly handles switching from non-markdown to markdown extensions', async () => {
+ const mdSpy = jest.fn();
+ await initEditor();
+ blobInstance.fetchMarkdownExtension = mdSpy;
+ expect(mdSpy).not.toHaveBeenCalled();
+ await emitter.fire({ newLanguage: 'markdown', oldLanguage: 'plaintext' });
+ expect(mdSpy).toHaveBeenCalled();
+ });
});
it('adds trailing newline to the blob content on submit', async () => {
diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js
index 677978d31ca..c6de3ee69f3 100644
--- a/spec/frontend/boards/board_card_inner_spec.js
+++ b/spec/frontend/boards/board_card_inner_spec.js
@@ -2,12 +2,15 @@ import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { range } from 'lodash';
import Vuex from 'vuex';
import { nextTick } from 'vue';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { issuableTypes } from '~/boards/constants';
+import eventHub from '~/boards/eventhub';
import defaultStore from '~/boards/stores';
+import { updateHistory } from '~/lib/utils/url_utility';
import { mockLabelList, mockIssue, mockIssueFullPath } from './mock_data';
jest.mock('~/lib/utils/url_utility');
@@ -34,7 +37,7 @@ describe('Board card component', () => {
let list;
let store;
- const findBoardBlockedIcon = () => wrapper.find(BoardBlockedIcon);
+ const findBoardBlockedIcon = () => wrapper.findComponent(BoardBlockedIcon);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findEpicCountablesTotalTooltip = () => wrapper.findComponent(GlTooltip);
const findEpicCountables = () => wrapper.findByTestId('epic-countables');
@@ -45,9 +48,14 @@ describe('Board card component', () => {
const findEpicProgressTooltip = () => wrapper.findByTestId('epic-progress-tooltip-content');
const findHiddenIssueIcon = () => wrapper.findByTestId('hidden-icon');
+ const performSearchMock = jest.fn();
+
const createStore = ({ isEpicBoard = false, isProjectBoard = false } = {}) => {
store = new Vuex.Store({
...defaultStore,
+ actions: {
+ performSearch: performSearchMock,
+ },
state: {
...defaultStore.state,
issuableType: issuableTypes.issue,
@@ -70,7 +78,6 @@ describe('Board card component', () => {
...props,
},
stubs: {
- GlLabel: true,
GlLoadingIcon: true,
},
directives: {
@@ -179,7 +186,7 @@ describe('Board card component', () => {
describe('confidential issue', () => {
beforeEach(() => {
- wrapper.setProps({
+ createWrapper({
item: {
...wrapper.props('item'),
confidential: true,
@@ -194,7 +201,7 @@ describe('Board card component', () => {
describe('hidden issue', () => {
beforeEach(() => {
- wrapper.setProps({
+ createWrapper({
item: {
...wrapper.props('item'),
hidden: true,
@@ -219,7 +226,7 @@ describe('Board card component', () => {
describe('with assignee', () => {
describe('with avatar', () => {
beforeEach(() => {
- wrapper.setProps({
+ createWrapper({
item: {
...wrapper.props('item'),
assignees: [user],
@@ -272,7 +279,7 @@ describe('Board card component', () => {
beforeEach(() => {
global.gon.default_avatar_url = 'default_avatar';
- wrapper.setProps({
+ createWrapper({
item: {
...wrapper.props('item'),
assignees: [
@@ -301,7 +308,7 @@ describe('Board card component', () => {
describe('multiple assignees', () => {
beforeEach(() => {
- wrapper.setProps({
+ createWrapper({
item: {
...wrapper.props('item'),
assignees: [
@@ -342,7 +349,7 @@ describe('Board card component', () => {
avatarUrl: 'test_image',
});
- wrapper.setProps({
+ createWrapper({
item: {
...wrapper.props('item'),
assignees,
@@ -368,7 +375,7 @@ describe('Board card component', () => {
avatarUrl: 'test_image',
})),
];
- wrapper.setProps({
+ createWrapper({
item: {
...wrapper.props('item'),
assignees,
@@ -384,31 +391,74 @@ describe('Board card component', () => {
describe('labels', () => {
beforeEach(() => {
- wrapper.setProps({ item: { ...issue, labels: [list.label, label1] } });
+ createWrapper({ item: { ...issue, labels: [list.label, label1] } });
});
it('does not render list label but renders all other labels', () => {
- expect(wrapper.findAll(GlLabel).length).toBe(1);
- const label = wrapper.find(GlLabel);
+ expect(wrapper.findAllComponents(GlLabel).length).toBe(1);
+ const label = wrapper.findComponent(GlLabel);
expect(label.props('title')).toEqual(label1.title);
expect(label.props('description')).toEqual(label1.description);
expect(label.props('backgroundColor')).toEqual(label1.color);
});
it('does not render label if label does not have an ID', async () => {
- wrapper.setProps({ item: { ...issue, labels: [label1, { title: 'closed' }] } });
+ createWrapper({ item: { ...issue, labels: [label1, { title: 'closed' }] } });
await nextTick();
- expect(wrapper.findAll(GlLabel).length).toBe(1);
+ expect(wrapper.findAllComponents(GlLabel).length).toBe(1);
expect(wrapper.text()).not.toContain('closed');
});
+ });
- describe('when label params arent set', () => {
- it('passes the right target to GlLabel', () => {
- expect(wrapper.findAll(GlLabel).at(0).props('target')).toEqual(
- '?label_name[]=testing%20123',
- );
+ describe('filterByLabel method', () => {
+ beforeEach(() => {
+ createWrapper({
+ item: {
+ ...issue,
+ labels: [label1],
+ },
+ updateFilters: true,
+ });
+ });
+
+ describe('when selected label is not in the filter', () => {
+ beforeEach(() => {
+ setWindowLocation('?');
+ wrapper.findComponent(GlLabel).vm.$emit('click', label1);
+ });
+
+ it('calls updateHistory', () => {
+ expect(updateHistory).toHaveBeenCalledTimes(1);
+ });
+
+ it('dispatches performSearch vuex action', () => {
+ expect(performSearchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits updateTokens event', () => {
+ expect(eventHub.$emit).toHaveBeenCalledTimes(1);
+ expect(eventHub.$emit).toHaveBeenCalledWith('updateTokens');
+ });
+ });
+
+ describe('when selected label is already in the filter', () => {
+ beforeEach(() => {
+ setWindowLocation('?label_name[]=testing%20123');
+ wrapper.findComponent(GlLabel).vm.$emit('click', label1);
+ });
+
+ it('does not call updateHistory', () => {
+ expect(updateHistory).not.toHaveBeenCalled();
+ });
+
+ it('does not dispatch performSearch vuex action', () => {
+ expect(performSearchMock).not.toHaveBeenCalled();
+ });
+
+ it('does not emit updateTokens event', () => {
+ expect(eventHub.$emit).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index 14870ec76a2..2f9677680eb 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -132,7 +132,7 @@ describe('Board List Header Component', () => {
const icon = findCaret();
- expect(icon.props('icon')).toBe('chevron-right');
+ expect(icon.props('icon')).toBe('chevron-down');
});
it('should display expand icon when column is collapsed', async () => {
@@ -140,7 +140,7 @@ describe('Board List Header Component', () => {
const icon = findCaret();
- expect(icon.props('icon')).toBe('chevron-down');
+ expect(icon.props('icon')).toBe('chevron-right');
});
it('should dispatch toggleListCollapse when clicking the collapse icon', async () => {
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index ec9342cffc2..26ad9790840 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -17,6 +17,10 @@ export const mockBoard = {
id: 'gid://gitlab/Iteration/124',
title: 'Iteration 9',
},
+ iterationCadence: {
+ id: 'gid://gitlab/Iteration::Cadence/134',
+ title: 'Cadence 3',
+ },
assignee: {
id: 'gid://gitlab/User/1',
username: 'admin',
@@ -32,6 +36,7 @@ export const mockBoardConfig = {
milestoneTitle: '14.9',
iterationId: 'gid://gitlab/Iteration/124',
iterationTitle: 'Iteration 9',
+ iterationCadenceId: 'gid://gitlab/Iteration::Cadence/134',
assigneeId: 'gid://gitlab/User/1',
assigneeUsername: 'admin',
labels: [{ id: 'gid://gitlab/Label/32', title: 'Deliverable' }],
diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js
index 05dc7d28eaa..bd79060c54f 100644
--- a/spec/frontend/boards/project_select_spec.js
+++ b/spec/frontend/boards/project_select_spec.js
@@ -1,7 +1,14 @@
-import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlFormInput,
+} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import waitForPromises from 'helpers/wait_for_promises';
import ProjectSelect from '~/boards/components/project_select.vue';
import defaultState from '~/boards/stores/state';
@@ -61,6 +68,7 @@ describe('ProjectSelect component', () => {
provide: {
groupId: 1,
},
+ attachTo: document.body,
});
};
@@ -120,6 +128,17 @@ describe('ProjectSelect component', () => {
it('does not render empty search result message', () => {
expect(findEmptySearchMessage().exists()).toBe(false);
});
+
+ it('focuses on the search input', async () => {
+ const dropdownToggle = findGlDropdown().find('.dropdown-toggle');
+
+ await dropdownToggle.trigger('click');
+ await waitForPromises();
+ await nextTick();
+
+ const searchInput = findGlDropdown().findComponent(GlFormInput).element;
+ expect(document.activeElement).toEqual(searchInput);
+ });
});
describe('when no projects are being returned', () => {
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
index b30968c45d7..304f2aad98e 100644
--- a/spec/frontend/boards/stores/getters_spec.js
+++ b/spec/frontend/boards/stores/getters_spec.js
@@ -215,4 +215,33 @@ describe('Boards - Getters', () => {
expect(getters.isEpicBoard()).toBe(false);
});
});
+
+ describe('hasScope', () => {
+ const boardConfig = {
+ labels: [],
+ assigneeId: null,
+ iterationCadenceId: null,
+ iterationId: null,
+ milestoneId: null,
+ weight: null,
+ };
+
+ it('returns false when boardConfig is empty', () => {
+ const state = { boardConfig };
+
+ expect(getters.hasScope(state)).toBe(false);
+ });
+
+ it('returns true when boardScope has a label', () => {
+ const state = { boardConfig: { ...boardConfig, labels: ['foo'] } };
+
+ expect(getters.hasScope(state)).toBe(true);
+ });
+
+ it('returns true when boardConfig has a value other than null', () => {
+ const state = { boardConfig: { ...boardConfig, assigneeId: 3 } };
+
+ expect(getters.hasScope(state)).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/bootstrap_jquery_spec.js b/spec/frontend/bootstrap_jquery_spec.js
index d5d592e3839..15186600a8a 100644
--- a/spec/frontend/bootstrap_jquery_spec.js
+++ b/spec/frontend/bootstrap_jquery_spec.js
@@ -1,10 +1,15 @@
import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import '~/commons/bootstrap';
describe('Bootstrap jQuery extensions', () => {
describe('disable', () => {
beforeEach(() => {
- setFixtures('<input type="text" />');
+ setHTMLFixture('<input type="text" />');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('adds the disabled attribute', () => {
@@ -24,7 +29,11 @@ describe('Bootstrap jQuery extensions', () => {
describe('enable', () => {
beforeEach(() => {
- setFixtures('<input type="text" disabled="disabled" class="disabled" />');
+ setHTMLFixture('<input type="text" disabled="disabled" class="disabled" />');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('removes the disabled attribute', () => {
diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js
index 30fb140bc69..5ee1ca32141 100644
--- a/spec/frontend/bootstrap_linked_tabs_spec.js
+++ b/spec/frontend/bootstrap_linked_tabs_spec.js
@@ -1,8 +1,13 @@
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
describe('Linked Tabs', () => {
beforeEach(() => {
- loadFixtures('static/linked_tabs.html');
+ loadHTMLFixture('static/linked_tabs.html');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
describe('when is initialized', () => {
diff --git a/spec/frontend/branches/components/delete_branch_modal_spec.js b/spec/frontend/branches/components/delete_branch_modal_spec.js
index 0c6111bda9e..2b8c8d408c4 100644
--- a/spec/frontend/branches/components/delete_branch_modal_spec.js
+++ b/spec/frontend/branches/components/delete_branch_modal_spec.js
@@ -49,7 +49,7 @@ const findForm = () => wrapper.find('form');
describe('Delete branch modal', () => {
const expectedUnmergedWarning =
- 'This branch hasn’t been merged into default. To avoid data loss, consider merging this branch before deleting it.';
+ "This branch hasn't been merged into default. To avoid data loss, consider merging this branch before deleting it.";
afterEach(() => {
wrapper.destroy();
@@ -110,7 +110,7 @@ describe('Delete branch modal', () => {
"You're about to permanently delete the protected branch test_modal.";
const expectedMessageProtected = `${expectedWarningProtected} ${expectedUnmergedWarning}`;
const expectedConfirmationText =
- 'Once you confirm and press Yes, delete protected branch, it cannot be undone or recovered. Please type the following to confirm: test_modal';
+ 'After you confirm and select Yes, delete protected branch, you cannot recover this branch. Please type the following to confirm: test_modal';
beforeEach(() => {
createComponent({ isProtectedBranch: true });
diff --git a/spec/frontend/broadcast_notification_spec.js b/spec/frontend/broadcast_notification_spec.js
index cd947cd417a..5b9541dedfb 100644
--- a/spec/frontend/broadcast_notification_spec.js
+++ b/spec/frontend/broadcast_notification_spec.js
@@ -1,4 +1,5 @@
-import Cookies from 'js-cookie';
+import Cookies from '~/lib/utils/cookies';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initBroadcastNotifications from '~/broadcast_notification';
describe('broadcast message on dismiss', () => {
@@ -9,7 +10,7 @@ describe('broadcast message on dismiss', () => {
const endsAt = '2020-01-01T00:00:00Z';
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class="js-broadcast-notification-1">
<button class="js-dismiss-current-broadcast-notification" data-id="1" data-expire-date="${endsAt}"></button>
</div>
@@ -18,6 +19,10 @@ describe('broadcast message on dismiss', () => {
initBroadcastNotifications();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('removes broadcast message', () => {
dismiss();
diff --git a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
index 042376c71e8..ad5f8a56ced 100644
--- a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
+++ b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
@@ -10,6 +10,7 @@ import { secureFiles } from '../mock_data';
const dummyApiVersion = 'v3000';
const dummyProjectId = 1;
+const fileSizeLimit = 5;
const dummyUrlRoot = '/gitlab';
const dummyGon = {
api_version: dummyApiVersion,
@@ -33,9 +34,13 @@ describe('SecureFilesList', () => {
window.gon = originalGon;
});
- const createWrapper = (props = {}) => {
+ const createWrapper = (admin = true, props = {}) => {
wrapper = mount(SecureFilesList, {
- provide: { projectId: dummyProjectId },
+ provide: {
+ projectId: dummyProjectId,
+ admin,
+ fileSizeLimit,
+ },
...props,
});
};
@@ -46,6 +51,8 @@ describe('SecureFilesList', () => {
const findHeaderAt = (i) => wrapper.findAll('thead th').at(i);
const findPagination = () => wrapper.findAll('ul.pagination');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findUploadButton = () => wrapper.findAll('span.gl-button-text');
+ const findDeleteButton = () => wrapper.findAll('tbody tr td button.btn-danger');
describe('when secure files exist in a project', () => {
beforeEach(async () => {
@@ -57,7 +64,7 @@ describe('SecureFilesList', () => {
});
it('displays a table with expected headers', () => {
- const headers = ['Filename', 'Permissions', 'Uploaded'];
+ const headers = ['Filename', 'Uploaded'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
@@ -69,8 +76,7 @@ describe('SecureFilesList', () => {
const [secureFile] = secureFiles;
expect(findCell(0, 0).text()).toBe(secureFile.name);
- expect(findCell(0, 1).text()).toBe(secureFile.permissions);
- expect(findCell(0, 2).find(TimeAgoTooltip).props('time')).toBe(secureFile.created_at);
+ expect(findCell(0, 1).find(TimeAgoTooltip).props('time')).toBe(secureFile.created_at);
});
});
@@ -84,7 +90,7 @@ describe('SecureFilesList', () => {
});
it('displays a table with expected headers', () => {
- const headers = ['Filename', 'Permissions', 'Uploaded'];
+ const headers = ['Filename', 'Uploaded'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
@@ -136,4 +142,42 @@ describe('SecureFilesList', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
+
+ describe('admin permissions', () => {
+ describe('with admin permissions', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onGet(expectedUrl).reply(200, secureFiles);
+
+ createWrapper();
+ await waitForPromises();
+ });
+
+ it('displays the upload button', () => {
+ expect(findUploadButton().exists()).toBe(true);
+ });
+
+ it('displays a delete button', () => {
+ expect(findDeleteButton().exists()).toBe(true);
+ });
+ });
+
+ describe('without admin permissions', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onGet(expectedUrl).reply(200, secureFiles);
+
+ createWrapper(false);
+ await waitForPromises();
+ });
+
+ it('does not display the upload button', () => {
+ expect(findUploadButton().exists()).toBe(false);
+ });
+
+ it('does not display a delete button', () => {
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
index 1bca21b1d57..2210b0f48d6 100644
--- a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
+++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import VariableList from '~/ci_variable_list/ci_variable_list';
const HIDE_CLASS = 'hide';
@@ -10,7 +11,7 @@ describe('VariableList', () => {
describe('with only key/value inputs', () => {
describe('with no variables', () => {
beforeEach(() => {
- loadFixtures('pipeline_schedules/edit.html');
+ loadHTMLFixture('pipeline_schedules/edit.html');
$wrapper = $('.js-ci-variable-list-section');
variableList = new VariableList({
@@ -20,6 +21,10 @@ describe('VariableList', () => {
variableList.init();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should remove the row when clicking the remove button', () => {
$wrapper.find('.js-row-remove-button').trigger('click');
@@ -64,7 +69,7 @@ describe('VariableList', () => {
describe('with persisted variables', () => {
beforeEach(() => {
- loadFixtures('pipeline_schedules/edit_with_variables.html');
+ loadHTMLFixture('pipeline_schedules/edit_with_variables.html');
$wrapper = $('.js-ci-variable-list-section');
variableList = new VariableList({
@@ -74,6 +79,10 @@ describe('VariableList', () => {
variableList.init();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should have "Reveal values" button initially when there are already variables', () => {
expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values');
});
@@ -97,7 +106,7 @@ describe('VariableList', () => {
describe('toggleEnableRow method', () => {
beforeEach(() => {
- loadFixtures('pipeline_schedules/edit_with_variables.html');
+ loadHTMLFixture('pipeline_schedules/edit_with_variables.html');
$wrapper = $('.js-ci-variable-list-section');
variableList = new VariableList({
@@ -107,6 +116,10 @@ describe('VariableList', () => {
variableList.init();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should disable all key inputs', () => {
expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
index eee1362440d..57f666e29d6 100644
--- a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
+++ b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
@@ -1,11 +1,12 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
describe('NativeFormVariableList', () => {
let $wrapper;
beforeEach(() => {
- loadFixtures('pipeline_schedules/edit.html');
+ loadHTMLFixture('pipeline_schedules/edit.html');
$wrapper = $('.js-ci-variable-list-section');
setupNativeFormVariableList({
@@ -14,6 +15,10 @@ describe('NativeFormVariableList', () => {
});
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('onFormSubmit', () => {
it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => {
const $row = $wrapper.find('.js-row');
diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
index 2fedbbecd64..d26378d9382 100644
--- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
@@ -5,7 +5,12 @@ import Vuex from 'vuex';
import { mockTracking } from 'helpers/tracking_helper';
import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue';
import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue';
-import { AWS_ACCESS_KEY_ID, EVENT_LABEL, EVENT_ACTION } from '~/ci_variable_list/constants';
+import {
+ AWS_ACCESS_KEY_ID,
+ EVENT_LABEL,
+ EVENT_ACTION,
+ ENVIRONMENT_SCOPE_LINK_TITLE,
+} from '~/ci_variable_list/constants';
import createStore from '~/ci_variable_list/store';
import mockData from '../services/mock_data';
import ModalStub from '../stubs';
@@ -20,7 +25,11 @@ describe('Ci variable modal', () => {
const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$';
const createComponent = (method, options = {}) => {
- store = createStore({ maskableRegex, isGroup: options.isGroup });
+ store = createStore({
+ maskableRegex,
+ isGroup: options.isGroup,
+ environmentScopeLink: '/help/environments',
+ });
wrapper = method(CiVariableModal, {
attachTo: document.body,
stubs: {
@@ -213,6 +222,15 @@ describe('Ci variable modal', () => {
});
});
});
+
+ it('renders a link to documentation on scopes', () => {
+ createComponent(mount);
+
+ const link = wrapper.find('[data-testid="environment-scope-link"]');
+
+ expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE);
+ expect(link.attributes('href')).toBe('/help/environments');
+ });
});
describe('Validations', () => {
diff --git a/spec/frontend/clusters/agents/components/revoke_token_button_spec.js b/spec/frontend/clusters/agents/components/revoke_token_button_spec.js
new file mode 100644
index 00000000000..6521221cbd7
--- /dev/null
+++ b/spec/frontend/clusters/agents/components/revoke_token_button_spec.js
@@ -0,0 +1,239 @@
+import { GlButton, GlModal, GlFormInput, GlTooltip } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { ENTER_KEY } from '~/lib/utils/keys';
+import RevokeTokenButton from '~/clusters/agents/components/revoke_token_button.vue';
+import getClusterAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql';
+import revokeTokenMutation from '~/clusters/agents/graphql/mutations/revoke_token.mutation.graphql';
+import { TOKEN_STATUS_ACTIVE, MAX_LIST_COUNT } from '~/clusters/agents/constants';
+import { getTokenResponse, mockRevokeResponse, mockErrorRevokeResponse } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('RevokeTokenButton', () => {
+ let wrapper;
+ let toast;
+ let apolloProvider;
+ let revokeSpy;
+
+ const token = {
+ id: 'token-id',
+ name: 'token-name',
+ };
+ const cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ };
+ const agentName = 'cluster-agent';
+ const projectPath = 'path/to/project';
+
+ const defaultProvide = {
+ agentName,
+ projectPath,
+ canAdminCluster: true,
+ };
+ const propsData = {
+ token,
+ cursor,
+ };
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findRevokeBtn = () => wrapper.findComponent(GlButton);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
+ const findPrimaryAction = () => findModal().props('actionPrimary');
+ const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr];
+
+ const createMockApolloProvider = ({ mutationResponse }) => {
+ revokeSpy = jest.fn().mockResolvedValue(mutationResponse);
+
+ return createMockApollo([[revokeTokenMutation, revokeSpy]]);
+ };
+
+ const writeQuery = () => {
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: getClusterAgentQuery,
+ variables: {
+ agentName,
+ projectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
+ ...cursor,
+ },
+ data: getTokenResponse.data,
+ });
+ };
+
+ const createWrapper = async ({
+ mutationResponse = mockRevokeResponse,
+ provideData = {},
+ } = {}) => {
+ apolloProvider = createMockApolloProvider({ mutationResponse });
+
+ toast = jest.fn();
+
+ wrapper = shallowMountExtended(RevokeTokenButton, {
+ apolloProvider,
+ provide: {
+ ...defaultProvide,
+ ...provideData,
+ },
+ propsData,
+ stubs: {
+ GlModal,
+ GlTooltip,
+ },
+ mocks: { $toast: { show: toast } },
+ });
+ wrapper.vm.$refs.modal.hide = jest.fn();
+
+ writeQuery();
+ await nextTick();
+ };
+
+ const submitTokenToRevoke = async () => {
+ findRevokeBtn().vm.$emit('click');
+ findInput().vm.$emit('input', token.name);
+ await findModal().vm.$emit('primary');
+ await waitForPromises();
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ apolloProvider = null;
+ revokeSpy = null;
+ });
+
+ describe('revoke token action', () => {
+ it('displays a revoke button', () => {
+ expect(findRevokeBtn().attributes('aria-label')).toBe('Revoke token');
+ });
+
+ describe('when user cannot revoke token', () => {
+ beforeEach(() => {
+ createWrapper({ provideData: { canAdminCluster: false } });
+ });
+
+ it('disabled the button', () => {
+ expect(findRevokeBtn().attributes('disabled')).toBe('true');
+ });
+
+ it('shows a disabled tooltip', () => {
+ expect(findTooltip().attributes('title')).toBe(
+ 'Requires a Maintainer or greater role to perform this action',
+ );
+ });
+ });
+
+ describe('when user can create a token and clicks the button', () => {
+ beforeEach(() => {
+ findRevokeBtn().vm.$emit('click');
+ });
+
+ it('displays a delete confirmation modal', () => {
+ expect(findModal().isVisible()).toBe(true);
+ });
+
+ describe.each`
+ condition | tokenName | isDisabled | mutationCalled
+ ${'the input with token name is missing'} | ${''} | ${true} | ${false}
+ ${'the input with token name is incorrect'} | ${'wrong-name'} | ${true} | ${false}
+ ${'the input with token name is correct'} | ${token.name} | ${false} | ${true}
+ `('when $condition', ({ tokenName, isDisabled, mutationCalled }) => {
+ beforeEach(() => {
+ findRevokeBtn().vm.$emit('click');
+ findInput().vm.$emit('input', tokenName);
+ });
+
+ it(`${isDisabled ? 'disables' : 'enables'} the modal primary button`, () => {
+ expect(findPrimaryActionAttributes('disabled')).toBe(isDisabled);
+ });
+
+ describe('when user clicks the modal primary button', () => {
+ beforeEach(async () => {
+ await findModal().vm.$emit('primary');
+ });
+
+ if (mutationCalled) {
+ it('calls the revoke mutation', () => {
+ expect(revokeSpy).toHaveBeenCalledWith({ input: { id: token.id } });
+ });
+ } else {
+ it("doesn't call the revoke mutation", () => {
+ expect(revokeSpy).not.toHaveBeenCalled();
+ });
+ }
+ });
+
+ describe('when user presses the enter button', () => {
+ beforeEach(async () => {
+ await findInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+ });
+
+ if (mutationCalled) {
+ it('calls the revoke mutation', () => {
+ expect(revokeSpy).toHaveBeenCalledWith({ input: { id: token.id } });
+ });
+ } else {
+ it("doesn't call the revoke mutation", () => {
+ expect(revokeSpy).not.toHaveBeenCalled();
+ });
+ }
+ });
+ });
+ });
+
+ describe('when token was revoked successfully', () => {
+ beforeEach(async () => {
+ await submitTokenToRevoke();
+ });
+
+ it('calls the toast action', () => {
+ expect(toast).toHaveBeenCalledWith(`${token.name} successfully revoked`);
+ });
+ });
+
+ describe('when getting an error revoking token', () => {
+ beforeEach(async () => {
+ await createWrapper({ mutationResponse: mockErrorRevokeResponse });
+ await submitTokenToRevoke();
+ });
+
+ it('displays the error message', () => {
+ expect(toast).toHaveBeenCalledWith('could not revoke token');
+ });
+ });
+
+ describe('when the revoke modal was closed', () => {
+ beforeEach(async () => {
+ const loadingResponse = new Promise(() => {});
+ await createWrapper({ mutationResponse: loadingResponse });
+ await submitTokenToRevoke();
+ });
+
+ it('reenables the button', async () => {
+ expect(findPrimaryActionAttributes('loading')).toBe(true);
+ expect(findRevokeBtn().attributes('disabled')).toBe('true');
+
+ await findModal().vm.$emit('hide');
+
+ expect(findPrimaryActionAttributes('loading')).toBe(false);
+ expect(findRevokeBtn().attributes('disabled')).toBeUndefined();
+ });
+
+ it('clears the token name input', async () => {
+ expect(findInput().attributes('value')).toBe(token.name);
+
+ await findModal().vm.$emit('hide');
+
+ expect(findInput().attributes('value')).toBeUndefined();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js
index 2a0610b1b0a..b5345ea8915 100644
--- a/spec/frontend/clusters/clusters_bundle_spec.js
+++ b/spec/frontend/clusters/clusters_bundle_spec.js
@@ -1,5 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
-import { loadHTMLFixture } from 'helpers/fixtures';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { setTestTimeout } from 'helpers/timeout';
import Clusters from '~/clusters/clusters_bundle';
@@ -27,19 +27,17 @@ describe('Clusters', () => {
beforeEach(() => {
loadHTMLFixture('clusters/show_cluster.html');
- });
- beforeEach(() => {
mockGetClusterStatusRequest();
- });
- beforeEach(() => {
cluster = new Clusters();
});
afterEach(() => {
cluster.destroy();
mock.restore();
+
+ resetHTMLFixture();
});
describe('class constructor', () => {
diff --git a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
index 0bec2a5934e..656e72baf77 100644
--- a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
+++ b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
@@ -3,7 +3,7 @@
exports[`NewCluster renders the cluster component correctly 1`] = `
"<div class=\\"gl-pt-4\\">
<h4>Enter your Kubernetes cluster certificate details</h4>
- <p>Enter details about your cluster. <b-link-stub href=\\"/some/help/path\\" target=\\"_blank\\" event=\\"click\\" routertag=\\"a\\" class=\\"gl-link\\">How do I use a certificate to connect to my cluster?</b-link-stub>
+ <p>Enter details about your cluster. <b-link-stub href=\\"/help/user/project/clusters/add_existing_cluster\\" event=\\"click\\" routertag=\\"a\\" class=\\"gl-link\\">How do I use a certificate to connect to my cluster?</b-link-stub>
</p>
</div>"
`;
diff --git a/spec/frontend/clusters/components/new_cluster_spec.js b/spec/frontend/clusters/components/new_cluster_spec.js
index b62e678154c..f9df70b9f87 100644
--- a/spec/frontend/clusters/components/new_cluster_spec.js
+++ b/spec/frontend/clusters/components/new_cluster_spec.js
@@ -2,15 +2,13 @@ import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import NewCluster from '~/clusters/components/new_cluster.vue';
-import createClusterStore from '~/clusters/stores/new_cluster';
+import { helpPagePath } from '~/helpers/help_page_helper';
describe('NewCluster', () => {
- let store;
let wrapper;
const createWrapper = async () => {
- store = createClusterStore({ clusterConnectHelpPath: '/some/help/path' });
- wrapper = shallowMount(NewCluster, { store, stubs: { GlLink, GlSprintf } });
+ wrapper = shallowMount(NewCluster, { stubs: { GlLink, GlSprintf } });
await nextTick();
};
@@ -35,6 +33,8 @@ describe('NewCluster', () => {
});
it('renders a valid help link set by the backend', () => {
- expect(findLink().attributes('href')).toBe('/some/help/path');
+ expect(findLink().attributes('href')).toBe(
+ helpPagePath('user/project/clusters/add_existing_cluster'),
+ );
});
});
diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js
index dd278bcd2ce..67d442bfdc5 100644
--- a/spec/frontend/clusters/forms/components/integration_form_spec.js
+++ b/spec/frontend/clusters/forms/components/integration_form_spec.js
@@ -22,7 +22,7 @@ describe('ClusterIntegrationForm', () => {
store: createStore(storeValues),
provide: {
autoDevopsHelpPath: 'topics/autodevops/index',
- externalEndpointHelpPath: 'user/clusters/applications.md',
+ externalEndpointHelpPath: 'user/project/clusters/index.md#base-domain',
},
});
};
diff --git a/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js b/spec/frontend/clusters/gke_cluster_namespace/gke_cluster_namespace_spec.js
index c22167a078c..eeb876a608f 100644
--- a/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js
+++ b/spec/frontend/clusters/gke_cluster_namespace/gke_cluster_namespace_spec.js
@@ -1,4 +1,5 @@
-import initGkeNamespace from '~/create_cluster/gke_cluster_namespace';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import initGkeNamespace from '~/clusters/gke_cluster_namespace';
describe('GKE cluster namespace', () => {
const changeEvent = new Event('change');
@@ -10,7 +11,7 @@ describe('GKE cluster namespace', () => {
let glManaged;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<input class="js-gl-managed" type="checkbox" value="1" checked />
<div class="js-namespace">
<input type="text" />
@@ -27,6 +28,10 @@ describe('GKE cluster namespace', () => {
initGkeNamespace();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('GKE cluster namespace toggles', () => {
it('initially displays the GitLab-managed label and input', () => {
expect(isHidden(glManaged)).toEqual(false);
diff --git a/spec/frontend/clusters/mock_data.js b/spec/frontend/clusters/mock_data.js
index 63840486d0d..f3736f03e03 100644
--- a/spec/frontend/clusters/mock_data.js
+++ b/spec/frontend/clusters/mock_data.js
@@ -220,3 +220,15 @@ export const getTokenResponse = {
},
},
};
+
+export const mockRevokeResponse = {
+ data: { clusterAgentTokenRevoke: { errors: [] } },
+};
+
+export const mockErrorRevokeResponse = {
+ data: {
+ clusterAgentTokenRevoke: {
+ errors: ['could not revoke token'],
+ },
+ },
+};
diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js
index a466a35428a..2a43b45a2f5 100644
--- a/spec/frontend/clusters_list/components/agent_table_spec.js
+++ b/spec/frontend/clusters_list/components/agent_table_spec.js
@@ -13,6 +13,7 @@ const defaultConfigHelpUrl =
const provideData = {
gitlabVersion: '14.8',
+ kasVersion: '14.8',
};
const propsData = {
agents: clusterAgents,
@@ -26,7 +27,7 @@ const outdatedTitle = I18N_AGENT_TABLE.versionOutdatedTitle;
const mismatchTitle = I18N_AGENT_TABLE.versionMismatchTitle;
const mismatchOutdatedTitle = I18N_AGENT_TABLE.versionMismatchOutdatedTitle;
const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, {
- version: provideData.gitlabVersion,
+ version: provideData.kasVersion,
});
const mismatchText = I18N_AGENT_TABLE.versionMismatchText;
diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js
index 21dcc66c639..f4ee3f93cb5 100644
--- a/spec/frontend/clusters_list/components/clusters_actions_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ClustersActions from '~/clusters_list/components/clusters_actions.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -7,12 +7,10 @@ import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '~/clusters_list/consta
describe('ClustersActionsComponent', () => {
let wrapper;
- const newClusterPath = 'path/to/add/cluster';
const addClusterPath = 'path/to/connect/existing/cluster';
const newClusterDocsPath = 'path/to/create/new/cluster';
const defaultProvide = {
- newClusterPath,
addClusterPath,
newClusterDocsPath,
canAddCluster: true,
@@ -20,13 +18,13 @@ describe('ClustersActionsComponent', () => {
certificateBasedClustersEnabled: true,
};
+ const findButton = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findTooltip = () => wrapper.findComponent(GlTooltip);
const findDropdownItemIds = () =>
findDropdownItems().wrappers.map((x) => x.attributes('data-testid'));
const findDropdownItemTexts = () => findDropdownItems().wrappers.map((x) => x.text());
- const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link');
const findNewClusterDocsLink = () => wrapper.findByTestId('create-cluster-link');
const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link');
@@ -62,26 +60,6 @@ describe('ClustersActionsComponent', () => {
expect(findTooltip().exists()).toBe(false);
});
- describe('when user cannot add clusters', () => {
- beforeEach(() => {
- createWrapper({ canAddCluster: false });
- });
-
- it('disables dropdown', () => {
- expect(findDropdown().props('disabled')).toBe(true);
- });
-
- it('shows tooltip explaining why dropdown is disabled', () => {
- expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint);
- });
-
- it('does not bind split dropdown button', () => {
- const binding = getBinding(findDropdown().element, 'gl-modal-directive');
-
- expect(binding.value).toBe(false);
- });
- });
-
describe('when on project level', () => {
it(`displays default action as ${CLUSTERS_ACTIONS.connectWithAgent}`, () => {
expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectWithAgent);
@@ -93,27 +71,41 @@ describe('ClustersActionsComponent', () => {
expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
});
- it('renders a dropdown with 3 actions items', () => {
- expect(findDropdownItemIds()).toEqual([
- 'create-cluster-link',
- 'new-cluster-link',
- 'connect-cluster-link',
- ]);
+ it('renders a dropdown with 2 actions items', () => {
+ expect(findDropdownItemIds()).toEqual(['create-cluster-link', 'connect-cluster-link']);
});
it('renders correct texts for the dropdown items', () => {
expect(findDropdownItemTexts()).toEqual([
CLUSTERS_ACTIONS.createCluster,
- CLUSTERS_ACTIONS.createClusterCertificate,
CLUSTERS_ACTIONS.connectClusterCertificate,
]);
});
it('renders correct href attributes for the links', () => {
expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath);
- expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath);
});
+
+ describe('when user cannot add clusters', () => {
+ beforeEach(() => {
+ createWrapper({ canAddCluster: false });
+ });
+
+ it('disables dropdown', () => {
+ expect(findDropdown().props('disabled')).toBe(true);
+ });
+
+ it('shows tooltip explaining why dropdown is disabled', () => {
+ expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.actionsDisabledHint);
+ });
+
+ it('does not bind split dropdown button', () => {
+ const binding = getBinding(findDropdown().element, 'gl-modal-directive');
+
+ expect(binding.value).toBe(false);
+ });
+ });
});
describe('when on group or admin level', () => {
@@ -121,26 +113,34 @@ describe('ClustersActionsComponent', () => {
createWrapper({ displayClusterAgents: false });
});
- it(`displays default action as ${CLUSTERS_ACTIONS.connectClusterDeprecated}`, () => {
- expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectClusterDeprecated);
+ it("doesn't render a dropdown", () => {
+ expect(findDropdown().exists()).toBe(false);
});
- it('renders a dropdown with 1 action item', () => {
- expect(findDropdownItemIds()).toEqual(['new-cluster-link']);
+ it('render an action button', () => {
+ expect(findButton().exists()).toBe(true);
});
- it('renders correct text for the dropdown item', () => {
- expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createClusterDeprecated]);
+ it(`displays default action as ${CLUSTERS_ACTIONS.connectClusterDeprecated}`, () => {
+ expect(findButton().text()).toBe(CLUSTERS_ACTIONS.connectClusterDeprecated);
});
- it('renders correct href attributes for the links', () => {
- expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
+ it('renders correct href attribute for the button', () => {
+ expect(findButton().attributes('href')).toBe(addClusterPath);
});
- it('does not bind dropdown button to modal', () => {
- const binding = getBinding(findDropdown().element, 'gl-modal-directive');
+ describe('when user cannot add clusters', () => {
+ beforeEach(() => {
+ createWrapper({ displayClusterAgents: false, canAddCluster: false });
+ });
+
+ it('disables action button', () => {
+ expect(findButton().props('disabled')).toBe(true);
+ });
- expect(binding.value).toBe(false);
+ it('shows tooltip explaining why dropdown is disabled', () => {
+ expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.actionsDisabledHint);
+ });
});
});
});
diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js
index f2f97092c5a..b85047dc816 100644
--- a/spec/frontend/code_navigation/components/app_spec.js
+++ b/spec/frontend/code_navigation/components/app_spec.js
@@ -1,6 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
+
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import App from '~/code_navigation/components/app.vue';
import Popover from '~/code_navigation/components/popover.vue';
import createState from '~/code_navigation/store/state';
@@ -75,12 +77,14 @@ describe('Code navigation app component', () => {
});
it('calls showDefinition when clicking blob viewer', () => {
- setFixtures('<div class="blob-viewer"></div>');
+ setHTMLFixture('<div class="blob-viewer"></div>');
factory();
document.querySelector('.blob-viewer').click();
expect(showDefinition).toHaveBeenCalled();
+
+ resetHTMLFixture();
});
});
diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js
index c26416aca94..c47a9e697b6 100644
--- a/spec/frontend/code_navigation/store/actions_spec.js
+++ b/spec/frontend/code_navigation/store/actions_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import testAction from 'helpers/vuex_action_helper';
import actions from '~/code_navigation/store/actions';
import { setCurrentHoverElement, addInteractionClass } from '~/code_navigation/utils';
@@ -174,12 +175,16 @@ describe('Code navigation actions', () => {
let target;
beforeEach(() => {
- setFixtures(
+ setHTMLFixture(
'<div data-path="index.js"><div class="line"><div class="js-test"></div></div></div>',
);
target = document.querySelector('.js-test');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('returns early when no data exists', () => {
return testAction(actions.showDefinition, { target }, {}, [], []);
});
diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js
index 682c8bce8c5..b8448709f0b 100644
--- a/spec/frontend/code_navigation/utils/index_spec.js
+++ b/spec/frontend/code_navigation/utils/index_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import {
cachedData,
getCurrentHoverElement,
@@ -35,11 +36,15 @@ describe('setCurrentHoverElement', () => {
describe('addInteractionClass', () => {
beforeEach(() => {
- setFixtures(
+ setHTMLFixture(
'<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"><span>console</span><span>.</span><span>log</span></div><div id="LC2" class="line"><span>function</span></div></div></div>',
);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it.each`
line | char | index
${0} | ${0} | ${0}
@@ -59,7 +64,7 @@ describe('addInteractionClass', () => {
describe('wrapTextNodes', () => {
beforeEach(() => {
- setFixtures(
+ setHTMLFixture(
'<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"> Text </div></div></div>',
);
});
diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js
index a049a6997f0..db1516ed4ec 100644
--- a/spec/frontend/commits_spec.js
+++ b/spec/frontend/commits_spec.js
@@ -1,6 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import 'vendor/jquery.endless-scroll';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import CommitsList from '~/commits';
import axios from '~/lib/utils/axios_utils';
import Pager from '~/pager';
@@ -9,7 +10,7 @@ describe('Commits List', () => {
let commitsList;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/main">
<input id="commits-search">
</form>
@@ -19,6 +20,10 @@ describe('Commits List', () => {
commitsList = new CommitsList(25);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should be defined', () => {
expect(CommitsList).toBeDefined();
});
diff --git a/spec/frontend/commons/nav/user_merge_requests_spec.js b/spec/frontend/commons/nav/user_merge_requests_spec.js
index 8f974051232..f660cc8e9de 100644
--- a/spec/frontend/commons/nav/user_merge_requests_spec.js
+++ b/spec/frontend/commons/nav/user_merge_requests_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import * as UserApi from '~/api/user_api';
import {
openUserCountsBroadcast,
@@ -24,11 +25,15 @@ describe('User Merge Requests', () => {
newBroadcastChannelMock = jest.fn().mockImplementation(() => channelMock);
global.BroadcastChannel = newBroadcastChannelMock;
- setFixtures(
+ setHTMLFixture(
`<div><div class="${MR_COUNT_CLASS}">0</div><div class="js-assigned-mr-count"></div><div class="js-reviewer-mr-count"></div></div>`,
);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
const findMRCountText = () => document.body.querySelector(`.${MR_COUNT_CLASS}`).textContent;
describe('refreshUserMergeRequestCounts', () => {
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
index e508cddd6f9..a63cca006da 100644
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
+++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`content_editor/components/toolbar_button displays tertiary, small button with a provided label and icon 1`] = `
-"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-button btn-default-tertiary btn-icon\\">
+exports[`content_editor/components/toolbar_button displays tertiary, medium button with a provided label and icon 1`] = `
+"<b-button-stub size=\\"md\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-button btn-default-tertiary btn-icon\\">
<!---->
<gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub>
<!---->
diff --git a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js
index 074c311495f..3a15ea45f40 100644
--- a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js
@@ -1,14 +1,14 @@
import { BubbleMenu } from '@tiptap/vue-2';
-import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import Vue from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue';
+import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader';
-import { createTestEditor, emitEditorEvent } from '../test_utils';
+import { createTestEditor, emitEditorEvent } from '../../test_utils';
-describe('content_editor/components/code_block_bubble_menu', () => {
+describe('content_editor/components/bubble_menus/code_block', () => {
let wrapper;
let tiptapEditor;
let bubbleMenu;
@@ -52,7 +52,7 @@ describe('content_editor/components/code_block_bubble_menu', () => {
await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
- expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']);
+ expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
});
it('selects plaintext language by default', async () => {
@@ -82,12 +82,26 @@ describe('content_editor/components/code_block_bubble_menu', () => {
expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)');
});
- it('delete button deletes the code block', async () => {
- tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
+ describe('copy button', () => {
+ it('copies the text of the code block', async () => {
+ jest.spyOn(navigator.clipboard, 'writeText');
+
+ tiptapEditor.commands.insertContent('<pre lang="javascript">var a = Math.PI / 2;</pre>');
+
+ await wrapper.findByTestId('copy-code-block').vm.$emit('click');
- await wrapper.findComponent(GlButton).vm.$emit('click');
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('var a = Math.PI / 2;');
+ });
+ });
- expect(tiptapEditor.getText()).toBe('');
+ describe('delete button', () => {
+ it('deletes the code block', async () => {
+ tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
+
+ await wrapper.findByTestId('delete-code-block').vm.$emit('click');
+
+ expect(tiptapEditor.getText()).toBe('');
+ });
});
describe('when opened and search is changed', () => {
@@ -110,7 +124,7 @@ describe('content_editor/components/code_block_bubble_menu', () => {
describe('when dropdown item is clicked', () => {
beforeEach(async () => {
- jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue();
+ jest.spyOn(codeBlockLanguageLoader, 'loadLanguage').mockResolvedValue();
findDropdownItems().at(1).vm.$emit('click');
@@ -118,7 +132,7 @@ describe('content_editor/components/code_block_bubble_menu', () => {
});
it('loads language', () => {
- expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']);
+ expect(codeBlockLanguageLoader.loadLanguage).toHaveBeenCalledWith('java');
});
it('sets code block', () => {
diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js
index 192ddee78c6..6479c0ba008 100644
--- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js
@@ -1,15 +1,15 @@
import { BubbleMenu } from '@tiptap/vue-2';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue';
+import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue';
import {
BUBBLE_MENU_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
-import { createTestEditor } from '../test_utils';
+import { createTestEditor } from '../../test_utils';
-describe('content_editor/components/formatting_bubble_menu', () => {
+describe('content_editor/components/bubble_menus/formatting', () => {
let wrapper;
let trackingSpy;
let tiptapEditor;
@@ -42,15 +42,16 @@ describe('content_editor/components/formatting_bubble_menu', () => {
const bubbleMenu = wrapper.findComponent(BubbleMenu);
expect(bubbleMenu.props().editor).toBe(tiptapEditor);
- expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']);
+ expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
});
describe.each`
testId | controlProps
- ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'primary' }}
- ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'primary' }}
- ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'primary' }}
- ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'primary' }}
+ ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'tertiary' }}
+ ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'tertiary' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'tertiary' }}
+ ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'tertiary' }}
+ ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' }, size: 'medium', category: 'tertiary' }}
`('given a $testId toolbar control', ({ testId, controlProps }) => {
beforeEach(() => {
buildWrapper();
@@ -60,7 +61,7 @@ describe('content_editor/components/formatting_bubble_menu', () => {
expect(wrapper.findByTestId(testId).exists()).toBe(true);
Object.keys(controlProps).forEach((propName) => {
- expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]);
+ expect(wrapper.findByTestId(testId).props(propName)).toEqual(controlProps[propName]);
});
});
diff --git a/spec/frontend/content_editor/components/bubble_menus/link_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_spec.js
new file mode 100644
index 00000000000..ba6d8da9584
--- /dev/null
+++ b/spec/frontend/content_editor/components/bubble_menus/link_spec.js
@@ -0,0 +1,227 @@
+import { GlLink, GlForm } from '@gitlab/ui';
+import { BubbleMenu } from '@tiptap/vue-2';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link.vue';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import Link from '~/content_editor/extensions/link';
+import { createTestEditor, emitEditorEvent } from '../../test_utils';
+
+const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() });
+
+describe('content_editor/components/bubble_menus/link', () => {
+ let wrapper;
+ let tiptapEditor;
+ let contentEditor;
+ let bubbleMenu;
+ let eventHub;
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor({ extensions: [Link] });
+ contentEditor = { resolveUrl: jest.fn() };
+ eventHub = eventHubFactory();
+ };
+
+ const buildWrapper = () => {
+ wrapper = mountExtended(LinkBubbleMenu, {
+ provide: {
+ tiptapEditor,
+ contentEditor,
+ eventHub,
+ },
+ });
+ };
+
+ const expectLinkButtonsToExist = (exist = true) => {
+ expect(wrapper.findComponent(GlLink).exists()).toBe(exist);
+ expect(wrapper.findByTestId('copy-link-url').exists()).toBe(exist);
+ expect(wrapper.findByTestId('edit-link').exists()).toBe(exist);
+ expect(wrapper.findByTestId('remove-link').exists()).toBe(exist);
+ };
+
+ beforeEach(async () => {
+ buildEditor();
+ buildWrapper();
+
+ tiptapEditor
+ .chain()
+ .insertContent(
+ 'Download <a href="/path/to/project/-/wikis/uploads/my_file.pdf" data-canonical-src="uploads/my_file.pdf" title="Click here to download">PDF File</a>',
+ )
+ .setTextSelection(14) // put cursor in the middle of the link
+ .run();
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders bubble menu component', async () => {
+ expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
+ expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
+ });
+
+ it('shows a clickable link to the URL in the link node', async () => {
+ const link = wrapper.findComponent(GlLink);
+ expect(link.attributes()).toEqual(
+ expect.objectContaining({
+ href: '/path/to/project/-/wikis/uploads/my_file.pdf',
+ 'aria-label': 'uploads/my_file.pdf',
+ title: 'uploads/my_file.pdf',
+ target: '_blank',
+ }),
+ );
+ expect(link.text()).toBe('uploads/my_file.pdf');
+ });
+
+ describe('copy button', () => {
+ it('copies the canonical link to clipboard', async () => {
+ jest.spyOn(navigator.clipboard, 'writeText');
+
+ await wrapper.findByTestId('copy-link-url').vm.$emit('click');
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('uploads/my_file.pdf');
+ });
+ });
+
+ describe('remove link button', () => {
+ it('removes the link', async () => {
+ await wrapper.findByTestId('remove-link').vm.$emit('click');
+
+ expect(tiptapEditor.getHTML()).toBe('<p>Download PDF File</p>');
+ });
+ });
+
+ describe('for a placeholder link', () => {
+ beforeEach(async () => {
+ tiptapEditor
+ .chain()
+ .clearContent()
+ .insertContent('Dummy link')
+ .selectAll()
+ .setLink({ href: '' })
+ .setTextSelection(4)
+ .run();
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+ });
+
+ it('directly opens the edit form for a placeholder link', async () => {
+ expectLinkButtonsToExist(false);
+
+ expect(wrapper.findComponent(GlForm).exists()).toBe(true);
+ });
+
+ it('removes the link on clicking apply (if no change)', async () => {
+ await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent());
+
+ expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>');
+ });
+
+ it('removes the link on clicking cancel', async () => {
+ await wrapper.findByTestId('cancel-link').vm.$emit('click');
+
+ expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>');
+ });
+ });
+
+ describe('edit button', () => {
+ let linkHrefInput;
+ let linkTitleInput;
+
+ beforeEach(async () => {
+ await wrapper.findByTestId('edit-link').vm.$emit('click');
+
+ linkHrefInput = wrapper.findByTestId('link-href');
+ linkTitleInput = wrapper.findByTestId('link-title');
+ });
+
+ it('hides the link and copy/edit/remove link buttons', async () => {
+ expectLinkButtonsToExist(false);
+ });
+
+ it('shows a form to edit the link', () => {
+ expect(wrapper.findComponent(GlForm).exists()).toBe(true);
+
+ expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf');
+ expect(linkTitleInput.element.value).toBe('Click here to download');
+ });
+
+ it('extends selection to select the entire link', () => {
+ const { from, to } = tiptapEditor.state.selection;
+
+ expect(from).toBe(10);
+ expect(to).toBe(18);
+ });
+
+ it('shows the copy/edit/remove link buttons again if selection changes to another non-link and then back again to a link', async () => {
+ expectLinkButtonsToExist(false);
+
+ tiptapEditor.commands.setTextSelection(3);
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ tiptapEditor.commands.setTextSelection(14);
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expectLinkButtonsToExist(true);
+ expect(wrapper.findComponent(GlForm).exists()).toBe(false);
+ });
+
+ describe('after making changes in the form and clicking apply', () => {
+ beforeEach(async () => {
+ linkHrefInput.setValue('https://google.com');
+ linkTitleInput.setValue('Search Google');
+
+ contentEditor.resolveUrl.mockResolvedValue('https://google.com');
+
+ await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent());
+ });
+
+ it('updates prosemirror doc with new link', async () => {
+ expect(tiptapEditor.getHTML()).toBe(
+ '<p>Download <a target="_blank" rel="noopener noreferrer nofollow" href="https://google.com" title="Search Google" canonicalsrc="https://google.com">PDF File</a></p>',
+ );
+ });
+
+ it('updates the link in the bubble menu', () => {
+ const link = wrapper.findComponent(GlLink);
+ expect(link.attributes()).toEqual(
+ expect.objectContaining({
+ href: 'https://google.com',
+ 'aria-label': 'https://google.com',
+ title: 'https://google.com',
+ target: '_blank',
+ }),
+ );
+ expect(link.text()).toBe('https://google.com');
+ });
+ });
+
+ describe('after making changes in the form and clicking cancel', () => {
+ beforeEach(async () => {
+ linkHrefInput.setValue('https://google.com');
+ linkTitleInput.setValue('Search Google');
+
+ await wrapper.findByTestId('cancel-link').vm.$emit('click');
+ });
+
+ it('hides the form and shows the copy/edit/remove link buttons', () => {
+ expectLinkButtonsToExist();
+ });
+
+ it('resets the form with old values of the link from prosemirror', async () => {
+ // click edit once again to show the form back
+ await wrapper.findByTestId('edit-link').vm.$emit('click');
+
+ linkHrefInput = wrapper.findByTestId('link-href');
+ linkTitleInput = wrapper.findByTestId('link-title');
+
+ expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf');
+ expect(linkTitleInput.element.value).toBe('Click here to download');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/bubble_menus/media_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_spec.js
new file mode 100644
index 00000000000..8839caea80e
--- /dev/null
+++ b/spec/frontend/content_editor/components/bubble_menus/media_spec.js
@@ -0,0 +1,234 @@
+import { GlLink, GlForm } from '@gitlab/ui';
+import { BubbleMenu } from '@tiptap/vue-2';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media.vue';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import Image from '~/content_editor/extensions/image';
+import Audio from '~/content_editor/extensions/audio';
+import Video from '~/content_editor/extensions/video';
+import { createTestEditor, emitEditorEvent, mockChainedCommands } from '../../test_utils';
+import {
+ PROJECT_WIKI_ATTACHMENT_IMAGE_HTML,
+ PROJECT_WIKI_ATTACHMENT_AUDIO_HTML,
+ PROJECT_WIKI_ATTACHMENT_VIDEO_HTML,
+} from '../../test_constants';
+
+const TIPTAP_IMAGE_HTML = `<p>
+ <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon" data-canonical-src="https://gitlab.com/favicon.png">
+</p>`;
+
+const TIPTAP_AUDIO_HTML = `<p>
+ <span class="media-container audio-container"><audio src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></audio><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span>
+</p>`;
+
+const TIPTAP_VIDEO_HTML = `<p>
+ <span class="media-container video-container"><video src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></video><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span>
+</p>`;
+
+const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() });
+
+describe.each`
+ mediaType | mediaHTML | filePath | mediaOutputHTML
+ ${'image'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${'test-file.png'} | ${TIPTAP_IMAGE_HTML}
+ ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML}
+ ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML}
+`(
+ 'content_editor/components/bubble_menus/media ($mediaType)',
+ ({ mediaType, mediaHTML, filePath, mediaOutputHTML }) => {
+ let wrapper;
+ let tiptapEditor;
+ let contentEditor;
+ let bubbleMenu;
+ let eventHub;
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor({ extensions: [Image, Audio, Video] });
+ contentEditor = { resolveUrl: jest.fn() };
+ eventHub = eventHubFactory();
+ };
+
+ const buildWrapper = () => {
+ wrapper = mountExtended(MediaBubbleMenu, {
+ provide: {
+ tiptapEditor,
+ contentEditor,
+ eventHub,
+ },
+ });
+ };
+
+ const selectFile = async (file) => {
+ const input = wrapper.find({ ref: 'fileSelector' });
+
+ // override the property definition because `input.files` isn't directly modifyable
+ Object.defineProperty(input.element, 'files', { value: [file], writable: true });
+ await input.trigger('change');
+ };
+
+ const expectLinkButtonsToExist = (exist = true) => {
+ expect(wrapper.findComponent(GlLink).exists()).toBe(exist);
+ expect(wrapper.findByTestId('copy-media-src').exists()).toBe(exist);
+ expect(wrapper.findByTestId('edit-media').exists()).toBe(exist);
+ expect(wrapper.findByTestId('delete-media').exists()).toBe(exist);
+ };
+
+ beforeEach(async () => {
+ buildEditor();
+ buildWrapper();
+
+ tiptapEditor
+ .chain()
+ .insertContent(mediaHTML)
+ .setNodeSelection(4) // select the media
+ .run();
+
+ contentEditor.resolveUrl.mockResolvedValue(`/group1/project1/-/wikis/${filePath}`);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders bubble menu component', async () => {
+ expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
+ expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']);
+ });
+
+ it('shows a clickable link to the image', async () => {
+ const link = wrapper.findComponent(GlLink);
+ expect(link.attributes()).toEqual(
+ expect.objectContaining({
+ href: `/group1/project1/-/wikis/${filePath}`,
+ 'aria-label': filePath,
+ title: filePath,
+ target: '_blank',
+ }),
+ );
+ expect(link.text()).toBe(filePath);
+ });
+
+ describe('copy button', () => {
+ it(`copies the canonical link to the ${mediaType} to clipboard`, async () => {
+ jest.spyOn(navigator.clipboard, 'writeText');
+
+ await wrapper.findByTestId('copy-media-src').vm.$emit('click');
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(filePath);
+ });
+ });
+
+ describe(`remove ${mediaType} button`, () => {
+ it(`removes the ${mediaType}`, async () => {
+ await wrapper.findByTestId('delete-media').vm.$emit('click');
+
+ expect(tiptapEditor.getHTML()).toBe('<p>\n \n</p>');
+ });
+ });
+
+ describe(`replace ${mediaType} button`, () => {
+ it('uploads and replaces the selected image when file input changes', async () => {
+ const commands = mockChainedCommands(tiptapEditor, [
+ 'focus',
+ 'deleteSelection',
+ 'uploadAttachment',
+ 'run',
+ ]);
+ const file = new File(['foo'], 'foo.png', { type: 'image/png' });
+
+ await wrapper.findByTestId('replace-media').vm.$emit('click');
+ await selectFile(file);
+
+ expect(commands.focus).toHaveBeenCalled();
+ expect(commands.deleteSelection).toHaveBeenCalled();
+ expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
+ expect(commands.run).toHaveBeenCalled();
+ });
+ });
+
+ describe('edit button', () => {
+ let mediaSrcInput;
+ let mediaTitleInput;
+ let mediaAltInput;
+
+ beforeEach(async () => {
+ await wrapper.findByTestId('edit-media').vm.$emit('click');
+
+ mediaSrcInput = wrapper.findByTestId('media-src');
+ mediaTitleInput = wrapper.findByTestId('media-title');
+ mediaAltInput = wrapper.findByTestId('media-alt');
+ });
+
+ it('hides the link and copy/edit/remove link buttons', async () => {
+ expectLinkButtonsToExist(false);
+ });
+
+ it(`shows a form to edit the ${mediaType} src/title/alt`, () => {
+ expect(wrapper.findComponent(GlForm).exists()).toBe(true);
+
+ expect(mediaSrcInput.element.value).toBe(filePath);
+ expect(mediaTitleInput.element.value).toBe('');
+ expect(mediaAltInput.element.value).toBe('test-file');
+ });
+
+ describe('after making changes in the form and clicking apply', () => {
+ beforeEach(async () => {
+ mediaSrcInput.setValue('https://gitlab.com/favicon.png');
+ mediaAltInput.setValue('gitlab favicon');
+ mediaTitleInput.setValue('gitlab favicon');
+
+ contentEditor.resolveUrl.mockResolvedValue('https://gitlab.com/favicon.png');
+
+ await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent());
+ });
+
+ it(`updates prosemirror doc with new src to the ${mediaType}`, async () => {
+ expect(tiptapEditor.getHTML()).toBe(mediaOutputHTML);
+ });
+
+ it(`updates the link to the ${mediaType} in the bubble menu`, () => {
+ const link = wrapper.findComponent(GlLink);
+ expect(link.attributes()).toEqual(
+ expect.objectContaining({
+ href: 'https://gitlab.com/favicon.png',
+ 'aria-label': 'https://gitlab.com/favicon.png',
+ title: 'https://gitlab.com/favicon.png',
+ target: '_blank',
+ }),
+ );
+ expect(link.text()).toBe('https://gitlab.com/favicon.png');
+ });
+ });
+
+ describe('after making changes in the form and clicking cancel', () => {
+ beforeEach(async () => {
+ mediaSrcInput.setValue('https://gitlab.com/favicon.png');
+ mediaAltInput.setValue('gitlab favicon');
+ mediaTitleInput.setValue('gitlab favicon');
+
+ await wrapper.findByTestId('cancel-editing-media').vm.$emit('click');
+ });
+
+ it('hides the form and shows the copy/edit/remove link buttons', () => {
+ expectLinkButtonsToExist();
+ });
+
+ it(`resets the form with old values of the ${mediaType} from prosemirror`, async () => {
+ // click edit once again to show the form back
+ await wrapper.findByTestId('edit-media').vm.$emit('click');
+
+ mediaSrcInput = wrapper.findByTestId('media-src');
+ mediaTitleInput = wrapper.findByTestId('media-title');
+ mediaAltInput = wrapper.findByTestId('media-alt');
+
+ expect(mediaSrcInput.element.value).toBe(filePath);
+ expect(mediaAltInput.element.value).toBe('test-file');
+ expect(mediaTitleInput.element.value).toBe('');
+ });
+ });
+ });
+ },
+);
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 73fcfeab8bc..9ee3b017831 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -4,7 +4,7 @@ import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
-import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue';
+import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/formatting.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
import { emitEditorEvent } from '../test_utils';
diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js
index ce50482302d..1f1f7b338c6 100644
--- a/spec/frontend/content_editor/components/toolbar_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_button_spec.js
@@ -46,7 +46,7 @@ describe('content_editor/components/toolbar_button', () => {
wrapper.destroy();
});
- it('displays tertiary, small button with a provided label and icon', () => {
+ it('displays tertiary, medium button with a provided label and icon', () => {
buildWrapper();
expect(findButton().html()).toMatchSnapshot();
diff --git a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
index 415f1314a36..a564959a3a6 100644
--- a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
@@ -1,20 +1,33 @@
+import { nextTick } from 'vue';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
import { shallowMount } from '@vue/test-utils';
-import FrontmatterWrapper from '~/content_editor/components/wrappers/frontmatter.vue';
+import CodeBlockWrapper from '~/content_editor/components/wrappers/code_block.vue';
+import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader';
-describe('content/components/wrappers/frontmatter', () => {
+jest.mock('~/content_editor/services/code_block_language_loader');
+
+describe('content/components/wrappers/code_block', () => {
+ const language = 'yaml';
let wrapper;
+ let updateAttributesFn;
+
+ const createWrapper = async (nodeAttrs = { language }) => {
+ updateAttributesFn = jest.fn();
- const createWrapper = async (nodeAttrs = { language: 'yaml' }) => {
- wrapper = shallowMount(FrontmatterWrapper, {
+ wrapper = shallowMount(CodeBlockWrapper, {
propsData: {
node: {
attrs: nodeAttrs,
},
+ updateAttributes: updateAttributesFn,
},
});
};
+ beforeEach(() => {
+ codeBlockLanguageLoader.findLanguageBySyntax.mockReturnValue({ syntax: language });
+ });
+
afterEach(() => {
wrapper.destroy();
});
@@ -38,11 +51,21 @@ describe('content/components/wrappers/frontmatter', () => {
});
it('renders label indicating that code block is frontmatter', () => {
- createWrapper();
+ createWrapper({ isFrontmatter: true, language });
const label = wrapper.find('[data-testid="frontmatter-label"]');
expect(label.text()).toEqual('frontmatter:yaml');
expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']);
});
+
+ it('loads code block’s syntax highlight language', async () => {
+ createWrapper();
+
+ expect(codeBlockLanguageLoader.loadLanguage).toHaveBeenCalledWith(language);
+
+ await nextTick();
+
+ expect(updateAttributesFn).toHaveBeenCalledWith({ language });
+ });
});
diff --git a/spec/frontend/content_editor/components/wrappers/media_spec.js b/spec/frontend/content_editor/components/wrappers/media_spec.js
deleted file mode 100644
index 3e95e2f3914..00000000000
--- a/spec/frontend/content_editor/components/wrappers/media_spec.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import { GlLoadingIcon } from '@gitlab/ui';
-import { NodeViewWrapper } from '@tiptap/vue-2';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import MediaWrapper from '~/content_editor/components/wrappers/media.vue';
-
-describe('content/components/wrappers/media', () => {
- let wrapper;
-
- const createWrapper = async (nodeAttrs = {}) => {
- wrapper = shallowMountExtended(MediaWrapper, {
- propsData: {
- node: {
- attrs: nodeAttrs,
- type: {
- name: 'image',
- },
- },
- },
- });
- };
- const findMedia = () => wrapper.findByTestId('media');
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders a node-view-wrapper with display-inline-block class', () => {
- createWrapper();
-
- expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-display-inline-block');
- });
-
- it('renders an image that displays the node src', () => {
- const src = 'foobar.png';
-
- createWrapper({ src });
-
- expect(findMedia().attributes().src).toBe(src);
- });
-
- describe('when uploading', () => {
- beforeEach(() => {
- createWrapper({ uploading: true });
- });
-
- it('renders a gl-loading-icon component', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- });
-
- it('adds gl-opacity-5 class selector to the media tag', () => {
- expect(findMedia().classes()).toContain('gl-opacity-5');
- });
- });
-
- describe('when not uploading', () => {
- beforeEach(() => {
- createWrapper({ uploading: false });
- });
-
- it('does not render a gl-loading-icon component', () => {
- expect(findLoadingIcon().exists()).toBe(false);
- });
-
- it('does not add gl-opacity-5 class selector to the media tag', () => {
- expect(findMedia().classes()).not.toContain('gl-opacity-5');
- });
- });
-});
diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js
index d3c42104e47..d528096be34 100644
--- a/spec/frontend/content_editor/extensions/attachment_spec.js
+++ b/spec/frontend/content_editor/extensions/attachment_spec.js
@@ -11,32 +11,12 @@ import { VARIANT_DANGER } from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, createDocBuilder } from '../test_utils';
-
-const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
- <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png">
- <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png">
- </a>
-</p>`;
-
-const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto">
- <span class="media-container video-container">
- <video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4">
- </video>
- <a href="/himkp/test/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a>
- </span>
-</p>`;
-
-const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto">
- <span class="media-container audio-container">
- <audio src="/himkp/test/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3">
- </audio>
- <a href="/himkp/test/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a>
- </span>
-</p>`;
-
-const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto">
- <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a>
-</p>`;
+import {
+ PROJECT_WIKI_ATTACHMENT_IMAGE_HTML,
+ PROJECT_WIKI_ATTACHMENT_AUDIO_HTML,
+ PROJECT_WIKI_ATTACHMENT_VIDEO_HTML,
+ PROJECT_WIKI_ATTACHMENT_LINK_HTML,
+} from '../test_constants';
describe('content_editor/extensions/attachment', () => {
let tiptapEditor;
diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
index 02e5b1dc271..fc8460c7f84 100644
--- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
+++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
@@ -1,4 +1,5 @@
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import languageLoader from '~/content_editor/services/code_block_language_loader';
import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils';
const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true">
@@ -9,20 +10,20 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language
</code>
</pre>`;
+jest.mock('~/content_editor/services/code_block_language_loader');
+
describe('content_editor/extensions/code_block_highlight', () => {
let parsedCodeBlockHtmlFixture;
let tiptapEditor;
let doc;
let codeBlock;
- let languageLoader;
const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html');
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => {
- languageLoader = { loadLanguages: jest.fn() };
tiptapEditor = createTestEditor({
- extensions: [CodeBlockHighlight.configure({ languageLoader })],
+ extensions: [CodeBlockHighlight],
});
({
@@ -70,6 +71,8 @@ describe('content_editor/extensions/code_block_highlight', () => {
const language = 'javascript';
beforeEach(() => {
+ languageLoader.loadLanguageFromInputRule.mockReturnValueOnce({ language });
+
triggerNodeInputRule({
tiptapEditor,
inputRuleText: `${inputRule}${language} `,
@@ -83,7 +86,9 @@ describe('content_editor/extensions/code_block_highlight', () => {
});
it('loads language when language loader is available', () => {
- expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]);
+ expect(languageLoader.loadLanguageFromInputRule).toHaveBeenCalledWith(
+ expect.arrayContaining([`${inputRule}${language} `, language]),
+ );
});
});
});
diff --git a/spec/frontend/content_editor/extensions/diagram_spec.js b/spec/frontend/content_editor/extensions/diagram_spec.js
new file mode 100644
index 00000000000..b8d9e0b5aeb
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/diagram_spec.js
@@ -0,0 +1,16 @@
+import Diagram from '~/content_editor/extensions/diagram';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+
+describe('content_editor/extensions/diagram', () => {
+ it('inherits from code block highlight extension', () => {
+ expect(Diagram.parent).toBe(CodeBlockHighlight);
+ });
+
+ it('sets isDiagram attribute to true by default', () => {
+ expect(Diagram.config.addAttributes()).toEqual(
+ expect.objectContaining({
+ isDiagram: { default: true },
+ }),
+ );
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js
index 4f80c2cb81a..9bd29070858 100644
--- a/spec/frontend/content_editor/extensions/frontmatter_spec.js
+++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js
@@ -22,6 +22,10 @@ describe('content_editor/extensions/frontmatter', () => {
}));
});
+ it('inherits from code block highlight extension', () => {
+ expect(Frontmatter.parent).toBe(CodeBlockHighlight);
+ });
+
it('does not insert a frontmatter block when executing code block input rule', () => {
const expectedDoc = doc(codeBlock({ language: 'plaintext' }, ''));
const inputRuleText = '``` ';
@@ -31,6 +35,14 @@ describe('content_editor/extensions/frontmatter', () => {
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
+ it('sets isFrontmatter attribute to true by default', () => {
+ expect(Frontmatter.config.addAttributes()).toEqual(
+ expect.objectContaining({
+ isFrontmatter: { default: true },
+ }),
+ );
+ });
+
it.each`
command | result | resultDesc
${'toggleCodeBlock'} | ${() => doc(codeBlock(''))} | ${'code block element'}
diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
index 8f734c7dabc..5d46c2c0650 100644
--- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js
+++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
@@ -1,4 +1,7 @@
import PasteMarkdown from '~/content_editor/extensions/paste_markdown';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import Diagram from '~/content_editor/extensions/diagram';
+import Frontmatter from '~/content_editor/extensions/frontmatter';
import Bold from '~/content_editor/extensions/bold';
import { VARIANT_DANGER } from '~/flash';
import eventHubFactory from '~/helpers/event_hub_factory';
@@ -11,6 +14,12 @@ import {
import waitForPromises from 'helpers/wait_for_promises';
import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils';
+const CODE_BLOCK_HTML = '<pre lang="javascript">var a = 2;</pre>';
+const DIAGRAM_HTML =
+ '<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">';
+const FRONTMATTER_HTML = '<pre lang="yaml" data-lang-params="frontmatter">key: value</pre>';
+const PARAGRAPH_HTML = '<p>Just a regular paragraph</p>';
+
describe('content_editor/extensions/paste_markdown', () => {
let tiptapEditor;
let doc;
@@ -27,7 +36,13 @@ describe('content_editor/extensions/paste_markdown', () => {
jest.spyOn(eventHub, '$emit');
tiptapEditor = createTestEditor({
- extensions: [PasteMarkdown.configure({ renderMarkdown, eventHub }), Bold],
+ extensions: [
+ Bold,
+ CodeBlockHighlight,
+ Diagram,
+ Frontmatter,
+ PasteMarkdown.configure({ renderMarkdown, eventHub }),
+ ],
});
({
@@ -35,7 +50,7 @@ describe('content_editor/extensions/paste_markdown', () => {
} = createDocBuilder({
tiptapEditor,
names: {
- Bold: { markType: Bold.name },
+ bold: { markType: Bold.name },
},
}));
});
@@ -47,13 +62,11 @@ describe('content_editor/extensions/paste_markdown', () => {
};
const triggerPasteEventHandler = (event) => {
- let handled = false;
-
- tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
- handled = eventHandler(tiptapEditor.view, event);
+ return new Promise((resolve) => {
+ tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
+ resolve(eventHandler(tiptapEditor.view, event));
+ });
});
-
- return handled;
};
const triggerPasteEventHandlerAndWaitForTransaction = (event) => {
@@ -73,8 +86,20 @@ describe('content_editor/extensions/paste_markdown', () => {
${['text/plain', 'text/html']} | ${{}} | ${false} | ${'doesn’t handle html format'}
${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${true} | ${'handles vscode markdown'}
${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${false} | ${'doesn’t vscode code snippet'}
- `('$desc', ({ types, handled, data }) => {
- expect(triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(handled);
+ `('$desc', async ({ types, handled, data }) => {
+ expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(handled);
+ });
+
+ it.each`
+ nodeType | html | handled | desc
+ ${'codeBlock'} | ${CODE_BLOCK_HTML} | ${false} | ${'does not handle'}
+ ${'diagram'} | ${DIAGRAM_HTML} | ${false} | ${'does not handle'}
+ ${'frontmatter'} | ${FRONTMATTER_HTML} | ${false} | ${'does not handle'}
+ ${'paragraph'} | ${PARAGRAPH_HTML} | ${true} | ${'handles'}
+ `('$desc paste if currently a `$nodeType` is in focus', async ({ html, handled }) => {
+ tiptapEditor.commands.insertContent(html);
+
+ expect(await triggerPasteEventHandler(buildClipboardEvent())).toBe(handled);
});
describe('when pasting raw markdown source', () => {
@@ -105,16 +130,14 @@ describe('content_editor/extensions/paste_markdown', () => {
});
it(`triggers ${LOADING_ERROR_EVENT} event`, async () => {
- triggerPasteEventHandler(buildClipboardEvent());
-
+ await triggerPasteEventHandler(buildClipboardEvent());
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_ERROR_EVENT);
});
it(`triggers ${ALERT_EVENT} event`, async () => {
- triggerPasteEventHandler(buildClipboardEvent());
-
+ await triggerPasteEventHandler(buildClipboardEvent());
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith(ALERT_EVENT, {
diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js
new file mode 100644
index 00000000000..6348b97d918
--- /dev/null
+++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js
@@ -0,0 +1,248 @@
+import Bold from '~/content_editor/extensions/bold';
+import Blockquote from '~/content_editor/extensions/blockquote';
+import BulletList from '~/content_editor/extensions/bullet_list';
+import Code from '~/content_editor/extensions/code';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import HardBreak from '~/content_editor/extensions/hard_break';
+import Heading from '~/content_editor/extensions/heading';
+import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
+import Image from '~/content_editor/extensions/image';
+import Italic from '~/content_editor/extensions/italic';
+import Link from '~/content_editor/extensions/link';
+import ListItem from '~/content_editor/extensions/list_item';
+import OrderedList from '~/content_editor/extensions/ordered_list';
+import Sourcemap from '~/content_editor/extensions/sourcemap';
+import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
+import markdownSerializer from '~/content_editor/services/markdown_serializer';
+
+import { createTestEditor } from './test_utils';
+
+const tiptapEditor = createTestEditor({
+ extensions: [
+ Blockquote,
+ Bold,
+ BulletList,
+ Code,
+ CodeBlockHighlight,
+ HardBreak,
+ Heading,
+ HorizontalRule,
+ Image,
+ Italic,
+ Link,
+ ListItem,
+ OrderedList,
+ Sourcemap,
+ ],
+});
+
+describe('Client side Markdown processing', () => {
+ const deserialize = async (content) => {
+ const { document } = await remarkMarkdownDeserializer().deserialize({
+ schema: tiptapEditor.schema,
+ content,
+ });
+
+ return document;
+ };
+
+ const serialize = (document) =>
+ markdownSerializer({}).serialize({
+ doc: document,
+ pristineDoc: document,
+ });
+
+ it.each([
+ {
+ markdown: '__bold text__',
+ },
+ {
+ markdown: '**bold text**',
+ },
+ {
+ markdown: '<strong>bold text</strong>',
+ },
+ {
+ markdown: '<b>bold text</b>',
+ },
+ {
+ markdown: '_italic text_',
+ },
+ {
+ markdown: '*italic text*',
+ },
+ {
+ markdown: '<em>italic text</em>',
+ },
+ {
+ markdown: '<i>italic text</i>',
+ },
+ {
+ markdown: '`inline code`',
+ },
+ {
+ markdown: '**`inline code bold`**',
+ },
+ {
+ markdown: '__`inline code italics`__',
+ },
+ {
+ markdown: '[GitLab](https://gitlab.com "Go to GitLab")',
+ },
+ {
+ markdown: '**[GitLab](https://gitlab.com "Go to GitLab")**',
+ },
+ {
+ markdown: `
+This is a paragraph with a\\
+hard line break`,
+ },
+ {
+ markdown: '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")',
+ },
+ {
+ markdown: '---',
+ },
+ {
+ markdown: '***',
+ },
+ {
+ markdown: '___',
+ },
+ {
+ markdown: '<hr>',
+ },
+ {
+ markdown: '# Heading 1',
+ },
+ {
+ markdown: '## Heading 2',
+ },
+ {
+ markdown: '### Heading 3',
+ },
+ {
+ markdown: '#### Heading 4',
+ },
+ {
+ markdown: '##### Heading 5',
+ },
+ {
+ markdown: '###### Heading 6',
+ },
+
+ {
+ markdown: `
+ Heading
+ one
+ ======
+ `,
+ },
+ {
+ markdown: `
+ Heading
+ two
+ -------
+ `,
+ },
+ {
+ markdown: `
+ - List item 1
+ - List item 2
+ `,
+ },
+ {
+ markdown: `
+ * List item 1
+ * List item 2
+ `,
+ },
+ {
+ markdown: `
+ + List item 1
+ + List item 2
+ `,
+ },
+ {
+ markdown: `
+ 1. List item 1
+ 1. List item 2
+ `,
+ },
+ {
+ markdown: `
+ 1. List item 1
+ 2. List item 2
+ `,
+ },
+ {
+ markdown: `
+ 1) List item 1
+ 2) List item 2
+ `,
+ },
+ {
+ markdown: `
+ - List item 1
+ - Sub list item 1
+ `,
+ },
+ {
+ markdown: `
+ - List item 1 paragraph 1
+
+ List item 1 paragraph 2
+ - List item 2
+ `,
+ },
+ {
+ markdown: `
+ > This is a blockquote
+ `,
+ },
+ {
+ markdown: `
+ > - List item 1
+ > - List item 2
+ `,
+ },
+ {
+ markdown: `
+ const fn = () => 'GitLab';
+ `,
+ },
+ {
+ markdown: `
+ \`\`\`javascript
+ const fn = () => 'GitLab';
+ \`\`\`\
+ `,
+ },
+ {
+ markdown: `
+ ~~~javascript
+ const fn = () => 'GitLab';
+ ~~~
+ `,
+ },
+ {
+ markdown: `
+ \`\`\`
+ \`\`\`\
+ `,
+ },
+ {
+ markdown: `
+ \`\`\`javascript
+ const fn = () => 'GitLab';
+
+ \`\`\`\
+ `,
+ },
+ ])('processes %s correctly', async ({ markdown }) => {
+ const trimmed = markdown.trim();
+ const document = await deserialize(trimmed);
+
+ expect(serialize(document)).toEqual(trimmed);
+ });
+});
diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js
new file mode 100644
index 00000000000..f4e7d9bf881
--- /dev/null
+++ b/spec/frontend/content_editor/services/asset_resolver_spec.js
@@ -0,0 +1,23 @@
+import createAssetResolver from '~/content_editor/services/asset_resolver';
+
+describe('content_editor/services/asset_resolver', () => {
+ let renderMarkdown;
+ let assetResolver;
+
+ beforeEach(() => {
+ renderMarkdown = jest.fn();
+ assetResolver = createAssetResolver({ renderMarkdown });
+ });
+
+ describe('resolveUrl', () => {
+ it('resolves a canonical url to an absolute url', async () => {
+ renderMarkdown.mockResolvedValue(
+ '<p><a href="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png">link</a></p>',
+ );
+
+ expect(await assetResolver.resolveUrl('test-file.png')).toBe(
+ '/group1/project1/-/wikis/test-file.png',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js
index 905c1685b94..943de327762 100644
--- a/spec/frontend/content_editor/services/code_block_language_loader_spec.js
+++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js
@@ -53,47 +53,25 @@ describe('content_editor/services/code_block_language_loader', () => {
});
});
- describe('loadLanguages', () => {
+ describe('loadLanguage', () => {
it('loads highlight.js language packages identified by a list of languages', async () => {
- const languages = ['javascript', 'ruby'];
+ const language = 'javascript';
- await languageLoader.loadLanguages(languages);
+ await languageLoader.loadLanguage(language);
- languages.forEach((language) => {
- expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function));
- });
+ expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function));
});
describe('when language is already registered', () => {
it('does not load the language again', async () => {
- const languages = ['javascript'];
-
- await languageLoader.loadLanguages(languages);
- await languageLoader.loadLanguages(languages);
+ await languageLoader.loadLanguage('javascript');
+ await languageLoader.loadLanguage('javascript');
expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1);
});
});
});
- describe('loadLanguagesFromDOM', () => {
- it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => {
- const parser = new DOMParser();
- const { body } = parser.parseFromString(
- `
- <pre lang="javascript"></pre>
- <pre lang="ruby"></pre>
- `,
- 'text/html',
- );
-
- await languageLoader.loadLanguagesFromDOM(body);
-
- expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
- expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function));
- });
- });
-
describe('loadLanguageFromInputRule', () => {
it('loads highlight.js language packages identified from the input rule', async () => {
const match = new RegExp(backtickInputRegex).exec('```js ');
@@ -112,7 +90,7 @@ describe('content_editor/services/code_block_language_loader', () => {
expect(languageLoader.isLanguageLoaded(language)).toBe(false);
- await languageLoader.loadLanguages([language]);
+ await languageLoader.loadLanguage(language);
expect(languageLoader.isLanguageLoaded(language)).toBe(true);
});
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
index 5b7a27b501d..a3553e612ca 100644
--- a/spec/frontend/content_editor/services/content_editor_spec.js
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -11,7 +11,6 @@ describe('content_editor/services/content_editor', () => {
let contentEditor;
let serializer;
let deserializer;
- let languageLoader;
let eventHub;
let doc;
let p;
@@ -26,16 +25,14 @@ describe('content_editor/services/content_editor', () => {
tiptapEditor,
}));
- serializer = { deserialize: jest.fn() };
+ serializer = { serialize: jest.fn() };
deserializer = { deserialize: jest.fn() };
- languageLoader = { loadLanguagesFromDOM: jest.fn() };
eventHub = eventHubFactory();
contentEditor = new ContentEditor({
tiptapEditor,
serializer,
deserializer,
eventHub,
- languageLoader,
});
});
@@ -51,12 +48,12 @@ describe('content_editor/services/content_editor', () => {
describe('when setSerializedContent succeeds', () => {
let document;
- const dom = {};
+ const languages = ['javascript'];
const testMarkdown = '**bold text**';
beforeEach(() => {
document = doc(p('document'));
- deserializer.deserialize.mockResolvedValueOnce({ document, dom });
+ deserializer.deserialize.mockResolvedValueOnce({ document, languages });
});
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
@@ -77,12 +74,6 @@ describe('content_editor/services/content_editor', () => {
expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
});
-
- it('passes deserialized DOM document to language loader', async () => {
- await contentEditor.setSerializedContent(testMarkdown);
-
- expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom);
- });
});
describe('when setSerializedContent fails', () => {
diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js
index 6b2f28b3306..e1a30819ac8 100644
--- a/spec/frontend/content_editor/services/create_content_editor_spec.js
+++ b/spec/frontend/content_editor/services/create_content_editor_spec.js
@@ -1,8 +1,12 @@
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants';
import { createContentEditor } from '~/content_editor/services/create_content_editor';
+import createGlApiMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer';
+import createRemarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
import { createTestContentEditorExtension } from '../test_utils';
jest.mock('~/emoji');
+jest.mock('~/content_editor/services/remark_markdown_deserializer');
+jest.mock('~/content_editor/services/gl_api_markdown_deserializer');
describe('content_editor/services/create_content_editor', () => {
let renderMarkdown;
@@ -11,9 +15,36 @@ describe('content_editor/services/create_content_editor', () => {
beforeEach(() => {
renderMarkdown = jest.fn();
+ window.gon = {
+ features: {
+ preserveUnchangedMarkdown: false,
+ },
+ };
editor = createContentEditor({ renderMarkdown, uploadsPath });
});
+ describe('when preserveUnchangedMarkdown feature is on', () => {
+ beforeEach(() => {
+ window.gon.features.preserveUnchangedMarkdown = true;
+ });
+
+ it('provides a remark markdown deserializer to the content editor class', () => {
+ createContentEditor({ renderMarkdown, uploadsPath });
+ expect(createRemarkMarkdownDeserializer).toHaveBeenCalled();
+ });
+ });
+
+ describe('when preserveUnchangedMarkdown feature is off', () => {
+ beforeEach(() => {
+ window.gon.features.preserveUnchangedMarkdown = false;
+ });
+
+ it('provides a gl api markdown deserializer to the content editor class', () => {
+ createContentEditor({ renderMarkdown, uploadsPath });
+ expect(createGlApiMarkdownDeserializer).toHaveBeenCalledWith({ render: renderMarkdown });
+ });
+ });
+
it('sets gl-outline-0! class selector to the tiptapEditor instance', () => {
expect(editor.tiptapEditor.options.editorProps).toMatchObject({
attributes: {
@@ -22,30 +53,19 @@ describe('content_editor/services/create_content_editor', () => {
});
});
- it('provides the renderMarkdown function to the markdown serializer', async () => {
- const serializedContent = '**bold text**';
-
- renderMarkdown.mockReturnValueOnce('<p><b>bold text</b></p>');
-
- await editor.setSerializedContent(serializedContent);
-
- expect(renderMarkdown).toHaveBeenCalledWith(serializedContent);
- });
-
it('allows providing external content editor extensions', async () => {
const labelReference = 'this is a ~group::editor';
const { tiptapExtension, serializer } = createTestContentEditorExtension();
- renderMarkdown.mockReturnValueOnce(
- '<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>',
- );
editor = createContentEditor({
renderMarkdown,
extensions: [tiptapExtension],
serializerConfig: { nodes: { [tiptapExtension.name]: serializer } },
});
- await editor.setSerializedContent(labelReference);
+ editor.tiptapEditor.commands.setContent(
+ '<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>',
+ );
expect(editor.getSerializedContent()).toBe(labelReference);
});
diff --git a/spec/frontend/content_editor/services/markdown_deserializer_spec.js b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
index bea43a0effc..5458a42532f 100644
--- a/spec/frontend/content_editor/services/markdown_deserializer_spec.js
+++ b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
@@ -1,8 +1,8 @@
-import createMarkdownDeserializer from '~/content_editor/services/markdown_deserializer';
+import createMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer';
import Bold from '~/content_editor/extensions/bold';
import { createTestEditor, createDocBuilder } from '../test_utils';
-describe('content_editor/services/markdown_deserializer', () => {
+describe('content_editor/services/gl_api_markdown_deserializer', () => {
let renderMarkdown;
let doc;
let p;
@@ -32,7 +32,9 @@ describe('content_editor/services/markdown_deserializer', () => {
beforeEach(async () => {
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p>`);
+ renderMarkdown.mockResolvedValueOnce(
+ `<p><strong>${text}</strong></p><pre lang="javascript"></pre>`,
+ );
result = await deserializer.deserialize({
content: 'content',
@@ -40,13 +42,9 @@ describe('content_editor/services/markdown_deserializer', () => {
});
});
it('transforms HTML returned by render function to a ProseMirror document', async () => {
- const expectedDoc = doc(p(bold(text)));
+ const document = doc(p(bold(text)));
- expect(result.document.toJSON()).toEqual(expectedDoc.toJSON());
- });
-
- it('returns parsed HTML as a DOM object', () => {
- expect(result.dom.innerHTML).toEqual(`<p><strong>${text}</strong></p><!--content-->`);
+ expect(result.document.toJSON()).toEqual(document.toJSON());
});
});
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index 2b76dc6c984..25b7483f234 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -24,6 +24,7 @@ import Link from '~/content_editor/extensions/link';
import ListItem from '~/content_editor/extensions/list_item';
import OrderedList from '~/content_editor/extensions/ordered_list';
import Paragraph from '~/content_editor/extensions/paragraph';
+import Sourcemap from '~/content_editor/extensions/sourcemap';
import Strike from '~/content_editor/extensions/strike';
import Table from '~/content_editor/extensions/table';
import TableCell from '~/content_editor/extensions/table_cell';
@@ -32,6 +33,7 @@ import TableRow from '~/content_editor/extensions/table_row';
import TaskItem from '~/content_editor/extensions/task_item';
import TaskList from '~/content_editor/extensions/task_list';
import markdownSerializer from '~/content_editor/services/markdown_serializer';
+import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
import { createTestEditor, createDocBuilder } from '../test_utils';
jest.mock('~/emoji');
@@ -63,6 +65,7 @@ const tiptapEditor = createTestEditor({
Link,
ListItem,
OrderedList,
+ Sourcemap,
Strike,
Table,
TableCell,
@@ -151,8 +154,7 @@ const {
const serialize = (...content) =>
markdownSerializer({}).serialize({
- schema: tiptapEditor.schema,
- content: doc(...content).toJSON(),
+ doc: doc(...content),
});
describe('markdownSerializer', () => {
@@ -1159,4 +1161,42 @@ Oranges are orange [^1]
`.trim(),
);
});
+
+ it.each`
+ mark | content | modifiedContent
+ ${'bold'} | ${'**bold**'} | ${'**bold modified**'}
+ ${'bold'} | ${'__bold__'} | ${'__bold modified__'}
+ ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'}
+ ${'bold'} | ${'<b>bold</b>'} | ${'<b>bold modified</b>'}
+ ${'italic'} | ${'_italic_'} | ${'_italic modified_'}
+ ${'italic'} | ${'*italic*'} | ${'*italic modified*'}
+ ${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'}
+ ${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'}
+ ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'}
+ ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'}
+ ${'code'} | ${'`code`'} | ${'`code modified`'}
+ ${'code'} | ${'<code>code</code>'} | ${'<code>code modified</code>'}
+ `(
+ 'preserves original $mark syntax when sourceMarkdown is available',
+ async ({ content, modifiedContent }) => {
+ const { document } = await remarkMarkdownDeserializer().deserialize({
+ schema: tiptapEditor.schema,
+ content,
+ });
+
+ tiptapEditor
+ .chain()
+ .setContent(document.toJSON())
+ // changing the document ensures that block preservation doesn’t yield false positives
+ .insertContent(' modified')
+ .run();
+
+ const serialized = markdownSerializer({}).serialize({
+ pristineDoc: document,
+ doc: tiptapEditor.state.doc,
+ });
+
+ expect(serialized).toEqual(modifiedContent);
+ },
+ );
});
diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
index abd9588daff..8a304c73163 100644
--- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
+++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
@@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core';
import BulletList from '~/content_editor/extensions/bullet_list';
import ListItem from '~/content_editor/extensions/list_item';
import Paragraph from '~/content_editor/extensions/paragraph';
-import markdownDeserializer from '~/content_editor/services/markdown_deserializer';
+import markdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer';
import { getMarkdownSource, getFullSource } from '~/content_editor/services/markdown_sourcemap';
import { createTestEditor, createDocBuilder } from '../test_utils';
diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js
new file mode 100644
index 00000000000..45a0e4a8bd1
--- /dev/null
+++ b/spec/frontend/content_editor/test_constants.js
@@ -0,0 +1,25 @@
+export const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
+ <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png">
+ <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png">
+ </a>
+</p>`;
+
+export const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto">
+ <span class="media-container video-container">
+ <video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4">
+ </video>
+ <a href="/group1/project1/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a>
+ </span>
+</p>`;
+
+export const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto">
+ <span class="media-container audio-container">
+ <audio src="/group1/project1/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3">
+ </audio>
+ <a href="/group1/project1/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a>
+ </span>
+</p>`;
+
+export const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto">
+ <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a>
+</p>`;
diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js
index dde9d738235..4ed1ed97cbd 100644
--- a/spec/frontend/content_editor/test_utils.js
+++ b/spec/frontend/content_editor/test_utils.js
@@ -151,7 +151,7 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => {
* @param {*} params.action A function that triggers a transaction in the tiptap Editor
* @returns A promise that resolves when the transaction completes
*/
-export const waitUntilNextDocTransaction = ({ tiptapEditor, action }) => {
+export const waitUntilNextDocTransaction = ({ tiptapEditor, action = () => {} }) => {
return new Promise((resolve) => {
const handleTransaction = () => {
tiptapEditor.off('update', handleTransaction);
diff --git a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js
deleted file mode 100644
index 2f835867f5f..00000000000
--- a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import { GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import $ from 'jquery';
-
-import { nextTick } from 'vue';
-import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
-import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
-import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
-
-describe('ClusterFormDropdown', () => {
- let wrapper;
- const firstItem = { name: 'item 1', value: '1' };
- const secondItem = { name: 'item 2', value: '2' };
- const items = [firstItem, secondItem, { name: 'item 3', value: '3' }];
-
- beforeEach(() => {
- wrapper = shallowMount(ClusterFormDropdown);
- });
- afterEach(() => wrapper.destroy());
-
- describe('when initial value is provided', () => {
- it('sets selectedItem to initial value', async () => {
- wrapper.setProps({ items, value: secondItem.value });
-
- await nextTick();
- expect(wrapper.find(DropdownButton).props('toggleText')).toEqual(secondItem.name);
- });
- });
-
- describe('when no item is selected', () => {
- it('displays placeholder text', async () => {
- const placeholder = 'placeholder';
-
- wrapper.setProps({ placeholder });
-
- await nextTick();
- expect(wrapper.find(DropdownButton).props('toggleText')).toEqual(placeholder);
- });
- });
-
- describe('when an item is selected', () => {
- beforeEach(async () => {
- wrapper.setProps({ items });
- await nextTick();
- wrapper.findAll('.js-dropdown-item').at(1).trigger('click');
- await nextTick();
- });
-
- it('emits input event with selected item', () => {
- expect(wrapper.emitted('input')[0]).toEqual([secondItem.value]);
- });
- });
-
- describe('when multiple items are selected', () => {
- const value = ['1'];
-
- beforeEach(async () => {
- wrapper.setProps({ items, multiple: true, value });
-
- await nextTick();
- wrapper.findAll('.js-dropdown-item').at(0).trigger('click');
-
- await nextTick();
- wrapper.findAll('.js-dropdown-item').at(1).trigger('click');
-
- await nextTick();
- });
-
- it('emits input event with an array of selected items', () => {
- expect(wrapper.emitted('input')[1]).toEqual([[firstItem.value, secondItem.value]]);
- });
- });
-
- describe('when multiple items can be selected', () => {
- beforeEach(async () => {
- wrapper.setProps({ items, multiple: true, value: firstItem.value });
- await nextTick();
- });
-
- it('displays a checked GlIcon next to the item', () => {
- expect(wrapper.find(GlIcon).classes()).not.toContain('invisible');
- expect(wrapper.find(GlIcon).props('name')).toBe('mobile-issue-close');
- });
- });
-
- describe('when multiple values can be selected and initial value is null', () => {
- it('emits input event with an array of a single selected item', async () => {
- wrapper.setProps({ items, multiple: true, value: null });
-
- await nextTick();
- wrapper.findAll('.js-dropdown-item').at(0).trigger('click');
-
- expect(wrapper.emitted('input')[0]).toEqual([[firstItem.value]]);
- });
- });
-
- describe('when an item is selected and has a custom label property', () => {
- it('displays selected item custom label', async () => {
- const labelProperty = 'customLabel';
- const label = 'Name';
- const currentValue = '1';
- const customLabelItems = [{ [labelProperty]: label, value: currentValue }];
-
- wrapper.setProps({ labelProperty, items: customLabelItems, value: currentValue });
-
- await nextTick();
- expect(wrapper.find(DropdownButton).props('toggleText')).toEqual(label);
- });
- });
-
- describe('when loading', () => {
- it('dropdown button isLoading', async () => {
- await wrapper.setProps({ loading: true });
- expect(wrapper.find(DropdownButton).props('isLoading')).toBe(true);
- });
- });
-
- describe('when loading and loadingText is provided', () => {
- it('uses loading text as toggle button text', async () => {
- const loadingText = 'loading text';
-
- wrapper.setProps({ loading: true, loadingText });
-
- await nextTick();
- expect(wrapper.find(DropdownButton).props('toggleText')).toEqual(loadingText);
- });
- });
-
- describe('when disabled', () => {
- it('dropdown button isDisabled', async () => {
- wrapper.setProps({ disabled: true });
-
- await nextTick();
- expect(wrapper.find(DropdownButton).props('isDisabled')).toBe(true);
- });
- });
-
- describe('when disabled and disabledText is provided', () => {
- it('uses disabled text as toggle button text', async () => {
- const disabledText = 'disabled text';
-
- wrapper.setProps({ disabled: true, disabledText });
-
- await nextTick();
- expect(wrapper.find(DropdownButton).props('toggleText')).toBe(disabledText);
- });
- });
-
- describe('when has errors', () => {
- it('sets border-danger class selector to dropdown toggle', async () => {
- wrapper.setProps({ hasErrors: true });
-
- await nextTick();
- expect(wrapper.find(DropdownButton).classes('border-danger')).toBe(true);
- });
- });
-
- describe('when has errors and an error message', () => {
- it('displays error message', async () => {
- const errorMessage = 'error message';
-
- wrapper.setProps({ hasErrors: true, errorMessage });
-
- await nextTick();
- expect(wrapper.find('.js-eks-dropdown-error-message').text()).toEqual(errorMessage);
- });
- });
-
- describe('when no results are available', () => {
- it('displays empty text', async () => {
- const emptyText = 'error message';
-
- wrapper.setProps({ items: [], emptyText });
-
- await nextTick();
- expect(wrapper.find('.js-empty-text').text()).toEqual(emptyText);
- });
- });
-
- it('displays search field placeholder', async () => {
- const searchFieldPlaceholder = 'Placeholder';
-
- wrapper.setProps({ searchFieldPlaceholder });
-
- await nextTick();
- expect(wrapper.find(DropdownSearchInput).props('placeholderText')).toEqual(
- searchFieldPlaceholder,
- );
- });
-
- it('it filters results by search query', async () => {
- const searchQuery = secondItem.name;
-
- wrapper.setProps({ items });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ searchQuery });
-
- await nextTick();
- expect(wrapper.findAll('.js-dropdown-item').length).toEqual(1);
- expect(wrapper.find('.js-dropdown-item').text()).toEqual(secondItem.name);
- });
-
- it('focuses dropdown search input when dropdown is displayed', async () => {
- const dropdownEl = wrapper.find('.dropdown').element;
-
- expect(wrapper.find(DropdownSearchInput).props('focused')).toBe(false);
-
- $(dropdownEl).trigger('shown.bs.dropdown');
-
- await nextTick();
- expect(wrapper.find(DropdownSearchInput).props('focused')).toBe(true);
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
deleted file mode 100644
index c8020cf8308..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
-
-import CreateEksCluster from '~/create_cluster/eks_cluster/components/create_eks_cluster.vue';
-import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue';
-import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue';
-
-Vue.use(Vuex);
-
-describe('CreateEksCluster', () => {
- let vm;
- let state;
- const gitlabManagedClusterHelpPath = 'gitlab-managed-cluster-help-path';
- const namespacePerEnvironmentHelpPath = 'namespace-per-environment-help-path';
- const accountAndExternalIdsHelpPath = 'account-and-external-id-help-path';
- const createRoleArnHelpPath = 'role-arn-help-path';
- const kubernetesIntegrationHelpPath = 'kubernetes-integration';
- const externalLinkIcon = 'external-link';
-
- beforeEach(() => {
- state = { hasCredentials: false };
- const store = new Vuex.Store({
- state,
- });
-
- vm = shallowMount(CreateEksCluster, {
- propsData: {
- gitlabManagedClusterHelpPath,
- namespacePerEnvironmentHelpPath,
- accountAndExternalIdsHelpPath,
- createRoleArnHelpPath,
- externalLinkIcon,
- kubernetesIntegrationHelpPath,
- },
- store,
- });
- });
- afterEach(() => vm.destroy());
-
- describe('when credentials are provided', () => {
- beforeEach(() => {
- state.hasCredentials = true;
- });
-
- it('displays eks cluster configuration form when credentials are valid', () => {
- expect(vm.find(EksClusterConfigurationForm).exists()).toBe(true);
- });
-
- describe('passes to the cluster configuration form', () => {
- it('help url for kubernetes integration documentation', () => {
- expect(vm.find(EksClusterConfigurationForm).props('gitlabManagedClusterHelpPath')).toBe(
- gitlabManagedClusterHelpPath,
- );
- });
-
- it('help url for namespace per environment cluster documentation', () => {
- expect(vm.find(EksClusterConfigurationForm).props('namespacePerEnvironmentHelpPath')).toBe(
- namespacePerEnvironmentHelpPath,
- );
- });
-
- it('help url for gitlab managed cluster documentation', () => {
- expect(vm.find(EksClusterConfigurationForm).props('kubernetesIntegrationHelpPath')).toBe(
- kubernetesIntegrationHelpPath,
- );
- });
- });
- });
-
- describe('when credentials are invalid', () => {
- beforeEach(() => {
- state.hasCredentials = false;
- });
-
- it('displays service credentials form', () => {
- expect(vm.find(ServiceCredentialsForm).exists()).toBe(true);
- });
-
- describe('passes to the service credentials form', () => {
- it('help url for account and external ids', () => {
- expect(vm.find(ServiceCredentialsForm).props('accountAndExternalIdsHelpPath')).toBe(
- accountAndExternalIdsHelpPath,
- );
- });
-
- it('external link icon', () => {
- expect(vm.find(ServiceCredentialsForm).props('externalLinkIcon')).toBe(externalLinkIcon);
- });
-
- it('help url to create a role ARN', () => {
- expect(vm.find(ServiceCredentialsForm).props('createRoleArnHelpPath')).toBe(
- createRoleArnHelpPath,
- );
- });
- });
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
deleted file mode 100644
index 1509d26c99d..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
+++ /dev/null
@@ -1,562 +0,0 @@
-import { GlFormCheckbox } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import Vuex from 'vuex';
-
-import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue';
-import eksClusterFormState from '~/create_cluster/eks_cluster/store/state';
-import clusterDropdownStoreState from '~/create_cluster/store/cluster_dropdown/state';
-
-Vue.use(Vuex);
-
-describe('EksClusterConfigurationForm', () => {
- let store;
- let actions;
- let getters;
- let state;
- let rolesState;
- let vpcsState;
- let subnetsState;
- let keyPairsState;
- let securityGroupsState;
- let instanceTypesState;
- let vpcsActions;
- let rolesActions;
- let subnetsActions;
- let keyPairsActions;
- let securityGroupsActions;
- let vm;
-
- const createStore = (config = {}) => {
- actions = {
- createCluster: jest.fn(),
- setClusterName: jest.fn(),
- setEnvironmentScope: jest.fn(),
- setKubernetesVersion: jest.fn(),
- setRegion: jest.fn(),
- setVpc: jest.fn(),
- setSubnet: jest.fn(),
- setRole: jest.fn(),
- setKeyPair: jest.fn(),
- setSecurityGroup: jest.fn(),
- setInstanceType: jest.fn(),
- setNodeCount: jest.fn(),
- setGitlabManagedCluster: jest.fn(),
- };
- keyPairsActions = {
- fetchItems: jest.fn(),
- };
- vpcsActions = {
- fetchItems: jest.fn(),
- };
- subnetsActions = {
- fetchItems: jest.fn(),
- };
- rolesActions = {
- fetchItems: jest.fn(),
- };
- securityGroupsActions = {
- fetchItems: jest.fn(),
- };
- state = {
- ...eksClusterFormState(),
- ...config.initialState,
- };
- rolesState = {
- ...clusterDropdownStoreState(),
- ...config.rolesState,
- };
- vpcsState = {
- ...clusterDropdownStoreState(),
- ...config.vpcsState,
- };
- subnetsState = {
- ...clusterDropdownStoreState(),
- ...config.subnetsState,
- };
- keyPairsState = {
- ...clusterDropdownStoreState(),
- ...config.keyPairsState,
- };
- securityGroupsState = {
- ...clusterDropdownStoreState(),
- ...config.securityGroupsState,
- };
- instanceTypesState = {
- ...clusterDropdownStoreState(),
- ...config.instanceTypesState,
- };
- getters = {
- subnetValid: config?.getters?.subnetValid || (() => false),
- };
- store = new Vuex.Store({
- state,
- getters,
- actions,
- modules: {
- vpcs: {
- namespaced: true,
- state: vpcsState,
- actions: vpcsActions,
- },
- subnets: {
- namespaced: true,
- state: subnetsState,
- actions: subnetsActions,
- },
- roles: {
- namespaced: true,
- state: rolesState,
- actions: rolesActions,
- },
- keyPairs: {
- namespaced: true,
- state: keyPairsState,
- actions: keyPairsActions,
- },
- securityGroups: {
- namespaced: true,
- state: securityGroupsState,
- actions: securityGroupsActions,
- },
- instanceTypes: {
- namespaced: true,
- state: instanceTypesState,
- },
- },
- });
- };
-
- const createValidStateStore = (initialState) => {
- createStore({
- initialState: {
- clusterName: 'cluster name',
- environmentScope: '*',
- kubernetesVersion: '1.16',
- selectedRegion: 'region',
- selectedRole: 'role',
- selectedKeyPair: 'key pair',
- selectedVpc: 'vpc',
- selectedSubnet: ['subnet 1', 'subnet 2'],
- selectedSecurityGroup: 'group',
- selectedInstanceType: 'small-1',
- ...initialState,
- },
- getters: {
- subnetValid: () => true,
- },
- });
- };
-
- const buildWrapper = () => {
- vm = shallowMount(EksClusterConfigurationForm, {
- store,
- propsData: {
- gitlabManagedClusterHelpPath: '',
- namespacePerEnvironmentHelpPath: '',
- kubernetesIntegrationHelpPath: '',
- externalLinkIcon: '',
- },
- });
- };
-
- beforeEach(() => {
- createStore();
- buildWrapper();
- });
-
- afterEach(() => {
- vm.destroy();
- });
-
- const findCreateClusterButton = () => vm.find('.js-create-cluster');
- const findClusterNameInput = () => vm.find('[id=eks-cluster-name]');
- const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]');
- const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]');
- const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]');
- const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]');
- const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]');
- const findRoleDropdown = () => vm.find('[field-id="eks-role"]');
- const findSecurityGroupDropdown = () => vm.find('[field-id="eks-security-group"]');
- const findInstanceTypeDropdown = () => vm.find('[field-id="eks-instance-type"');
- const findNodeCountInput = () => vm.find('[id="eks-node-count"]');
- const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox);
-
- describe('when mounted', () => {
- it('fetches available roles', () => {
- expect(rolesActions.fetchItems).toHaveBeenCalled();
- });
-
- describe('when fetching vpcs and key pairs', () => {
- const region = 'us-west-2';
-
- beforeEach(() => {
- createValidStateStore({ selectedRegion: region });
- buildWrapper();
- });
-
- it('fetches available vpcs', () => {
- expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region });
- });
-
- it('fetches available key pairs', () => {
- expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region });
- });
-
- it('cleans selected vpc', () => {
- expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null });
- });
-
- it('cleans selected key pair', () => {
- expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair: null });
- });
-
- it('cleans selected subnet', () => {
- expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] });
- });
-
- it('cleans selected security group', () => {
- expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), {
- securityGroup: null,
- });
- });
- });
- });
-
- it('sets isLoadingRoles to RoleDropdown loading property', async () => {
- rolesState.isLoadingItems = true;
-
- await nextTick();
- expect(findRoleDropdown().props('loading')).toBe(rolesState.isLoadingItems);
- });
-
- it('sets roles to RoleDropdown items property', () => {
- expect(findRoleDropdown().props('items')).toBe(rolesState.items);
- });
-
- it('sets RoleDropdown hasErrors to true when loading roles failed', async () => {
- rolesState.loadingItemsError = new Error();
-
- await nextTick();
- expect(findRoleDropdown().props('hasErrors')).toEqual(true);
- });
-
- it('disables KeyPairDropdown when no region is selected', () => {
- expect(findKeyPairDropdown().props('disabled')).toBe(true);
- });
-
- it('enables KeyPairDropdown when no region is selected', async () => {
- state.selectedRegion = { name: 'west-1 ' };
-
- await nextTick();
- expect(findKeyPairDropdown().props('disabled')).toBe(false);
- });
-
- it('sets isLoadingKeyPairs to KeyPairDropdown loading property', async () => {
- keyPairsState.isLoadingItems = true;
-
- await nextTick();
- expect(findKeyPairDropdown().props('loading')).toBe(keyPairsState.isLoadingItems);
- });
-
- it('sets keyPairs to KeyPairDropdown items property', () => {
- expect(findKeyPairDropdown().props('items')).toBe(keyPairsState.items);
- });
-
- it('sets KeyPairDropdown hasErrors to true when loading key pairs fails', async () => {
- keyPairsState.loadingItemsError = new Error();
-
- await nextTick();
- expect(findKeyPairDropdown().props('hasErrors')).toEqual(true);
- });
-
- it('disables VpcDropdown when no region is selected', () => {
- expect(findVpcDropdown().props('disabled')).toBe(true);
- });
-
- it('enables VpcDropdown when no region is selected', async () => {
- state.selectedRegion = { name: 'west-1 ' };
-
- await nextTick();
- expect(findVpcDropdown().props('disabled')).toBe(false);
- });
-
- it('sets isLoadingVpcs to VpcDropdown loading property', async () => {
- vpcsState.isLoadingItems = true;
-
- await nextTick();
- expect(findVpcDropdown().props('loading')).toBe(vpcsState.isLoadingItems);
- });
-
- it('sets vpcs to VpcDropdown items property', () => {
- expect(findVpcDropdown().props('items')).toBe(vpcsState.items);
- });
-
- it('sets VpcDropdown hasErrors to true when loading vpcs fails', async () => {
- vpcsState.loadingItemsError = new Error();
-
- await nextTick();
- expect(findVpcDropdown().props('hasErrors')).toEqual(true);
- });
-
- it('disables SubnetDropdown when no vpc is selected', () => {
- expect(findSubnetDropdown().props('disabled')).toBe(true);
- });
-
- it('enables SubnetDropdown when a vpc is selected', async () => {
- state.selectedVpc = { name: 'vpc-1 ' };
-
- await nextTick();
- expect(findSubnetDropdown().props('disabled')).toBe(false);
- });
-
- it('sets isLoadingSubnets to SubnetDropdown loading property', async () => {
- subnetsState.isLoadingItems = true;
-
- await nextTick();
- expect(findSubnetDropdown().props('loading')).toBe(subnetsState.isLoadingItems);
- });
-
- it('sets subnets to SubnetDropdown items property', () => {
- expect(findSubnetDropdown().props('items')).toBe(subnetsState.items);
- });
-
- it('displays a validation error in the subnet dropdown when loading subnets fails', () => {
- createStore({
- subnetsState: {
- loadingItemsError: new Error(),
- },
- });
- buildWrapper();
-
- expect(findSubnetDropdown().props('hasErrors')).toEqual(true);
- });
-
- it('displays a validation error in the subnet dropdown when a single subnet is selected', () => {
- createStore({
- initialState: {
- selectedSubnet: ['subnet 1'],
- },
- });
- buildWrapper();
-
- expect(findSubnetDropdown().props('hasErrors')).toEqual(true);
- expect(findSubnetDropdown().props('errorMessage')).toEqual(
- 'You should select at least two subnets',
- );
- });
-
- it('disables SecurityGroupDropdown when no vpc is selected', () => {
- expect(findSecurityGroupDropdown().props('disabled')).toBe(true);
- });
-
- it('enables SecurityGroupDropdown when a vpc is selected', async () => {
- state.selectedVpc = { name: 'vpc-1 ' };
-
- await nextTick();
- expect(findSecurityGroupDropdown().props('disabled')).toBe(false);
- });
-
- it('sets isLoadingSecurityGroups to SecurityGroupDropdown loading property', async () => {
- securityGroupsState.isLoadingItems = true;
-
- await nextTick();
- expect(findSecurityGroupDropdown().props('loading')).toBe(securityGroupsState.isLoadingItems);
- });
-
- it('sets securityGroups to SecurityGroupDropdown items property', () => {
- expect(findSecurityGroupDropdown().props('items')).toBe(securityGroupsState.items);
- });
-
- it('sets SecurityGroupDropdown hasErrors to true when loading security groups fails', async () => {
- securityGroupsState.loadingItemsError = new Error();
-
- await nextTick();
- expect(findSecurityGroupDropdown().props('hasErrors')).toEqual(true);
- });
-
- it('dispatches setClusterName when cluster name input changes', () => {
- const clusterName = 'name';
-
- findClusterNameInput().vm.$emit('input', clusterName);
-
- expect(actions.setClusterName).toHaveBeenCalledWith(expect.anything(), { clusterName });
- });
-
- it('dispatches setEnvironmentScope when environment scope input changes', () => {
- const environmentScope = 'production';
-
- findEnvironmentScopeInput().vm.$emit('input', environmentScope);
-
- expect(actions.setEnvironmentScope).toHaveBeenCalledWith(expect.anything(), {
- environmentScope,
- });
- });
-
- it('dispatches setKubernetesVersion when kubernetes version dropdown changes', () => {
- const kubernetesVersion = { name: '1.11' };
-
- findKubernetesVersionDropdown().vm.$emit('input', kubernetesVersion);
-
- expect(actions.setKubernetesVersion).toHaveBeenCalledWith(expect.anything(), {
- kubernetesVersion,
- });
- });
-
- it('dispatches setGitlabManagedCluster when gitlab managed cluster input changes', () => {
- const gitlabManagedCluster = false;
-
- findGitlabManagedClusterCheckbox().vm.$emit('input', gitlabManagedCluster);
-
- expect(actions.setGitlabManagedCluster).toHaveBeenCalledWith(expect.anything(), {
- gitlabManagedCluster,
- });
- });
-
- describe('when vpc is selected', () => {
- const vpc = { name: 'vpc-1' };
- const region = 'east-1';
-
- beforeEach(() => {
- state.selectedRegion = region;
- findVpcDropdown().vm.$emit('input', vpc);
- });
-
- it('dispatches setVpc action', () => {
- expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc });
- });
-
- it('cleans selected subnet', () => {
- expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] });
- });
-
- it('cleans selected security group', () => {
- expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), {
- securityGroup: null,
- });
- });
-
- it('dispatches fetchSubnets action', () => {
- expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc, region });
- });
-
- it('dispatches fetchSecurityGroups action', () => {
- expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), {
- vpc,
- region,
- });
- });
- });
-
- describe('when a subnet is selected', () => {
- const subnet = { name: 'subnet-1' };
-
- beforeEach(() => {
- findSubnetDropdown().vm.$emit('input', subnet);
- });
-
- it('dispatches setSubnet action', () => {
- expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet });
- });
- });
-
- describe('when role is selected', () => {
- const role = { name: 'admin' };
-
- beforeEach(() => {
- findRoleDropdown().vm.$emit('input', role);
- });
-
- it('dispatches setRole action', () => {
- expect(actions.setRole).toHaveBeenCalledWith(expect.anything(), { role });
- });
- });
-
- describe('when key pair is selected', () => {
- const keyPair = { name: 'key pair' };
-
- beforeEach(() => {
- findKeyPairDropdown().vm.$emit('input', keyPair);
- });
-
- it('dispatches setKeyPair action', () => {
- expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair });
- });
- });
-
- describe('when security group is selected', () => {
- const securityGroup = { name: 'default group' };
-
- beforeEach(() => {
- findSecurityGroupDropdown().vm.$emit('input', securityGroup);
- });
-
- it('dispatches setSecurityGroup action', () => {
- expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { securityGroup });
- });
- });
-
- describe('when instance type is selected', () => {
- const instanceType = 'small-1';
-
- beforeEach(() => {
- findInstanceTypeDropdown().vm.$emit('input', instanceType);
- });
-
- it('dispatches setInstanceType action', () => {
- expect(actions.setInstanceType).toHaveBeenCalledWith(expect.anything(), { instanceType });
- });
- });
-
- it('dispatches setNodeCount when node count input changes', () => {
- const nodeCount = 5;
-
- findNodeCountInput().vm.$emit('input', nodeCount);
-
- expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount });
- });
-
- describe('when all cluster configuration fields are set', () => {
- it('enables create cluster button', () => {
- createValidStateStore();
- buildWrapper();
- expect(findCreateClusterButton().props('disabled')).toBe(false);
- });
- });
-
- describe('when at least one cluster configuration field is not set', () => {
- beforeEach(() => {
- createValidStateStore({
- clusterName: null,
- });
- buildWrapper();
- });
-
- it('disables create cluster button', () => {
- expect(findCreateClusterButton().props('disabled')).toBe(true);
- });
- });
-
- describe('when is creating cluster', () => {
- beforeEach(() => {
- createValidStateStore({
- isCreatingCluster: true,
- });
- buildWrapper();
- });
-
- it('sets create cluster button as loading', () => {
- expect(findCreateClusterButton().props('loading')).toBe(true);
- });
- });
-
- describe('clicking create cluster button', () => {
- beforeEach(() => {
- findCreateClusterButton().vm.$emit('click');
- });
-
- it('dispatches createCluster action', () => {
- expect(actions.createCluster).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js
deleted file mode 100644
index 0d823a18012..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js
+++ /dev/null
@@ -1,124 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import Vuex from 'vuex';
-import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue';
-import eksClusterState from '~/create_cluster/eks_cluster/store/state';
-
-Vue.use(Vuex);
-
-describe('ServiceCredentialsForm', () => {
- let vm;
- let state;
- let createRoleAction;
- const accountId = 'accountId';
- const externalId = 'externalId';
-
- beforeEach(() => {
- state = Object.assign(eksClusterState(), {
- accountId,
- externalId,
- });
- createRoleAction = jest.fn();
-
- const store = new Vuex.Store({
- state,
- actions: {
- createRole: createRoleAction,
- },
- });
- vm = shallowMount(ServiceCredentialsForm, {
- propsData: {
- accountAndExternalIdsHelpPath: '',
- createRoleArnHelpPath: '',
- externalLinkIcon: '',
- },
- store,
- });
- });
- afterEach(() => vm.destroy());
-
- const findAccountIdInput = () => vm.find('#gitlab-account-id');
- const findCopyAccountIdButton = () => vm.find('.js-copy-account-id-button');
- const findExternalIdInput = () => vm.find('#eks-external-id');
- const findCopyExternalIdButton = () => vm.find('.js-copy-external-id-button');
- const findInvalidCredentials = () => vm.find('.js-invalid-credentials');
- const findSubmitButton = () => vm.find(GlButton);
-
- it('displays provided account id', () => {
- expect(findAccountIdInput().attributes('value')).toBe(accountId);
- });
-
- it('allows to copy account id', () => {
- expect(findCopyAccountIdButton().props('text')).toBe(accountId);
- });
-
- it('displays provided external id', () => {
- expect(findExternalIdInput().attributes('value')).toBe(externalId);
- });
-
- it('allows to copy external id', () => {
- expect(findCopyExternalIdButton().props('text')).toBe(externalId);
- });
-
- it('disables submit button when role ARN is not provided', () => {
- expect(findSubmitButton().attributes('disabled')).toBeTruthy();
- });
-
- it('enables submit button when role ARN is not provided', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ roleArn: '123' });
-
- await nextTick();
- expect(findSubmitButton().attributes('disabled')).toBeFalsy();
- });
-
- it('dispatches createRole action when submit button is clicked', () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ roleArn: '123' }); // set role ARN to enable button
-
- findSubmitButton().vm.$emit('click', new Event('click'));
-
- expect(createRoleAction).toHaveBeenCalled();
- });
-
- describe('when is creating role', () => {
- beforeEach(async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ roleArn: '123' }); // set role ARN to enable button
-
- state.isCreatingRole = true;
-
- await nextTick();
- });
-
- it('disables submit button', () => {
- expect(findSubmitButton().props('disabled')).toBe(true);
- });
-
- it('sets submit button as loading', () => {
- expect(findSubmitButton().props('loading')).toBe(true);
- });
-
- it('displays Authenticating label on submit button', () => {
- expect(findSubmitButton().text()).toBe('Authenticating');
- });
- });
-
- describe('when role can’t be created', () => {
- beforeEach(() => {
- state.createRoleError = 'Invalid credentials';
- });
-
- it('displays invalid role warning banner', () => {
- expect(findInvalidCredentials().exists()).toBe(true);
- });
-
- it('displays invalid role error message', () => {
- expect(findInvalidCredentials().text()).toContain(state.createRoleError);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
deleted file mode 100644
index 7b93b6d0a09..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
+++ /dev/null
@@ -1,178 +0,0 @@
-import EC2 from 'aws-sdk/clients/ec2';
-import AWS from 'aws-sdk/global';
-import {
- setAWSConfig,
- fetchRoles,
- fetchKeyPairs,
- fetchVpcs,
- fetchSubnets,
- fetchSecurityGroups,
-} from '~/create_cluster/eks_cluster/services/aws_services_facade';
-
-const mockListRolesPromise = jest.fn();
-const mockDescribeRegionsPromise = jest.fn();
-const mockDescribeKeyPairsPromise = jest.fn();
-const mockDescribeVpcsPromise = jest.fn();
-const mockDescribeSubnetsPromise = jest.fn();
-const mockDescribeSecurityGroupsPromise = jest.fn();
-
-jest.mock('aws-sdk/clients/iam', () =>
- jest.fn().mockImplementation(() => ({
- listRoles: jest.fn().mockReturnValue({ promise: mockListRolesPromise }),
- })),
-);
-
-jest.mock('aws-sdk/clients/ec2', () =>
- jest.fn().mockImplementation(() => ({
- describeRegions: jest.fn().mockReturnValue({ promise: mockDescribeRegionsPromise }),
- describeKeyPairs: jest.fn().mockReturnValue({ promise: mockDescribeKeyPairsPromise }),
- describeVpcs: jest.fn().mockReturnValue({ promise: mockDescribeVpcsPromise }),
- describeSubnets: jest.fn().mockReturnValue({ promise: mockDescribeSubnetsPromise }),
- describeSecurityGroups: jest
- .fn()
- .mockReturnValue({ promise: mockDescribeSecurityGroupsPromise }),
- })),
-);
-
-describe('awsServicesFacade', () => {
- let region;
- let vpc;
-
- beforeEach(() => {
- region = 'west-1';
- vpc = 'vpc-2';
- });
-
- it('setAWSConfig configures AWS SDK with provided credentials', () => {
- const awsCredentials = {
- accessKeyId: 'access-key',
- secretAccessKey: 'secret-key',
- sessionToken: 'session-token',
- region,
- };
-
- setAWSConfig({ awsCredentials });
-
- expect(AWS.config).toEqual(awsCredentials);
- });
-
- describe('when fetchRoles succeeds', () => {
- let roles;
- let rolesOutput;
-
- beforeEach(() => {
- roles = [
- { RoleName: 'admin', Arn: 'aws::admin' },
- { RoleName: 'read-only', Arn: 'aws::read-only' },
- ];
- rolesOutput = roles.map(({ RoleName: name, Arn: value }) => ({ name, value }));
-
- mockListRolesPromise.mockResolvedValueOnce({ Roles: roles });
- });
-
- it('return list of regions where each item has a name and value', () => {
- return expect(fetchRoles()).resolves.toEqual(rolesOutput);
- });
- });
-
- describe('when fetchKeyPairs succeeds', () => {
- let keyPairs;
- let keyPairsOutput;
-
- beforeEach(() => {
- keyPairs = [{ KeyName: 'key-pair' }, { KeyName: 'key-pair-2' }];
- keyPairsOutput = keyPairs.map(({ KeyName: name }) => ({ name, value: name }));
-
- mockDescribeKeyPairsPromise.mockResolvedValueOnce({ KeyPairs: keyPairs });
- });
-
- it('instantatiates ec2 service with provided region', () => {
- fetchKeyPairs({ region });
- expect(EC2).toHaveBeenCalledWith({ region });
- });
-
- it('return list of key pairs where each item has a name and value', () => {
- return expect(fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput);
- });
- });
-
- describe('when fetchVpcs succeeds', () => {
- let vpcs;
- let vpcsOutput;
-
- beforeEach(() => {
- vpcs = [
- { VpcId: 'vpc-1', Tags: [] },
- { VpcId: 'vpc-2', Tags: [] },
- ];
- vpcsOutput = vpcs.map(({ VpcId: vpcId }) => ({ name: vpcId, value: vpcId }));
-
- mockDescribeVpcsPromise.mockResolvedValueOnce({ Vpcs: vpcs });
- });
-
- it('instantatiates ec2 service with provided region', () => {
- fetchVpcs({ region });
- expect(EC2).toHaveBeenCalledWith({ region });
- });
-
- it('return list of vpcs where each item has a name and value', () => {
- return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
- });
- });
-
- describe('when vpcs has a Name tag', () => {
- const vpcName = 'vpc name';
- const vpcId = 'vpc id';
- let vpcs;
- let vpcsOutput;
-
- beforeEach(() => {
- vpcs = [{ VpcId: vpcId, Tags: [{ Key: 'Name', Value: vpcName }] }];
- vpcsOutput = [{ name: vpcName, value: vpcId }];
-
- mockDescribeVpcsPromise.mockResolvedValueOnce({ Vpcs: vpcs });
- });
-
- it('uses name tag value as the vpc name', () => {
- return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
- });
- });
-
- describe('when fetchSubnets succeeds', () => {
- let subnets;
- let subnetsOutput;
-
- beforeEach(() => {
- subnets = [{ SubnetId: 'subnet-1' }, { SubnetId: 'subnet-2' }];
- subnetsOutput = subnets.map(({ SubnetId }) => ({ name: SubnetId, value: SubnetId }));
-
- mockDescribeSubnetsPromise.mockResolvedValueOnce({ Subnets: subnets });
- });
-
- it('return list of subnets where each item has a name and value', () => {
- return expect(fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput);
- });
- });
-
- describe('when fetchSecurityGroups succeeds', () => {
- let securityGroups;
- let securityGroupsOutput;
-
- beforeEach(() => {
- securityGroups = [
- { GroupName: 'admin group', GroupId: 'group-1' },
- { GroupName: 'basic group', GroupId: 'group-2' },
- ];
- securityGroupsOutput = securityGroups.map(({ GroupId: value, GroupName: name }) => ({
- name,
- value,
- }));
-
- mockDescribeSecurityGroupsPromise.mockResolvedValueOnce({ SecurityGroups: securityGroups });
- });
-
- it('return list of security groups where each item has a name and value', () => {
- return expect(fetchSecurityGroups({ region, vpc })).resolves.toEqual(securityGroupsOutput);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
deleted file mode 100644
index 8d7b22fe4ff..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
+++ /dev/null
@@ -1,366 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
-import testAction from 'helpers/vuex_action_helper';
-import { DEFAULT_REGION } from '~/create_cluster/eks_cluster/constants';
-import * as actions from '~/create_cluster/eks_cluster/store/actions';
-import {
- SET_CLUSTER_NAME,
- SET_ENVIRONMENT_SCOPE,
- SET_KUBERNETES_VERSION,
- SET_REGION,
- SET_VPC,
- SET_KEY_PAIR,
- SET_SUBNET,
- SET_ROLE,
- SET_SECURITY_GROUP,
- SET_GITLAB_MANAGED_CLUSTER,
- SET_NAMESPACE_PER_ENVIRONMENT,
- SET_INSTANCE_TYPE,
- SET_NODE_COUNT,
- REQUEST_CREATE_ROLE,
- CREATE_ROLE_SUCCESS,
- CREATE_ROLE_ERROR,
- REQUEST_CREATE_CLUSTER,
- CREATE_CLUSTER_ERROR,
-} from '~/create_cluster/eks_cluster/store/mutation_types';
-import createState from '~/create_cluster/eks_cluster/store/state';
-import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-
-jest.mock('~/flash');
-
-describe('EKS Cluster Store Actions', () => {
- let clusterName;
- let environmentScope;
- let kubernetesVersion;
- let region;
- let vpc;
- let subnet;
- let role;
- let keyPair;
- let securityGroup;
- let instanceType;
- let nodeCount;
- let gitlabManagedCluster;
- let namespacePerEnvironment;
- let mock;
- let state;
- let newClusterUrl;
-
- beforeEach(() => {
- clusterName = 'my cluster';
- environmentScope = 'production';
- kubernetesVersion = '1.16';
- region = 'regions-1';
- vpc = 'vpc-1';
- subnet = 'subnet-1';
- role = 'role-1';
- keyPair = 'key-pair-1';
- securityGroup = 'default group';
- instanceType = 'small-1';
- nodeCount = '5';
- gitlabManagedCluster = true;
- namespacePerEnvironment = true;
-
- newClusterUrl = '/clusters/1';
-
- state = {
- ...createState(),
- createRolePath: '/clusters/roles/',
- createClusterPath: '/clusters/',
- };
- });
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it.each`
- action | mutation | payload | payloadDescription
- ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'}
- ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'}
- ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'}
- ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'}
- ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'}
- ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'}
- ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'}
- ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'}
- ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'}
- ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'}
- ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'}
- ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
- ${'setNamespacePerEnvironment'} | ${SET_NAMESPACE_PER_ENVIRONMENT} | ${namespacePerEnvironment} | ${'namespace per environment'}
- `(`$action commits $mutation with $payloadDescription payload`, (data) => {
- const { action, mutation, payload } = data;
-
- testAction(actions[action], payload, state, [{ type: mutation, payload }]);
- });
-
- describe('createRole', () => {
- const payload = {
- roleArn: 'role_arn',
- externalId: 'externalId',
- };
- const response = {
- accessKeyId: 'access-key-id',
- secretAccessKey: 'secret-key-id',
- };
-
- describe('when request succeeds with default region', () => {
- beforeEach(() => {
- mock
- .onPost(state.createRolePath, {
- role_arn: payload.roleArn,
- role_external_id: payload.externalId,
- region: DEFAULT_REGION,
- })
- .reply(201, response);
- });
-
- it('dispatches createRoleSuccess action', () =>
- testAction(
- actions.createRole,
- payload,
- state,
- [],
- [
- { type: 'requestCreateRole' },
- {
- type: 'createRoleSuccess',
- payload: {
- region: DEFAULT_REGION,
- ...response,
- },
- },
- ],
- ));
- });
-
- describe('when request succeeds with custom region', () => {
- const customRegion = 'custom-region';
-
- beforeEach(() => {
- mock
- .onPost(state.createRolePath, {
- role_arn: payload.roleArn,
- role_external_id: payload.externalId,
- region: customRegion,
- })
- .reply(201, response);
- });
-
- it('dispatches createRoleSuccess action', () =>
- testAction(
- actions.createRole,
- {
- selectedRegion: customRegion,
- ...payload,
- },
- state,
- [],
- [
- { type: 'requestCreateRole' },
- {
- type: 'createRoleSuccess',
- payload: {
- region: customRegion,
- ...response,
- },
- },
- ],
- ));
- });
-
- describe('when request fails', () => {
- let error;
-
- beforeEach(() => {
- error = new Error('Request failed with status code 400');
- mock
- .onPost(state.createRolePath, {
- role_arn: payload.roleArn,
- role_external_id: payload.externalId,
- region: DEFAULT_REGION,
- })
- .reply(400, null);
- });
-
- it('dispatches createRoleError action', () =>
- testAction(
- actions.createRole,
- payload,
- state,
- [],
- [{ type: 'requestCreateRole' }, { type: 'createRoleError', payload: { error } }],
- ));
- });
-
- describe('when request fails with a message', () => {
- beforeEach(() => {
- const errResp = { message: 'Something failed' };
-
- mock
- .onPost(state.createRolePath, {
- role_arn: payload.roleArn,
- role_external_id: payload.externalId,
- region: DEFAULT_REGION,
- })
- .reply(4, errResp);
- });
-
- it('dispatches createRoleError action', () =>
- testAction(
- actions.createRole,
- payload,
- state,
- [],
- [
- { type: 'requestCreateRole' },
- { type: 'createRoleError', payload: { error: 'Something failed' } },
- ],
- ));
- });
- });
-
- describe('requestCreateRole', () => {
- it('commits requestCreaterole mutation', () => {
- testAction(actions.requestCreateRole, null, state, [{ type: REQUEST_CREATE_ROLE }]);
- });
- });
-
- describe('createRoleSuccess', () => {
- it('sets region and commits createRoleSuccess mutation', () => {
- testAction(
- actions.createRoleSuccess,
- { region },
- state,
- [{ type: CREATE_ROLE_SUCCESS }],
- [{ type: 'setRegion', payload: { region } }],
- );
- });
- });
-
- describe('createRoleError', () => {
- it('commits createRoleError mutation', () => {
- const payload = {
- error: new Error(),
- };
-
- testAction(actions.createRoleError, payload, state, [{ type: CREATE_ROLE_ERROR, payload }]);
- });
- });
-
- describe('createCluster', () => {
- let requestPayload;
-
- beforeEach(() => {
- requestPayload = {
- name: clusterName,
- environment_scope: environmentScope,
- managed: gitlabManagedCluster,
- namespace_per_environment: namespacePerEnvironment,
- provider_aws_attributes: {
- kubernetes_version: kubernetesVersion,
- region,
- vpc_id: vpc,
- subnet_ids: subnet,
- role_arn: role,
- key_name: keyPair,
- security_group_id: securityGroup,
- instance_type: instanceType,
- num_nodes: nodeCount,
- },
- };
- state = Object.assign(createState(), {
- clusterName,
- environmentScope,
- kubernetesVersion,
- selectedRegion: region,
- selectedVpc: vpc,
- selectedSubnet: subnet,
- selectedRole: role,
- selectedKeyPair: keyPair,
- selectedSecurityGroup: securityGroup,
- selectedInstanceType: instanceType,
- nodeCount,
- gitlabManagedCluster,
- namespacePerEnvironment,
- });
- });
-
- describe('when request succeeds', () => {
- beforeEach(() => {
- mock.onPost(state.createClusterPath, requestPayload).reply(201, null, {
- location: '/clusters/1',
- });
- });
-
- it('dispatches createClusterSuccess action', () =>
- testAction(
- actions.createCluster,
- null,
- state,
- [],
- [
- { type: 'requestCreateCluster' },
- { type: 'createClusterSuccess', payload: newClusterUrl },
- ],
- ));
- });
-
- describe('when request fails', () => {
- let response;
-
- beforeEach(() => {
- response = 'Request failed with status code 400';
- mock.onPost(state.createClusterPath, requestPayload).reply(400, response);
- });
-
- it('dispatches createRoleError action', () =>
- testAction(
- actions.createCluster,
- null,
- state,
- [],
- [{ type: 'requestCreateCluster' }, { type: 'createClusterError', payload: response }],
- ));
- });
- });
-
- describe('requestCreateCluster', () => {
- it('commits requestCreateCluster mutation', () => {
- testAction(actions.requestCreateCluster, null, state, [{ type: REQUEST_CREATE_CLUSTER }]);
- });
- });
-
- describe('createClusterSuccess', () => {
- useMockLocationHelper();
-
- it('redirects to the new cluster URL', () => {
- actions.createClusterSuccess(null, newClusterUrl);
-
- expect(window.location.assign).toHaveBeenCalledWith(newClusterUrl);
- });
- });
-
- describe('createClusterError', () => {
- let payload;
-
- beforeEach(() => {
- payload = { name: ['Create cluster failed'] };
- });
-
- it('commits createClusterError mutation and displays flash message', () =>
- testAction(actions.createClusterError, payload, state, [
- { type: CREATE_CLUSTER_ERROR, payload },
- ]).then(() => {
- expect(createFlash).toHaveBeenCalledWith({
- message: payload.name[0],
- });
- }));
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/getters_spec.js b/spec/frontend/create_cluster/eks_cluster/store/getters_spec.js
deleted file mode 100644
index 46c37961dd3..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/store/getters_spec.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { subnetValid } from '~/create_cluster/eks_cluster/store/getters';
-
-describe('EKS Cluster Store Getters', () => {
- describe('subnetValid', () => {
- it('returns true if there are 2 or more selected subnets', () => {
- expect(subnetValid({ selectedSubnet: [1, 2] })).toBe(true);
- });
-
- it.each([[[], [1]]])('returns false if there are 1 or less selected subnets', (subnets) => {
- expect(subnetValid({ selectedSubnet: subnets })).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js
deleted file mode 100644
index 54d66e79be7..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js
+++ /dev/null
@@ -1,161 +0,0 @@
-import {
- SET_CLUSTER_NAME,
- SET_ENVIRONMENT_SCOPE,
- SET_KUBERNETES_VERSION,
- SET_REGION,
- SET_VPC,
- SET_KEY_PAIR,
- SET_SUBNET,
- SET_ROLE,
- SET_SECURITY_GROUP,
- SET_INSTANCE_TYPE,
- SET_NODE_COUNT,
- SET_GITLAB_MANAGED_CLUSTER,
- REQUEST_CREATE_ROLE,
- CREATE_ROLE_SUCCESS,
- CREATE_ROLE_ERROR,
- REQUEST_CREATE_CLUSTER,
- CREATE_CLUSTER_ERROR,
-} from '~/create_cluster/eks_cluster/store/mutation_types';
-import mutations from '~/create_cluster/eks_cluster/store/mutations';
-import createState from '~/create_cluster/eks_cluster/store/state';
-
-describe('Create EKS cluster store mutations', () => {
- let clusterName;
- let environmentScope;
- let kubernetesVersion;
- let state;
- let region;
- let vpc;
- let subnet;
- let role;
- let keyPair;
- let securityGroup;
- let instanceType;
- let nodeCount;
- let gitlabManagedCluster;
-
- beforeEach(() => {
- clusterName = 'my cluster';
- environmentScope = 'production';
- kubernetesVersion = '11.1';
- region = { name: 'regions-1' };
- vpc = { name: 'vpc-1' };
- subnet = { name: 'subnet-1' };
- role = { name: 'role-1' };
- keyPair = { name: 'key pair' };
- securityGroup = { name: 'default group' };
- instanceType = 'small-1';
- nodeCount = '5';
- gitlabManagedCluster = false;
-
- state = createState();
- });
-
- it.each`
- mutation | mutatedProperty | payload | expectedValue | expectedValueDescription
- ${SET_CLUSTER_NAME} | ${'clusterName'} | ${{ clusterName }} | ${clusterName} | ${'cluster name'}
- ${SET_ENVIRONMENT_SCOPE} | ${'environmentScope'} | ${{ environmentScope }} | ${environmentScope} | ${'environment scope'}
- ${SET_KUBERNETES_VERSION} | ${'kubernetesVersion'} | ${{ kubernetesVersion }} | ${kubernetesVersion} | ${'kubernetes version'}
- ${SET_ROLE} | ${'selectedRole'} | ${{ role }} | ${role} | ${'selected role payload'}
- ${SET_REGION} | ${'selectedRegion'} | ${{ region }} | ${region} | ${'selected region payload'}
- ${SET_KEY_PAIR} | ${'selectedKeyPair'} | ${{ keyPair }} | ${keyPair} | ${'selected key pair payload'}
- ${SET_VPC} | ${'selectedVpc'} | ${{ vpc }} | ${vpc} | ${'selected vpc payload'}
- ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected subnet payload'}
- ${SET_SECURITY_GROUP} | ${'selectedSecurityGroup'} | ${{ securityGroup }} | ${securityGroup} | ${'selected security group payload'}
- ${SET_INSTANCE_TYPE} | ${'selectedInstanceType'} | ${{ instanceType }} | ${instanceType} | ${'selected instance type payload'}
- ${SET_NODE_COUNT} | ${'nodeCount'} | ${{ nodeCount }} | ${nodeCount} | ${'node count payload'}
- ${SET_GITLAB_MANAGED_CLUSTER} | ${'gitlabManagedCluster'} | ${{ gitlabManagedCluster }} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
- `(`$mutation sets $mutatedProperty to $expectedValueDescription`, (data) => {
- const { mutation, mutatedProperty, payload, expectedValue } = data;
-
- mutations[mutation](state, payload);
- expect(state[mutatedProperty]).toBe(expectedValue);
- });
-
- describe(`mutation ${REQUEST_CREATE_ROLE}`, () => {
- beforeEach(() => {
- mutations[REQUEST_CREATE_ROLE](state);
- });
-
- it('sets isCreatingRole to true', () => {
- expect(state.isCreatingRole).toBe(true);
- });
-
- it('sets createRoleError to null', () => {
- expect(state.createRoleError).toBe(null);
- });
-
- it('sets hasCredentials to false', () => {
- expect(state.hasCredentials).toBe(false);
- });
- });
-
- describe(`mutation ${CREATE_ROLE_SUCCESS}`, () => {
- beforeEach(() => {
- mutations[CREATE_ROLE_SUCCESS](state);
- });
-
- it('sets isCreatingRole to false', () => {
- expect(state.isCreatingRole).toBe(false);
- });
-
- it('sets createRoleError to null', () => {
- expect(state.createRoleError).toBe(null);
- });
-
- it('sets hasCredentials to false', () => {
- expect(state.hasCredentials).toBe(true);
- });
- });
-
- describe(`mutation ${CREATE_ROLE_ERROR}`, () => {
- const error = new Error();
-
- beforeEach(() => {
- mutations[CREATE_ROLE_ERROR](state, { error });
- });
-
- it('sets isCreatingRole to false', () => {
- expect(state.isCreatingRole).toBe(false);
- });
-
- it('sets createRoleError to the error object', () => {
- expect(state.createRoleError).toBe(error);
- });
-
- it('sets hasCredentials to false', () => {
- expect(state.hasCredentials).toBe(false);
- });
- });
-
- describe(`mutation ${REQUEST_CREATE_CLUSTER}`, () => {
- beforeEach(() => {
- mutations[REQUEST_CREATE_CLUSTER](state);
- });
-
- it('sets isCreatingCluster to true', () => {
- expect(state.isCreatingCluster).toBe(true);
- });
-
- it('sets createClusterError to null', () => {
- expect(state.createClusterError).toBe(null);
- });
- });
-
- describe(`mutation ${CREATE_CLUSTER_ERROR}`, () => {
- const error = new Error();
-
- beforeEach(() => {
- mutations[CREATE_CLUSTER_ERROR](state, { error });
- });
-
- it('sets isCreatingRole to false', () => {
- expect(state.isCreatingCluster).toBe(false);
- });
-
- it('sets createRoleError to the error object', () => {
- expect(state.createClusterError).toBe(error);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js
deleted file mode 100644
index f46b84da939..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import Vuex from 'vuex';
-import GkeMachineTypeDropdown from '~/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue';
-import createState from '~/create_cluster/gke_cluster/store/state';
-import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
-import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
-import { selectedMachineTypeMock, gapiMachineTypesResponseMock } from '../mock_data';
-
-const componentConfig = {
- fieldId: 'cluster_provider_gcp_attributes_gcp_machine_type',
- fieldName: 'cluster[provider_gcp_attributes][gcp_machine_type]',
-};
-const setMachineType = jest.fn();
-
-const LABELS = {
- LOADING: 'Fetching machine types',
- DISABLED_NO_PROJECT: 'Select project and zone to choose machine type',
- DISABLED_NO_ZONE: 'Select zone to choose machine type',
- DEFAULT: 'Select machine type',
-};
-
-Vue.use(Vuex);
-
-const createComponent = (store, propsData = componentConfig) =>
- shallowMount(GkeMachineTypeDropdown, {
- propsData,
- store,
- });
-
-const createStore = (initialState = {}, getters = {}) =>
- new Vuex.Store({
- state: {
- ...createState(),
- ...initialState,
- },
- getters: {
- hasZone: () => false,
- ...getters,
- },
- actions: {
- setMachineType,
- },
- });
-
-describe('GkeMachineTypeDropdown', () => {
- let wrapper;
- let store;
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const dropdownButtonLabel = () => wrapper.find(DropdownButton).props('toggleText');
- const dropdownHiddenInputValue = () => wrapper.find(DropdownHiddenInput).props('value');
-
- describe('shows various toggle text depending on state', () => {
- it('returns disabled state toggle text when no project and zone are selected', () => {
- store = createStore({
- projectHasBillingEnabled: false,
- });
- wrapper = createComponent(store);
-
- expect(dropdownButtonLabel()).toBe(LABELS.DISABLED_NO_PROJECT);
- });
-
- it('returns disabled state toggle text when no zone is selected', () => {
- store = createStore({
- projectHasBillingEnabled: true,
- });
- wrapper = createComponent(store);
-
- expect(dropdownButtonLabel()).toBe(LABELS.DISABLED_NO_ZONE);
- });
-
- it('returns loading toggle text', async () => {
- store = createStore();
- wrapper = createComponent(store);
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isLoading: true });
-
- await nextTick();
- expect(dropdownButtonLabel()).toBe(LABELS.LOADING);
- });
-
- it('returns default toggle text', () => {
- store = createStore(
- {
- projectHasBillingEnabled: true,
- },
- { hasZone: () => true },
- );
- wrapper = createComponent(store);
-
- expect(dropdownButtonLabel()).toBe(LABELS.DEFAULT);
- });
-
- it('returns machine type name if machine type selected', () => {
- store = createStore(
- {
- projectHasBillingEnabled: true,
- selectedMachineType: selectedMachineTypeMock,
- },
- { hasZone: () => true },
- );
- wrapper = createComponent(store);
-
- expect(dropdownButtonLabel()).toBe(selectedMachineTypeMock);
- });
- });
-
- describe('form input', () => {
- it('reflects new value when dropdown item is clicked', async () => {
- store = createStore({
- machineTypes: gapiMachineTypesResponseMock.items,
- });
- wrapper = createComponent(store);
-
- expect(dropdownHiddenInputValue()).toBe('');
-
- wrapper.find('.dropdown-content button').trigger('click');
-
- await nextTick();
- expect(setMachineType).toHaveBeenCalledWith(expect.anything(), selectedMachineTypeMock);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js
deleted file mode 100644
index addb0ef72a0..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
-import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
-import GkeNetworkDropdown from '~/create_cluster/gke_cluster/components/gke_network_dropdown.vue';
-import createClusterDropdownState from '~/create_cluster/store/cluster_dropdown/state';
-
-Vue.use(Vuex);
-
-describe('GkeNetworkDropdown', () => {
- let wrapper;
- let store;
- const defaultProps = { fieldName: 'field-name' };
- const selectedNetwork = { selfLink: '123456' };
- const projectId = '6789';
- const region = 'east-1';
- const setNetwork = jest.fn();
- const setSubnetwork = jest.fn();
- const fetchSubnetworks = jest.fn();
-
- const buildStore = ({ clusterDropdownState } = {}) =>
- new Vuex.Store({
- state: {
- selectedNetwork,
- },
- actions: {
- setNetwork,
- setSubnetwork,
- },
- getters: {
- hasZone: () => false,
- region: () => region,
- projectId: () => projectId,
- },
- modules: {
- networks: {
- namespaced: true,
- state: {
- ...createClusterDropdownState(),
- ...(clusterDropdownState || {}),
- },
- },
- subnetworks: {
- namespaced: true,
- actions: {
- fetchItems: fetchSubnetworks,
- },
- },
- },
- });
-
- const buildWrapper = (propsData = defaultProps) =>
- shallowMount(GkeNetworkDropdown, {
- propsData,
- store,
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('sets correct field-name', () => {
- const fieldName = 'field-name';
-
- store = buildStore();
- wrapper = buildWrapper({ fieldName });
-
- expect(wrapper.find(ClusterFormDropdown).props('fieldName')).toBe(fieldName);
- });
-
- it('sets selected network as the dropdown value', () => {
- store = buildStore();
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('value')).toBe(selectedNetwork);
- });
-
- it('maps networks store items to the dropdown items property', () => {
- const items = [{ name: 'network' }];
-
- store = buildStore({ clusterDropdownState: { items } });
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('items')).toBe(items);
- });
-
- describe('when network dropdown store is loading items', () => {
- it('sets network dropdown as loading', () => {
- store = buildStore({ clusterDropdownState: { isLoadingItems: true } });
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('loading')).toBe(true);
- });
- });
-
- describe('when there is no selected zone', () => {
- it('disables the network dropdown', () => {
- store = buildStore();
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('disabled')).toBe(true);
- });
- });
-
- describe('when an error occurs while loading networks', () => {
- it('sets the network dropdown as having errors', () => {
- store = buildStore({ clusterDropdownState: { loadingItemsError: new Error() } });
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('hasErrors')).toBe(true);
- });
- });
-
- describe('when dropdown emits input event', () => {
- beforeEach(() => {
- store = buildStore();
- wrapper = buildWrapper();
- wrapper.find(ClusterFormDropdown).vm.$emit('input', selectedNetwork);
- });
-
- it('cleans selected subnetwork', () => {
- expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), '');
- });
-
- it('dispatches the setNetwork action', () => {
- expect(setNetwork).toHaveBeenCalledWith(expect.anything(), selectedNetwork);
- });
-
- it('fetches subnetworks for the selected project, region, and network', () => {
- expect(fetchSubnetworks).toHaveBeenCalledWith(expect.anything(), {
- project: projectId,
- region,
- network: selectedNetwork.selfLink,
- });
- });
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js
deleted file mode 100644
index 36f8d4bd1e8..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import Vuex from 'vuex';
-import GkeProjectIdDropdown from '~/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue';
-import createState from '~/create_cluster/gke_cluster/store/state';
-import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
-import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
-import { selectedProjectMock, gapiProjectsResponseMock } from '../mock_data';
-
-const componentConfig = {
- docsUrl: 'https://console.cloud.google.com/home/dashboard',
- fieldId: 'cluster_provider_gcp_attributes_gcp_project_id',
- fieldName: 'cluster[provider_gcp_attributes][gcp_project_id]',
-};
-
-const LABELS = {
- LOADING: 'Fetching projects',
- VALIDATING_PROJECT_BILLING: 'Validating project billing status',
- DEFAULT: 'Select project',
- EMPTY: 'No projects found',
-};
-
-Vue.use(Vuex);
-
-describe('GkeProjectIdDropdown', () => {
- let wrapper;
- let vuexStore;
- let setProject;
-
- beforeEach(() => {
- setProject = jest.fn();
- });
-
- const createStore = (initialState = {}, getters = {}) =>
- new Vuex.Store({
- state: {
- ...createState(),
- ...initialState,
- },
- actions: {
- fetchProjects: jest.fn().mockResolvedValueOnce([]),
- setProject,
- },
- getters: {
- hasProject: () => false,
- ...getters,
- },
- });
-
- const createComponent = (store, propsData = componentConfig) =>
- shallowMount(GkeProjectIdDropdown, {
- propsData,
- store,
- });
-
- const bootstrap = (initialState, getters) => {
- vuexStore = createStore(initialState, getters);
- wrapper = createComponent(vuexStore);
- };
-
- const dropdownButtonLabel = () => wrapper.find(DropdownButton).props('toggleText');
- const dropdownHiddenInputValue = () => wrapper.find(DropdownHiddenInput).props('value');
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('toggleText', () => {
- it('returns loading toggle text', () => {
- bootstrap();
-
- expect(dropdownButtonLabel()).toBe(LABELS.LOADING);
- });
-
- it('returns project billing validation text', () => {
- bootstrap({ isValidatingProjectBilling: true });
-
- expect(dropdownButtonLabel()).toBe(LABELS.VALIDATING_PROJECT_BILLING);
- });
-
- it('returns default toggle text', async () => {
- bootstrap();
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isLoading: false });
-
- await nextTick();
- expect(dropdownButtonLabel()).toBe(LABELS.DEFAULT);
- });
-
- it('returns project name if project selected', async () => {
- bootstrap(
- {
- selectedProject: selectedProjectMock,
- },
- {
- hasProject: () => true,
- },
- );
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isLoading: false });
-
- await nextTick();
- expect(dropdownButtonLabel()).toBe(selectedProjectMock.name);
- });
-
- it('returns empty toggle text', async () => {
- bootstrap({
- projects: null,
- });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isLoading: false });
-
- await nextTick();
- expect(dropdownButtonLabel()).toBe(LABELS.EMPTY);
- });
- });
-
- describe('selectItem', () => {
- it('reflects new value when dropdown item is clicked', async () => {
- bootstrap({ projects: gapiProjectsResponseMock.projects });
-
- expect(dropdownHiddenInputValue()).toBe('');
-
- wrapper.find('.dropdown-content button').trigger('click');
-
- await nextTick();
- expect(setProject).toHaveBeenCalledWith(
- expect.anything(),
- gapiProjectsResponseMock.projects[0],
- );
- });
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_submit_button_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_submit_button_spec.js
deleted file mode 100644
index 2bf9158628c..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_submit_button_spec.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
-import GkeSubmitButton from '~/create_cluster/gke_cluster/components/gke_submit_button.vue';
-
-Vue.use(Vuex);
-
-describe('GkeSubmitButton', () => {
- let wrapper;
- let store;
- let hasValidData;
-
- const buildStore = () =>
- new Vuex.Store({
- getters: {
- hasValidData,
- },
- });
-
- const buildWrapper = () =>
- shallowMount(GkeSubmitButton, {
- store,
- });
-
- const bootstrap = () => {
- store = buildStore();
- wrapper = buildWrapper();
- };
-
- beforeEach(() => {
- hasValidData = jest.fn();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('is disabled when hasValidData is false', () => {
- hasValidData.mockReturnValueOnce(false);
- bootstrap();
-
- expect(wrapper.attributes('disabled')).toBe('disabled');
- });
-
- it('is not disabled when hasValidData is true', () => {
- hasValidData.mockReturnValueOnce(true);
- bootstrap();
-
- expect(wrapper.attributes('disabled')).toBeFalsy();
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js
deleted file mode 100644
index 9df680d94b5..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
-import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
-import GkeSubnetworkDropdown from '~/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue';
-import createClusterDropdownState from '~/create_cluster/store/cluster_dropdown/state';
-
-Vue.use(Vuex);
-
-describe('GkeSubnetworkDropdown', () => {
- let wrapper;
- let store;
- const defaultProps = { fieldName: 'field-name' };
- const selectedSubnetwork = '123456';
- const setSubnetwork = jest.fn();
-
- const buildStore = ({ clusterDropdownState } = {}) =>
- new Vuex.Store({
- state: {
- selectedSubnetwork,
- },
- actions: {
- setSubnetwork,
- },
- getters: {
- hasNetwork: () => false,
- },
- modules: {
- subnetworks: {
- namespaced: true,
- state: {
- ...createClusterDropdownState(),
- ...(clusterDropdownState || {}),
- },
- },
- },
- });
-
- const buildWrapper = (propsData = defaultProps) =>
- shallowMount(GkeSubnetworkDropdown, {
- propsData,
- store,
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('sets correct field-name', () => {
- const fieldName = 'field-name';
-
- store = buildStore();
- wrapper = buildWrapper({ fieldName });
-
- expect(wrapper.find(ClusterFormDropdown).props('fieldName')).toBe(fieldName);
- });
-
- it('sets selected subnetwork as the dropdown value', () => {
- store = buildStore();
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('value')).toBe(selectedSubnetwork);
- });
-
- it('maps subnetworks store items to the dropdown items property', () => {
- const items = [{ name: 'subnetwork' }];
-
- store = buildStore({ clusterDropdownState: { items } });
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('items')).toBe(items);
- });
-
- describe('when subnetwork dropdown store is loading items', () => {
- it('sets subnetwork dropdown as loading', () => {
- store = buildStore({ clusterDropdownState: { isLoadingItems: true } });
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('loading')).toBe(true);
- });
- });
-
- describe('when there is no selected network', () => {
- it('disables the subnetwork dropdown', () => {
- store = buildStore();
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('disabled')).toBe(true);
- });
- });
-
- describe('when an error occurs while loading subnetworks', () => {
- it('sets the subnetwork dropdown as having errors', () => {
- store = buildStore({ clusterDropdownState: { loadingItemsError: new Error() } });
- wrapper = buildWrapper();
-
- expect(wrapper.find(ClusterFormDropdown).props('hasErrors')).toBe(true);
- });
- });
-
- describe('when dropdown emits input event', () => {
- it('dispatches the setSubnetwork action', () => {
- store = buildStore();
- wrapper = buildWrapper();
-
- wrapper.find(ClusterFormDropdown).vm.$emit('input', selectedSubnetwork);
-
- expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), selectedSubnetwork);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js
deleted file mode 100644
index 7b4c228b879..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import GkeZoneDropdown from '~/create_cluster/gke_cluster/components/gke_zone_dropdown.vue';
-import { createStore } from '~/create_cluster/gke_cluster/store';
-import {
- SET_PROJECT,
- SET_ZONES,
- SET_PROJECT_BILLING_STATUS,
-} from '~/create_cluster/gke_cluster/store/mutation_types';
-import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
-import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
-import { selectedZoneMock, selectedProjectMock, gapiZonesResponseMock } from '../mock_data';
-
-const propsData = {
- fieldId: 'cluster_provider_gcp_attributes_gcp_zone',
- fieldName: 'cluster[provider_gcp_attributes][gcp_zone]',
-};
-
-const LABELS = {
- LOADING: 'Fetching zones',
- DISABLED: 'Select project to choose zone',
- DEFAULT: 'Select zone',
-};
-
-describe('GkeZoneDropdown', () => {
- let store;
- let wrapper;
-
- beforeEach(() => {
- store = createStore();
- wrapper = shallowMount(GkeZoneDropdown, { propsData, store });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('toggleText', () => {
- let dropdownButton;
-
- beforeEach(() => {
- dropdownButton = wrapper.find(DropdownButton);
- });
-
- it('returns disabled state toggle text', () => {
- expect(dropdownButton.props('toggleText')).toBe(LABELS.DISABLED);
- });
-
- describe('isLoading', () => {
- beforeEach(async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isLoading: true });
- await nextTick();
- });
-
- it('returns loading toggle text', () => {
- expect(dropdownButton.props('toggleText')).toBe(LABELS.LOADING);
- });
- });
-
- describe('project is set', () => {
- beforeEach(async () => {
- wrapper.vm.$store.commit(SET_PROJECT, selectedProjectMock);
- wrapper.vm.$store.commit(SET_PROJECT_BILLING_STATUS, true);
- await nextTick();
- });
-
- it('returns default toggle text', () => {
- expect(dropdownButton.props('toggleText')).toBe(LABELS.DEFAULT);
- });
- });
-
- describe('project is selected', () => {
- beforeEach(async () => {
- wrapper.vm.setItem(selectedZoneMock);
- await nextTick();
- });
-
- it('returns project name if project selected', () => {
- expect(dropdownButton.props('toggleText')).toBe(selectedZoneMock);
- });
- });
- });
-
- describe('selectItem', () => {
- beforeEach(async () => {
- wrapper.vm.$store.commit(SET_ZONES, gapiZonesResponseMock.items);
- await nextTick();
- });
-
- it('reflects new value when dropdown item is clicked', async () => {
- const dropdown = wrapper.find(DropdownHiddenInput);
-
- expect(dropdown.attributes('value')).toBe('');
-
- wrapper.find('.dropdown-content button').trigger('click');
-
- await nextTick();
- expect(dropdown.attributes('value')).toBe(selectedZoneMock);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/gapi_loader_spec.js b/spec/frontend/create_cluster/gke_cluster/gapi_loader_spec.js
deleted file mode 100644
index 9e4d6996340..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/gapi_loader_spec.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import gapiLoader from '~/create_cluster/gke_cluster/gapi_loader';
-
-describe('gapiLoader', () => {
- // A mock for document.head.appendChild to intercept the script tag injection.
- let mockDOMHeadAppendChild;
-
- beforeEach(() => {
- mockDOMHeadAppendChild = jest.spyOn(document.head, 'appendChild');
- });
-
- afterEach(() => {
- mockDOMHeadAppendChild.mockRestore();
- delete window.gapi;
- delete window.gapiPromise;
- delete window.onGapiLoad;
- });
-
- it('returns a promise', () => {
- expect(gapiLoader()).toBeInstanceOf(Promise);
- });
-
- it('returns the same promise when already loading', () => {
- const first = gapiLoader();
- const second = gapiLoader();
- expect(first).toBe(second);
- });
-
- it('resolves the promise when the script loads correctly', async () => {
- mockDOMHeadAppendChild.mockImplementationOnce((script) => {
- script.removeAttribute('src');
- script.appendChild(
- document.createTextNode(`window.gapi = 'hello gapi'; window.onGapiLoad()`),
- );
- document.head.appendChild(script);
- });
- await expect(gapiLoader()).resolves.toBe('hello gapi');
- expect(mockDOMHeadAppendChild).toHaveBeenCalled();
- });
-
- it('rejects the promise when the script fails loading', async () => {
- mockDOMHeadAppendChild.mockImplementationOnce((script) => {
- script.onerror(new Error('hello error'));
- });
- await expect(gapiLoader()).rejects.toThrow('hello error');
- expect(mockDOMHeadAppendChild).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/helpers.js b/spec/frontend/create_cluster/gke_cluster/helpers.js
deleted file mode 100644
index 026e99fa8f4..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/helpers.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import {
- gapiProjectsResponseMock,
- gapiZonesResponseMock,
- gapiMachineTypesResponseMock,
-} from './mock_data';
-
-const cloudbilling = {
- projects: {
- getBillingInfo: jest.fn(
- () =>
- new Promise((resolve) => {
- resolve({
- result: { billingEnabled: true },
- });
- }),
- ),
- },
-};
-
-const cloudresourcemanager = {
- projects: {
- list: jest.fn(
- () =>
- new Promise((resolve) => {
- resolve({
- result: { ...gapiProjectsResponseMock },
- });
- }),
- ),
- },
-};
-
-const compute = {
- zones: {
- list: jest.fn(
- () =>
- new Promise((resolve) => {
- resolve({
- result: { ...gapiZonesResponseMock },
- });
- }),
- ),
- },
- machineTypes: {
- list: jest.fn(
- () =>
- new Promise((resolve) => {
- resolve({
- result: { ...gapiMachineTypesResponseMock },
- });
- }),
- ),
- },
-};
-
-const gapi = {
- client: {
- cloudbilling,
- cloudresourcemanager,
- compute,
- },
-};
-
-export { gapi as default };
diff --git a/spec/frontend/create_cluster/gke_cluster/mock_data.js b/spec/frontend/create_cluster/gke_cluster/mock_data.js
deleted file mode 100644
index d9f5dbc636f..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/mock_data.js
+++ /dev/null
@@ -1,75 +0,0 @@
-export const emptyProjectMock = {
- projectId: '',
- name: '',
-};
-
-export const selectedProjectMock = {
- projectId: 'gcp-project-123',
- name: 'gcp-project',
-};
-
-export const selectedZoneMock = 'us-central1-a';
-
-export const selectedMachineTypeMock = 'n1-standard-2';
-
-export const gapiProjectsResponseMock = {
- projects: [
- {
- projectNumber: '1234',
- projectId: 'gcp-project-123',
- lifecycleState: 'ACTIVE',
- name: 'gcp-project',
- createTime: '2017-12-16T01:48:29.129Z',
- parent: {
- type: 'organization',
- id: '12345',
- },
- },
- ],
-};
-
-export const gapiZonesResponseMock = {
- kind: 'compute#zoneList',
- id: 'projects/gitlab-internal-153318/zones',
- items: [
- {
- kind: 'compute#zone',
- id: '2000',
- creationTimestamp: '1969-12-31T16:00:00.000-08:00',
- name: 'us-central1-a',
- description: 'us-central1-a',
- status: 'UP',
- region:
- 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/regions/us-central1',
- selfLink:
- 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a',
- availableCpuPlatforms: ['Intel Skylake', 'Intel Broadwell', 'Intel Sandy Bridge'],
- },
- ],
- selfLink: 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones',
-};
-
-export const gapiMachineTypesResponseMock = {
- kind: 'compute#machineTypeList',
- id: 'projects/gitlab-internal-153318/zones/us-central1-a/machineTypes',
- items: [
- {
- kind: 'compute#machineType',
- id: '3002',
- creationTimestamp: '1969-12-31T16:00:00.000-08:00',
- name: 'n1-standard-2',
- description: '2 vCPUs, 7.5 GB RAM',
- guestCpus: 2,
- memoryMb: 7680,
- imageSpaceGb: 10,
- maximumPersistentDisks: 64,
- maximumPersistentDisksSizeGb: '65536',
- zone: 'us-central1-a',
- selfLink:
- 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes/n1-standard-2',
- isSharedCpu: false,
- },
- ],
- selfLink:
- 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes',
-};
diff --git a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js
deleted file mode 100644
index c365cb6a9f4..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js
+++ /dev/null
@@ -1,141 +0,0 @@
-import testAction from 'helpers/vuex_action_helper';
-import * as actions from '~/create_cluster/gke_cluster/store/actions';
-import * as types from '~/create_cluster/gke_cluster/store/mutation_types';
-import createState from '~/create_cluster/gke_cluster/store/state';
-import gapi from '../helpers';
-import {
- selectedProjectMock,
- selectedZoneMock,
- selectedMachineTypeMock,
- gapiProjectsResponseMock,
- gapiZonesResponseMock,
- gapiMachineTypesResponseMock,
-} from '../mock_data';
-
-describe('GCP Cluster Dropdown Store Actions', () => {
- describe('setProject', () => {
- it('should set project', () => {
- return testAction(
- actions.setProject,
- selectedProjectMock,
- { selectedProject: {} },
- [{ type: 'SET_PROJECT', payload: selectedProjectMock }],
- [],
- );
- });
- });
-
- describe('setZone', () => {
- it('should set zone', () => {
- return testAction(
- actions.setZone,
- selectedZoneMock,
- { selectedZone: '' },
- [{ type: 'SET_ZONE', payload: selectedZoneMock }],
- [],
- );
- });
- });
-
- describe('setMachineType', () => {
- it('should set machine type', () => {
- return testAction(
- actions.setMachineType,
- selectedMachineTypeMock,
- { selectedMachineType: '' },
- [{ type: 'SET_MACHINE_TYPE', payload: selectedMachineTypeMock }],
- [],
- );
- });
- });
-
- describe('setIsValidatingProjectBilling', () => {
- it('should set machine type', () => {
- return testAction(
- actions.setIsValidatingProjectBilling,
- true,
- { isValidatingProjectBilling: null },
- [{ type: 'SET_IS_VALIDATING_PROJECT_BILLING', payload: true }],
- [],
- );
- });
- });
-
- describe('async fetch methods', () => {
- let originalGapi;
-
- beforeAll(() => {
- originalGapi = window.gapi;
- window.gapi = gapi;
- window.gapiPromise = Promise.resolve(gapi);
- });
-
- afterAll(() => {
- window.gapi = originalGapi;
- delete window.gapiPromise;
- });
-
- describe('fetchProjects', () => {
- it('fetches projects from Google API', () => {
- const state = createState();
-
- return testAction(
- actions.fetchProjects,
- null,
- state,
- [{ type: types.SET_PROJECTS, payload: gapiProjectsResponseMock.projects }],
- [],
- );
- });
- });
-
- describe('validateProjectBilling', () => {
- it('checks project billing status from Google API', () => {
- return testAction(
- actions.validateProjectBilling,
- true,
- {
- selectedProject: selectedProjectMock,
- selectedZone: '',
- selectedMachineType: '',
- projectHasBillingEnabled: null,
- },
- [
- { type: 'SET_ZONE', payload: '' },
- { type: 'SET_MACHINE_TYPE', payload: '' },
- { type: 'SET_PROJECT_BILLING_STATUS', payload: true },
- ],
- [{ type: 'setIsValidatingProjectBilling', payload: false }],
- );
- });
- });
-
- describe('fetchZones', () => {
- it('fetches zones from Google API', () => {
- const state = createState();
-
- return testAction(
- actions.fetchZones,
- null,
- state,
- [{ type: types.SET_ZONES, payload: gapiZonesResponseMock.items }],
- [],
- );
- });
- });
-
- describe('fetchMachineTypes', () => {
- it('fetches machine types from Google API', () => {
- const state = createState();
-
- return testAction(
- actions.fetchMachineTypes,
- null,
- state,
- [{ type: types.SET_MACHINE_TYPES, payload: gapiMachineTypesResponseMock.items }],
- [],
- );
- });
- });
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/stores/getters_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/getters_spec.js
deleted file mode 100644
index 39106c3f6ca..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/stores/getters_spec.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import {
- hasProject,
- hasZone,
- hasMachineType,
- hasValidData,
-} from '~/create_cluster/gke_cluster/store/getters';
-import { selectedProjectMock, selectedZoneMock, selectedMachineTypeMock } from '../mock_data';
-
-describe('GCP Cluster Dropdown Store Getters', () => {
- let state;
-
- describe('valid states', () => {
- beforeEach(() => {
- state = {
- projectHasBillingEnabled: true,
- selectedProject: selectedProjectMock,
- selectedZone: selectedZoneMock,
- selectedMachineType: selectedMachineTypeMock,
- };
- });
-
- describe('hasProject', () => {
- it('should return true when project is selected', () => {
- expect(hasProject(state)).toEqual(true);
- });
- });
-
- describe('hasZone', () => {
- it('should return true when zone is selected', () => {
- expect(hasZone(state)).toEqual(true);
- });
- });
-
- describe('hasMachineType', () => {
- it('should return true when machine type is selected', () => {
- expect(hasMachineType(state)).toEqual(true);
- });
- });
-
- describe('hasValidData', () => {
- it('should return true when a project, zone and machine type are selected', () => {
- expect(hasValidData(state, { hasZone: true, hasMachineType: true })).toEqual(true);
- });
- });
- });
-
- describe('invalid states', () => {
- beforeEach(() => {
- state = {
- selectedProject: {
- projectId: '',
- name: '',
- },
- selectedZone: '',
- selectedMachineType: '',
- };
- });
-
- describe('hasProject', () => {
- it('should return false when project is not selected', () => {
- expect(hasProject(state)).toEqual(false);
- });
- });
-
- describe('hasZone', () => {
- it('should return false when zone is not selected', () => {
- expect(hasZone(state)).toEqual(false);
- });
- });
-
- describe('hasMachineType', () => {
- it('should return false when machine type is not selected', () => {
- expect(hasMachineType(state)).toEqual(false);
- });
- });
-
- describe('hasValidData', () => {
- let getters;
-
- beforeEach(() => {
- getters = { hasZone: true, hasMachineType: true };
- });
-
- it('should return false when project is not billable', () => {
- state.projectHasBillingEnabled = false;
-
- expect(hasValidData(state, getters)).toEqual(false);
- });
-
- it('should return false when zone is not selected', () => {
- getters.hasZone = false;
-
- expect(hasValidData(state, getters)).toEqual(false);
- });
-
- it('should return false when machine type is not selected', () => {
- getters.hasMachineType = false;
-
- expect(hasValidData(state, getters)).toEqual(false);
- });
- });
- });
-});
diff --git a/spec/frontend/create_cluster/gke_cluster/stores/mutations_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/mutations_spec.js
deleted file mode 100644
index 4493d49af43..00000000000
--- a/spec/frontend/create_cluster/gke_cluster/stores/mutations_spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import * as types from '~/create_cluster/gke_cluster/store/mutation_types';
-import mutations from '~/create_cluster/gke_cluster/store/mutations';
-import createState from '~/create_cluster/gke_cluster/store/state';
-import {
- gapiProjectsResponseMock,
- gapiZonesResponseMock,
- gapiMachineTypesResponseMock,
-} from '../mock_data';
-
-describe('GCP Cluster Dropdown Store Mutations', () => {
- describe.each`
- mutation | stateProperty | mockData
- ${types.SET_PROJECTS} | ${'projects'} | ${gapiProjectsResponseMock.projects}
- ${types.SET_ZONES} | ${'zones'} | ${gapiZonesResponseMock.items}
- ${types.SET_MACHINE_TYPES} | ${'machineTypes'} | ${gapiMachineTypesResponseMock.items}
- ${types.SET_MACHINE_TYPE} | ${'selectedMachineType'} | ${gapiMachineTypesResponseMock.items[0].name}
- ${types.SET_ZONE} | ${'selectedZone'} | ${gapiZonesResponseMock.items[0].name}
- ${types.SET_PROJECT} | ${'selectedProject'} | ${gapiProjectsResponseMock.projects[0]}
- ${types.SET_PROJECT_BILLING_STATUS} | ${'projectHasBillingEnabled'} | ${true}
- ${types.SET_IS_VALIDATING_PROJECT_BILLING} | ${'isValidatingProjectBilling'} | ${true}
- `('$mutation', ({ mutation, stateProperty, mockData }) => {
- it(`should set the mutation payload to the ${stateProperty} state property`, () => {
- const state = createState();
-
- expect(state[stateProperty]).not.toBe(mockData);
-
- mutations[mutation](state, mockData);
-
- expect(state[stateProperty]).toBe(mockData);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/init_create_cluster_spec.js b/spec/frontend/create_cluster/init_create_cluster_spec.js
deleted file mode 100644
index 42d1ceed864..00000000000
--- a/spec/frontend/create_cluster/init_create_cluster_spec.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import initGkeDropdowns from '~/create_cluster/gke_cluster';
-import initGkeNamespace from '~/create_cluster/gke_cluster_namespace';
-import initCreateCluster from '~/create_cluster/init_create_cluster';
-import PersistentUserCallout from '~/persistent_user_callout';
-
-// This import is loaded dynamically in `init_create_cluster`.
-// Let's eager import it here so that the first spec doesn't timeout.
-// https://gitlab.com/gitlab-org/gitlab/issues/118499
-import '~/create_cluster/eks_cluster';
-
-jest.mock('~/create_cluster/gke_cluster', () => jest.fn());
-jest.mock('~/create_cluster/gke_cluster_namespace', () => jest.fn());
-jest.mock('~/persistent_user_callout', () => ({
- factory: jest.fn(),
-}));
-
-describe('initCreateCluster', () => {
- let document;
- let gon;
-
- beforeEach(() => {
- document = {
- body: { dataset: {} },
- querySelector: jest.fn(),
- };
- gon = { features: {} };
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe.each`
- pageSuffix | page
- ${':clusters:new'} | ${'project:clusters:new'}
- ${':clusters:create_gcp'} | ${'groups:clusters:create_gcp'}
- ${':clusters:create_user'} | ${'admin:clusters:create_user'}
- `('when cluster page ends in $pageSuffix', ({ page }) => {
- beforeEach(() => {
- document.body.dataset = { page };
-
- initCreateCluster(document, gon);
- });
-
- it('initializes create GKE cluster app', () => {
- expect(initGkeDropdowns).toHaveBeenCalled();
- });
-
- it('initializes gcp signup offer banner', () => {
- expect(PersistentUserCallout.factory).toHaveBeenCalled();
- });
- });
-
- describe('when creating a project level cluster', () => {
- it('initializes gke namespace app', () => {
- document.body.dataset.page = 'project:clusters:new';
-
- initCreateCluster(document, gon);
-
- expect(initGkeNamespace).toHaveBeenCalled();
- });
- });
-
- describe.each`
- clusterLevel | page
- ${'group level'} | ${'groups:clusters:new'}
- ${'instance level'} | ${'admin:clusters:create_gcp'}
- `('when creating a $clusterLevel cluster', ({ page }) => {
- it('does not initialize gke namespace app', () => {
- document.body.dataset = { page };
-
- initCreateCluster(document, gon);
-
- expect(initGkeNamespace).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js b/spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js
deleted file mode 100644
index c0e8b11cf1e..00000000000
--- a/spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import testAction from 'helpers/vuex_action_helper';
-
-import actionsFactory from '~/create_cluster/store/cluster_dropdown/actions';
-import * as types from '~/create_cluster/store/cluster_dropdown/mutation_types';
-import createState from '~/create_cluster/store/cluster_dropdown/state';
-
-describe('Cluster dropdown Store Actions', () => {
- const items = [{ name: 'item 1' }];
- let fetchFn;
- let actions;
-
- beforeEach(() => {
- fetchFn = jest.fn();
- actions = actionsFactory(fetchFn);
- });
-
- describe('fetchItems', () => {
- describe('on success', () => {
- beforeEach(() => {
- fetchFn.mockResolvedValueOnce(items);
- actions = actionsFactory(fetchFn);
- });
-
- it('dispatches success with received items', () =>
- testAction(
- actions.fetchItems,
- null,
- createState(),
- [],
- [
- { type: 'requestItems' },
- {
- type: 'receiveItemsSuccess',
- payload: { items },
- },
- ],
- ));
- });
-
- describe('on failure', () => {
- const error = new Error('Could not fetch items');
-
- beforeEach(() => {
- fetchFn.mockRejectedValueOnce(error);
- });
-
- it('dispatches success with received items', () =>
- testAction(
- actions.fetchItems,
- null,
- createState(),
- [],
- [
- { type: 'requestItems' },
- {
- type: 'receiveItemsError',
- payload: { error },
- },
- ],
- ));
- });
- });
-
- describe('requestItems', () => {
- it(`commits ${types.REQUEST_ITEMS} mutation`, () =>
- testAction(actions.requestItems, null, createState(), [{ type: types.REQUEST_ITEMS }]));
- });
-
- describe('receiveItemsSuccess', () => {
- it(`commits ${types.RECEIVE_ITEMS_SUCCESS} mutation`, () =>
- testAction(actions.receiveItemsSuccess, { items }, createState(), [
- {
- type: types.RECEIVE_ITEMS_SUCCESS,
- payload: {
- items,
- },
- },
- ]));
- });
-
- describe('receiveItemsError', () => {
- it(`commits ${types.RECEIVE_ITEMS_ERROR} mutation`, () => {
- const error = new Error('Error fetching items');
-
- testAction(actions.receiveItemsError, { error }, createState(), [
- {
- type: types.RECEIVE_ITEMS_ERROR,
- payload: {
- error,
- },
- },
- ]);
- });
- });
-});
diff --git a/spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js b/spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js
deleted file mode 100644
index 197fcfc2600..00000000000
--- a/spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import {
- REQUEST_ITEMS,
- RECEIVE_ITEMS_SUCCESS,
- RECEIVE_ITEMS_ERROR,
-} from '~/create_cluster/store/cluster_dropdown/mutation_types';
-import mutations from '~/create_cluster/store/cluster_dropdown/mutations';
-import createState from '~/create_cluster/store/cluster_dropdown/state';
-
-describe('Cluster dropdown store mutations', () => {
- let state;
- let emptyPayload;
- let items;
- let error;
-
- beforeEach(() => {
- emptyPayload = {};
- items = [{ name: 'item 1' }];
- error = new Error('could not load error');
- state = createState();
- });
-
- it.each`
- mutation | mutatedProperty | payload | expectedValue | expectedValueDescription
- ${REQUEST_ITEMS} | ${'isLoadingItems'} | ${emptyPayload} | ${true} | ${true}
- ${REQUEST_ITEMS} | ${'loadingItemsError'} | ${emptyPayload} | ${null} | ${null}
- ${RECEIVE_ITEMS_SUCCESS} | ${'isLoadingItems'} | ${{ items }} | ${false} | ${false}
- ${RECEIVE_ITEMS_SUCCESS} | ${'items'} | ${{ items }} | ${items} | ${'items payload'}
- ${RECEIVE_ITEMS_ERROR} | ${'isLoadingItems'} | ${{ error }} | ${false} | ${false}
- ${RECEIVE_ITEMS_ERROR} | ${'error'} | ${{ error }} | ${error} | ${'received error object'}
- `(`$mutation sets $mutatedProperty to $expectedValueDescription`, (data) => {
- const { mutation, mutatedProperty, payload, expectedValue } = data;
-
- mutations[mutation](state, payload);
- expect(state[mutatedProperty]).toBe(expectedValue);
- });
-});
diff --git a/spec/frontend/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js
index 143ccb9b930..aea4bc6017d 100644
--- a/spec/frontend/create_item_dropdown_spec.js
+++ b/spec/frontend/create_item_dropdown_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import CreateItemDropdown from '~/create_item_dropdown';
const DROPDOWN_ITEM_DATA = [
@@ -41,12 +42,13 @@ describe('CreateItemDropdown', () => {
}
beforeEach(() => {
- loadFixtures('static/create_item_dropdown.html');
+ loadHTMLFixture('static/create_item_dropdown.html');
$wrapperEl = $('.js-create-item-dropdown-fixture-root');
});
afterEach(() => {
$wrapperEl.remove();
+ resetHTMLFixture();
});
describe('items', () => {
diff --git a/spec/frontend/crm/contact_form_wrapper_spec.js b/spec/frontend/crm/contact_form_wrapper_spec.js
index 6307889a7aa..5e1743701e4 100644
--- a/spec/frontend/crm/contact_form_wrapper_spec.js
+++ b/spec/frontend/crm/contact_form_wrapper_spec.js
@@ -1,22 +1,23 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
import ContactFormWrapper from '~/crm/contacts/components/contact_form_wrapper.vue';
import ContactForm from '~/crm/components/form.vue';
import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql';
import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql';
+import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
+import { getGroupContactsQueryResponse, getGroupOrganizationsQueryResponse } from './mock_data';
describe('Customer relations contact form wrapper', () => {
+ Vue.use(VueApollo);
let wrapper;
+ let fakeApollo;
const findContactForm = () => wrapper.findComponent(ContactForm);
- const $apollo = {
- queries: {
- contacts: {
- loading: false,
- },
- },
- };
const $route = {
params: {
id: 7,
@@ -33,56 +34,79 @@ describe('Customer relations contact form wrapper', () => {
groupFullPath: 'flightjs',
groupId: 26,
},
- mocks: {
- $apollo,
- $route,
- },
+ apolloProvider: fakeApollo,
+ mocks: { $route },
});
};
+ beforeEach(() => {
+ fakeApollo = createMockApollo([
+ [getGroupContactsQuery, jest.fn().mockResolvedValue(getGroupContactsQueryResponse)],
+ [getGroupOrganizationsQuery, jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse)],
+ ]);
+ });
+
afterEach(() => {
wrapper.destroy();
+ fakeApollo = null;
});
- describe('in edit mode', () => {
- it('should render contact form with correct props', () => {
- mountComponent({ isEditMode: true });
+ describe.each`
+ mode | title | successMessage | mutation | existingId
+ ${'edit'} | ${'Edit contact'} | ${'Contact has been updated.'} | ${updateContactMutation} | ${contacts[0].id}
+ ${'create'} | ${'New contact'} | ${'Contact has been added.'} | ${createContactMutation} | ${null}
+ `('in $mode mode', ({ mode, title, successMessage, mutation, existingId }) => {
+ beforeEach(() => {
+ const isEditMode = mode === 'edit';
+ mountComponent({ isEditMode });
- const contactForm = findContactForm();
- expect(contactForm.props('fields')).toHaveLength(5);
- expect(contactForm.props('title')).toBe('Edit contact');
- expect(contactForm.props('successMessage')).toBe('Contact has been updated.');
- expect(contactForm.props('mutation')).toBe(updateContactMutation);
- expect(contactForm.props('getQuery')).toMatchObject({
- query: getGroupContactsQuery,
- variables: { groupFullPath: 'flightjs' },
- });
- expect(contactForm.props('getQueryNodePath')).toBe('group.contacts');
- expect(contactForm.props('existingId')).toBe(contacts[0].id);
- expect(contactForm.props('additionalCreateParams')).toMatchObject({
- groupId: 'gid://gitlab/Group/26',
- });
+ return waitForPromises();
+ });
+
+ it('renders correct getQuery prop', () => {
+ expect(findContactForm().props('getQueryNodePath')).toBe('group.contacts');
});
- });
- describe('in create mode', () => {
- it('should render contact form with correct props', () => {
- mountComponent();
+ it('renders correct mutation prop', () => {
+ expect(findContactForm().props('mutation')).toBe(mutation);
+ });
- const contactForm = findContactForm();
- expect(contactForm.props('fields')).toHaveLength(5);
- expect(contactForm.props('title')).toBe('New contact');
- expect(contactForm.props('successMessage')).toBe('Contact has been added.');
- expect(contactForm.props('mutation')).toBe(createContactMutation);
- expect(contactForm.props('getQuery')).toMatchObject({
- query: getGroupContactsQuery,
- variables: { groupFullPath: 'flightjs' },
- });
- expect(contactForm.props('getQueryNodePath')).toBe('group.contacts');
- expect(contactForm.props('existingId')).toBeNull();
- expect(contactForm.props('additionalCreateParams')).toMatchObject({
+ it('renders correct additionalCreateParams prop', () => {
+ expect(findContactForm().props('additionalCreateParams')).toMatchObject({
groupId: 'gid://gitlab/Group/26',
});
});
+
+ it('renders correct existingId prop', () => {
+ expect(findContactForm().props('existingId')).toBe(existingId);
+ });
+
+ it('renders correct fields prop', () => {
+ expect(findContactForm().props('fields')).toEqual([
+ { name: 'firstName', label: 'First name', required: true },
+ { name: 'lastName', label: 'Last name', required: true },
+ { name: 'email', label: 'Email', required: true },
+ { name: 'phone', label: 'Phone' },
+ {
+ name: 'organizationId',
+ label: 'Organization',
+ values: [
+ { text: 'No organization', value: null },
+ { text: 'ABC Company', value: 'gid://gitlab/CustomerRelations::Organization/2' },
+ { text: 'GitLab', value: 'gid://gitlab/CustomerRelations::Organization/3' },
+ { text: 'Test Inc', value: 'gid://gitlab/CustomerRelations::Organization/1' },
+ ],
+ },
+ { name: 'description', label: 'Description' },
+ ]);
+ });
+
+ it('renders correct title prop', () => {
+ expect(findContactForm().props('title')).toBe(title);
+ });
+
+ it('renders correct successMessage prop', () => {
+ expect(findContactForm().props('successMessage')).toBe(successMessage);
+ });
});
});
diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js
index b02d94e9cb1..3a6989a00f1 100644
--- a/spec/frontend/crm/contacts_root_spec.js
+++ b/spec/frontend/crm/contacts_root_spec.js
@@ -105,7 +105,7 @@ describe('Customer relations contacts root app', () => {
const issueLink = findIssuesLinks().at(0);
expect(issueLink.exists()).toBe(true);
- expect(issueLink.attributes('href')).toBe('/issues?scope=all&state=opened&crm_contact_id=16');
+ expect(issueLink.attributes('href')).toBe('/issues?crm_contact_id=16');
});
});
});
diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/form_spec.js
index 5c349b24ea1..d39f0795f5f 100644
--- a/spec/frontend/crm/form_spec.js
+++ b/spec/frontend/crm/form_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert } from '@gitlab/ui';
+import { GlAlert, GlFormInput, GlFormSelect, GlFormGroup } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
@@ -100,6 +100,14 @@ describe('Reusable form component', () => {
{ name: 'email', label: 'Email', required: true },
{ name: 'phone', label: 'Phone' },
{ name: 'description', label: 'Description' },
+ {
+ name: 'organizationId',
+ label: 'Organization',
+ values: [
+ { key: 'gid://gitlab/CustomerRelations::Organization/1', value: 'GitLab' },
+ { key: 'gid://gitlab/CustomerRelations::Organization/2', value: 'ABC Corp' },
+ ],
+ },
],
getQuery: {
query: getGroupContactsQuery,
@@ -270,4 +278,51 @@ describe('Reusable form component', () => {
});
},
);
+
+ describe('edit form', () => {
+ beforeEach(() => {
+ mountContactUpdate();
+ });
+
+ it.each`
+ index | id | componentName | value
+ ${0} | ${'firstName'} | ${'GlFormInput'} | ${'Marty'}
+ ${1} | ${'lastName'} | ${'GlFormInput'} | ${'McFly'}
+ ${2} | ${'email'} | ${'GlFormInput'} | ${'example@gitlab.com'}
+ ${4} | ${'description'} | ${'GlFormInput'} | ${undefined}
+ ${3} | ${'phone'} | ${'GlFormInput'} | ${undefined}
+ ${5} | ${'organizationId'} | ${'GlFormSelect'} | ${'gid://gitlab/CustomerRelations::Organization/2'}
+ `(
+ 'should render a $componentName for #$id with the value "$value"',
+ ({ index, id, componentName, value }) => {
+ const component = componentName === 'GlFormInput' ? GlFormInput : GlFormSelect;
+ const findFormGroup = (at) => wrapper.findAllComponents(GlFormGroup).at(at);
+ const findFormElement = () => findFormGroup(index).find(component);
+
+ expect(findFormElement().attributes('id')).toBe(id);
+ expect(findFormElement().attributes('value')).toBe(value);
+ },
+ );
+
+ it('should include updated values in update mutation', () => {
+ wrapper.find('#firstName').vm.$emit('input', 'Michael');
+ wrapper
+ .find('#organizationId')
+ .vm.$emit('input', 'gid://gitlab/CustomerRelations::Organization/1');
+
+ findForm().trigger('submit');
+
+ expect(handler).toHaveBeenCalledWith('updateContact', {
+ input: {
+ description: null,
+ email: 'example@gitlab.com',
+ firstName: 'Michael',
+ id: 'gid://gitlab/CustomerRelations::Contact/12',
+ lastName: 'McFly',
+ organizationId: 'gid://gitlab/CustomerRelations::Organization/1',
+ phone: null,
+ },
+ });
+ });
+ });
});
diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js
index 231208d938e..1780a5945a6 100644
--- a/spec/frontend/crm/organizations_root_spec.js
+++ b/spec/frontend/crm/organizations_root_spec.js
@@ -102,9 +102,7 @@ describe('Customer relations organizations root app', () => {
const issueLink = findIssuesLinks().at(0);
expect(issueLink.exists()).toBe(true);
- expect(issueLink.attributes('href')).toBe(
- '/issues?scope=all&state=opened&crm_organization_id=2',
- );
+ expect(issueLink.attributes('href')).toBe('/issues?crm_organization_id=2');
});
});
});
diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js
index bdf35f904ed..7b1ef71da63 100644
--- a/spec/frontend/cycle_analytics/base_spec.js
+++ b/spec/frontend/cycle_analytics/base_spec.js
@@ -143,12 +143,9 @@ describe('Value stream analytics component', () => {
expect(findFilters().props()).toEqual({
groupId,
groupPath,
- canToggleAggregation: false,
endDate: createdBefore,
hasDateRangeFilter: true,
hasProjectFilter: false,
- isAggregationEnabled: false,
- isUpdatingAggregationData: false,
selectedProjects: [],
startDate: createdAfter,
});
diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js
index c482bd4e910..1fe1dbbb75c 100644
--- a/spec/frontend/cycle_analytics/mock_data.js
+++ b/spec/frontend/cycle_analytics/mock_data.js
@@ -40,7 +40,7 @@ export const summary = [
{ value: '20', title: 'New Issues' },
{ value: null, title: 'Commits' },
{ value: null, title: 'Deploys' },
- { value: null, title: 'Deployment Frequency', unit: 'per day' },
+ { value: null, title: 'Deployment Frequency', unit: '/day' },
];
export const issueStage = {
@@ -130,7 +130,7 @@ export const convertedData = {
{ value: '20', title: 'New Issues' },
{ value: '-', title: 'Commits' },
{ value: '-', title: 'Deploys' },
- { value: '-', title: 'Deployment Frequency', unit: 'per day' },
+ { value: '-', title: 'Deployment Frequency', unit: '/day' },
],
};
diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js
index 107fe5fc865..0d15d67866d 100644
--- a/spec/frontend/cycle_analytics/stage_table_spec.js
+++ b/spec/frontend/cycle_analytics/stage_table_spec.js
@@ -329,7 +329,7 @@ describe('StageTable', () => {
]);
});
- it('with sortDesc=false will toggle the direction field', async () => {
+ it('with sortDesc=false will toggle the direction field', () => {
expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
triggerTableSort(false);
diff --git a/spec/frontend/cycle_analytics/value_stream_filters_spec.js b/spec/frontend/cycle_analytics/value_stream_filters_spec.js
index 5a0b046393a..6e96a6d756a 100644
--- a/spec/frontend/cycle_analytics/value_stream_filters_spec.js
+++ b/spec/frontend/cycle_analytics/value_stream_filters_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { GlToggle } from '@gitlab/ui';
import Daterange from '~/analytics/shared/components/daterange.vue';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import FilterBar from '~/cycle_analytics/components/filter_bar.vue';
@@ -30,7 +29,6 @@ describe('ValueStreamFilters', () => {
const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdownFilter);
const findDateRangePicker = () => wrapper.findComponent(Daterange);
const findFilterBar = () => wrapper.findComponent(FilterBar);
- const findAggregationToggle = () => wrapper.findComponent(GlToggle);
beforeEach(() => {
wrapper = createComponent();
@@ -59,10 +57,6 @@ describe('ValueStreamFilters', () => {
expect(findDateRangePicker().exists()).toBe(true);
});
- it('will not render the aggregation toggle', () => {
- expect(findAggregationToggle().exists()).toBe(false);
- });
-
it('will emit `selectProject` when a project is selected', () => {
findProjectsDropdown().vm.$emit('selected');
@@ -94,52 +88,4 @@ describe('ValueStreamFilters', () => {
expect(findProjectsDropdown().exists()).toBe(false);
});
});
-
- describe('canToggleAggregation = true', () => {
- beforeEach(() => {
- wrapper = createComponent({ isAggregationEnabled: false, canToggleAggregation: true });
- });
-
- it('will render the aggregation toggle', () => {
- expect(findAggregationToggle().exists()).toBe(true);
- });
-
- it('will set the aggregation toggle to the `isAggregationEnabled` value', () => {
- expect(findAggregationToggle().props('value')).toBe(false);
-
- wrapper = createComponent({
- isAggregationEnabled: true,
- canToggleAggregation: true,
- });
-
- expect(findAggregationToggle().props('value')).toBe(true);
- });
-
- it('will emit `toggleAggregation` when the toggle is changed', async () => {
- expect(wrapper.emitted('toggleAggregation')).toBeUndefined();
-
- await findAggregationToggle().vm.$emit('change', true);
-
- expect(wrapper.emitted('toggleAggregation')).toHaveLength(1);
- expect(wrapper.emitted('toggleAggregation')).toEqual([[true]]);
- });
- });
-
- describe('isUpdatingAggregationData = true', () => {
- beforeEach(() => {
- wrapper = createComponent({ canToggleAggregation: true, isUpdatingAggregationData: true });
- });
-
- it('will disable the aggregation toggle', () => {
- expect(findAggregationToggle().props('disabled')).toBe(true);
- });
-
- it('will not emit `toggleAggregation` when the toggle is changed', async () => {
- expect(wrapper.emitted('toggleAggregation')).toBeUndefined();
-
- await findAggregationToggle().vm.$emit('change', true);
-
- expect(wrapper.emitted('toggleAggregation')).toBeUndefined();
- });
- });
});
diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
index 6199e61df0c..4a3e8146b13 100644
--- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
+++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
@@ -1,11 +1,11 @@
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
-import { METRICS_POPOVER_CONTENT } from '~/analytics/shared/constants';
+import { VSA_METRICS_GROUPS, METRICS_POPOVER_CONTENT } from '~/analytics/shared/constants';
import { prepareTimeMetricsData } from '~/analytics/shared/utils';
import MetricTile from '~/analytics/shared/components/metric_tile.vue';
import createFlash from '~/flash';
@@ -27,7 +27,7 @@ describe('ValueStreamMetrics', () => {
});
const createComponent = (props = {}) => {
- return shallowMount(ValueStreamMetrics, {
+ return shallowMountExtended(ValueStreamMetrics, {
propsData: {
requestPath,
requestParams: {},
@@ -38,6 +38,7 @@ describe('ValueStreamMetrics', () => {
};
const findMetrics = () => wrapper.findAllComponents(MetricTile);
+ const findMetricsGroups = () => wrapper.findAllByTestId('vsa-metrics-group');
const expectToHaveRequest = (fields) => {
expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
@@ -63,24 +64,6 @@ describe('ValueStreamMetrics', () => {
expect(wrapper.findComponent(GlSkeletonLoading).exists()).toBe(true);
});
- it('renders hidden MetricTile components for each metric', async () => {
- await waitForPromises();
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ isLoading: true });
-
- await nextTick();
-
- const components = findMetrics();
-
- expect(components).toHaveLength(metricsData.length);
-
- metricsData.forEach((metric, index) => {
- expect(components.at(index).isVisible()).toBe(false);
- });
- });
-
describe('with data loaded', () => {
beforeEach(async () => {
await waitForPromises();
@@ -160,6 +143,27 @@ describe('ValueStreamMetrics', () => {
});
});
});
+
+ describe('groupBy', () => {
+ beforeEach(async () => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
+ wrapper = createComponent({ groupBy: VSA_METRICS_GROUPS });
+ await waitForPromises();
+ });
+
+ it('renders the metrics as separate groups', () => {
+ const groups = findMetricsGroups();
+ expect(groups).toHaveLength(VSA_METRICS_GROUPS.length);
+ });
+
+ it('renders titles for each group', () => {
+ const groups = findMetricsGroups();
+ groups.wrappers.forEach((g, index) => {
+ const { title } = VSA_METRICS_GROUPS[index];
+ expect(g.html()).toContain(title);
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js
index bec91fe5fc5..b18d53b317d 100644
--- a/spec/frontend/deprecated_jquery_dropdown_spec.js
+++ b/spec/frontend/deprecated_jquery_dropdown_spec.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import mockProjects from 'test_fixtures_static/projects.json';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import '~/lib/utils/common_utils';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -64,7 +65,7 @@ describe('deprecatedJQueryDropdown', () => {
}
beforeEach(() => {
- loadFixtures('static/deprecated_jquery_dropdown.html');
+ loadHTMLFixture('static/deprecated_jquery_dropdown.html');
test.dropdownContainerElement = $('.dropdown.inline');
test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement);
test.projectsData = JSON.parse(JSON.stringify(mockProjects));
@@ -73,6 +74,8 @@ describe('deprecatedJQueryDropdown', () => {
afterEach(() => {
$('body').off('keydown');
test.dropdownContainerElement.off('keyup');
+
+ resetHTMLFixture();
});
it('should open on click', () => {
diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
index 0cef18c60de..d2d1fe6b2d8 100644
--- a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
@@ -31,10 +31,6 @@ describe('Design reply form component', () => {
});
}
- beforeEach(() => {
- gon.features = { markdownContinueLists: true };
- });
-
afterEach(() => {
wrapper.destroy();
});
diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js
index a818a86bef6..e8426216c1c 100644
--- a/spec/frontend/design_management/components/design_sidebar_spec.js
+++ b/spec/frontend/design_management/components/design_sidebar_spec.js
@@ -1,7 +1,7 @@
import { GlCollapse, GlPopover } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Cookies from 'js-cookie';
import { nextTick } from 'vue';
+import Cookies from '~/lib/utils/cookies';
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue';
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
index abd455ae750..243cc9d891d 100644
--- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -16,6 +16,7 @@ exports[`Design management index page designs renders error 1`] = `
primarybuttontext=""
secondarybuttonlink=""
secondarybuttontext=""
+ showicon="true"
title=""
variant="danger"
>
diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
index 31b3117cb6c..8f12dc8fb06 100644
--- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -180,6 +180,7 @@ exports[`Design management design index page with error GlAlert is rendered in c
primarybuttontext=""
secondarybuttonlink=""
secondarybuttontext=""
+ showicon="true"
title=""
variant="danger"
>
diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
index cd472920bb9..bd538996349 100644
--- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js
+++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
@@ -20,7 +20,6 @@ function makeLoadMoreLinesPayload({
sinceLine,
toLine,
oldLineNumber,
- diffViewType,
fileHash,
nextLineNumbers = {},
unfold = false,
@@ -28,12 +27,11 @@ function makeLoadMoreLinesPayload({
isExpandDown = false,
}) {
return {
- endpoint: 'contextLinesPath',
+ endpoint: diffFileMockData.context_lines_path,
params: {
since: sinceLine,
to: toLine,
offset: toLine + 1 - oldLineNumber,
- view: diffViewType,
unfold,
bottom,
},
@@ -70,10 +68,11 @@ describe('DiffExpansionCell', () => {
const createComponent = (options = {}) => {
const defaults = {
fileHash: mockFile.file_hash,
- contextLinesPath: 'contextLinesPath',
line: mockLine,
isTop: false,
isBottom: false,
+ file: mockFile,
+ inline: true,
};
const propsData = { ...defaults, ...options };
@@ -124,7 +123,7 @@ describe('DiffExpansionCell', () => {
describe('any row', () => {
[
- { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: { parallel_diff_lines: [] } },
+ { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: cloneDeep(diffFileMockData) },
].forEach(({ diffViewType, file, lineIndex }) => {
describe(`with diffViewType (${diffViewType})`, () => {
beforeEach(() => {
@@ -140,12 +139,12 @@ describe('DiffExpansionCell', () => {
it('on expand all clicked, dispatch loadMoreLines', () => {
const oldLineNumber = mockLine.meta_data.old_pos;
const newLineNumber = mockLine.meta_data.new_pos;
- const previousIndex = getPreviousLineIndex(diffViewType, mockFile, {
+ const previousIndex = getPreviousLineIndex(mockFile, {
oldLineNumber,
newLineNumber,
});
- const wrapper = createComponent();
+ const wrapper = createComponent({ file });
findExpandAll(wrapper).click();
@@ -156,7 +155,6 @@ describe('DiffExpansionCell', () => {
toLine: newLineNumber - 1,
sinceLine: previousIndex,
oldLineNumber,
- diffViewType,
}),
);
});
@@ -168,7 +166,7 @@ describe('DiffExpansionCell', () => {
const oldLineNumber = mockLine.meta_data.old_pos;
const newLineNumber = mockLine.meta_data.new_pos;
- const wrapper = createComponent();
+ const wrapper = createComponent({ file });
findExpandUp(wrapper).trigger('click');
@@ -196,17 +194,16 @@ describe('DiffExpansionCell', () => {
mockLine.meta_data.old_pos = 200;
mockLine.meta_data.new_pos = 200;
- const wrapper = createComponent();
+ const wrapper = createComponent({ file });
findExpandDown(wrapper).trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('diffs/loadMoreLines', {
- endpoint: 'contextLinesPath',
+ endpoint: diffFileMockData.context_lines_path,
params: {
since: 1,
to: 21, // the load amount, plus 1 line
offset: 0,
- view: diffViewType,
unfold: true,
bottom: true,
},
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index 3b567fbc704..cc595e58dda 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -1,5 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
-import Cookies from 'js-cookie';
+import Cookies from '~/lib/utils/cookies';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index d8611b1ce1b..57e623b843d 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -131,7 +131,14 @@ describe('DiffsStoreMutations', () => {
const options = {
lineNumbers: { oldLineNumber: 1, newLineNumber: 2 },
contextLines: [
- { old_line: 1, new_line: 1, line_code: 'ff9200_1_1', discussions: [], hasForm: false },
+ {
+ old_line: 1,
+ new_line: 1,
+ line_code: 'ff9200_1_1',
+ discussions: [],
+ hasForm: false,
+ type: 'expanded',
+ },
],
fileHash: 'ff9200',
params: {
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 03bcaab0d2b..8ae51a58819 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -51,21 +51,19 @@ describe('DiffsStoreUtils', () => {
});
describe('getPreviousLineIndex', () => {
- describe(`with diffViewType (inline) in split diffs`, () => {
- let diffFile;
+ let diffFile;
- beforeEach(() => {
- diffFile = { ...clone(diffFileMockData) };
- });
+ beforeEach(() => {
+ diffFile = { ...clone(diffFileMockData) };
+ });
- it('should return the correct previous line number', () => {
- expect(
- utils.getPreviousLineIndex(INLINE_DIFF_VIEW_TYPE, diffFile, {
- oldLineNumber: 3,
- newLineNumber: 5,
- }),
- ).toBe(4);
- });
+ it('should return the correct previous line number', () => {
+ expect(
+ utils.getPreviousLineIndex(diffFile, {
+ oldLineNumber: 3,
+ newLineNumber: 5,
+ }),
+ ).toBe(4);
});
});
diff --git a/spec/frontend/diffs/utils/diff_file_spec.js b/spec/frontend/diffs/utils/diff_file_spec.js
index 3223b6c2dab..778897be3ba 100644
--- a/spec/frontend/diffs/utils/diff_file_spec.js
+++ b/spec/frontend/diffs/utils/diff_file_spec.js
@@ -3,6 +3,7 @@ import {
getShortShaFromFile,
stats,
isNotDiffable,
+ match,
} from '~/diffs/utils/diff_file';
import { diffViewerModes } from '~/ide/constants';
import mockDiffFile from '../mock_data/diff_file';
@@ -149,6 +150,38 @@ describe('diff_file utilities', () => {
expect(preppedFile).not.toHaveProp('id');
});
+
+ it.each`
+ index
+ ${null}
+ ${undefined}
+ ${-1}
+ ${false}
+ ${true}
+ ${'idx'}
+ ${'42'}
+ `('does not set the order property if an invalid index ($index) is provided', ({ index }) => {
+ const preppedFile = prepareRawDiffFile({
+ file: files[0],
+ allFiles: files,
+ index,
+ });
+
+ /* expect.anything() doesn't match null or undefined */
+ expect(preppedFile).toEqual(expect.not.objectContaining({ order: null }));
+ expect(preppedFile).toEqual(expect.not.objectContaining({ order: undefined }));
+ expect(preppedFile).toEqual(expect.not.objectContaining({ order: expect.anything() }));
+ });
+
+ it('sets the provided valid index to the order property', () => {
+ const preppedFile = prepareRawDiffFile({
+ file: files[0],
+ allFiles: files,
+ index: 42,
+ });
+
+ expect(preppedFile).toEqual(expect.objectContaining({ order: 42 }));
+ });
});
describe('getShortShaFromFile', () => {
@@ -230,4 +263,42 @@ describe('diff_file utilities', () => {
expect(isNotDiffable(file)).toBe(false);
});
});
+
+ describe('match', () => {
+ const authorityFileId = '68296a4f-f1c7-445a-bd0e-6e3b02c4eec0';
+ const fih = 'file_identifier_hash';
+ const fihs = 'file identifier hashes';
+ let authorityFile;
+
+ beforeAll(() => {
+ const files = getDiffFiles();
+
+ authorityFile = prepareRawDiffFile({
+ file: files[0],
+ allFiles: files,
+ });
+
+ Object.freeze(authorityFile);
+ });
+
+ describe.each`
+ mode | comparisonFiles | keyName
+ ${'universal'} | ${[{ [fih]: 'ABC1' }, { id: 'foo' }, { id: authorityFileId }]} | ${'ids'}
+ ${'mr'} | ${[{ id: authorityFileId }, { [fih]: 'ABC2' }, { [fih]: 'ABC1' }]} | ${fihs}
+ `('$mode mode', ({ mode, comparisonFiles, keyName }) => {
+ it(`fails to match if files or ${keyName} aren't present`, () => {
+ expect(match({ fileA: authorityFile, fileB: undefined, mode })).toBe(false);
+ expect(match({ fileA: authorityFile, fileB: null, mode })).toBe(false);
+ expect(match({ fileA: authorityFile, fileB: comparisonFiles[0], mode })).toBe(false);
+ });
+
+ it(`fails to match if the ${keyName} aren't the same`, () => {
+ expect(match({ fileA: authorityFile, fileB: comparisonFiles[1], mode })).toBe(false);
+ });
+
+ it(`matches if the ${keyName} are the same`, () => {
+ expect(match({ fileA: authorityFile, fileB: comparisonFiles[2], mode })).toBe(true);
+ });
+ });
+ });
});
diff --git a/spec/frontend/diffs/utils/queue_events_spec.js b/spec/frontend/diffs/utils/queue_events_spec.js
index 007748d8b2c..ad2745f5188 100644
--- a/spec/frontend/diffs/utils/queue_events_spec.js
+++ b/spec/frontend/diffs/utils/queue_events_spec.js
@@ -1,11 +1,15 @@
import api from '~/api';
-import { DEFER_DURATION } from '~/diffs/constants';
+import { DEFER_DURATION, TRACKING_CAP_KEY, TRACKING_CAP_LENGTH } from '~/diffs/constants';
import { queueRedisHllEvents } from '~/diffs/utils/queue_events';
jest.mock('~/api', () => ({
trackRedisHllUserEvent: jest.fn(),
}));
+beforeAll(() => {
+ localStorage.clear();
+});
+
describe('diffs events queue', () => {
describe('queueRedisHllEvents', () => {
it('does not dispatch the event immediately', () => {
@@ -17,6 +21,7 @@ describe('diffs events queue', () => {
queueRedisHllEvents(['know_event']);
jest.advanceTimersByTime(DEFER_DURATION + 1);
expect(api.trackRedisHllUserEvent).toHaveBeenCalled();
+ expect(localStorage.getItem(TRACKING_CAP_KEY)).toBe(null);
});
it('increase defer duration based on the provided events count', () => {
@@ -32,5 +37,35 @@ describe('diffs events queue', () => {
deferDuration *= index + 1;
});
});
+
+ describe('with tracking cap verification', () => {
+ const currentTimestamp = Date.now();
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('dispatches the event if cap value is not found', () => {
+ queueRedisHllEvents(['know_event'], { verifyCap: true });
+ jest.advanceTimersByTime(DEFER_DURATION + 1);
+ expect(api.trackRedisHllUserEvent).toHaveBeenCalled();
+ expect(localStorage.getItem(TRACKING_CAP_KEY)).toBe(currentTimestamp.toString());
+ });
+
+ it('dispatches the event if cap value is less than limit', () => {
+ localStorage.setItem(TRACKING_CAP_KEY, 1);
+ queueRedisHllEvents(['know_event'], { verifyCap: true });
+ jest.advanceTimersByTime(DEFER_DURATION + 1);
+ expect(api.trackRedisHllUserEvent).toHaveBeenCalled();
+ expect(localStorage.getItem(TRACKING_CAP_KEY)).toBe(currentTimestamp.toString());
+ });
+
+ it('does not dispatch the event if cap value is greater than limit', () => {
+ localStorage.setItem(TRACKING_CAP_KEY, currentTimestamp - (TRACKING_CAP_LENGTH + 1));
+ queueRedisHllEvents(['know_event'], { verifyCap: true });
+ jest.advanceTimersByTime(DEFER_DURATION + 1);
+ expect(api.trackRedisHllUserEvent).toHaveBeenCalled();
+ });
+ });
});
});
diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js
index 11414e8890d..a633de9ef56 100644
--- a/spec/frontend/dropzone_input_spec.js
+++ b/spec/frontend/dropzone_input_spec.js
@@ -1,6 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import mock from 'xhr-mock';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table';
@@ -45,7 +46,7 @@ describe('dropzone_input', () => {
};
beforeEach(() => {
- loadFixtures('issues/new-issue.html');
+ loadHTMLFixture('issues/new-issue.html');
form = $('#new_issue');
form.data('uploads-path', TEST_UPLOAD_PATH);
@@ -54,6 +55,8 @@ describe('dropzone_input', () => {
afterEach(() => {
form = null;
+
+ resetHTMLFixture();
});
it('pastes Markdown tables', () => {
diff --git a/spec/frontend/editor/components/helpers.js b/spec/frontend/editor/components/helpers.js
index 3e6cd2a236d..12f90390c18 100644
--- a/spec/frontend/editor/components/helpers.js
+++ b/spec/frontend/editor/components/helpers.js
@@ -1,12 +1,28 @@
import { EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants';
+import { apolloProvider } from '~/editor/components/source_editor_toolbar_graphql';
+import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
export const buildButton = (id = 'foo-bar-btn', options = {}) => {
return {
__typename: 'Item',
id,
label: options.label || 'Foo Bar Button',
- icon: options.icon || 'foo-bar',
+ icon: options.icon || 'check',
selected: options.selected || false,
group: options.group || EDITOR_TOOLBAR_RIGHT_GROUP,
+ onClick: options.onClick || (() => {}),
+ category: options.category || 'primary',
+ selectedLabel: options.selectedLabel || 'smth',
};
};
+
+export const warmUpCacheWithItems = (items = []) => {
+ apolloProvider.defaultClient.cache.writeQuery({
+ query: getToolbarItemsQuery,
+ data: {
+ items: {
+ nodes: items,
+ },
+ },
+ });
+};
diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
index 5135091af4a..1475d451ab3 100644
--- a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
+++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
@@ -1,43 +1,26 @@
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
+import { nextTick } from 'vue';
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import createMockApollo from 'helpers/mock_apollo_helper';
import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue';
-import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql';
-import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql';
import { buildButton } from './helpers';
-Vue.use(VueApollo);
-
describe('Source Editor Toolbar button', () => {
let wrapper;
- let mockApollo;
const defaultBtn = buildButton();
const findButton = () => wrapper.findComponent(GlButton);
- const createComponentWithApollo = ({ propsData } = {}) => {
- mockApollo = createMockApollo();
- mockApollo.clients.defaultClient.cache.writeQuery({
- query: getToolbarItemQuery,
- variables: { id: defaultBtn.id },
- data: {
- item: {
- ...defaultBtn,
- },
- },
- });
-
+ const createComponent = (props = { button: defaultBtn }) => {
wrapper = shallowMount(SourceEditorToolbarButton, {
- propsData,
- apolloProvider: mockApollo,
+ propsData: {
+ ...props,
+ },
});
};
afterEach(() => {
wrapper.destroy();
- mockApollo = null;
+ wrapper = null;
});
describe('default', () => {
@@ -49,98 +32,51 @@ describe('Source Editor Toolbar button', () => {
category: 'secondary',
variant: 'info',
};
+
+ it('does not render the button if the props have not been passed', () => {
+ createComponent({});
+ expect(findButton().vm).toBeUndefined();
+ });
+
it('renders a default button without props', async () => {
- createComponentWithApollo();
+ createComponent();
const btn = findButton();
expect(btn.exists()).toBe(true);
expect(btn.props()).toMatchObject(defaultProps);
});
it('renders a button based on the props passed', async () => {
- createComponentWithApollo({
- propsData: {
- button: customProps,
- },
+ createComponent({
+ button: customProps,
});
const btn = findButton();
expect(btn.props()).toMatchObject(customProps);
});
});
- describe('button updates', () => {
- it('it properly updates button on Apollo cache update', async () => {
- const { id } = defaultBtn;
-
- createComponentWithApollo({
- propsData: {
- button: {
- id,
- },
- },
- });
-
- expect(findButton().props('selected')).toBe(false);
-
- mockApollo.clients.defaultClient.cache.writeQuery({
- query: getToolbarItemQuery,
- variables: { id },
- data: {
- item: {
- ...defaultBtn,
- selected: true,
- },
- },
- });
-
- jest.runOnlyPendingTimers();
- await nextTick();
-
- expect(findButton().props('selected')).toBe(true);
- });
- });
-
describe('click handler', () => {
- it('fires the click handler on the button when available', () => {
+ it('fires the click handler on the button when available', async () => {
const spy = jest.fn();
- createComponentWithApollo({
- propsData: {
- button: {
- onClick: spy,
- },
+ createComponent({
+ button: {
+ onClick: spy,
},
});
expect(spy).not.toHaveBeenCalled();
findButton().vm.$emit('click');
+
+ await nextTick();
expect(spy).toHaveBeenCalled();
});
- it('emits the "click" event', () => {
- createComponentWithApollo();
+ it('emits the "click" event', async () => {
+ createComponent();
jest.spyOn(wrapper.vm, '$emit');
expect(wrapper.vm.$emit).not.toHaveBeenCalled();
+
findButton().vm.$emit('click');
+ await nextTick();
+
expect(wrapper.vm.$emit).toHaveBeenCalledWith('click');
});
- it('triggers the mutation exposing the changed "selected" prop', () => {
- const { id } = defaultBtn;
- createComponentWithApollo({
- propsData: {
- button: {
- id,
- },
- },
- });
- jest.spyOn(wrapper.vm.$apollo, 'mutate');
- expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled();
- findButton().vm.$emit('click');
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: updateToolbarItemMutation,
- variables: {
- id,
- propsToUpdate: {
- selected: true,
- },
- },
- });
- });
});
});
diff --git a/spec/frontend/editor/components/source_editor_toolbar_graphql_spec.js b/spec/frontend/editor/components/source_editor_toolbar_graphql_spec.js
new file mode 100644
index 00000000000..41c48aa0a58
--- /dev/null
+++ b/spec/frontend/editor/components/source_editor_toolbar_graphql_spec.js
@@ -0,0 +1,112 @@
+import { apolloProvider } from '~/editor/components/source_editor_toolbar_graphql';
+import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
+import removeItemsMutation from '~/editor/graphql/remove_items.mutation.graphql';
+import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql';
+import addToolbarItemsMutation from '~/editor/graphql/add_items.mutation.graphql';
+import { buildButton, warmUpCacheWithItems } from './helpers';
+
+describe('Source Editor toolbar Apollo client', () => {
+ const item1 = buildButton('foo');
+ const item2 = buildButton('bar');
+
+ const getItems = () =>
+ apolloProvider.defaultClient.cache.readQuery({ query: getToolbarItemsQuery })?.items?.nodes ||
+ [];
+ const getItem = (id) => {
+ return getItems().find((item) => item.id === id);
+ };
+
+ afterEach(() => {
+ apolloProvider.defaultClient.clearStore();
+ });
+
+ describe('Mutations', () => {
+ describe('addToolbarItems', () => {
+ function addButtons(items) {
+ return apolloProvider.defaultClient.mutate({
+ mutation: addToolbarItemsMutation,
+ variables: {
+ items,
+ },
+ });
+ }
+ it.each`
+ cache | idsToAdd | itemsToAdd | expectedResult | comment
+ ${[]} | ${'empty array'} | ${[]} | ${[]} | ${''}
+ ${[]} | ${'undefined'} | ${undefined} | ${[]} | ${''}
+ ${[]} | ${item2.id} | ${[item2]} | ${[item2]} | ${''}
+ ${[]} | ${item1.id} | ${[item1]} | ${[item1]} | ${''}
+ ${[]} | ${[item1.id, item2.id]} | ${[item1, item2]} | ${[item1, item2]} | ${''}
+ ${[]} | ${[item1.id]} | ${item1} | ${[item1]} | ${'does not fail if the item is an Object'}
+ ${[item2]} | ${[item1.id]} | ${item1} | ${[item2, item1]} | ${'does not fail if the item is an Object'}
+ ${[item1]} | ${[item2.id]} | ${[item2]} | ${[item1, item2]} | ${'correctly adds items to the pre-populated cache'}
+ `('adds $idsToAdd item(s) to $cache', async ({ cache, itemsToAdd, expectedResult }) => {
+ await warmUpCacheWithItems(cache);
+ await addButtons(itemsToAdd);
+ await expect(getItems()).toEqual(expectedResult);
+ });
+ });
+
+ describe('removeToolbarItems', () => {
+ function removeButtons(ids) {
+ return apolloProvider.defaultClient.mutate({
+ mutation: removeItemsMutation,
+ variables: {
+ ids,
+ },
+ });
+ }
+
+ it.each`
+ cache | cacheIds | toRemove | expected
+ ${[item1, item2]} | ${[item1.id, item2.id]} | ${[item1.id]} | ${[item2]}
+ ${[item1, item2]} | ${[item1.id, item2.id]} | ${[item2.id]} | ${[item1]}
+ ${[item1, item2]} | ${[item1.id, item2.id]} | ${[item1.id, item2.id]} | ${[]}
+ ${[item1]} | ${[item1.id]} | ${[item1.id]} | ${[]}
+ ${[item2]} | ${[item2.id]} | ${[]} | ${[item2]}
+ ${[]} | ${['undefined']} | ${[item1.id]} | ${[]}
+ ${[item1]} | ${[item1.id]} | ${[item2.id]} | ${[item1]}
+ `('removes $toRemove from the $cacheIds toolbar', async ({ cache, toRemove, expected }) => {
+ await warmUpCacheWithItems(cache);
+
+ expect(getItems()).toHaveLength(cache.length);
+
+ await removeButtons(toRemove);
+
+ expect(getItems()).toHaveLength(expected.length);
+ expect(getItems()).toEqual(expected);
+ });
+ });
+
+ describe('updateToolbarItem', () => {
+ function mutateButton(item, propsToUpdate = {}) {
+ return apolloProvider.defaultClient.mutate({
+ mutation: updateToolbarItemMutation,
+ variables: {
+ id: item.id,
+ propsToUpdate,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ warmUpCacheWithItems([item1, item2]);
+ });
+
+ it('updates the toolbar items', async () => {
+ expect(getItem(item1.id).selected).toBe(false);
+ expect(getItem(item2.id).selected).toBe(false);
+
+ await mutateButton(item1, { selected: true });
+
+ expect(getItem(item1.id).selected).toBe(true);
+ expect(getItem(item2.id).selected).toBe(false);
+
+ await mutateButton(item2, { selected: true });
+
+ expect(getItem(item1.id).selected).toBe(true);
+ expect(getItem(item2.id).selected).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/editor/extensions/source_editor_toolbar_ext_spec.js b/spec/frontend/editor/extensions/source_editor_toolbar_ext_spec.js
new file mode 100644
index 00000000000..fa5a3b2987e
--- /dev/null
+++ b/spec/frontend/editor/extensions/source_editor_toolbar_ext_spec.js
@@ -0,0 +1,156 @@
+import Vue from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import SourceEditorToolbar from '~/editor/components/source_editor_toolbar.vue';
+import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext';
+import EditorInstance from '~/editor/source_editor_instance';
+import { apolloProvider } from '~/editor/components/source_editor_toolbar_graphql';
+import { buildButton, warmUpCacheWithItems } from '../components/helpers';
+
+describe('Source Editor Toolbar Extension', () => {
+ let instance;
+
+ const createInstance = (baseInstance = {}) => {
+ return new EditorInstance(baseInstance);
+ };
+ const getDefaultEl = () => document.getElementById('editor-toolbar');
+ const getCustomEl = () => document.getElementById('custom-toolbar');
+ const item1 = buildButton('foo');
+ const item2 = buildButton('bar');
+
+ beforeEach(() => {
+ setHTMLFixture('<div id="editor-toolbar"></div><div id="custom-toolbar"></div>');
+ });
+
+ afterEach(() => {
+ apolloProvider.defaultClient.clearStore();
+ resetHTMLFixture();
+ });
+
+ describe('onSetup', () => {
+ beforeEach(() => {
+ instance = createInstance();
+ });
+
+ it.each`
+ id | type | prefix | expectedElFn
+ ${undefined} | ${'default'} | ${'Sets up'} | ${getDefaultEl}
+ ${'custom-toolbar'} | ${'custom'} | ${'Sets up'} | ${getCustomEl}
+ ${'non-existing'} | ${'default'} | ${'Does not set up'} | ${getDefaultEl}
+ `('Sets up the Vue application on $type node when node is $id', ({ id, expectedElFn }) => {
+ jest.spyOn(Vue, 'extend');
+ jest.spyOn(ToolbarExtension, 'setupVue');
+
+ const el = document.getElementById(id);
+ const expectedEl = expectedElFn();
+
+ instance.use({ definition: ToolbarExtension, setupOptions: { el } });
+
+ if (expectedEl) {
+ expect(ToolbarExtension.setupVue).toHaveBeenCalledWith(expectedEl);
+ expect(Vue.extend).toHaveBeenCalledWith(SourceEditorToolbar);
+ } else {
+ expect(ToolbarExtension.setupVue).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('public API', () => {
+ beforeEach(async () => {
+ await warmUpCacheWithItems();
+ instance = createInstance();
+ instance.use({ definition: ToolbarExtension });
+ });
+
+ describe('getAllItems', () => {
+ it('returns the list of all toolbar items', async () => {
+ await expect(instance.toolbar.getAllItems()).toEqual([]);
+ await warmUpCacheWithItems([item1, item2]);
+ await expect(instance.toolbar.getAllItems()).toEqual([item1, item2]);
+ });
+ });
+
+ describe('getItem', () => {
+ it('returns a toolbar item by id', async () => {
+ await expect(instance.toolbar.getItem(item1.id)).toEqual(undefined);
+ await warmUpCacheWithItems([item1]);
+ await expect(instance.toolbar.getItem(item1.id)).toEqual(item1);
+ });
+ });
+
+ describe('addItems', () => {
+ it.each`
+ idsToAdd | itemsToAdd | expectedResult
+ ${'empty array'} | ${[]} | ${[]}
+ ${'undefined'} | ${undefined} | ${[]}
+ ${item2.id} | ${[item2]} | ${[item2]}
+ ${item1.id} | ${[item1]} | ${[item1]}
+ ${[item1.id, item2.id]} | ${[item1, item2]} | ${[item1, item2]}
+ `('adds $idsToAdd item(s) to cache', async ({ itemsToAdd, expectedResult }) => {
+ await instance.toolbar.addItems(itemsToAdd);
+ await expect(instance.toolbar.getAllItems()).toEqual(expectedResult);
+ });
+
+ it('correctly adds items to the pre-populated cache', async () => {
+ await warmUpCacheWithItems([item1]);
+ await instance.toolbar.addItems([item2]);
+ await expect(instance.toolbar.getAllItems()).toEqual([item1, item2]);
+ });
+
+ it('does not fail if the item is an Object', async () => {
+ await instance.toolbar.addItems(item1);
+ await expect(instance.toolbar.getAllItems()).toEqual([item1]);
+ });
+ });
+
+ describe('removeItems', () => {
+ beforeEach(async () => {
+ await warmUpCacheWithItems([item1, item2]);
+ });
+
+ it.each`
+ idsToRemove | expectedResult
+ ${undefined} | ${[item1, item2]}
+ ${[]} | ${[item1, item2]}
+ ${[item1.id]} | ${[item2]}
+ ${[item2.id]} | ${[item1]}
+ ${[item1.id, item2.id]} | ${[]}
+ `(
+ 'successfully removes $idsToRemove from [foo, bar]',
+ async ({ idsToRemove, expectedResult }) => {
+ await instance.toolbar.removeItems(idsToRemove);
+ await expect(instance.toolbar.getAllItems()).toEqual(expectedResult);
+ },
+ );
+ });
+
+ describe('updateItem', () => {
+ const updatedProp = {
+ icon: 'book',
+ };
+
+ beforeEach(async () => {
+ await warmUpCacheWithItems([item1, item2]);
+ });
+
+ it.each`
+ itemsToUpdate | idToUpdate | propsToUpdate | expectedResult
+ ${undefined} | ${'undefined'} | ${undefined} | ${[item1, item2]}
+ ${item2.id} | ${item2.id} | ${undefined} | ${[item1, item2]}
+ ${item2.id} | ${item2.id} | ${{}} | ${[item1, item2]}
+ ${[item1]} | ${item1.id} | ${updatedProp} | ${[{ ...item1, ...updatedProp }, item2]}
+ ${[item2]} | ${item2.id} | ${updatedProp} | ${[item1, { ...item2, ...updatedProp }]}
+ `(
+ 'updates $idToUpdate item in cache with $propsToUpdate',
+ async ({ idToUpdate, propsToUpdate, expectedResult }) => {
+ await instance.toolbar.updateItem(idToUpdate, propsToUpdate);
+ await expect(instance.toolbar.getAllItems()).toEqual(expectedResult);
+ if (propsToUpdate) {
+ await expect(instance.toolbar.getItem(idToUpdate)).toEqual(
+ expect.objectContaining(propsToUpdate),
+ );
+ }
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json
index 89420bbc35f..666a4852957 100644
--- a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json
@@ -97,7 +97,10 @@
"expire_in": "1 week",
"reports": {
"junit": "result.xml",
- "cobertura": "cobertura-coverage.xml",
+ "coverage_report": {
+ "coverage_format": "cobertura",
+ "path": "cobertura-coverage.xml"
+ },
"codequality": "codequality.json",
"sast": "sast.json",
"dependency_scanning": "scan.json",
@@ -147,7 +150,10 @@
"artifacts": {
"reports": {
"junit": ["result.xml"],
- "cobertura": ["cobertura-coverage.xml"],
+ "coverage_report": {
+ "coverage_format": "cobertura",
+ "path": "cobertura-coverage.xml"
+ },
"codequality": ["codequality.json"],
"sast": ["sast.json"],
"dependency_scanning": ["scan.json"],
diff --git a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
index 2f6d277ca75..9a14e1a55eb 100644
--- a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
+++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
@@ -1,4 +1,5 @@
import { languages } from 'monaco-editor';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import ciSchemaPath from '~/editor/schema/ci.json';
@@ -19,7 +20,7 @@ describe('~/editor/editor_ci_config_ext', () => {
let originalGitlabUrl;
const createMockEditor = ({ blobPath = defaultBlobPath } = {}) => {
- setFixtures('<div id="editor"></div>');
+ setHTMLFixture('<div id="editor"></div>');
editorEl = document.getElementById('editor');
editor = new SourceEditor();
instance = editor.createInstance({
@@ -45,7 +46,9 @@ describe('~/editor/editor_ci_config_ext', () => {
afterEach(() => {
instance.dispose();
+
editorEl.remove();
+ resetHTMLFixture();
});
describe('registerCiSchema', () => {
diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js
index 6606557fd1f..eab39ccaba1 100644
--- a/spec/frontend/editor/source_editor_extension_base_spec.js
+++ b/spec/frontend/editor/source_editor_extension_base_spec.js
@@ -1,4 +1,5 @@
import { Range } from 'monaco-editor';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import setWindowLocation from 'helpers/set_window_location_helper';
import {
@@ -39,12 +40,13 @@ describe('The basis for an Source Editor extension', () => {
};
beforeEach(() => {
- setFixtures(generateLines());
+ setHTMLFixture(generateLines());
event = generateEventMock();
});
afterEach(() => {
jest.clearAllMocks();
+ resetHTMLFixture();
});
describe('onUse callback', () => {
@@ -253,7 +255,7 @@ describe('The basis for an Source Editor extension', () => {
});
it('does not create a link if the event is triggered on a wrong node', () => {
- setFixtures('<div class="wrong-class">3</div>');
+ setHTMLFixture('<div class="wrong-class">3</div>');
SourceEditorExtension.createAnchor = jest.fn();
const wrongEvent = generateEventMock({ el: document.querySelector('.wrong-class') });
diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js
index eecd23bff6e..3e8c287df2f 100644
--- a/spec/frontend/editor/source_editor_markdown_ext_spec.js
+++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import { Range, Position } from 'monaco-editor';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import SourceEditor from '~/editor/source_editor';
import axios from '~/lib/utils/axios_utils';
@@ -27,7 +28,7 @@ describe('Markdown Extension for Source Editor', () => {
beforeEach(() => {
mockAxios = new MockAdapter(axios);
- setFixtures('<div id="editor" data-editor-loading></div>');
+ setHTMLFixture('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
editor = new SourceEditor();
instance = editor.createInstance({
@@ -42,6 +43,8 @@ describe('Markdown Extension for Source Editor', () => {
instance.dispose();
editorEl.remove();
mockAxios.restore();
+
+ resetHTMLFixture();
});
describe('getSelectedText', () => {
diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
index c8d016e10ac..1926f3e268e 100644
--- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
+++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
@@ -1,5 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
-import { editor as monacoEditor } from 'monaco-editor';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
@@ -30,7 +30,6 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
const secondLine = 'multiline';
const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
- const plaintextPath = 'foo.txt';
const markdownPath = 'foo.md';
const responseData = '<div>FooBar</div>';
@@ -41,7 +40,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
beforeEach(() => {
mockAxios = new MockAdapter(axios);
- setFixtures('<div id="editor" data-editor-loading></div>');
+ setHTMLFixture('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
editor = new SourceEditor();
instance = editor.createInstance({
@@ -49,6 +48,13 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
blobPath: markdownPath,
blobContent: text,
});
+
+ instance.toolbar = {
+ addItems: jest.fn(),
+ updateItem: jest.fn(),
+ removeItems: jest.fn(),
+ };
+
extension = instance.use({
definition: EditorMarkdownPreviewExtension,
setupOptions: { previewMarkdownPath },
@@ -60,64 +66,20 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
instance.dispose();
editorEl.remove();
mockAxios.restore();
+ resetHTMLFixture();
});
it('sets up the preview on the instance', () => {
expect(instance.markdownPreview).toEqual({
el: undefined,
- action: expect.any(Object),
+ actions: expect.any(Object),
shown: false,
modelChangeListener: undefined,
path: previewMarkdownPath,
+ actionShowPreviewCondition: expect.any(Object),
});
});
- describe('model language changes listener', () => {
- let cleanupSpy;
- let actionSpy;
-
- beforeEach(async () => {
- cleanupSpy = jest.fn();
- actionSpy = jest.fn();
- spyOnApi(extension, {
- cleanup: cleanupSpy,
- setupPreviewAction: actionSpy,
- });
- await togglePreview();
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- it('cleans up when switching away from markdown', () => {
- expect(cleanupSpy).not.toHaveBeenCalled();
- expect(actionSpy).not.toHaveBeenCalled();
-
- instance.updateModelLanguage(plaintextPath);
-
- expect(cleanupSpy).toHaveBeenCalled();
- expect(actionSpy).not.toHaveBeenCalled();
- });
-
- it.each`
- oldLanguage | newLanguage | setupCalledTimes
- ${'plaintext'} | ${'markdown'} | ${1}
- ${'markdown'} | ${'markdown'} | ${0}
- ${'markdown'} | ${'plaintext'} | ${0}
- ${'markdown'} | ${undefined} | ${0}
- ${undefined} | ${'markdown'} | ${1}
- `(
- 'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage',
- ({ oldLanguage, newLanguage, setupCalledTimes } = {}) => {
- expect(actionSpy).not.toHaveBeenCalled();
- instance.updateModelLanguage(oldLanguage);
- instance.updateModelLanguage(newLanguage);
- expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
- },
- );
- });
-
describe('model change listener', () => {
let cleanupSpy;
let actionSpy;
@@ -142,33 +104,22 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
-
- it('cleans up the preview when the model changes', () => {
- instance.setModel(monacoEditor.createModel('foo'));
- expect(cleanupSpy).toHaveBeenCalled();
- });
-
- it.each`
- language | setupCalledTimes
- ${'markdown'} | ${1}
- ${'plaintext'} | ${0}
- ${undefined} | ${0}
- `(
- 'correctly handles actions when the new model is $language',
- ({ language, setupCalledTimes } = {}) => {
- instance.setModel(monacoEditor.createModel('foo', language));
-
- expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
- },
- );
});
- describe('cleanup', () => {
+ describe('onBeforeUnuse', () => {
beforeEach(async () => {
mockAxios.onPost().reply(200, { body: responseData });
await togglePreview();
});
+ it('removes the registered buttons from the toolbar', () => {
+ expect(instance.toolbar.removeItems).not.toHaveBeenCalled();
+ instance.unuse(extension);
+ expect(instance.toolbar.removeItems).toHaveBeenCalledWith([
+ EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
+ ]);
+ });
+
it('disposes the modelChange listener and does not fetch preview on content changes', () => {
expect(instance.markdownPreview.modelChangeListener).toBeDefined();
const fetchPreviewSpy = jest.fn();
@@ -176,7 +127,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
fetchPreview: fetchPreviewSpy,
});
- instance.cleanup();
+ instance.unuse(extension);
instance.setValue('Foo Bar');
jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY);
@@ -186,17 +137,11 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
it('removes the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
- instance.cleanup();
+ instance.unuse(extension);
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null);
});
- it('toggles the `shown` flag', () => {
- expect(instance.markdownPreview.shown).toBe(true);
- instance.cleanup();
- expect(instance.markdownPreview.shown).toBe(false);
- });
-
it('toggles the panel only if the preview is visible', () => {
const { el: previewEl } = instance.markdownPreview;
const parentEl = previewEl.parentElement;
@@ -204,13 +149,13 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
expect(previewEl).toBeVisible();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true);
- instance.cleanup();
+ instance.unuse(extension);
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
- instance.cleanup();
+ instance.unuse(extension);
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
@@ -222,12 +167,12 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
expect(instance.markdownPreview.shown).toBe(true);
- instance.cleanup();
+ instance.unuse(extension);
const { width: newWidth } = instance.getLayoutInfo();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
- instance.cleanup();
+ instance.unuse(extension);
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
});
});
@@ -305,6 +250,12 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
mockAxios.onPost().reply(200, { body: responseData });
});
+ it('toggles the condition to toggle preview/hide actions in the context menu', () => {
+ expect(instance.markdownPreview.actionShowPreviewCondition.get()).toBe(true);
+ instance.togglePreview();
+ expect(instance.markdownPreview.actionShowPreviewCondition.get()).toBe(false);
+ });
+
it('toggles preview flag on instance', () => {
expect(instance.markdownPreview.shown).toBe(false);
diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js
index 049cab3a83b..b3d914e6755 100644
--- a/spec/frontend/editor/source_editor_spec.js
+++ b/spec/frontend/editor/source_editor_spec.js
@@ -1,4 +1,5 @@
import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import {
SOURCE_EDITOR_INSTANCE_ERROR_NO_EL,
URI_PREFIX,
@@ -33,7 +34,7 @@ describe('Base editor', () => {
const blobGlobalId = 'snippet_777';
beforeEach(() => {
- setFixtures('<div id="editor" data-editor-loading></div>');
+ setHTMLFixture('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
defaultArguments = { el: editorEl, blobPath, blobContent, blobGlobalId };
editor = new SourceEditor();
@@ -45,6 +46,8 @@ describe('Base editor', () => {
monacoEditor.getModels().forEach((model) => {
model.dispose();
});
+
+ resetHTMLFixture();
});
const uriFilePath = joinPaths('/', URI_PREFIX, blobGlobalId, blobPath);
@@ -244,7 +247,7 @@ describe('Base editor', () => {
const readOnlyIndex = '78'; // readOnly option has the internal index of 78 in the editor's options
beforeEach(() => {
- setFixtures('<div id="editor1"></div><div id="editor2"></div>');
+ setHTMLFixture('<div id="editor1"></div><div id="editor2"></div>');
editorEl1 = document.getElementById('editor1');
editorEl2 = document.getElementById('editor2');
inst1Args = {
@@ -262,6 +265,7 @@ describe('Base editor', () => {
afterEach(() => {
editor.dispose();
+ resetHTMLFixture();
});
it('can initialize several instances of the same editor', () => {
diff --git a/spec/frontend/editor/source_editor_yaml_ext_spec.js b/spec/frontend/editor/source_editor_yaml_ext_spec.js
index b603b0e3a98..14ec7f8b93f 100644
--- a/spec/frontend/editor/source_editor_yaml_ext_spec.js
+++ b/spec/frontend/editor/source_editor_yaml_ext_spec.js
@@ -1,4 +1,5 @@
import { Document } from 'yaml';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import SourceEditor from '~/editor/source_editor';
import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
@@ -8,7 +9,7 @@ let baseExtension;
let yamlExtension;
const getEditorInstance = (editorInstanceOptions = {}) => {
- setFixtures('<div id="editor"></div>');
+ setHTMLFixture('<div id="editor"></div>');
return new SourceEditor().createInstance({
el: document.getElementById('editor'),
blobPath: '.gitlab-ci.yml',
@@ -18,7 +19,7 @@ const getEditorInstance = (editorInstanceOptions = {}) => {
};
const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => {
- setFixtures('<div id="editor"></div>');
+ setHTMLFixture('<div id="editor"></div>');
const instance = getEditorInstance(editorInstanceOptions);
[baseExtension, yamlExtension] = instance.use([
{ definition: SourceEditorExtension },
@@ -35,6 +36,10 @@ const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOpt
};
describe('YamlCreatorExtension', () => {
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('constructor', () => {
it('saves setupOptions options on the extension, but does not expose those to instance', () => {
const highlightPath = 'foo';
diff --git a/spec/frontend/editor/utils_spec.js b/spec/frontend/editor/utils_spec.js
index 97d3e9e081d..e561cad1086 100644
--- a/spec/frontend/editor/utils_spec.js
+++ b/spec/frontend/editor/utils_spec.js
@@ -1,4 +1,5 @@
import { editor as monacoEditor } from 'monaco-editor';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import * as utils from '~/editor/utils';
import { DEFAULT_THEME } from '~/ide/lib/themes';
@@ -14,10 +15,14 @@ describe('Source Editor utils', () => {
describe('clearDomElement', () => {
beforeEach(() => {
- setFixtures('<div id="foo"><div id="bar">Foo</div></div>');
+ setHTMLFixture('<div id="foo"><div id="bar">Foo</div></div>');
el = document.getElementById('foo');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('removes all child nodes from an element', () => {
expect(el.children.length).toBe(1);
utils.clearDomElement(el);
@@ -68,10 +73,14 @@ describe('Source Editor utils', () => {
beforeEach(() => {
jest.spyOn(monacoEditor, 'colorizeElement').mockImplementation();
jest.spyOn(monacoEditor, 'setTheme').mockImplementation();
- setFixtures('<pre id="foo"></pre>');
+ setHTMLFixture('<pre id="foo"></pre>');
el = document.getElementById('foo');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('colorizes the element and applies the preference theme', () => {
expect(monacoEditor.colorizeElement).not.toHaveBeenCalled();
expect(monacoEditor.setTheme).not.toHaveBeenCalled();
diff --git a/spec/frontend/emoji/components/utils_spec.js b/spec/frontend/emoji/components/utils_spec.js
index 03eeb6b6bf7..56f514ee9a8 100644
--- a/spec/frontend/emoji/components/utils_spec.js
+++ b/spec/frontend/emoji/components/utils_spec.js
@@ -1,7 +1,7 @@
-import Cookies from 'js-cookie';
+import Cookies from '~/lib/utils/cookies';
import { getFrequentlyUsedEmojis, addToFrequentlyUsed } from '~/emoji/components/utils';
-jest.mock('js-cookie');
+jest.mock('~/lib/utils/cookies');
describe('getFrequentlyUsedEmojis', () => {
it('it returns null when no saved emojis set', () => {
diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js
index f2027252f05..37b897bf65d 100644
--- a/spec/frontend/environments/environment_folder_spec.js
+++ b/spec/frontend/environments/environment_folder_spec.js
@@ -15,12 +15,13 @@ Vue.use(VueApollo);
describe('~/environments/components/environments_folder.vue', () => {
let wrapper;
let environmentFolderMock;
+ let intervalMock;
let nestedEnvironment;
const findLink = () => wrapper.findByRole('link', { name: s__('Environments|Show all') });
const createApolloProvider = () => {
- const mockResolvers = { Query: { folder: environmentFolderMock } };
+ const mockResolvers = { Query: { folder: environmentFolderMock, interval: intervalMock } };
return createMockApollo([], mockResolvers);
};
@@ -40,6 +41,8 @@ describe('~/environments/components/environments_folder.vue', () => {
environmentFolderMock = jest.fn();
[nestedEnvironment] = resolvedEnvironmentsApp.environments;
environmentFolderMock.mockReturnValue(resolvedFolder);
+ intervalMock = jest.fn();
+ intervalMock.mockReturnValue(2000);
});
afterEach(() => {
@@ -70,6 +73,8 @@ describe('~/environments/components/environments_folder.vue', () => {
beforeEach(() => {
collapse = wrapper.findComponent(GlCollapse);
icons = wrapper.findAllComponents(GlIcon);
+ jest.spyOn(wrapper.vm.$apollo.queries.folder, 'startPolling');
+ jest.spyOn(wrapper.vm.$apollo.queries.folder, 'stopPolling');
});
it('is collapsed by default', () => {
@@ -93,6 +98,8 @@ describe('~/environments/components/environments_folder.vue', () => {
expect(iconNames).toEqual(['angle-down', 'folder-open']);
expect(folderName.classes('gl-font-weight-bold')).toBe(true);
expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath);
+
+ expect(wrapper.vm.$apollo.queries.folder.startPolling).toHaveBeenCalledWith(2000);
});
it('displays all environments when opened', async () => {
@@ -106,6 +113,16 @@ describe('~/environments/components/environments_folder.vue', () => {
.wrappers.map((w) => w.text());
expect(environments).toEqual(expect.arrayContaining(names));
});
+
+ it('stops polling on click', async () => {
+ await button.trigger('click');
+ expect(wrapper.vm.$apollo.queries.folder.startPolling).toHaveBeenCalledWith(2000);
+
+ const collapseButton = wrapper.findByRole('button', { name: __('Collapse') });
+ await collapseButton.trigger('click');
+
+ expect(wrapper.vm.$apollo.queries.folder.stopPolling).toHaveBeenCalled();
+ });
});
});
diff --git a/spec/frontend/filterable_list_spec.js b/spec/frontend/filterable_list_spec.js
index 3fd5d198e3a..e7197ac6dbf 100644
--- a/spec/frontend/filterable_list_spec.js
+++ b/spec/frontend/filterable_list_spec.js
@@ -1,4 +1,4 @@
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import FilterableList from '~/filterable_list';
describe('FilterableList', () => {
@@ -20,6 +20,10 @@ describe('FilterableList', () => {
List = new FilterableList(form, filter, holder);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('processes input parameters', () => {
expect(List.filterForm).toEqual(form);
expect(List.listFilterElement).toEqual(filter);
diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js
index ee0eef6a1b6..26f12673f68 100644
--- a/spec/frontend/filtered_search/dropdown_user_spec.js
+++ b/spec/frontend/filtered_search/dropdown_user_spec.js
@@ -1,3 +1,4 @@
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import DropdownUser from '~/filtered_search/dropdown_user';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
@@ -80,7 +81,7 @@ describe('Dropdown User', () => {
let authorFilterDropdownElement;
beforeEach(() => {
- loadFixtures(fixtureTemplate);
+ loadHTMLFixture(fixtureTemplate);
authorFilterDropdownElement = document.querySelector('#js-dropdown-author');
const dummyInput = document.createElement('div');
dropdown = new DropdownUser({
@@ -89,6 +90,10 @@ describe('Dropdown User', () => {
});
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
const findCurrentUserElement = () =>
authorFilterDropdownElement.querySelector('.js-current-user');
diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js
index 4c1e79eba42..2030b45b44c 100644
--- a/spec/frontend/filtered_search/dropdown_utils_spec.js
+++ b/spec/frontend/filtered_search/dropdown_utils_spec.js
@@ -1,3 +1,4 @@
+import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
@@ -43,13 +44,17 @@ describe('Dropdown Utils', () => {
};
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<input type="text" id="test" />
`);
input = document.getElementById('test');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should filter without symbol', () => {
input.value = 'roo';
@@ -142,7 +147,7 @@ describe('Dropdown Utils', () => {
let allowedKeys;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<ul class="tokens-container">
<li class="input-token">
<input class="filtered-search" type="text" id="test" />
@@ -350,7 +355,7 @@ describe('Dropdown Utils', () => {
let authorToken;
beforeEach(() => {
- loadFixtures(issuableListFixture);
+ loadHTMLFixture(issuableListFixture);
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
diff --git a/spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js b/spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js
index e9ee69ca163..dff6d11a320 100644
--- a/spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js
@@ -1,5 +1,6 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
describe('Filtered Search Dropdown Manager', () => {
@@ -20,7 +21,7 @@ describe('Filtered Search Dropdown Manager', () => {
}
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<ul class="tokens-container">
<li class="input-token">
<input class="filtered-search">
@@ -29,6 +30,10 @@ describe('Filtered Search Dropdown Manager', () => {
`);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('input has no existing value', () => {
it('should add just tokenName', () => {
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'milestone' });
diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js
index 911a507af4c..5e68725c03e 100644
--- a/spec/frontend/filtered_search/filtered_search_manager_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js
@@ -1,5 +1,5 @@
import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
-
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
@@ -64,7 +64,7 @@ describe('Filtered Search Manager', () => {
}
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class="filtered-search-box">
<form>
<ul class="tokens-container list-unstyled">
@@ -80,6 +80,10 @@ describe('Filtered Search Manager', () => {
jest.spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').mockImplementation();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
const initializeManager = ({ useDefaultState } = {}) => {
jest.spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').mockImplementation();
jest.spyOn(FilteredSearchManager.prototype, 'tokenChange').mockImplementation();
diff --git a/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js b/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js
index c4e125e96da..0e5c94edd05 100644
--- a/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js
@@ -1,5 +1,6 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper';
import waitForPromises from 'helpers/wait_for_promises';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
@@ -24,7 +25,7 @@ describe('Filtered Search Visual Tokens', () => {
mock = new MockAdapter(axios);
mock.onGet().reply(200);
- setFixtures(`
+ setHTMLFixture(`
<ul class="tokens-container">
${FilteredSearchSpecHelper.createInputHTML()}
</ul>
@@ -35,6 +36,10 @@ describe('Filtered Search Visual Tokens', () => {
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('getLastVisualTokenBeforeInput', () => {
it('returns when there are no visual tokens', () => {
const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput();
@@ -241,7 +246,7 @@ describe('Filtered Search Visual Tokens', () => {
let tokenElement;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class="test-area">
${subject.createVisualTokenElementHTML('custom-token')}
</div>
diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js
index bf526a8d371..e52ffa7bd9f 100644
--- a/spec/frontend/filtered_search/visual_token_value_spec.js
+++ b/spec/frontend/filtered_search/visual_token_value_spec.js
@@ -1,5 +1,6 @@
import { escape } from 'lodash';
import labelData from 'test_fixtures/labels/project_labels.json';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper';
import { TEST_HOST } from 'helpers/test_constants';
import DropdownUtils from '~/filtered_search/dropdown_utils';
@@ -28,7 +29,7 @@ describe('Filtered Search Visual Tokens', () => {
let bugLabelToken;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<ul class="tokens-container">
${FilteredSearchSpecHelper.createInputHTML()}
</ul>
@@ -39,6 +40,10 @@ describe('Filtered Search Visual Tokens', () => {
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('updateUserTokenAppearance', () => {
let usersCacheSpy;
diff --git a/spec/frontend/fixtures/api_merge_requests.rb b/spec/frontend/fixtures/api_merge_requests.rb
index 47321fbbeaa..75bc8c8df25 100644
--- a/spec/frontend/fixtures/api_merge_requests.rb
+++ b/spec/frontend/fixtures/api_merge_requests.rb
@@ -9,11 +9,13 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do
let_it_be(:admin) { create(:admin, name: 'root') }
let_it_be(:namespace) { create(:namespace, name: 'gitlab-test' )}
let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') }
+ let_it_be(:early_mrs) do
+ 4.times { |i| create(:merge_request, source_project: project, source_branch: "branch-#{i}") }
+ end
+
let_it_be(:mr) { create(:merge_request, source_project: project) }
it 'api/merge_requests/get.json' do
- 4.times { |i| create(:merge_request, source_project: project, source_branch: "branch-#{i}") }
-
get api("/projects/#{project.id}/merge_requests", admin)
expect(response).to be_successful
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index 25049ee4722..e17e73a93c4 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -67,7 +67,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- runner_query = 'details/runner.query.graphql'
+ runner_query = 'show/runner.query.graphql'
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{runner_query}")
@@ -91,7 +91,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- runner_projects_query = 'details/runner_projects.query.graphql'
+ runner_projects_query = 'show/runner_projects.query.graphql'
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{runner_projects_query}")
@@ -107,7 +107,23 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- runner_jobs_query = 'details/runner_jobs.query.graphql'
+ runner_jobs_query = 'show/runner_jobs.query.graphql'
+
+ let_it_be(:query) do
+ get_graphql_query_as_string("#{query_path}#{runner_jobs_query}")
+ end
+
+ it "#{fixtures_path}#{runner_jobs_query}.json" do
+ post_graphql(query, current_user: admin, variables: {
+ id: instance_runner.to_global_id.to_s
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ describe GraphQL::Query, type: :request do
+ runner_jobs_query = 'edit/runner_form.query.graphql'
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{runner_jobs_query}")
diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js
index 942e2c330fa..6cd32ff6b40 100644
--- a/spec/frontend/flash_spec.js
+++ b/spec/frontend/flash_spec.js
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/browser';
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import createFlash, {
hideFlash,
addDismissFlashClickListener,
@@ -93,7 +93,7 @@ describe('Flash', () => {
if (alert) {
alert.$destroy();
}
- document.querySelector('.flash-container')?.remove();
+ resetHTMLFixture();
});
it('adds alert element into the document by default', () => {
@@ -330,7 +330,7 @@ describe('Flash', () => {
});
afterEach(() => {
- document.querySelector('.js-content-wrapper').remove();
+ resetHTMLFixture();
});
it('adds flash alert element into the document by default', () => {
diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js
index ba989bf53ab..32c66c0d288 100644
--- a/spec/frontend/frequent_items/components/app_spec.js
+++ b/spec/frontend/frequent_items/components/app_spec.js
@@ -6,7 +6,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import App from '~/frequent_items/components/app.vue';
import FrequentItemsList from '~/frequent_items/components/frequent_items_list.vue';
-import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants';
+import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants';
import eventHub from '~/frequent_items/event_hub';
import { createStore } from '~/frequent_items/store';
import { getTopFrequentItems } from '~/frequent_items/utils';
@@ -200,15 +200,15 @@ describe('Frequent Items App Component', () => {
]);
});
- it('should increase frequency, when created an hour later', () => {
- const hourLater = Date.now() + HOUR_IN_MS + 1;
+ it('should increase frequency, when created 15 minutes later', () => {
+ const fifteenMinutesLater = Date.now() + FIFTEEN_MINUTES_IN_MS + 1;
- jest.spyOn(Date, 'now').mockReturnValue(hourLater);
- createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: hourLater } });
+ jest.spyOn(Date, 'now').mockReturnValue(fifteenMinutesLater);
+ createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: fifteenMinutesLater } });
expect(getStoredProjects()).toEqual([
expect.objectContaining({
- lastAccessedOn: hourLater,
+ lastAccessedOn: fifteenMinutesLater,
frequency: 2,
}),
]);
diff --git a/spec/frontend/frequent_items/utils_spec.js b/spec/frontend/frequent_items/utils_spec.js
index 8c3841558f4..33c655a6ffd 100644
--- a/spec/frontend/frequent_items/utils_spec.js
+++ b/spec/frontend/frequent_items/utils_spec.js
@@ -1,5 +1,5 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants';
+import { FIFTEEN_MINUTES_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants';
import {
isMobile,
getTopFrequentItems,
@@ -67,8 +67,8 @@ describe('Frequent Items utils spec', () => {
describe('updateExistingFrequentItem', () => {
const LAST_ACCESSED = 1497979281815;
- const WITHIN_AN_HOUR = LAST_ACCESSED + HOUR_IN_MS;
- const OVER_AN_HOUR = WITHIN_AN_HOUR + 1;
+ const WITHIN_FIFTEEN_MINUTES = LAST_ACCESSED + FIFTEEN_MINUTES_IN_MS;
+ const OVER_FIFTEEN_MINUTES = WITHIN_FIFTEEN_MINUTES + 1;
const EXISTING_ITEM = Object.freeze({
...mockProject,
frequency: 1,
@@ -76,10 +76,10 @@ describe('Frequent Items utils spec', () => {
});
it.each`
- desc | existingProps | newProps | expected
- ${'updates item if accessed over an hour ago'} | ${{}} | ${{ lastAccessedOn: OVER_AN_HOUR }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }}
- ${'does not update is accessed with an hour'} | ${{}} | ${{ lastAccessedOn: WITHIN_AN_HOUR }} | ${{ lastAccessedOn: EXISTING_ITEM.lastAccessedOn, frequency: 1 }}
- ${'updates if lastAccessedOn not found'} | ${{ lastAccessedOn: undefined }} | ${{ lastAccessedOn: WITHIN_AN_HOUR }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }}
+ desc | existingProps | newProps | expected
+ ${'updates item if accessed over 15 minutes ago'} | ${{}} | ${{ lastAccessedOn: OVER_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }}
+ ${'does not update is accessed with 15 minutes'} | ${{}} | ${{ lastAccessedOn: WITHIN_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: EXISTING_ITEM.lastAccessedOn, frequency: 1 }}
+ ${'updates if lastAccessedOn not found'} | ${{ lastAccessedOn: undefined }} | ${{ lastAccessedOn: WITHIN_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }}
`('$desc', ({ existingProps, newProps, expected }) => {
const newItem = {
...EXISTING_ITEM,
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 1ab3286fe4c..aa98b2774ea 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -2,6 +2,7 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import GfmAutoComplete, { membersBeforeSave, highlighter } from 'ee_else_ce/gfm_auto_complete';
import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import '~/lib/utils/jquery_at_who';
@@ -722,7 +723,7 @@ describe('GfmAutoComplete', () => {
let $textarea;
beforeEach(() => {
- setFixtures('<textarea></textarea>');
+ setHTMLFixture('<textarea></textarea>');
autocomplete = new GfmAutoComplete(dataSources);
$textarea = $('textarea');
autocomplete.setup($textarea, { labels: true });
@@ -730,6 +731,7 @@ describe('GfmAutoComplete', () => {
afterEach(() => {
autocomplete.destroy();
+ resetHTMLFixture();
});
const triggerDropdown = (text) => {
diff --git a/spec/frontend/gl_field_errors_spec.js b/spec/frontend/gl_field_errors_spec.js
index ada3b34e6b1..92d04927ee5 100644
--- a/spec/frontend/gl_field_errors_spec.js
+++ b/spec/frontend/gl_field_errors_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import GlFieldErrors from '~/gl_field_errors';
describe('GL Style Field Errors', () => {
@@ -9,13 +10,17 @@ describe('GL Style Field Errors', () => {
});
beforeEach(() => {
- loadFixtures('static/gl_field_errors.html');
+ loadHTMLFixture('static/gl_field_errors.html');
const $form = $('form.gl-show-field-errors');
testContext.$form = $form;
testContext.fieldErrors = new GlFieldErrors($form);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should select the correct input elements', () => {
expect(testContext.$form).toBeDefined();
expect(testContext.$form.length).toBe(1);
diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js
index de4a57a7319..6412fe8bb33 100644
--- a/spec/frontend/google_tag_manager/index_spec.js
+++ b/spec/frontend/google_tag_manager/index_spec.js
@@ -14,7 +14,7 @@ import {
trackTransaction,
trackAddToCartUsageTab,
} from '~/google_tag_manager';
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { logError } from '~/lib/logger';
jest.mock('~/lib/logger');
@@ -216,6 +216,10 @@ describe('~/google_tag_manager/index', () => {
subject();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it.each(expectedEvents)('when %p', ({ selector, trigger, expectation }) => {
expect(spy).not.toHaveBeenCalled();
@@ -443,6 +447,8 @@ describe('~/google_tag_manager/index', () => {
expect(spy).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
+
+ resetHTMLFixture();
});
});
@@ -468,6 +474,8 @@ describe('~/google_tag_manager/index', () => {
'Unexpected error while pushing to dataLayer',
pushError,
);
+
+ resetHTMLFixture();
});
});
});
diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js
index 0bb50fc3e6f..0a1596b492d 100644
--- a/spec/frontend/gpg_badges_spec.js
+++ b/spec/frontend/gpg_badges_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'spec/test_constants';
import GpgBadges from '~/gpg_badges';
import axios from '~/lib/utils/axios_utils';
@@ -18,7 +19,7 @@ describe('GpgBadges', () => {
const dummyUrl = `${TEST_HOST}/dummy/signatures`;
const setForm = ({ utf8 = '✓', search = '' } = {}) => {
- setFixtures(`
+ setHTMLFixture(`
<form
class="commits-search-form js-signature-container" data-signatures-path="${dummyUrl}" action="${dummyUrl}"
method="get">
@@ -38,24 +39,27 @@ describe('GpgBadges', () => {
afterEach(() => {
mock.restore();
+ resetHTMLFixture();
});
it('does not make a request if there is no container element', async () => {
- setFixtures('');
+ setHTMLFixture('');
jest.spyOn(axios, 'get').mockImplementation(() => {});
await GpgBadges.fetch();
expect(axios.get).not.toHaveBeenCalled();
+ resetHTMLFixture();
});
it('throws an error if the endpoint is missing', async () => {
- setFixtures('<div class="js-signature-container"></div>');
+ setHTMLFixture('<div class="js-signature-container"></div>');
jest.spyOn(axios, 'get').mockImplementation(() => {});
await expect(GpgBadges.fetch()).rejects.toEqual(
new Error('Missing commit signatures endpoint!'),
);
expect(axios.get).not.toHaveBeenCalled();
+ resetHTMLFixture();
});
it('fetches commit signatures', async () => {
diff --git a/spec/frontend/group_settings/components/shared_runners_form_spec.js b/spec/frontend/group_settings/components/shared_runners_form_spec.js
index 26e9cd39cfd..70a22c86e62 100644
--- a/spec/frontend/group_settings/components/shared_runners_form_spec.js
+++ b/spec/frontend/group_settings/components/shared_runners_form_spec.js
@@ -1,47 +1,46 @@
-import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
import MockAxiosAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import SharedRunnersForm from '~/group_settings/components/shared_runners_form.vue';
import axios from '~/lib/utils/axios_utils';
-const provide = {
- updatePath: '/test/update',
- sharedRunnersAvailability: 'enabled',
- parentSharedRunnersAvailability: null,
- runnerDisabled: 'disabled',
- runnerEnabled: 'enabled',
- runnerAllowOverride: 'allow_override',
-};
-
-jest.mock('~/flash');
+const UPDATE_PATH = '/test/update';
+const RUNNER_ENABLED_VALUE = 'enabled';
+const RUNNER_DISABLED_VALUE = 'disabled_and_unoverridable';
+const RUNNER_ALLOW_OVERRIDE_VALUE = 'disabled_with_override';
describe('group_settings/components/shared_runners_form', () => {
let wrapper;
let mock;
- const createComponent = (provides = {}) => {
- wrapper = shallowMount(SharedRunnersForm, {
+ const createComponent = (provide = {}) => {
+ wrapper = shallowMountExtended(SharedRunnersForm, {
provide: {
+ updatePath: UPDATE_PATH,
+ sharedRunnersSetting: RUNNER_ENABLED_VALUE,
+ parentSharedRunnersSetting: null,
+ runnerEnabledValue: RUNNER_ENABLED_VALUE,
+ runnerDisabledValue: RUNNER_DISABLED_VALUE,
+ runnerAllowOverrideValue: RUNNER_ALLOW_OVERRIDE_VALUE,
...provide,
- ...provides,
},
});
};
- const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const findErrorAlert = () => wrapper.find(GlAlert);
- const findEnabledToggle = () => wrapper.find('[data-testid="enable-runners-toggle"]');
- const findOverrideToggle = () => wrapper.find('[data-testid="override-runners-toggle"]');
- const changeToggle = (toggle) => toggle.vm.$emit('change', !toggle.props('value'));
+ const findAlert = (variant) =>
+ wrapper
+ .findAllComponents(GlAlert)
+ .filter((w) => w.props('variant') === variant)
+ .at(0);
+ const findSharedRunnersToggle = () => wrapper.findByTestId('shared-runners-toggle');
+ const findOverrideToggle = () => wrapper.findByTestId('override-runners-toggle');
const getSharedRunnersSetting = () => JSON.parse(mock.history.put[0].data).shared_runners_setting;
- const isLoadingIconVisible = () => findLoadingIcon().exists();
beforeEach(() => {
mock = new MockAxiosAdapter(axios);
-
- mock.onPut(provide.updatePath).reply(200);
+ mock.onPut(UPDATE_PATH).reply(200);
});
afterEach(() => {
@@ -51,102 +50,122 @@ describe('group_settings/components/shared_runners_form', () => {
mock.restore();
});
- describe('with default', () => {
+ describe('default state', () => {
beforeEach(() => {
createComponent();
});
- it('loading icon does not exist', () => {
- expect(isLoadingIconVisible()).toBe(false);
+ it('"Enable shared runners" toggle is enabled', () => {
+ expect(findSharedRunnersToggle().props()).toMatchObject({
+ isLoading: false,
+ disabled: false,
+ });
});
- it('enabled toggle exists', () => {
- expect(findEnabledToggle().exists()).toBe(true);
+ it('"Override the group setting" is disabled', () => {
+ expect(findOverrideToggle().props()).toMatchObject({
+ isLoading: false,
+ disabled: true,
+ });
});
+ });
- it('override toggle does not exist', () => {
- expect(findOverrideToggle().exists()).toBe(false);
+ describe('When group disabled shared runners', () => {
+ it(`toggles are not disabled with setting ${RUNNER_DISABLED_VALUE}`, () => {
+ createComponent({ sharedRunnersSetting: RUNNER_DISABLED_VALUE });
+
+ expect(findSharedRunnersToggle().props('disabled')).toBe(false);
+ expect(findOverrideToggle().props('disabled')).toBe(false);
});
});
- describe('loading icon', () => {
- it('shows and hides the loading icon on request', async () => {
- createComponent();
+ describe('When parent group disabled shared runners', () => {
+ it('toggles are disabled', () => {
+ createComponent({
+ sharedRunnersSetting: RUNNER_DISABLED_VALUE,
+ parentSharedRunnersSetting: RUNNER_DISABLED_VALUE,
+ });
+
+ expect(findSharedRunnersToggle().props('disabled')).toBe(true);
+ expect(findOverrideToggle().props('disabled')).toBe(true);
+ expect(findAlert('warning').exists()).toBe(true);
+ });
+ });
- expect(isLoadingIconVisible()).toBe(false);
+ describe('loading state', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- findEnabledToggle().vm.$emit('change', true);
+ it('is not loading by default', () => {
+ expect(findSharedRunnersToggle().props('isLoading')).toBe(false);
+ expect(findOverrideToggle().props('isLoading')).toBe(false);
+ });
+ it('is loading immediately after request', async () => {
+ findSharedRunnersToggle().vm.$emit('change', true);
await nextTick();
- expect(isLoadingIconVisible()).toBe(true);
+ expect(findSharedRunnersToggle().props('isLoading')).toBe(true);
+ expect(findOverrideToggle().props('isLoading')).toBe(true);
+ });
+
+ it('does not update settings while loading', async () => {
+ findSharedRunnersToggle().vm.$emit('change', true);
+ findSharedRunnersToggle().vm.$emit('change', false);
+ await waitForPromises();
+ expect(mock.history.put.length).toBe(1);
+ });
+
+ it('is not loading state after completed request', async () => {
+ findSharedRunnersToggle().vm.$emit('change', true);
await waitForPromises();
- expect(isLoadingIconVisible()).toBe(false);
+ expect(findSharedRunnersToggle().props('isLoading')).toBe(false);
+ expect(findOverrideToggle().props('isLoading')).toBe(false);
});
});
- describe('enable toggle', () => {
+ describe('"Enable shared runners" toggle', () => {
beforeEach(() => {
createComponent();
});
- it('enabling the toggle sends correct payload', async () => {
- findEnabledToggle().vm.$emit('change', true);
-
+ it('sends correct payload when turned on', async () => {
+ findSharedRunnersToggle().vm.$emit('change', true);
await waitForPromises();
- expect(getSharedRunnersSetting()).toEqual(provide.runnerEnabled);
- expect(findOverrideToggle().exists()).toBe(false);
+ expect(getSharedRunnersSetting()).toEqual(RUNNER_ENABLED_VALUE);
+ expect(findOverrideToggle().props('disabled')).toBe(true);
});
- it('disabling the toggle sends correct payload', async () => {
- findEnabledToggle().vm.$emit('change', false);
-
+ it('sends correct payload when turned off', async () => {
+ findSharedRunnersToggle().vm.$emit('change', false);
await waitForPromises();
- expect(getSharedRunnersSetting()).toEqual(provide.runnerDisabled);
- expect(findOverrideToggle().exists()).toBe(true);
+ expect(getSharedRunnersSetting()).toEqual(RUNNER_DISABLED_VALUE);
+ expect(findOverrideToggle().props('disabled')).toBe(false);
});
});
- describe('override toggle', () => {
+ describe('"Override the group setting" toggle', () => {
beforeEach(() => {
- createComponent({ sharedRunnersAvailability: provide.runnerAllowOverride });
+ createComponent({ sharedRunnersSetting: RUNNER_ALLOW_OVERRIDE_VALUE });
});
it('enabling the override toggle sends correct payload', async () => {
findOverrideToggle().vm.$emit('change', true);
-
await waitForPromises();
- expect(getSharedRunnersSetting()).toEqual(provide.runnerAllowOverride);
+ expect(getSharedRunnersSetting()).toEqual(RUNNER_ALLOW_OVERRIDE_VALUE);
});
it('disabling the override toggle sends correct payload', async () => {
findOverrideToggle().vm.$emit('change', false);
-
await waitForPromises();
- expect(getSharedRunnersSetting()).toEqual(provide.runnerDisabled);
- });
- });
-
- describe('toggle disabled state', () => {
- it(`toggles are not disabled with setting ${provide.runnerDisabled}`, () => {
- createComponent({ sharedRunnersAvailability: provide.runnerDisabled });
- expect(findEnabledToggle().props('disabled')).toBe(false);
- expect(findOverrideToggle().props('disabled')).toBe(false);
- });
-
- it('toggles are disabled', () => {
- createComponent({
- sharedRunnersAvailability: provide.runnerDisabled,
- parentSharedRunnersAvailability: provide.runnerDisabled,
- });
- expect(findEnabledToggle().props('disabled')).toBe(true);
- expect(findOverrideToggle().props('disabled')).toBe(true);
+ expect(getSharedRunnersSetting()).toEqual(RUNNER_DISABLED_VALUE);
});
});
@@ -156,16 +175,16 @@ describe('group_settings/components/shared_runners_form', () => {
${{ error: 'Undefined error' }} | ${'Undefined error Refresh the page and try again.'}
`(`with error $errorObj`, ({ errorObj, message }) => {
beforeEach(async () => {
- mock.onPut(provide.updatePath).reply(500, errorObj);
+ mock.onPut(UPDATE_PATH).reply(500, errorObj);
createComponent();
- changeToggle(findEnabledToggle());
+ findSharedRunnersToggle().vm.$emit('change', false);
await waitForPromises();
});
it('error should be shown', () => {
- expect(findErrorAlert().text()).toBe(message);
+ expect(findAlert('danger').text()).toBe(message);
});
});
});
diff --git a/spec/frontend/groups/landing_spec.js b/spec/frontend/groups/landing_spec.js
index d60adea202b..2c2c19ee0c7 100644
--- a/spec/frontend/groups/landing_spec.js
+++ b/spec/frontend/groups/landing_spec.js
@@ -1,4 +1,4 @@
-import Cookies from 'js-cookie';
+import Cookies from '~/lib/utils/cookies';
import Landing from '~/groups/landing';
describe('Landing', () => {
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 937bc9aa478..19849fba63c 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import initTodoToggle, { initNavUserDropdownTracking } from '~/header';
+import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
describe('Header', () => {
describe('Todos notification', () => {
@@ -17,7 +18,11 @@ describe('Header', () => {
beforeEach(() => {
initTodoToggle();
- loadFixtures(fixtureTemplate);
+ loadHTMLFixture(fixtureTemplate);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('should update todos-count after receiving the todo:toggle event', () => {
@@ -57,7 +62,7 @@ describe('Header', () => {
let trackingSpy;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<li class="js-nav-user-dropdown">
<a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a>
</li>`);
@@ -70,6 +75,7 @@ describe('Header', () => {
afterEach(() => {
unmockTracking();
+ resetHTMLFixture();
});
it('sends a tracking event when the dropdown is opened and contains Buy Pipeline minutes link', () => {
diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js
index 703bdbd342f..2236b5aa261 100644
--- a/spec/frontend/helpers/startup_css_helper_spec.js
+++ b/spec/frontend/helpers/startup_css_helper_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
describe('waitForCSSLoaded', () => {
@@ -41,17 +42,19 @@ describe('waitForCSSLoaded', () => {
describe('with startup css enabled', () => {
it('should dispatch CSSLoaded when the assets are cached or already loaded', async () => {
- setFixtures(`
+ setHTMLFixture(`
<link href="one.css" data-startupcss="loaded">
<link href="two.css" data-startupcss="loaded">
`);
await waitForCSSLoaded(mockedCallback);
expect(mockedCallback).toHaveBeenCalledTimes(1);
+
+ resetHTMLFixture();
});
it('should wait to call CssLoaded until the assets are loaded', async () => {
- setFixtures(`
+ setHTMLFixture(`
<link href="one.css" data-startupcss="loading">
<link href="two.css" data-startupcss="loading">
`);
@@ -63,6 +66,8 @@ describe('waitForCSSLoaded', () => {
await events;
expect(mockedCallback).toHaveBeenCalledTimes(1);
+
+ resetHTMLFixture();
});
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/message_field_spec.js b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js
index e66de6bb0b0..ace266aec5e 100644
--- a/spec/frontend/ide/components/commit_sidebar/message_field_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js
@@ -1,4 +1,5 @@
import Vue, { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import createComponent from 'helpers/vue_mount_component_helper';
import CommitMessageField from '~/ide/components/commit_sidebar/message_field.vue';
@@ -7,7 +8,7 @@ describe('IDE commit message field', () => {
let vm;
beforeEach(() => {
- setFixtures('<div id="app"></div>');
+ setHTMLFixture('<div id="app"></div>');
vm = createComponent(
Component,
@@ -21,6 +22,8 @@ describe('IDE commit message field', () => {
afterEach(() => {
vm.$destroy();
+
+ resetHTMLFixture();
});
it('adds is-focused class on focus', async () => {
diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js
index 5a7419d6dce..3eafe9e7ccb 100644
--- a/spec/frontend/ide/components/new_dropdown/upload_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js
@@ -70,7 +70,9 @@ describe('new dropdown upload', () => {
});
it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', async () => {
- const waitForCreate = new Promise((resolve) => vm.$on('create', resolve));
+ const waitForCreate = new Promise((resolve) => {
+ vm.$on('create', resolve);
+ });
vm.createFile(textTarget, textFile);
diff --git a/spec/frontend/image_diff/image_diff_spec.js b/spec/frontend/image_diff/image_diff_spec.js
index 710aa7108a8..f8faa8d78c2 100644
--- a/spec/frontend/image_diff/image_diff_spec.js
+++ b/spec/frontend/image_diff/image_diff_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import imageDiffHelper from '~/image_diff/helpers/index';
import ImageDiff from '~/image_diff/image_diff';
@@ -9,7 +10,7 @@ describe('ImageDiff', () => {
let imageDiff;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div id="element">
<div class="diff-file">
<div class="js-image-frame">
@@ -35,6 +36,10 @@ describe('ImageDiff', () => {
element = document.getElementById('element');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('constructor', () => {
beforeEach(() => {
imageDiff = new ImageDiff(element, {
diff --git a/spec/frontend/image_diff/init_discussion_tab_spec.js b/spec/frontend/image_diff/init_discussion_tab_spec.js
index f6f05037c95..3b427f0d54d 100644
--- a/spec/frontend/image_diff/init_discussion_tab_spec.js
+++ b/spec/frontend/image_diff/init_discussion_tab_spec.js
@@ -1,9 +1,10 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initImageDiffHelper from '~/image_diff/helpers/init_image_diff';
import initDiscussionTab from '~/image_diff/init_discussion_tab';
describe('initDiscussionTab', () => {
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class="timeline-content">
<div class="diff-file js-image-file"></div>
<div class="diff-file js-image-file"></div>
@@ -11,6 +12,10 @@ describe('initDiscussionTab', () => {
`);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should pass canCreateNote as false to initImageDiff', () => {
jest
.spyOn(initImageDiffHelper, 'initImageDiff')
diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js
index 2b401fc46bf..d789e964e4c 100644
--- a/spec/frontend/image_diff/replaced_image_diff_spec.js
+++ b/spec/frontend/image_diff/replaced_image_diff_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import imageDiffHelper from '~/image_diff/helpers/index';
import ImageDiff from '~/image_diff/image_diff';
@@ -9,7 +10,7 @@ describe('ReplacedImageDiff', () => {
let replacedImageDiff;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div id="element">
<div class="two-up">
<div class="js-image-frame">
@@ -36,6 +37,10 @@ describe('ReplacedImageDiff', () => {
element = document.getElementById('element');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
function setupImageFrameEls() {
replacedImageDiff.imageFrameEls = [];
replacedImageDiff.imageFrameEls[viewTypes.TWO_UP] = element.querySelector(
diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js
index b17ff2e0f52..1939e43e5dc 100644
--- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js
@@ -9,7 +9,7 @@ import createFlash from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { STATUSES } from '~/import_entities/constants';
-import { i18n } from '~/import_entities/import_groups/constants';
+import { i18n, ROOT_NAMESPACE } from '~/import_entities/import_groups/constants';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
@@ -45,6 +45,8 @@ describe('import table', () => {
const findImportButtons = () =>
wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import');
const findPaginationDropdown = () => wrapper.find('[data-testid="page-size"]');
+ const findTargetNamespaceDropdown = (rowWrapper) =>
+ rowWrapper.find('[data-testid="target-namespace-selector"]');
const findPaginationDropdownText = () => findPaginationDropdown().find('button').text();
const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]');
@@ -70,6 +72,7 @@ describe('import table', () => {
groupPathRegex: /.*/,
jobsPath: '/fake_job_path',
sourceUrl: SOURCE_URL,
+ historyPath: '/fake_history_path',
},
apolloProvider,
});
@@ -136,6 +139,32 @@ describe('import table', () => {
expect(wrapper.findAll('tbody tr')).toHaveLength(FAKE_GROUPS.length);
});
+ it('correctly maintains root namespace as last import target', async () => {
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: [
+ {
+ ...generateFakeEntry({ id: 1, status: STATUSES.FINISHED }),
+ lastImportTarget: {
+ id: 1,
+ targetNamespace: ROOT_NAMESPACE.fullPath,
+ newName: 'does-not-matter',
+ },
+ },
+ ],
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
+ });
+
+ await waitForPromises();
+ const firstRow = wrapper.find('tbody tr');
+ const targetNamespaceDropdownButton = findTargetNamespaceDropdown(firstRow).find(
+ '[aria-haspopup]',
+ );
+ expect(targetNamespaceDropdownButton.text()).toBe('No parent');
+ });
+
it('does not render status string when result list is empty', async () => {
createComponent({
bulkImportSourceGroups: jest.fn().mockResolvedValue({
diff --git a/spec/frontend/import_entities/import_groups/utils_spec.js b/spec/frontend/import_entities/import_groups/utils_spec.js
new file mode 100644
index 00000000000..2892c5c217b
--- /dev/null
+++ b/spec/frontend/import_entities/import_groups/utils_spec.js
@@ -0,0 +1,56 @@
+import { STATUSES } from '~/import_entities/constants';
+import { isFinished, isAvailableForImport } from '~/import_entities/import_groups/utils';
+
+const FINISHED_STATUSES = [STATUSES.FINISHED, STATUSES.FAILED, STATUSES.TIMEOUT];
+const OTHER_STATUSES = Object.values(STATUSES).filter(
+ (status) => !FINISHED_STATUSES.includes(status),
+);
+describe('gitlab migration status utils', () => {
+ describe('isFinished', () => {
+ it.each(FINISHED_STATUSES.map((s) => [s]))(
+ 'reports group as finished when import status is %s',
+ (status) => {
+ expect(isFinished({ progress: { status } })).toBe(true);
+ },
+ );
+
+ it.each(OTHER_STATUSES.map((s) => [s]))(
+ 'does not report group as finished when import status is %s',
+ (status) => {
+ expect(isFinished({ progress: { status } })).toBe(false);
+ },
+ );
+
+ it('does not report group as finished when there is no progress', () => {
+ expect(isFinished({ progress: null })).toBe(false);
+ });
+
+ it('does not report group as finished when status is unknown', () => {
+ expect(isFinished({ progress: { status: 'weird' } })).toBe(false);
+ });
+ });
+
+ describe('isAvailableForImport', () => {
+ it.each(FINISHED_STATUSES.map((s) => [s]))(
+ 'reports group as available for import when status is %s',
+ (status) => {
+ expect(isAvailableForImport({ progress: { status } })).toBe(true);
+ },
+ );
+
+ it.each(OTHER_STATUSES.map((s) => [s]))(
+ 'does not report group as not available for import when status is %s',
+ (status) => {
+ expect(isAvailableForImport({ progress: { status } })).toBe(false);
+ },
+ );
+
+ it('reports group as available for import when there is no progress', () => {
+ expect(isAvailableForImport({ progress: null })).toBe(true);
+ });
+
+ it('reports group as finished when status is unknown', () => {
+ expect(isFinished({ progress: { status: 'weird' } })).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
index 88fcedd31b2..140fec3863b 100644
--- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
@@ -1,4 +1,4 @@
-import { GlLoadingIcon, GlButton, GlIntersectionObserver, GlFormInput } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlIntersectionObserver, GlSearchBoxByClick } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
@@ -15,7 +15,7 @@ describe('ImportProjectsTable', () => {
const findFilterField = () =>
wrapper
- .findAllComponents(GlFormInput)
+ .findAllComponents(GlSearchBoxByClick)
.wrappers.find((w) => w.attributes('placeholder') === 'Filter by name');
const providerTitle = 'THE PROVIDER';
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
index feee14c9c40..7e24aa439d4 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
@@ -57,6 +57,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
</gl-button-stub>
<gl-modal-stub
+ arialabel=""
dismisslabel="Close"
modalclass=""
modalid="resetWebhookModal"
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index ca481e009cf..a2bdece821f 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -1,4 +1,4 @@
-import { GlForm } from '@gitlab/ui';
+import { GlBadge, GlForm } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import * as Sentry from '@sentry/browser';
@@ -18,11 +18,18 @@ import {
integrationLevels,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
+ billingPlans,
+ billingPlanNames,
} from '~/integrations/constants';
import { createStore } from '~/integrations/edit/store';
import httpStatus from '~/lib/utils/http_status';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
-import { mockIntegrationProps, mockField, mockSectionConnection } from '../mock_data';
+import {
+ mockIntegrationProps,
+ mockField,
+ mockSectionConnection,
+ mockSectionJiraIssues,
+} from '../mock_data';
jest.mock('@sentry/browser');
jest.mock('~/lib/utils/url_utility');
@@ -72,6 +79,7 @@ describe('IntegrationForm', () => {
const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group');
const findTestButton = () => wrapper.findByTestId('test-button');
const findTriggerFields = () => wrapper.findComponent(TriggerFields);
+ const findGlBadge = () => wrapper.findComponent(GlBadge);
const findGlForm = () => wrapper.findComponent(GlForm);
const findRedirectToField = () => wrapper.findByTestId('redirect-to-field');
const findDynamicField = () => wrapper.findComponent(DynamicField);
@@ -327,9 +335,21 @@ describe('IntegrationForm', () => {
expect(connectionSection.find('h4').text()).toBe(mockSectionConnection.title);
expect(connectionSection.find('p').text()).toBe(mockSectionConnection.description);
+ expect(findGlBadge().exists()).toBe(false);
expect(findConnectionSectionComponent().exists()).toBe(true);
});
+ it('renders GlBadge when `plan` is present', () => {
+ createComponent({
+ customStateProps: {
+ sections: [mockSectionConnection, mockSectionJiraIssues],
+ },
+ });
+
+ expect(findGlBadge().exists()).toBe(true);
+ expect(findGlBadge().text()).toMatchInterpolatedText(billingPlanNames[billingPlans.PREMIUM]);
+ });
+
it('passes only fields with section type', () => {
const sectionFields = [
{ name: 'username', type: 'text', section: mockSectionConnection.type },
diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
index 94e370a485f..b4c5d4f9957 100644
--- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
@@ -10,7 +10,6 @@ describe('JiraIssuesFields', () => {
let wrapper;
const defaultProps = {
- showJiraIssuesIntegration: true,
showJiraVulnerabilitiesIntegration: true,
upgradePlanPath: 'https://gitlab.com',
};
@@ -42,8 +41,6 @@ describe('JiraIssuesFields', () => {
findEnableCheckbox().find('[type=checkbox]').attributes('disabled');
const findProjectKey = () => wrapper.findComponent(GlFormInput);
const findProjectKeyFormGroup = () => wrapper.findByTestId('project-key-form-group');
- const findPremiumUpgradeCTA = () => wrapper.findByTestId('premium-upgrade-cta');
- const findUltimateUpgradeCTA = () => wrapper.findByTestId('ultimate-upgrade-cta');
const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities');
const setEnableCheckbox = async (isEnabled = true) =>
findEnableCheckbox().vm.$emit('input', isEnabled);
@@ -55,19 +52,16 @@ describe('JiraIssuesFields', () => {
describe('template', () => {
describe.each`
- showJiraIssuesIntegration | showJiraVulnerabilitiesIntegration
- ${false} | ${false}
- ${false} | ${true}
- ${true} | ${false}
- ${true} | ${true}
+ showJiraIssuesIntegration
+ ${false}
+ ${true}
`(
- 'when `showJiraIssuesIntegration` is $jiraIssues and `showJiraVulnerabilitiesIntegration` is $jiraVulnerabilities',
- ({ showJiraIssuesIntegration, showJiraVulnerabilitiesIntegration }) => {
+ 'when showJiraIssuesIntegration = $showJiraIssuesIntegration',
+ ({ showJiraIssuesIntegration }) => {
beforeEach(() => {
createComponent({
props: {
showJiraIssuesIntegration,
- showJiraVulnerabilitiesIntegration,
},
});
});
@@ -77,39 +71,12 @@ describe('JiraIssuesFields', () => {
expect(findEnableCheckbox().exists()).toBe(true);
expect(findEnableCheckboxDisabled()).toBeUndefined();
});
-
- it('does not render the Premium CTA', () => {
- expect(findPremiumUpgradeCTA().exists()).toBe(false);
- });
-
- if (!showJiraVulnerabilitiesIntegration) {
- it.each`
- scenario | enableJiraIssues
- ${'when "Enable Jira issues" is checked, renders Ultimate upgrade CTA'} | ${true}
- ${'when "Enable Jira issues" is unchecked, does not render Ultimate upgrade CTA'} | ${false}
- `('$scenario', async ({ enableJiraIssues }) => {
- if (enableJiraIssues) {
- await setEnableCheckbox();
- }
- expect(findUltimateUpgradeCTA().exists()).toBe(enableJiraIssues);
- });
- }
} else {
- it('does not render enable checkbox', () => {
- expect(findEnableCheckbox().exists()).toBe(false);
- });
-
- it('renders the Premium CTA', () => {
- const premiumUpgradeCTA = findPremiumUpgradeCTA();
-
- expect(premiumUpgradeCTA.exists()).toBe(true);
- expect(premiumUpgradeCTA.props('upgradePlanPath')).toBe(defaultProps.upgradePlanPath);
+ it('renders enable checkbox as disabled', () => {
+ expect(findEnableCheckbox().exists()).toBe(true);
+ expect(findEnableCheckboxDisabled()).toBe('disabled');
});
}
-
- it('does not render the Ultimate CTA', () => {
- expect(findUltimateUpgradeCTA().exists()).toBe(false);
- });
},
);
diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js
index 36850a0a33a..ac0c7d244e3 100644
--- a/spec/frontend/integrations/edit/mock_data.js
+++ b/spec/frontend/integrations/edit/mock_data.js
@@ -37,3 +37,11 @@ export const mockSectionConnection = {
title: 'Connection details',
description: 'Learn more on how to configure this integration.',
};
+
+export const mockSectionJiraIssues = {
+ type: 'jira_issues',
+ title: 'Issues',
+ description:
+ 'Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. Learn more.',
+ plan: 'premium',
+};
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index 84317da39e6..13985ce7d74 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -1,4 +1,4 @@
-import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlModal, GlSprintf, GlFormGroup } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { stubComponent } from 'helpers/stub_component';
@@ -15,6 +15,7 @@ import {
MEMBERS_MODAL_CELEBRATE_INTRO,
MEMBERS_MODAL_CELEBRATE_TITLE,
MEMBERS_PLACEHOLDER,
+ MEMBERS_PLACEHOLDER_DISABLED,
MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
LEARN_GITLAB,
} from '~/invite_members/constants';
@@ -28,6 +29,8 @@ import {
propsData,
inviteSource,
newProjectPath,
+ freeUsersLimit,
+ membersCount,
user1,
user2,
user3,
@@ -45,12 +48,13 @@ describe('InviteMembersModal', () => {
let wrapper;
let mock;
- const createComponent = (props = {}) => {
+ const createComponent = (props = {}, stubs = {}) => {
wrapper = shallowMountExtended(InviteMembersModal, {
provide: {
newProjectPath,
},
propsData: {
+ usersLimitDataset: {},
...propsData,
...props,
},
@@ -62,16 +66,17 @@ describe('InviteMembersModal', () => {
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
}),
GlEmoji,
+ ...stubs,
},
});
};
- const createInviteMembersToProjectWrapper = () => {
- createComponent({ isProject: true });
+ const createInviteMembersToProjectWrapper = (usersLimitDataset = {}, stubs = {}) => {
+ createComponent({ usersLimitDataset, isProject: true }, stubs);
};
- const createInviteMembersToGroupWrapper = () => {
- createComponent({ isProject: false });
+ const createInviteMembersToGroupWrapper = (usersLimitDataset = {}, stubs = {}) => {
+ createComponent({ usersLimitDataset, isProject: false }, stubs);
};
beforeEach(() => {
@@ -95,7 +100,7 @@ describe('InviteMembersModal', () => {
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () =>
findMembersFormGroup().attributes('invalid-feedback');
- const membersFormGroupDescription = () => findMembersFormGroup().attributes('description');
+ const membersFormGroupText = () => findMembersFormGroup().text();
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done');
const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks');
@@ -259,16 +264,33 @@ describe('InviteMembersModal', () => {
expect(wrapper.findComponent(ModalConfetti).exists()).toBe(false);
});
- it('includes the correct invitee, type, and formatted name', () => {
+ it('includes the correct invitee', () => {
expect(findIntroText()).toBe("You're inviting members to the test name project.");
expect(findCelebrationEmoji().exists()).toBe(false);
- expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER);
+ });
+
+ describe('members form group description', () => {
+ it('renders correct description', () => {
+ createInviteMembersToProjectWrapper({ freeUsersLimit, membersCount }, { GlFormGroup });
+ expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER);
+ });
+
+ describe('when reached user limit', () => {
+ it('renders correct description', () => {
+ createInviteMembersToProjectWrapper(
+ { freeUsersLimit, membersCount: 5 },
+ { GlFormGroup },
+ );
+
+ expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER_DISABLED);
+ });
+ });
});
});
describe('when inviting members with celebration', () => {
beforeEach(async () => {
- createComponent({ isProject: true });
+ createInviteMembersToProjectWrapper();
await triggerOpenModal({ mode: 'celebrate' });
});
@@ -285,7 +307,28 @@ describe('InviteMembersModal', () => {
`${MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT} ${MEMBERS_MODAL_CELEBRATE_INTRO}`,
);
expect(findCelebrationEmoji().exists()).toBe(true);
- expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER);
+ });
+
+ describe('members form group description', () => {
+ it('renders correct description', async () => {
+ createInviteMembersToProjectWrapper({ freeUsersLimit, membersCount }, { GlFormGroup });
+ await triggerOpenModal({ mode: 'celebrate' });
+
+ expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER);
+ });
+
+ describe('when reached user limit', () => {
+ it('renders correct description', async () => {
+ createInviteMembersToProjectWrapper(
+ { freeUsersLimit, membersCount: 5 },
+ { GlFormGroup },
+ );
+
+ await triggerOpenModal({ mode: 'celebrate' });
+
+ expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER_DISABLED);
+ });
+ });
});
});
});
@@ -295,7 +338,20 @@ describe('InviteMembersModal', () => {
createInviteMembersToGroupWrapper();
expect(findIntroText()).toBe("You're inviting members to the test name group.");
- expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER);
+ });
+
+ describe('members form group description', () => {
+ it('renders correct description', () => {
+ createInviteMembersToGroupWrapper({ freeUsersLimit, membersCount }, { GlFormGroup });
+ expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER);
+ });
+
+ describe('when reached user limit', () => {
+ it('renders correct description', () => {
+ createInviteMembersToGroupWrapper({ freeUsersLimit, membersCount: 5 }, { GlFormGroup });
+ expect(membersFormGroupText()).toContain(MEMBERS_PLACEHOLDER_DISABLED);
+ });
+ });
});
});
});
diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js
index 8355ae67f20..010f7b999fc 100644
--- a/spec/frontend/invite_members/components/invite_modal_base_spec.js
+++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js
@@ -6,18 +6,30 @@ import {
GlSprintf,
GlLink,
GlModal,
+ GlIcon,
} from '@gitlab/ui';
import { stubComponent } from 'helpers/stub_component';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import InviteModalBase from '~/invite_members/components/invite_modal_base.vue';
import ContentTransition from '~/vue_shared/components/content_transition.vue';
-import { CANCEL_BUTTON_TEXT, INVITE_BUTTON_TEXT } from '~/invite_members/constants';
-import { propsData } from '../mock_data/modal_base';
+
+import {
+ CANCEL_BUTTON_TEXT,
+ INVITE_BUTTON_TEXT_DISABLED,
+ INVITE_BUTTON_TEXT,
+ CANCEL_BUTTON_TEXT_DISABLED,
+ ON_SHOW_TRACK_LABEL,
+ ON_CLOSE_TRACK_LABEL,
+ ON_SUBMIT_TRACK_LABEL,
+} from '~/invite_members/constants';
+
+import { propsData, membersPath, purchasePath } from '../mock_data/modal_base';
describe('InviteModalBase', () => {
let wrapper;
- const createComponent = (props = {}) => {
+ const createComponent = (props = {}, stubs = {}) => {
wrapper = shallowMountExtended(InviteModalBase, {
propsData: {
...propsData,
@@ -33,8 +45,9 @@ describe('InviteModalBase', () => {
GlDropdownItem: true,
GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
- props: ['state', 'invalidFeedback', 'description'],
+ props: ['state', 'invalidFeedback'],
}),
+ ...stubs,
},
});
};
@@ -48,8 +61,12 @@ describe('InviteModalBase', () => {
const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.findComponent(GlLink);
+ const findIcon = () => wrapper.findComponent(GlIcon);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
+ const findDisabledInput = () => wrapper.findByTestId('disabled-input');
+ const findCancelButton = () => wrapper.find('.js-modal-action-cancel');
+ const findActionButton = () => wrapper.find('.js-modal-action-primary');
describe('rendering the modal', () => {
beforeEach(() => {
@@ -106,11 +123,103 @@ describe('InviteModalBase', () => {
it('renders the members form group', () => {
expect(findMembersFormGroup().props()).toEqual({
- description: propsData.formGroupDescription,
invalidFeedback: '',
state: null,
});
});
+
+ it('renders description', () => {
+ createComponent({}, { GlFormGroup });
+
+ expect(findMembersFormGroup().text()).toContain(propsData.formGroupDescription);
+ });
+
+ describe('when users limit is reached', () => {
+ let trackingSpy;
+
+ const expectTracking = (action, label) =>
+ expect(trackingSpy).toHaveBeenCalledWith('default', action, {
+ label,
+ category: 'default',
+ });
+
+ beforeEach(() => {
+ createComponent(
+ { usersLimitDataset: { membersPath, purchasePath }, reachedLimit: true },
+ { GlModal, GlFormGroup },
+ );
+ });
+
+ it('renders correct blocks', () => {
+ expect(findIcon().exists()).toBe(true);
+ expect(findDisabledInput().exists()).toBe(true);
+ expect(findDropdown().exists()).toBe(false);
+ expect(findDatepicker().exists()).toBe(false);
+ });
+
+ it('renders correct buttons', () => {
+ const cancelButton = findCancelButton();
+ const actionButton = findActionButton();
+
+ expect(cancelButton.attributes('href')).toBe(purchasePath);
+ expect(cancelButton.text()).toBe(CANCEL_BUTTON_TEXT_DISABLED);
+ expect(actionButton.attributes('href')).toBe(membersPath);
+ expect(actionButton.text()).toBe(INVITE_BUTTON_TEXT_DISABLED);
+ });
+
+ it('tracks actions', () => {
+ createComponent({ reachedLimit: true }, { GlFormGroup, GlModal });
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ const modal = wrapper.findComponent(GlModal);
+
+ modal.vm.$emit('shown');
+ expectTracking('render', ON_SHOW_TRACK_LABEL);
+
+ modal.vm.$emit('cancel', { preventDefault: jest.fn() });
+ expectTracking('click_button', ON_CLOSE_TRACK_LABEL);
+
+ modal.vm.$emit('primary', { preventDefault: jest.fn() });
+ expectTracking('click_button', ON_SUBMIT_TRACK_LABEL);
+
+ unmockTracking();
+ });
+
+ describe('when free user namespace', () => {
+ it('hides cancel button', () => {
+ createComponent(
+ {
+ usersLimitDataset: { membersPath, purchasePath, userNamespace: true },
+ reachedLimit: true,
+ },
+ { GlModal, GlFormGroup },
+ );
+
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when users limit is not reached', () => {
+ const textRegex = /Select a role.+Read more about role permissions Access expiration date \(optional\)/;
+
+ beforeEach(() => {
+ createComponent({ reachedLimit: false }, { GlModal, GlFormGroup });
+ });
+
+ it('renders correct blocks', () => {
+ expect(findIcon().exists()).toBe(false);
+ expect(findDisabledInput().exists()).toBe(false);
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDatepicker().exists()).toBe(true);
+ expect(wrapper.findComponent(GlModal).text()).toMatch(textRegex);
+ });
+
+ it('renders correct buttons', () => {
+ expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT);
+ expect(findActionButton().text()).toBe(INVITE_BUTTON_TEXT);
+ });
+ });
});
it('with isLoading, shows loading for invite button', () => {
@@ -127,7 +236,6 @@ describe('InviteModalBase', () => {
});
expect(findMembersFormGroup().props()).toEqual({
- description: propsData.formGroupDescription,
invalidFeedback: 'invalid message!',
state: false,
});
diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js
index c779cf2ee3f..4c9adbfcc44 100644
--- a/spec/frontend/invite_members/components/user_limit_notification_spec.js
+++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js
@@ -2,21 +2,31 @@ import { GlAlert, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue';
+import {
+ REACHED_LIMIT_MESSAGE,
+ REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
+} from '~/invite_members/constants';
+
+import { freeUsersLimit, membersCount } from '../mock_data/member_modal';
+
describe('UserLimitNotification', () => {
let wrapper;
const findAlert = () => wrapper.findComponent(GlAlert);
- const createComponent = (providers = {}) => {
+ const createComponent = (reachedLimit = false, usersLimitDataset = {}) => {
wrapper = shallowMountExtended(UserLimitNotification, {
- provide: {
- name: 'my group',
- newTrialRegistrationPath: 'newTrialRegistrationPath',
- purchasePath: 'purchasePath',
- freeUsersLimit: 5,
- membersCount: 1,
- ...providers,
+ propsData: {
+ reachedLimit,
+ usersLimitDataset: {
+ freeUsersLimit,
+ membersCount,
+ newTrialRegistrationPath: 'newTrialRegistrationPath',
+ purchasePath: 'purchasePath',
+ ...usersLimitDataset,
+ },
},
+ provide: { name: 'my group' },
stubs: { GlSprintf },
});
};
@@ -26,21 +36,17 @@ describe('UserLimitNotification', () => {
});
describe('when limit is not reached', () => {
- beforeEach(() => {
+ it('renders empty block', () => {
createComponent();
- });
- it('renders empty block', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe('when close to limit', () => {
- beforeEach(() => {
- createComponent({ membersCount: 3 });
- });
-
it("renders user's limit notification", () => {
+ createComponent(false, { membersCount: 3 });
+
const alert = findAlert();
expect(alert.attributes('title')).toEqual(
@@ -54,18 +60,27 @@ describe('UserLimitNotification', () => {
});
describe('when limit is reached', () => {
- beforeEach(() => {
- createComponent({ membersCount: 5 });
- });
-
it("renders user's limit notification", () => {
+ createComponent(true);
+
const alert = findAlert();
expect(alert.attributes('title')).toEqual("You've reached your 5 members limit for my group");
+ expect(alert.text()).toEqual(REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE);
+ });
- expect(alert.text()).toEqual(
- 'New members will be unable to participate. You can manage your members by removing ones you no longer need. To get more members an owner of this namespace can start a trial or upgrade to a paid tier.',
- );
+ describe('when free user namespace', () => {
+ it("renders user's limit notification", () => {
+ createComponent(true, { userNamespace: true });
+
+ const alert = findAlert();
+
+ expect(alert.attributes('title')).toEqual(
+ "You've reached your 5 members limit for my group",
+ );
+
+ expect(alert.text()).toEqual(REACHED_LIMIT_MESSAGE);
+ });
});
});
});
diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js
index 1b0cc57fb5b..474234cfacb 100644
--- a/spec/frontend/invite_members/mock_data/member_modal.js
+++ b/spec/frontend/invite_members/mock_data/member_modal.js
@@ -18,6 +18,8 @@ export const propsData = {
export const inviteSource = 'unknown';
export const newProjectPath = 'projects/new';
+export const freeUsersLimit = 5;
+export const membersCount = 1;
export const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' };
diff --git a/spec/frontend/invite_members/mock_data/modal_base.js b/spec/frontend/invite_members/mock_data/modal_base.js
index ea5a8d2b00d..565e8d4df1e 100644
--- a/spec/frontend/invite_members/mock_data/modal_base.js
+++ b/spec/frontend/invite_members/mock_data/modal_base.js
@@ -9,3 +9,6 @@ export const propsData = {
labelSearchField: '_label_search_field_',
formGroupDescription: '_form_group_description_',
};
+
+export const membersPath = '/members_path';
+export const purchasePath = '/purchase_path';
diff --git a/spec/frontend/issuable/components/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js
index c8380e42787..e3a36dc8820 100644
--- a/spec/frontend/issuable/components/issuable_header_warnings_spec.js
+++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js
@@ -66,7 +66,15 @@ describe('IssuableHeaderWarnings', () => {
});
it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => {
- expect(findConfidentialIcon().exists()).toBe(confidentialStatus);
+ const confidentialEl = findConfidentialIcon();
+ expect(confidentialEl.exists()).toBe(confidentialStatus);
+
+ if (confidentialStatus && !hiddenStatus) {
+ expect(confidentialEl.props()).toMatchObject({
+ workspaceType: 'project',
+ issuableType: 'issue',
+ });
+ }
});
it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => {
diff --git a/spec/frontend/issuable/components/status_box_spec.js b/spec/frontend/issuable/components/status_box_spec.js
index 9cbf023dbd6..728b8958b9b 100644
--- a/spec/frontend/issuable/components/status_box_spec.js
+++ b/spec/frontend/issuable/components/status_box_spec.js
@@ -1,71 +1,53 @@
-import { GlIcon, GlSprintf } from '@gitlab/ui';
+import { GlBadge, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import StatusBox from '~/issuable/components/status_box.vue';
let wrapper;
function factory(propsData) {
- wrapper = shallowMount(StatusBox, { propsData, stubs: { GlSprintf } });
+ wrapper = shallowMount(StatusBox, { propsData, stubs: { GlBadge } });
}
-const testCases = [
- {
- name: 'Open',
- state: 'opened',
- class: 'status-box-open',
- icon: 'issue-open-m',
- },
- {
- name: 'Open',
- state: 'locked',
- class: 'status-box-open',
- icon: 'issue-open-m',
- },
- {
- name: 'Closed',
- state: 'closed',
- class: 'status-box-mr-closed',
- icon: 'issue-close',
- },
- {
- name: 'Merged',
- state: 'merged',
- class: 'status-box-mr-merged',
- icon: 'git-merge',
- },
-];
-
describe('Merge request status box component', () => {
+ const findBadge = () => wrapper.findComponent(GlBadge);
+
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
- testCases.forEach((testCase) => {
- describe(`when merge request is ${testCase.name}`, () => {
- it('renders human readable test', () => {
+ describe.each`
+ issuableType | badgeText | initialState | badgeClass | badgeVariant | badgeIcon
+ ${'merge_request'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'merge-request-open'}
+ ${'merge_request'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'danger'} | ${'merge-request-close'}
+ ${'merge_request'} | ${'Merged'} | ${'merged'} | ${'issuable-status-badge-merged'} | ${'info'} | ${'merge'}
+ ${'issue'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'issues'}
+ ${'issue'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'info'} | ${'issue-closed'}
+ `(
+ 'with issuableType set to "$issuableType" and state set to "$initialState"',
+ ({ issuableType, badgeText, initialState, badgeClass, badgeVariant, badgeIcon }) => {
+ beforeEach(() => {
factory({
- initialState: testCase.state,
+ initialState,
+ issuableType,
});
-
- expect(wrapper.text()).toContain(testCase.name);
});
- it('sets css class', () => {
- factory({
- initialState: testCase.state,
- });
+ it(`renders badge with text '${badgeText}'`, () => {
+ expect(findBadge().text()).toBe(badgeText);
+ });
- expect(wrapper.classes()).toContain(testCase.class);
+ it(`sets badge css class as '${badgeClass}'`, () => {
+ expect(findBadge().classes()).toContain(badgeClass);
});
- it('renders icon', () => {
- factory({
- initialState: testCase.state,
- });
+ it(`sets badge variant as '${badgeVariant}`, () => {
+ expect(findBadge().props('variant')).toBe(badgeVariant);
+ });
- expect(wrapper.findComponent(GlIcon).props('name')).toBe(testCase.icon);
+ it(`sets badge icon as '${badgeIcon}'`, () => {
+ expect(findBadge().findComponent(GlIcon).props('name')).toBe(badgeIcon);
});
- });
- });
+ },
+ );
});
diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js
index 99ed18cf5bd..a1583076b41 100644
--- a/spec/frontend/issuable/issuable_form_spec.js
+++ b/spec/frontend/issuable/issuable_form_spec.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import IssuableForm from '~/issuable/issuable_form';
import setWindowLocation from 'helpers/set_window_location_helper';
@@ -11,7 +11,7 @@ describe('IssuableForm', () => {
};
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<form>
<input name="[title]" />
</form>
@@ -19,6 +19,10 @@ describe('IssuableForm', () => {
createIssuable($('form'));
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('initAutosave', () => {
it('creates autosave with the searchTerm included', () => {
setWindowLocation('https://gitlab.test/foo?bar=true');
@@ -28,7 +32,7 @@ describe('IssuableForm', () => {
});
it("creates autosave fields without the searchTerm if it's an issue new form", () => {
- setFixtures(`
+ setHTMLFixture(`
<form data-new-issue-path="/issues/new">
<input name="[title]" />
</form>
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
index b59717a1f60..1a03ea58b60 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
@@ -300,16 +300,27 @@ describe('RelatedIssuesRoot', () => {
expect(wrapper.vm.state.pendingReferences[1]).toEqual('random');
});
- it('prepends # when user enters a numeric value [0-9]', async () => {
- const input = '23';
+ it.each`
+ pathIdSeparator
+ ${'#'}
+ ${'&'}
+ `(
+ 'prepends $pathIdSeparator when user enters a numeric value [0-9]',
+ async ({ pathIdSeparator }) => {
+ const input = '23';
+
+ await wrapper.setProps({
+ pathIdSeparator,
+ });
- wrapper.vm.onInput({
- untouchedRawReferences: input.trim().split(/\s/),
- touchedReference: input,
- });
+ wrapper.vm.onInput({
+ untouchedRawReferences: input.trim().split(/\s/),
+ touchedReference: input,
+ });
- expect(wrapper.vm.inputValue).toBe(`#${input}`);
- });
+ expect(wrapper.vm.inputValue).toBe(`${pathIdSeparator}${input}`);
+ },
+ );
it('prepends # when user enters a number', async () => {
const input = 23;
diff --git a/spec/frontend/issues/issue_spec.js b/spec/frontend/issues/issue_spec.js
index 8a089b372ff..089ea8dbbad 100644
--- a/spec/frontend/issues/issue_spec.js
+++ b/spec/frontend/issues/issue_spec.js
@@ -1,5 +1,6 @@
import { getByText } from '@testing-library/dom';
import MockAdapter from 'axios-mock-adapter';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import Issue from '~/issues/issue';
import axios from '~/lib/utils/axios_utils';
@@ -24,11 +25,11 @@ describe('Issue', () => {
const getIssueCounter = () => document.querySelector('.issue_counter');
const getOpenStatusBox = () =>
getByText(document, (_, el) => el.textContent.match(/Open/), {
- selector: '.status-box-open',
+ selector: '.issuable-status-badge-open',
});
const getClosedStatusBox = () =>
getByText(document, (_, el) => el.textContent.match(/Closed/), {
- selector: '.status-box-issue-closed',
+ selector: '.issuable-status-badge-closed',
});
describe.each`
@@ -38,9 +39,9 @@ describe('Issue', () => {
`('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => {
beforeEach(() => {
if (isIssueInitiallyOpen) {
- loadFixtures('issues/open-issue.html');
+ loadHTMLFixture('issues/open-issue.html');
} else {
- loadFixtures('issues/closed-issue.html');
+ loadHTMLFixture('issues/closed-issue.html');
}
testContext.issueCounter = getIssueCounter();
@@ -50,6 +51,10 @@ describe('Issue', () => {
testContext.issueCounter.textContent = '1,001';
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it(`has the proper visible status box when ${isIssueInitiallyOpen ? 'open' : 'closed'}`, () => {
if (isIssueInitiallyOpen) {
expect(testContext.statusBoxClosed).toHaveClass('hidden');
diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js
index 5a9bd1ff8e4..d92ba527b5c 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -5,8 +5,11 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import { cloneDeep } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
+import getIssuesWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_without_crm.query.graphql';
+import getIssuesCountsWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_counts_without_crm.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
@@ -58,6 +61,7 @@ describe('CE IssuesListApp component', () => {
let wrapper;
Vue.use(VueApollo);
+ Vue.use(VueRouter);
const defaultProvide = {
autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path',
@@ -78,6 +82,7 @@ describe('CE IssuesListApp component', () => {
isAnonymousSearchDisabled: false,
isIssueRepositioningDisabled: false,
isProject: true,
+ isPublicVisibilityRestricted: false,
isSignedIn: true,
jiraIntegrationPath: 'jira/integration/path',
newIssuePath: 'new/issue/path',
@@ -107,6 +112,7 @@ describe('CE IssuesListApp component', () => {
const mountComponent = ({
provide = {},
+ data = {},
issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse),
issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse),
sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse),
@@ -115,16 +121,21 @@ describe('CE IssuesListApp component', () => {
const requestHandlers = [
[getIssuesQuery, issuesQueryResponse],
[getIssuesCountsQuery, issuesCountsQueryResponse],
+ [getIssuesWithoutCrmQuery, issuesQueryResponse],
+ [getIssuesCountsWithoutCrmQuery, issuesCountsQueryResponse],
[setSortPreferenceMutation, sortPreferenceMutationResponse],
];
- const apolloProvider = createMockApollo(requestHandlers);
return mountFn(IssuesListApp, {
- apolloProvider,
+ apolloProvider: createMockApollo(requestHandlers),
+ router: new VueRouter({ mode: 'history' }),
provide: {
...defaultProvide,
...provide,
},
+ data() {
+ return data;
+ },
});
};
@@ -139,10 +150,10 @@ describe('CE IssuesListApp component', () => {
});
describe('IssuableList', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent();
jest.runOnlyPendingTimers();
- await waitForPromises();
+ return waitForPromises();
});
it('renders', () => {
@@ -167,10 +178,6 @@ describe('CE IssuesListApp component', () => {
useKeysetPagination: true,
hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage,
hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage,
- urlParams: {
- sort: urlSortParams[CREATED_DESC],
- state: IssuableStates.Opened,
- },
});
});
});
@@ -200,7 +207,7 @@ describe('CE IssuesListApp component', () => {
describe('csv import/export component', () => {
describe('when user is signed in', () => {
- beforeEach(async () => {
+ beforeEach(() => {
setWindowLocation('?search=refactor&state=opened');
wrapper = mountComponent({
@@ -209,12 +216,12 @@ describe('CE IssuesListApp component', () => {
});
jest.runOnlyPendingTimers();
- await waitForPromises();
+ return waitForPromises();
});
it('renders', () => {
expect(findCsvImportExportButtons().props()).toMatchObject({
- exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&sort=created_date&state=opened`,
+ exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&state=opened`,
issuableCount: 1,
});
});
@@ -252,11 +259,9 @@ describe('CE IssuesListApp component', () => {
it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', async () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount });
-
jest.spyOn(eventHub, '$emit');
findGlButtonAt(2).vm.$emit('click');
-
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit');
@@ -297,32 +302,25 @@ describe('CE IssuesListApp component', () => {
describe('page', () => {
it('page_after is set from the url params', () => {
setWindowLocation('?page_after=randomCursorString');
-
wrapper = mountComponent();
- expect(findIssuableList().props('urlParams')).toMatchObject({
- page_after: 'randomCursorString',
- });
+ expect(wrapper.vm.$route.query).toMatchObject({ page_after: 'randomCursorString' });
});
it('page_before is set from the url params', () => {
setWindowLocation('?page_before=anotherRandomCursorString');
-
wrapper = mountComponent();
- expect(findIssuableList().props('urlParams')).toMatchObject({
- page_before: 'anotherRandomCursorString',
- });
+ expect(wrapper.vm.$route.query).toMatchObject({ page_before: 'anotherRandomCursorString' });
});
});
describe('search', () => {
it('is set from the url params', () => {
setWindowLocation(locationSearch);
-
wrapper = mountComponent();
- expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' });
+ expect(wrapper.vm.$route.query).toMatchObject({ search: 'find issues' });
});
});
@@ -333,10 +331,7 @@ describe('CE IssuesListApp component', () => {
it.each(oldEnumSortValues)('initial sort is set with value %s', (sort) => {
wrapper = mountComponent({ provide: { initialSort: sort } });
- expect(findIssuableList().props()).toMatchObject({
- initialSortBy: getSortKey(sort),
- urlParams: { sort },
- });
+ expect(findIssuableList().props('initialSortBy')).toBe(getSortKey(sort));
});
});
@@ -346,10 +341,7 @@ describe('CE IssuesListApp component', () => {
it.each(graphQLEnumSortValues)('initial sort is set with value %s', (sort) => {
wrapper = mountComponent({ provide: { initialSort: sort.toLowerCase() } });
- expect(findIssuableList().props()).toMatchObject({
- initialSortBy: sort,
- urlParams: { sort: urlSortParams[sort] },
- });
+ expect(findIssuableList().props('initialSortBy')).toBe(sort);
});
});
@@ -359,10 +351,7 @@ describe('CE IssuesListApp component', () => {
(sort) => {
wrapper = mountComponent({ provide: { initialSort: sort } });
- expect(findIssuableList().props()).toMatchObject({
- initialSortBy: CREATED_DESC,
- urlParams: { sort: urlSortParams[CREATED_DESC] },
- });
+ expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC);
},
);
});
@@ -375,10 +364,7 @@ describe('CE IssuesListApp component', () => {
});
it('changes the sort to the default of created descending', () => {
- expect(findIssuableList().props()).toMatchObject({
- initialSortBy: CREATED_DESC,
- urlParams: { sort: urlSortParams[CREATED_DESC] },
- });
+ expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC);
});
it('shows an alert to tell the user that manual reordering is disabled', () => {
@@ -393,9 +379,7 @@ describe('CE IssuesListApp component', () => {
describe('state', () => {
it('is set from the url params', () => {
const initialState = IssuableStates.All;
-
setWindowLocation(`?state=${initialState}`);
-
wrapper = mountComponent();
expect(findIssuableList().props('currentTab')).toBe(initialState);
@@ -405,7 +389,6 @@ describe('CE IssuesListApp component', () => {
describe('filter tokens', () => {
it('is set from the url params', () => {
setWindowLocation(locationSearch);
-
wrapper = mountComponent();
expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens);
@@ -414,7 +397,6 @@ describe('CE IssuesListApp component', () => {
describe('when anonymous searching is performed', () => {
beforeEach(() => {
setWindowLocation(locationSearch);
-
wrapper = mountComponent({
provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
});
@@ -649,12 +631,12 @@ describe('CE IssuesListApp component', () => {
${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues}
${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts}
`('when there is an error $error', ({ mountOption, message }) => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent({
[mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')),
});
jest.runOnlyPendingTimers();
- await waitForPromises();
+ return waitForPromises();
});
it('shows an error message', () => {
@@ -676,29 +658,51 @@ describe('CE IssuesListApp component', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
wrapper = mountComponent();
+ jest.spyOn(wrapper.vm.$router, 'push');
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
- it('updates to the new tab', () => {
+ it('updates ui to the new tab', () => {
expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
});
- });
- describe.each(['next-page', 'previous-page'])(
- 'when "%s" event is emitted by IssuableList',
- (event) => {
- beforeEach(() => {
- wrapper = mountComponent();
+ it('updates url to the new tab', () => {
+ expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
+ query: expect.objectContaining({ state: IssuableStates.Closed }),
+ });
+ });
+ });
- findIssuableList().vm.$emit(event);
+ describe.each`
+ event | paramName | paramValue
+ ${'next-page'} | ${'page_after'} | ${'endCursor'}
+ ${'previous-page'} | ${'page_before'} | ${'startCursor'}
+ `('when "$event" event is emitted by IssuableList', ({ event, paramName, paramValue }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ data: {
+ pageInfo: {
+ endCursor: 'endCursor',
+ startCursor: 'startCursor',
+ },
+ },
});
+ jest.spyOn(wrapper.vm.$router, 'push');
+
+ findIssuableList().vm.$emit(event);
+ });
+
+ it('scrolls to the top', () => {
+ expect(scrollUp).toHaveBeenCalled();
+ });
- it('scrolls to the top', () => {
- expect(scrollUp).toHaveBeenCalled();
+ it(`updates url with "${paramName}" param`, () => {
+ expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
+ query: expect.objectContaining({ [paramName]: paramValue }),
});
- },
- );
+ });
+ });
describe('when "reorder" event is emitted by IssuableList', () => {
const issueOne = {
@@ -752,18 +756,17 @@ describe('CE IssuesListApp component', () => {
`(
'when moving issue $description',
({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent({
provide: { isProject },
issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)),
});
jest.runOnlyPendingTimers();
- await waitForPromises();
+ return waitForPromises();
});
it('makes API call to reorder the issue', async () => {
findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
-
await waitForPromises();
expect(axiosMock.history.put[0]).toMatchObject({
@@ -780,19 +783,18 @@ describe('CE IssuesListApp component', () => {
});
describe('when unsuccessful', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent({
issuesQueryResponse: jest.fn().mockResolvedValue(response()),
});
jest.runOnlyPendingTimers();
- await waitForPromises();
+ return waitForPromises();
});
it('displays an error message', async () => {
axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500);
findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
-
await waitForPromises();
expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError);
@@ -808,14 +810,14 @@ describe('CE IssuesListApp component', () => {
'updates to the new sort when payload is `%s`',
async (sortKey) => {
wrapper = mountComponent();
+ jest.spyOn(wrapper.vm.$router, 'push');
findIssuableList().vm.$emit('sort', sortKey);
-
jest.runOnlyPendingTimers();
await nextTick();
- expect(findIssuableList().props('urlParams')).toMatchObject({
- sort: urlSortParams[sortKey],
+ expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
+ query: expect.objectContaining({ sort: urlSortParams[sortKey] }),
});
},
);
@@ -827,14 +829,13 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
provide: { initialSort, isIssueRepositioningDisabled: true },
});
+ jest.spyOn(wrapper.vm.$router, 'push');
findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC);
});
it('does not update the sort to manual', () => {
- expect(findIssuableList().props('urlParams')).toMatchObject({
- sort: urlSortParams[initialSort],
- });
+ expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
});
it('shows an alert to tell the user that manual reordering is disabled', () => {
@@ -899,11 +900,14 @@ describe('CE IssuesListApp component', () => {
describe('when "filter" event is emitted by IssuableList', () => {
it('updates IssuableList with url params', async () => {
wrapper = mountComponent();
+ jest.spyOn(wrapper.vm.$router, 'push');
findIssuableList().vm.$emit('filter', filteredTokens);
await nextTick();
- expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
+ expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
+ query: expect.objectContaining(urlParams),
+ });
});
describe('when anonymous searching is performed', () => {
@@ -911,19 +915,13 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
});
+ jest.spyOn(wrapper.vm.$router, 'push');
findIssuableList().vm.$emit('filter', filteredTokens);
});
- it('does not update IssuableList with url params ', async () => {
- const defaultParams = {
- page_after: null,
- page_before: null,
- sort: 'created_date',
- state: 'opened',
- };
-
- expect(findIssuableList().props('urlParams')).toEqual(defaultParams);
+ it('does not update url params', () => {
+ expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
});
it('shows an alert to tell the user they must be signed in to search', () => {
@@ -935,4 +933,23 @@ describe('CE IssuesListApp component', () => {
});
});
});
+
+ describe('public visibility', () => {
+ it.each`
+ description | isPublicVisibilityRestricted | isSignedIn | hideUsers
+ ${'shows users when public visibility is not restricted and is not signed in'} | ${false} | ${false} | ${false}
+ ${'shows users when public visibility is not restricted and is signed in'} | ${false} | ${true} | ${false}
+ ${'hides users when public visibility is restricted and is not signed in'} | ${true} | ${false} | ${true}
+ ${'shows users when public visibility is restricted and is signed in'} | ${true} | ${true} | ${false}
+ `('$description', ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => {
+ const mockQuery = jest.fn().mockResolvedValue(defaultQueryResponse);
+ wrapper = mountComponent({
+ provide: { isPublicVisibilityRestricted, isSignedIn },
+ issuesQueryResponse: mockQuery,
+ });
+ jest.runOnlyPendingTimers();
+
+ expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers }));
+ });
+ });
});
diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index b1a135ceb18..42f2d08082e 100644
--- a/spec/frontend/issues/list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
@@ -117,6 +117,7 @@ export const locationSearch = [
'not[author_username]=marge',
'assignee_username[]=bart',
'assignee_username[]=lisa',
+ 'assignee_username[]=5',
'not[assignee_username][]=patty',
'not[assignee_username][]=selma',
'milestone_title=season+3',
@@ -146,6 +147,8 @@ export const locationSearch = [
'not[epic_id]=34',
'weight=1',
'not[weight]=3',
+ 'crm_contact_id=123',
+ 'crm_organization_id=456',
].join('&');
export const locationSearchWithSpecialValues = [
@@ -165,6 +168,7 @@ export const filteredTokens = [
{ type: 'author_username', value: { data: 'marge', operator: OPERATOR_IS_NOT } },
{ type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS } },
+ { type: 'assignee_username', value: { data: '5', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: 'patty', operator: OPERATOR_IS_NOT } },
{ type: 'assignee_username', value: { data: 'selma', operator: OPERATOR_IS_NOT } },
{ type: 'milestone', value: { data: 'season 3', operator: OPERATOR_IS } },
@@ -194,6 +198,8 @@ export const filteredTokens = [
{ type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } },
{ type: 'weight', value: { data: '1', operator: OPERATOR_IS } },
{ type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } },
+ { type: 'crm_contact', value: { data: '123', operator: OPERATOR_IS } },
+ { type: 'crm_organization', value: { data: '456', operator: OPERATOR_IS } },
{ type: 'filtered-search-term', value: { data: 'find' } },
{ type: 'filtered-search-term', value: { data: 'issues' } },
];
@@ -212,7 +218,7 @@ export const filteredTokensWithSpecialValues = [
export const apiParams = {
authorUsername: 'homer',
- assigneeUsernames: ['bart', 'lisa'],
+ assigneeUsernames: ['bart', 'lisa', '5'],
milestoneTitle: ['season 3', 'season 4'],
labelName: ['cartoon', 'tv'],
releaseTag: ['v3', 'v4'],
@@ -222,6 +228,8 @@ export const apiParams = {
iterationId: ['4', '12'],
epicId: '12',
weight: '1',
+ crmContactId: '123',
+ crmOrganizationId: '456',
not: {
authorUsername: 'marge',
assigneeUsernames: ['patty', 'selma'],
@@ -251,7 +259,7 @@ export const apiParamsWithSpecialValues = {
export const urlParams = {
author_username: 'homer',
'not[author_username]': 'marge',
- 'assignee_username[]': ['bart', 'lisa'],
+ 'assignee_username[]': ['bart', 'lisa', '5'],
'not[assignee_username][]': ['patty', 'selma'],
milestone_title: ['season 3', 'season 4'],
'not[milestone_title]': ['season 20', 'season 30'],
@@ -270,6 +278,8 @@ export const urlParams = {
'not[epic_id]': '34',
weight: '1',
'not[weight]': '3',
+ crm_contact_id: '123',
+ crm_organization_id: '456',
};
export const urlParamsWithSpecialValues = {
diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js
index a60350d91c5..ce0477883d7 100644
--- a/spec/frontend/issues/list/utils_spec.js
+++ b/spec/frontend/issues/list/utils_spec.js
@@ -1,3 +1,5 @@
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
import {
apiParams,
apiParamsWithSpecialValues,
@@ -24,6 +26,7 @@ import {
getSortOptions,
isSortKey,
} from '~/issues/list/utils';
+import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
describe('getInitialPageParams', () => {
it.each(Object.keys(urlSortParams))(
@@ -124,24 +127,50 @@ describe('getFilterTokens', () => {
filteredTokensWithSpecialValues,
);
});
+
+ it.each`
+ description | argument
+ ${'an undefined value'} | ${undefined}
+ ${'an irrelevant value'} | ${'?unrecognised=parameter'}
+ `('returns an empty filtered search term given $description', ({ argument }) => {
+ expect(getFilterTokens(argument)).toEqual([
+ {
+ id: expect.any(String),
+ type: FILTERED_SEARCH_TERM,
+ value: { data: '' },
+ },
+ ]);
+ });
});
describe('convertToApiParams', () => {
+ beforeEach(() => {
+ setWindowLocation(TEST_HOST);
+ });
+
it('returns api params given filtered tokens', () => {
expect(convertToApiParams(filteredTokens)).toEqual(apiParams);
});
it('returns api params given filtered tokens with special values', () => {
+ setWindowLocation('?assignee_id=123');
+
expect(convertToApiParams(filteredTokensWithSpecialValues)).toEqual(apiParamsWithSpecialValues);
});
});
describe('convertToUrlParams', () => {
+ beforeEach(() => {
+ setWindowLocation(TEST_HOST);
+ });
+
it('returns url params given filtered tokens', () => {
expect(convertToUrlParams(filteredTokens)).toEqual(urlParams);
});
it('returns url params given filtered tokens with special values', () => {
+ setWindowLocation('?assignee_id=123');
+
expect(convertToUrlParams(filteredTokensWithSpecialValues)).toEqual(urlParamsWithSpecialValues);
});
});
diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js
index 5ab64d8e9ca..27604b8ccf3 100644
--- a/spec/frontend/issues/show/components/app_spec.js
+++ b/spec/frontend/issues/show/components/app_spec.js
@@ -1,10 +1,12 @@
-import { GlIntersectionObserver } from '@gitlab/ui';
+import { GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import '~/behaviors/markdown/render_gfm';
-import { IssuableStatus, IssuableStatusText } from '~/issues/constants';
+import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants';
import IssuableApp from '~/issues/show/components/app.vue';
import DescriptionComponent from '~/issues/show/components/description.vue';
import EditedComponent from '~/issues/show/components/edited.vue';
@@ -70,7 +72,7 @@ describe('Issuable output', () => {
};
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div>
<title>Title</title>
<div class="detail-page-description content-block">
@@ -105,6 +107,7 @@ describe('Issuable output', () => {
realtimeRequestCount = 0;
wrapper.vm.poll.stop();
wrapper.destroy();
+ resetHTMLFixture();
});
it('should render a title/description/edited and update title/description/edited on update', () => {
@@ -465,6 +468,31 @@ describe('Issuable output', () => {
expect(findStickyHeader().text()).toContain('Sticky header title');
});
+ it('shows with title for an epic', async () => {
+ wrapper.setProps({ issuableType: 'epic' });
+
+ await nextTick();
+
+ expect(findStickyHeader().text()).toContain('Sticky header title');
+ });
+
+ it.each`
+ issuableType | issuableStatus | statusIcon
+ ${IssuableType.Issue} | ${IssuableStatus.Open} | ${'issues'}
+ ${IssuableType.Issue} | ${IssuableStatus.Closed} | ${'issue-closed'}
+ ${IssuableType.Epic} | ${IssuableStatus.Open} | ${'epic'}
+ ${IssuableType.Epic} | ${IssuableStatus.Closed} | ${'epic-closed'}
+ `(
+ 'shows with state icon "$statusIcon" for $issuableType when status is $issuableStatus',
+ async ({ issuableType, issuableStatus, statusIcon }) => {
+ wrapper.setProps({ issuableType, issuableStatus });
+
+ await nextTick();
+
+ expect(findStickyHeader().findComponent(GlIcon).props('name')).toBe(statusIcon);
+ },
+ );
+
it.each`
title | state
${'shows with Open when status is opened'} | ${IssuableStatus.Open}
@@ -487,7 +515,14 @@ describe('Issuable output', () => {
await nextTick();
- expect(findConfidentialBadge().exists()).toBe(isConfidential);
+ const confidentialEl = findConfidentialBadge();
+ expect(confidentialEl.exists()).toBe(isConfidential);
+ if (isConfidential) {
+ expect(confidentialEl.props()).toMatchObject({
+ workspaceType: 'project',
+ issuableType: 'issue',
+ });
+ }
});
it.each`
@@ -613,4 +648,14 @@ describe('Issuable output', () => {
expect(wrapper.vm.updateStoreState).toHaveBeenCalled();
});
});
+
+ describe('listItemReorder event', () => {
+ it('makes request to update issue', async () => {
+ const description = 'I have been updated!';
+ findDescription().vm.$emit('listItemReorder', description);
+ await waitForPromises();
+
+ expect(mock.history.put[0].data).toContain(description);
+ });
+ });
});
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index 0b3daadae1d..1ae04531a6b 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -1,14 +1,20 @@
import $ from 'jquery';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import '~/behaviors/markdown/render_gfm';
import { GlTooltip, GlModal } from '@gitlab/ui';
+
import setWindowLocation from 'helpers/set_window_location_helper';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
import { mockTracking } from 'helpers/tracking_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
import Description from '~/issues/show/components/description.vue';
import { updateHistory } from '~/lib/utils/url_utility';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import TaskList from '~/task_list';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
@@ -27,17 +33,29 @@ jest.mock('~/task_list');
const showModal = jest.fn();
const hideModal = jest.fn();
+const showDetailsModal = jest.fn();
const $toast = {
show: jest.fn(),
};
+const workItemQueryResponse = {
+ data: {
+ workItem: null,
+ },
+};
+
+const queryHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
+
describe('Description component', () => {
let wrapper;
+ Vue.use(VueApollo);
+
const findGfmContent = () => wrapper.find('[data-testid="gfm-content"]');
const findTextarea = () => wrapper.find('[data-testid="textarea"]');
const findTaskActionButtons = () => wrapper.findAll('.js-add-task');
const findConvertToTaskButton = () => wrapper.find('.js-add-task');
+ const findTaskLink = () => wrapper.find('a.gfm-issue');
const findTooltips = () => wrapper.findAllComponents(GlTooltip);
const findModal = () => wrapper.findComponent(GlModal);
@@ -52,6 +70,7 @@ describe('Description component', () => {
...props,
},
provide,
+ apolloProvider: createMockApollo([[workItemQuery, queryHandler]]),
mocks: {
$toast,
},
@@ -62,6 +81,11 @@ describe('Description component', () => {
hide: hideModal,
},
}),
+ WorkItemDetailModal: stubComponent(WorkItemDetailModal, {
+ methods: {
+ show: showDetailsModal,
+ },
+ }),
},
});
}
@@ -296,15 +320,15 @@ describe('Description component', () => {
});
it('shows toast after delete success', async () => {
- findWorkItemDetailModal().vm.$emit('workItemDeleted');
+ const newDesc = 'description';
+ findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc);
+ expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]);
expect($toast.show).toHaveBeenCalledWith('Work item deleted');
});
});
describe('work items detail', () => {
- const findTaskLink = () => wrapper.find('a.gfm-issue');
-
describe('when opening and closing', () => {
beforeEach(() => {
createComponent({
@@ -319,11 +343,9 @@ describe('Description component', () => {
});
it('opens when task button is clicked', async () => {
- expect(findWorkItemDetailModal().props('visible')).toBe(false);
-
await findTaskLink().trigger('click');
- expect(findWorkItemDetailModal().props('visible')).toBe(true);
+ expect(showDetailsModal).toHaveBeenCalled();
expect(updateHistory).toHaveBeenCalledWith({
url: `${TEST_HOST}/?work_item_id=2`,
replace: true,
@@ -333,12 +355,9 @@ describe('Description component', () => {
it('closes from an open state', async () => {
await findTaskLink().trigger('click');
- expect(findWorkItemDetailModal().props('visible')).toBe(true);
-
findWorkItemDetailModal().vm.$emit('close');
await nextTick();
- expect(findWorkItemDetailModal().props('visible')).toBe(false);
expect(updateHistory).toHaveBeenLastCalledWith({
url: `${TEST_HOST}/`,
replace: true,
@@ -364,16 +383,17 @@ describe('Description component', () => {
describe('when url query `work_item_id` exists', () => {
it.each`
- behavior | workItemId | visible
- ${'opens'} | ${'123'} | ${true}
- ${'does not open'} | ${'123e'} | ${false}
- ${'does not open'} | ${'12e3'} | ${false}
- ${'does not open'} | ${'1e23'} | ${false}
- ${'does not open'} | ${'x'} | ${false}
- ${'does not open'} | ${'undefined'} | ${false}
+ behavior | workItemId | modalOpened
+ ${'opens'} | ${'2'} | ${1}
+ ${'does not open'} | ${'123'} | ${0}
+ ${'does not open'} | ${'123e'} | ${0}
+ ${'does not open'} | ${'12e3'} | ${0}
+ ${'does not open'} | ${'1e23'} | ${0}
+ ${'does not open'} | ${'x'} | ${0}
+ ${'does not open'} | ${'undefined'} | ${0}
`(
'$behavior when url contains `work_item_id=$workItemId`',
- async ({ workItemId, visible }) => {
+ async ({ workItemId, modalOpened }) => {
setWindowLocation(`?work_item_id=${workItemId}`);
createComponent({
@@ -381,10 +401,43 @@ describe('Description component', () => {
provide: { glFeatures: { workItems: true } },
});
- expect(findWorkItemDetailModal().props('visible')).toBe(visible);
+ expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened);
},
);
});
});
+
+ describe('when hovering task links', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ descriptionHtml: descriptionHtmlWithTask,
+ },
+ provide: {
+ glFeatures: { workItems: true },
+ },
+ });
+ return nextTick();
+ });
+
+ it('prefetches work item detail after work item link is hovered for 150ms', async () => {
+ await findTaskLink().trigger('mouseover');
+ jest.advanceTimersByTime(150);
+ await waitForPromises();
+
+ expect(queryHandler).toHaveBeenCalledWith({
+ id: 'gid://gitlab/WorkItem/2',
+ });
+ });
+
+ it('does not work item detail after work item link is hovered for less than 150ms', async () => {
+ await findTaskLink().trigger('mouseover');
+ await findTaskLink().trigger('mouseout');
+ jest.advanceTimersByTime(150);
+ await waitForPromises();
+
+ expect(queryHandler).not.toHaveBeenCalled();
+ });
+ });
});
});
diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js
index 0dcd70ac19b..d0e33f0b980 100644
--- a/spec/frontend/issues/show/components/fields/description_spec.js
+++ b/spec/frontend/issues/show/components/fields/description_spec.js
@@ -24,7 +24,6 @@ describe('Description field component', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit');
- gon.features = { markdownContinueLists: true };
});
afterEach(() => {
diff --git a/spec/frontend/issues/show/components/title_spec.js b/spec/frontend/issues/show/components/title_spec.js
index 29b5353ef1c..7560b733ae6 100644
--- a/spec/frontend/issues/show/components/title_spec.js
+++ b/spec/frontend/issues/show/components/title_spec.js
@@ -1,4 +1,5 @@
import Vue, { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import titleComponent from '~/issues/show/components/title.vue';
import eventHub from '~/issues/show/event_hub';
import Store from '~/issues/show/stores';
@@ -6,7 +7,7 @@ import Store from '~/issues/show/stores';
describe('Title component', () => {
let vm;
beforeEach(() => {
- setFixtures(`<title />`);
+ setHTMLFixture(`<title />`);
const Component = Vue.extend(titleComponent);
const store = new Store({
@@ -25,6 +26,10 @@ describe('Title component', () => {
}).$mount();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('renders title HTML', () => {
expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>');
});
diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js
index 7b0b8ca686a..909789b7a0f 100644
--- a/spec/frontend/issues/show/mock_data/mock_data.js
+++ b/spec/frontend/issues/show/mock_data/mock_data.js
@@ -77,7 +77,22 @@ export const descriptionHtmlWithTask = `
<ul data-sourcepos="1:1-3:7" class="task-list" dir="auto">
<li data-sourcepos="1:1-1:10" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled>
- <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip">1 (#48)</a>
+ <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip" data-issue-type="task">1 (#48)</a>
+ </li>
+ <li data-sourcepos="2:1-2:7" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> 2
+ </li>
+ <li data-sourcepos="3:1-3:7" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> 3
+ </li>
+ </ul>
+`;
+
+export const descriptionHtmlWithIssue = `
+ <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto">
+ <li data-sourcepos="1:1-1:10" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled>
+ <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip" data-issue-type="issue">1 (#48)</a>
</li>
<li data-sourcepos="2:1-2:7" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled> 2
diff --git a/spec/frontend/issues/show/utils_spec.js b/spec/frontend/issues/show/utils_spec.js
new file mode 100644
index 00000000000..e5f14cfc01a
--- /dev/null
+++ b/spec/frontend/issues/show/utils_spec.js
@@ -0,0 +1,40 @@
+import { convertDescriptionWithNewSort } from '~/issues/show/utils';
+
+describe('app/assets/javascripts/issues/show/utils.js', () => {
+ describe('convertDescriptionWithNewSort', () => {
+ it('converts markdown description with new list sort order', () => {
+ const description = `I am text
+
+- Item 1
+- Item 2
+ - Item 3
+ - Item 4
+- Item 5`;
+
+ // Drag Item 2 + children to Item 1's position
+ const html = `<ul data-sourcepos="3:1-8:0">
+ <li data-sourcepos="4:1-4:8">
+ Item 2
+ <ul data-sourcepos="5:1-6:10">
+ <li data-sourcepos="5:1-5:10">Item 3</li>
+ <li data-sourcepos="6:1-6:10">Item 4</li>
+ </ul>
+ </li>
+ <li data-sourcepos="3:1-3:8">Item 1</li>
+ <li data-sourcepos="7:1-8:0">Item 5</li>
+ <ul>`;
+ const list = document.createElement('div');
+ list.innerHTML = html;
+
+ const expected = `I am text
+
+- Item 2
+ - Item 3
+ - Item 4
+- Item 1
+- Item 5`;
+
+ expect(convertDescriptionWithNewSort(description, list.firstChild)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
index 3d7bf7acb41..5df54abfc05 100644
--- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
@@ -7,21 +7,35 @@ import * as JiraConnectApi from '~/jira_connect/subscriptions/api';
import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue';
import GroupsListItem from '~/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue';
import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils';
+import {
+ I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
+ I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
+ INTEGRATIONS_DOC_LINK,
+} from '~/jira_connect/subscriptions/constants';
+import createStore from '~/jira_connect/subscriptions/store';
import { mockGroup1 } from '../../mock_data';
jest.mock('~/jira_connect/subscriptions/utils');
describe('GroupsListItem', () => {
let wrapper;
- const mockSubscriptionPath = 'subscriptionPath';
+ let store;
+
+ const mockAddSubscriptionsPath = '/addSubscriptionsPath';
+
+ const createComponent = ({ mountFn = shallowMount, provide } = {}) => {
+ store = createStore();
+
+ jest.spyOn(store, 'dispatch').mockImplementation();
- const createComponent = ({ mountFn = shallowMount } = {}) => {
wrapper = mountFn(GroupsListItem, {
+ store,
propsData: {
group: mockGroup1,
},
provide: {
- subscriptionsPath: mockSubscriptionPath,
+ addSubscriptionsPath: mockAddSubscriptionsPath,
+ ...provide,
},
});
};
@@ -51,62 +65,88 @@ describe('GroupsListItem', () => {
});
describe('on Link button click', () => {
- let addSubscriptionSpy;
+ describe('when jiraConnectOauth feature flag is disabled', () => {
+ let addSubscriptionSpy;
- beforeEach(() => {
- createComponent({ mountFn: mount });
+ beforeEach(() => {
+ createComponent({ mountFn: mount });
- addSubscriptionSpy = jest.spyOn(JiraConnectApi, 'addSubscription').mockResolvedValue();
- });
+ addSubscriptionSpy = jest.spyOn(JiraConnectApi, 'addSubscription').mockResolvedValue();
+ });
- it('sets button to loading and sends request', async () => {
- expect(findLinkButton().props('loading')).toBe(false);
+ it('sets button to loading and sends request', async () => {
+ expect(findLinkButton().props('loading')).toBe(false);
+
+ clickLinkButton();
+ await nextTick();
- clickLinkButton();
+ expect(findLinkButton().props('loading')).toBe(true);
+ await waitForPromises();
- await nextTick();
+ expect(addSubscriptionSpy).toHaveBeenCalledWith(
+ mockAddSubscriptionsPath,
+ mockGroup1.full_path,
+ );
+ expect(persistAlert).toHaveBeenCalledWith({
+ linkUrl: INTEGRATIONS_DOC_LINK,
+ message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
+ title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
+ variant: 'success',
+ });
+ });
- expect(findLinkButton().props('loading')).toBe(true);
+ describe('when request is successful', () => {
+ it('reloads the page', async () => {
+ clickLinkButton();
- await waitForPromises();
+ await waitForPromises();
- expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path);
- expect(persistAlert).toHaveBeenCalledWith({
- linkUrl: '/help/integration/jira_development_panel.html#use-the-integration',
- message:
- 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
- title: 'Namespace successfully linked',
- variant: 'success',
+ expect(reloadPage).toHaveBeenCalled();
+ });
});
- });
- describe('when request is successful', () => {
- it('reloads the page', async () => {
- clickLinkButton();
+ describe('when request has errors', () => {
+ const mockErrorMessage = 'error message';
+ const mockError = { response: { data: { error: mockErrorMessage } } };
- await waitForPromises();
+ beforeEach(() => {
+ addSubscriptionSpy = jest
+ .spyOn(JiraConnectApi, 'addSubscription')
+ .mockRejectedValue(mockError);
+ });
- expect(reloadPage).toHaveBeenCalled();
+ it('emits `error` event', async () => {
+ clickLinkButton();
+
+ await waitForPromises();
+
+ expect(reloadPage).not.toHaveBeenCalled();
+ expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage);
+ });
});
});
- describe('when request has errors', () => {
- const mockErrorMessage = 'error message';
- const mockError = { response: { data: { error: mockErrorMessage } } };
+ describe('when jiraConnectOauth feature flag is enabled', () => {
+ const mockSubscriptionsPath = '/subscriptions';
beforeEach(() => {
- addSubscriptionSpy = jest
- .spyOn(JiraConnectApi, 'addSubscription')
- .mockRejectedValue(mockError);
+ createComponent({
+ mountFn: mount,
+ provide: {
+ subscriptionsPath: mockSubscriptionsPath,
+ glFeatures: { jiraConnectOauth: true },
+ },
+ });
});
- it('emits `error` event', async () => {
+ it('dispatches `addSubscription` action', async () => {
clickLinkButton();
+ await nextTick();
- await waitForPromises();
-
- expect(reloadPage).not.toHaveBeenCalled();
- expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage);
+ expect(store.dispatch).toHaveBeenCalledWith('addSubscription', {
+ namespacePath: mockGroup1.full_path,
+ subscriptionsPath: mockSubscriptionsPath,
+ });
});
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
index ce02144f22f..9894141be5a 100644
--- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
@@ -3,8 +3,8 @@ import { nextTick } from 'vue';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue';
-import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue';
-import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.vue';
+import SignInPage from '~/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue';
+import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions_page.vue';
import UserLink from '~/jira_connect/subscriptions/components/user_link.vue';
import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue';
import createStore from '~/jira_connect/subscriptions/store';
@@ -12,6 +12,7 @@ import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants';
import { __ } from '~/locale';
import AccessorUtilities from '~/lib/utils/accessor';
+import * as api from '~/jira_connect/subscriptions/api';
import { mockSubscription } from '../mock_data';
jest.mock('~/jira_connect/subscriptions/utils', () => ({
@@ -31,7 +32,8 @@ describe('JiraConnectApp', () => {
const findBrowserSupportAlert = () => wrapper.findComponent(BrowserSupportAlert);
const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => {
- store = createStore();
+ store = createStore({ subscriptions: [mockSubscription] });
+ jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = mountFn(JiraConnectApp, {
store,
@@ -53,7 +55,6 @@ describe('JiraConnectApp', () => {
createComponent({
provide: {
usersPath,
- subscriptions: [mockSubscription],
},
});
});
@@ -79,14 +80,13 @@ describe('JiraConnectApp', () => {
createComponent({
provide: {
usersPath: '/user',
- subscriptions: [],
},
});
const userLink = findUserLink();
expect(userLink.exists()).toBe(true);
expect(userLink.props()).toEqual({
- hasSubscriptions: false,
+ hasSubscriptions: true,
user: null,
userSignedIn: false,
});
@@ -161,39 +161,11 @@ describe('JiraConnectApp', () => {
});
describe('when user signed out', () => {
- describe('when sign in page emits `sign-in-oauth` event', () => {
- const mockUser = { name: 'test' };
- beforeEach(async () => {
- createComponent({
- provide: {
- usersPath: '/mock',
- subscriptions: [],
- },
- });
- findSignInPage().vm.$emit('sign-in-oauth', mockUser);
-
- await nextTick();
- });
-
- it('hides sign in page and renders subscriptions page', () => {
- expect(findSignInPage().exists()).toBe(false);
- expect(findSubscriptionsPage().exists()).toBe(true);
- });
-
- it('sets correct UserLink props', () => {
- expect(findUserLink().props()).toMatchObject({
- user: mockUser,
- userSignedIn: true,
- });
- });
- });
-
describe('when sign in page emits `error` event', () => {
beforeEach(async () => {
createComponent({
provide: {
usersPath: '/mock',
- subscriptions: [],
},
});
findSignInPage().vm.$emit('error');
@@ -235,4 +207,31 @@ describe('JiraConnectApp', () => {
});
},
);
+
+ describe('when `jiraConnectOauth` feature flag is enabled', () => {
+ const mockSubscriptionsPath = '/mockSubscriptionsPath';
+
+ beforeEach(() => {
+ jest.spyOn(api, 'fetchSubscriptions').mockResolvedValue({ data: { subscriptions: [] } });
+
+ createComponent({
+ provide: {
+ glFeatures: { jiraConnectOauth: true },
+ subscriptionsPath: mockSubscriptionsPath,
+ },
+ });
+ });
+
+ describe('when component mounts', () => {
+ it('dispatches `fetchSubscriptions` action', async () => {
+ expect(store.dispatch).toHaveBeenCalledWith('fetchSubscriptions', mockSubscriptionsPath);
+ });
+ });
+
+ describe('when oauth button emits `sign-in-oauth` event', () => {
+ it('dispatches `fetchSubscriptions` action', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('fetchSubscriptions', mockSubscriptionsPath);
+ });
+ });
+ });
});
diff --git a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js
index 18274cd4362..8730e124ae7 100644
--- a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js
@@ -11,9 +11,14 @@ import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatus from '~/lib/utils/http_status';
import AccessorUtilities from '~/lib/utils/accessor';
+import { getCurrentUser } from '~/rest_api';
+import createStore from '~/jira_connect/subscriptions/store';
+import { SET_ACCESS_TOKEN } from '~/jira_connect/subscriptions/store/mutation_types';
jest.mock('~/lib/utils/accessor');
jest.mock('~/jira_connect/subscriptions/utils');
+jest.mock('~/jira_connect/subscriptions/api');
+jest.mock('~/rest_api');
jest.mock('~/jira_connect/subscriptions/pkce', () => ({
createCodeVerifier: jest.fn().mockReturnValue('mock-verifier'),
createCodeChallenge: jest.fn().mockResolvedValue('mock-challenge'),
@@ -28,9 +33,15 @@ const mockOauthMetadata = {
describe('SignInOauthButton', () => {
let wrapper;
let mockAxios;
+ let store;
const createComponent = ({ slots } = {}) => {
+ store = createStore();
+ jest.spyOn(store, 'dispatch').mockImplementation();
+ jest.spyOn(store, 'commit').mockImplementation();
+
wrapper = shallowMount(SignInOauthButton, {
+ store,
slots,
provide: {
oauthMetadata: mockOauthMetadata,
@@ -114,10 +125,6 @@ describe('SignInOauthButton', () => {
await waitForPromises();
});
- it('emits `error` event', () => {
- expect(wrapper.emitted('error')).toBeTruthy();
- });
-
it('does not emit `sign-in` event', () => {
expect(wrapper.emitted('sign-in')).toBeFalsy();
});
@@ -147,7 +154,7 @@ describe('SignInOauthButton', () => {
mockAxios
.onPost(mockOauthMetadata.oauth_token_url)
.replyOnce(httpStatus.OK, { access_token: mockAccessToken });
- mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.OK, mockUser);
+ getCurrentUser.mockResolvedValue({ data: mockUser });
window.dispatchEvent(new MessageEvent('message', mockEvent));
@@ -161,25 +168,25 @@ describe('SignInOauthButton', () => {
});
});
- it('executes GET request to fetch user data', () => {
- expect(axios.get).toHaveBeenCalledWith('/api/v4/user', {
- headers: { Authorization: `Bearer ${mockAccessToken}` },
- });
+ it('dispatches loadCurrentUser action', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('loadCurrentUser', mockAccessToken);
+ });
+
+ it('commits SET_ACCESS_TOKEN mutation with correct access token', () => {
+ expect(store.commit).toHaveBeenCalledWith(SET_ACCESS_TOKEN, mockAccessToken);
});
it('emits `sign-in` event with user data', () => {
- expect(wrapper.emitted('sign-in')[0]).toEqual([mockUser]);
+ expect(wrapper.emitted('sign-in')[0]).toBeTruthy();
});
});
describe('when API requests fail', () => {
beforeEach(async () => {
jest.spyOn(axios, 'post');
- jest.spyOn(axios, 'get');
mockAxios
.onPost(mockOauthMetadata.oauth_token_url)
- .replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { access_token: mockAccessToken });
- mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.INTERNAL_SERVER_ERROR, mockUser);
+ .replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
window.dispatchEvent(new MessageEvent('message', mockEvent));
@@ -187,7 +194,7 @@ describe('SignInOauthButton', () => {
});
it('emits `error` event', () => {
- expect(wrapper.emitted('error')).toBeTruthy();
+ expect(wrapper.emitted('error')[0]).toEqual([]);
});
it('does not emit `sign-in` event', () => {
diff --git a/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js
index 2aad533f677..2d7c58fc278 100644
--- a/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js
@@ -20,12 +20,11 @@ describe('SubscriptionsList', () => {
let store;
const createComponent = () => {
- store = createStore();
+ store = createStore({
+ subscriptions: [mockSubscription],
+ });
wrapper = mount(SubscriptionsList, {
- provide: {
- subscriptions: [mockSubscription],
- },
store,
});
};
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js
index 97d1b077164..1649920b48b 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue';
+import SignInGitlabCom from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue';
import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue';
import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
@@ -15,7 +15,7 @@ const defaultProvide = {
usersPath: mockUsersPath,
};
-describe('SignInPage', () => {
+describe('SignInGitlabCom', () => {
let wrapper;
let store;
@@ -26,7 +26,7 @@ describe('SignInPage', () => {
const createComponent = ({ props, jiraConnectOauthEnabled } = {}) => {
store = createStore();
- wrapper = shallowMount(SignInPage, {
+ wrapper = shallowMount(SignInGitlabCom, {
store,
provide: {
...defaultProvide,
@@ -49,7 +49,7 @@ describe('SignInPage', () => {
describe('template', () => {
describe.each`
scenario | hasSubscriptions | signInButtonText
- ${'with subscriptions'} | ${true} | ${SignInPage.i18n.signInButtonTextWithSubscriptions}
+ ${'with subscriptions'} | ${true} | ${SignInGitlabCom.i18n.signInButtonTextWithSubscriptions}
${'without subscriptions'} | ${false} | ${I18N_DEFAULT_SIGN_IN_BUTTON_TEXT}
`('$scenario', ({ hasSubscriptions, signInButtonText }) => {
describe('when `jiraConnectOauthEnabled` feature flag is disabled', () => {
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js
new file mode 100644
index 00000000000..f4be8bf121d
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js
@@ -0,0 +1,83 @@
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import SignInGitlabMultiversion from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue';
+import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue';
+import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
+
+describe('SignInGitlabMultiversion', () => {
+ let wrapper;
+
+ const findVersionSelectForm = () => wrapper.findComponent(VersionSelectForm);
+ const findSignInOauthButton = () => wrapper.findComponent(SignInOauthButton);
+ const findSubtitle = () => wrapper.findByTestId('subtitle');
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(SignInGitlabMultiversion);
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when version is not selected', () => {
+ describe('VersionSelectForm', () => {
+ it('renders version select form', () => {
+ createComponent();
+
+ expect(findVersionSelectForm().exists()).toBe(true);
+ });
+
+ describe('when form emits "submit" event', () => {
+ it('hides the version select form and shows the sign in button', async () => {
+ createComponent();
+
+ findVersionSelectForm().vm.$emit('submit', 'gitlab.mycompany.com');
+ await nextTick();
+
+ expect(findVersionSelectForm().exists()).toBe(false);
+ expect(findSignInOauthButton().exists()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('when version is selected', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ findVersionSelectForm().vm.$emit('submit', 'gitlab.mycompany.com');
+ await nextTick();
+ });
+
+ describe('sign in button', () => {
+ it('renders sign in button', () => {
+ expect(findSignInOauthButton().exists()).toBe(true);
+ });
+
+ describe('when button emits `sign-in` event', () => {
+ it('emits `sign-in-oauth` event', () => {
+ const button = findSignInOauthButton();
+
+ const mockUser = { name: 'test' };
+ button.vm.$emit('sign-in', mockUser);
+
+ expect(wrapper.emitted('sign-in-oauth')[0]).toEqual([mockUser]);
+ });
+ });
+
+ describe('when button emits `error` event', () => {
+ it('emits `error` event', () => {
+ const button = findSignInOauthButton();
+ button.vm.$emit('error');
+
+ expect(wrapper.emitted('error')).toBeTruthy();
+ });
+ });
+ });
+
+ it('renders correct subtitle', () => {
+ expect(findSubtitle().text()).toBe(SignInGitlabMultiversion.i18n.signInSubtitle);
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js
new file mode 100644
index 00000000000..29e7fe7a5b2
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form_spec.js
@@ -0,0 +1,69 @@
+import { GlFormInput, GlFormRadioGroup, GlForm } from '@gitlab/ui';
+import { nextTick } from 'vue';
+
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue';
+
+describe('VersionSelectForm', () => {
+ let wrapper;
+
+ const findFormRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+
+ const submitForm = () => findForm().vm.$emit('submit', new Event('submit'));
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(VersionSelectForm);
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default state', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('selects saas radio option by default', () => {
+ expect(findFormRadioGroup().vm.$attrs.checked).toBe(VersionSelectForm.radioOptions.saas);
+ });
+
+ it('does not render instance input', () => {
+ expect(findInput().exists()).toBe(false);
+ });
+
+ describe('when form is submitted', () => {
+ it('emits "submit" event with gitlab.com as the payload', () => {
+ submitForm();
+
+ expect(wrapper.emitted('submit')[0][0]).toBe('https://gitlab.com');
+ });
+ });
+ });
+
+ describe('when "self-managed" radio option is selected', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ findFormRadioGroup().vm.$emit('input', VersionSelectForm.radioOptions.selfManaged);
+ await nextTick();
+ });
+
+ it('reveals the self-managed input field', () => {
+ expect(findInput().exists()).toBe(true);
+ });
+
+ describe('when form is submitted', () => {
+ it('emits "submit" event with the input field value as the payload', () => {
+ const mockInstanceUrl = 'https://gitlab.example.com';
+
+ findInput().vm.$emit('input', mockInstanceUrl);
+ submitForm();
+
+ expect(wrapper.emitted('submit')[0][0]).toBe(mockInstanceUrl);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js
new file mode 100644
index 00000000000..65b08fba592
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_page_spec.js
@@ -0,0 +1,82 @@
+import { shallowMount } from '@vue/test-utils';
+
+import SignInPage from '~/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue';
+import SignInGitlabCom from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue';
+import SignInGitlabMultiversion from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue';
+import createStore from '~/jira_connect/subscriptions/store';
+
+describe('SignInPage', () => {
+ let wrapper;
+ let store;
+
+ const findSignInGitlabCom = () => wrapper.findComponent(SignInGitlabCom);
+ const findSignInGitabMultiversion = () => wrapper.findComponent(SignInGitlabMultiversion);
+
+ const createComponent = ({
+ props = {},
+ jiraConnectOauthEnabled,
+ jiraConnectOauthSelfManagedEnabled,
+ } = {}) => {
+ store = createStore();
+
+ wrapper = shallowMount(SignInPage, {
+ store,
+ provide: {
+ glFeatures: {
+ jiraConnectOauth: jiraConnectOauthEnabled,
+ jiraConnectOauthSelfManaged: jiraConnectOauthSelfManagedEnabled,
+ },
+ },
+ propsData: { hasSubscriptions: false, ...props },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ jiraConnectOauthEnabled | jiraConnectOauthSelfManagedEnabled | shouldRenderDotCom | shouldRenderMultiversion
+ ${false} | ${false} | ${true} | ${false}
+ ${false} | ${true} | ${true} | ${false}
+ ${true} | ${false} | ${true} | ${false}
+ ${true} | ${true} | ${false} | ${true}
+ `(
+ 'renders correct component when jiraConnectOauth is $jiraConnectOauthEnabled and jiraConnectOauthSelfManaged is $jiraConnectOauthSelfManagedEnabled',
+ ({
+ jiraConnectOauthEnabled,
+ jiraConnectOauthSelfManagedEnabled,
+ shouldRenderDotCom,
+ shouldRenderMultiversion,
+ }) => {
+ createComponent({ jiraConnectOauthEnabled, jiraConnectOauthSelfManagedEnabled });
+
+ expect(findSignInGitlabCom().exists()).toBe(shouldRenderDotCom);
+ expect(findSignInGitabMultiversion().exists()).toBe(shouldRenderMultiversion);
+ },
+ );
+
+ describe('when jiraConnectOauthSelfManaged is false', () => {
+ beforeEach(() => {
+ createComponent({ jiraConnectOauthSelfManaged: false, props: { hasSubscriptions: true } });
+ });
+
+ it('renders SignInGitlabCom with correct props', () => {
+ expect(findSignInGitlabCom().props()).toEqual({ hasSubscriptions: true });
+ });
+
+ describe('when error event is emitted', () => {
+ it('emits another error event', () => {
+ findSignInGitlabCom().vm.$emit('error');
+ expect(wrapper.emitted('error')[0]).toBeTruthy();
+ });
+ });
+
+ describe('when sign-in-oauth event is emitted', () => {
+ it('emits another sign-in-oauth event', () => {
+ findSignInGitlabCom().vm.$emit('sign-in-oauth');
+ expect(wrapper.emitted('sign-in-oauth')[0]).toEqual([]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js
new file mode 100644
index 00000000000..4956af76ead
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js
@@ -0,0 +1,71 @@
+import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions_page.vue';
+import AddNamespaceButton from '~/jira_connect/subscriptions/components/add_namespace_button.vue';
+import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
+import createStore from '~/jira_connect/subscriptions/store';
+
+describe('SubscriptionsPage', () => {
+ let wrapper;
+ let store;
+
+ const findAddNamespaceButton = () => wrapper.findComponent(AddNamespaceButton);
+ const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+ const createComponent = ({ props, initialState } = {}) => {
+ store = createStore(initialState);
+
+ wrapper = shallowMount(SubscriptionsPage, {
+ store,
+ propsData: { hasSubscriptions: false, ...props },
+ stubs: {
+ GlEmptyState,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ describe.each`
+ scenario | subscriptionsLoading | hasSubscriptions | expectSubscriptionsList | expectEmptyState
+ ${'with subscriptions loading'} | ${true} | ${false} | ${false} | ${false}
+ ${'with subscriptions'} | ${false} | ${true} | ${true} | ${false}
+ ${'without subscriptions'} | ${false} | ${false} | ${false} | ${true}
+ `(
+ '$scenario',
+ ({ subscriptionsLoading, hasSubscriptions, expectEmptyState, expectSubscriptionsList }) => {
+ beforeEach(() => {
+ createComponent({
+ initialState: { subscriptionsLoading },
+ props: {
+ hasSubscriptions,
+ },
+ });
+ });
+
+ it(`${
+ subscriptionsLoading ? 'does not render' : 'renders'
+ } button to add namespace`, () => {
+ expect(findAddNamespaceButton().exists()).toBe(!subscriptionsLoading);
+ });
+
+ it(`${subscriptionsLoading ? 'renders' : 'does not render'} GlLoadingIcon`, () => {
+ expect(findGlLoadingIcon().exists()).toBe(subscriptionsLoading);
+ });
+
+ it(`${expectEmptyState ? 'renders' : 'does not render'} empty state`, () => {
+ expect(findEmptyState().exists()).toBe(expectEmptyState);
+ });
+
+ it(`${expectSubscriptionsList ? 'renders' : 'does not render'} subscriptions list`, () => {
+ expect(findSubscriptionsList().exists()).toBe(expectSubscriptionsList);
+ });
+ },
+ );
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_spec.js b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_spec.js
deleted file mode 100644
index 198278efc1f..00000000000
--- a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_spec.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { GlEmptyState } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.vue';
-import AddNamespaceButton from '~/jira_connect/subscriptions/components/add_namespace_button.vue';
-import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
-import createStore from '~/jira_connect/subscriptions/store';
-
-describe('SubscriptionsPage', () => {
- let wrapper;
- let store;
-
- const findAddNamespaceButton = () => wrapper.findComponent(AddNamespaceButton);
- const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList);
- const findEmptyState = () => wrapper.findComponent(GlEmptyState);
-
- const createComponent = ({ props } = {}) => {
- store = createStore();
-
- wrapper = shallowMount(SubscriptionsPage, {
- store,
- propsData: props,
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('template', () => {
- describe.each`
- scenario | expectSubscriptionsList | expectEmptyState
- ${'with subscriptions'} | ${true} | ${false}
- ${'without subscriptions'} | ${false} | ${true}
- `('$scenario', ({ expectEmptyState, expectSubscriptionsList }) => {
- beforeEach(() => {
- createComponent({
- props: {
- hasSubscriptions: expectSubscriptionsList,
- },
- });
- });
-
- it('renders button to add namespace', () => {
- expect(findAddNamespaceButton().exists()).toBe(true);
- });
-
- it(`${expectEmptyState ? 'renders' : 'does not render'} empty state`, () => {
- expect(findEmptyState().exists()).toBe(expectEmptyState);
- });
-
- it(`${expectSubscriptionsList ? 'renders' : 'does not render'} subscriptions list`, () => {
- expect(findSubscriptionsList().exists()).toBe(expectSubscriptionsList);
- });
- });
- });
-});
diff --git a/spec/frontend/jira_connect/subscriptions/store/actions_spec.js b/spec/frontend/jira_connect/subscriptions/store/actions_spec.js
new file mode 100644
index 00000000000..53b5d8e70af
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/store/actions_spec.js
@@ -0,0 +1,172 @@
+import testAction from 'helpers/vuex_action_helper';
+
+import * as types from '~/jira_connect/subscriptions/store/mutation_types';
+import {
+ fetchSubscriptions,
+ loadCurrentUser,
+ addSubscription,
+} from '~/jira_connect/subscriptions/store/actions';
+import state from '~/jira_connect/subscriptions/store/state';
+import * as api from '~/jira_connect/subscriptions/api';
+import * as userApi from '~/api/user_api';
+import * as integrationsApi from '~/api/integrations_api';
+import {
+ I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE,
+ I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
+ I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
+ INTEGRATIONS_DOC_LINK,
+} from '~/jira_connect/subscriptions/constants';
+import * as utils from '~/jira_connect/subscriptions/utils';
+
+describe('JiraConnect actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('fetchSubscriptions', () => {
+ const mockUrl = '/mock-url';
+
+ describe('when API request is successful', () => {
+ it('should commit SET_SUBSCRIPTIONS_LOADING and SET_SUBSCRIPTIONS mutations', async () => {
+ jest.spyOn(api, 'fetchSubscriptions').mockResolvedValue({ data: { subscriptions: [] } });
+
+ await testAction(
+ fetchSubscriptions,
+ mockUrl,
+ mockedState,
+ [
+ { type: types.SET_SUBSCRIPTIONS_LOADING, payload: true },
+ { type: types.SET_SUBSCRIPTIONS, payload: [] },
+ { type: types.SET_SUBSCRIPTIONS_LOADING, payload: false },
+ ],
+ [],
+ );
+
+ expect(api.fetchSubscriptions).toHaveBeenCalledWith(mockUrl);
+ });
+ });
+
+ describe('when API request fails', () => {
+ it('should commit SET_SUBSCRIPTIONS_LOADING, SET_SUBSCRIPTIONS_ERROR and SET_ALERT mutations', async () => {
+ jest.spyOn(api, 'fetchSubscriptions').mockRejectedValue();
+
+ await testAction(
+ fetchSubscriptions,
+ mockUrl,
+ mockedState,
+ [
+ { type: types.SET_SUBSCRIPTIONS_LOADING, payload: true },
+ { type: types.SET_SUBSCRIPTIONS_ERROR, payload: true },
+ {
+ type: types.SET_ALERT,
+ payload: { message: I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE, variant: 'danger' },
+ },
+ { type: types.SET_SUBSCRIPTIONS_LOADING, payload: false },
+ ],
+ [],
+ );
+
+ expect(api.fetchSubscriptions).toHaveBeenCalledWith(mockUrl);
+ });
+ });
+ });
+
+ describe('loadCurrentUser', () => {
+ const mockAccessToken = 'abcd1234';
+
+ describe('when API request succeeds', () => {
+ it('commits the SET_ACCESS_TOKEN and SET_CURRENT_USER mutations', async () => {
+ const mockUser = { name: 'root' };
+ jest.spyOn(userApi, 'getCurrentUser').mockResolvedValue({ data: mockUser });
+
+ await testAction(
+ loadCurrentUser,
+ mockAccessToken,
+ mockedState,
+ [{ type: types.SET_CURRENT_USER, payload: mockUser }],
+ [],
+ );
+
+ expect(userApi.getCurrentUser).toHaveBeenCalledWith({
+ headers: { Authorization: `Bearer ${mockAccessToken}` },
+ });
+ });
+ });
+
+ describe('when API request fails', () => {
+ it('commits the SET_CURRENT_USER_ERROR mutation', async () => {
+ jest.spyOn(userApi, 'getCurrentUser').mockRejectedValue();
+
+ await testAction(
+ loadCurrentUser,
+ mockAccessToken,
+ mockedState,
+ [{ type: types.SET_CURRENT_USER_ERROR }],
+ [],
+ );
+ });
+ });
+ });
+
+ describe('addSubscription', () => {
+ const mockNamespace = 'gitlab-org/gitlab';
+ const mockSubscriptionsPath = '/subscriptions';
+
+ beforeEach(() => {
+ jest.spyOn(utils, 'getJwt').mockReturnValue('1234');
+ });
+
+ describe('when API request succeeds', () => {
+ it('commits the SET_ACCESS_TOKEN and SET_CURRENT_USER mutations', async () => {
+ jest
+ .spyOn(integrationsApi, 'addJiraConnectSubscription')
+ .mockResolvedValue({ success: true });
+
+ await testAction(
+ addSubscription,
+ { namespacePath: mockNamespace, subscriptionsPath: mockSubscriptionsPath },
+ mockedState,
+ [
+ { type: types.ADD_SUBSCRIPTION_LOADING, payload: true },
+ {
+ type: types.SET_ALERT,
+ payload: {
+ title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
+ message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
+ linkUrl: INTEGRATIONS_DOC_LINK,
+ variant: 'success',
+ },
+ },
+ { type: types.ADD_SUBSCRIPTION_LOADING, payload: false },
+ ],
+ [{ type: 'fetchSubscriptions', payload: mockSubscriptionsPath }],
+ );
+
+ expect(integrationsApi.addJiraConnectSubscription).toHaveBeenCalledWith(mockNamespace, {
+ accessToken: null,
+ jwt: '1234',
+ });
+ });
+ });
+
+ describe('when API request fails', () => {
+ it('commits the SET_CURRENT_USER_ERROR mutation', async () => {
+ jest.spyOn(integrationsApi, 'addJiraConnectSubscription').mockRejectedValue();
+
+ await testAction(
+ addSubscription,
+ mockNamespace,
+ mockedState,
+ [
+ { type: types.ADD_SUBSCRIPTION_LOADING, payload: true },
+ { type: types.ADD_SUBSCRIPTION_ERROR },
+ { type: types.ADD_SUBSCRIPTION_LOADING, payload: false },
+ ],
+ [],
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js
index 84a33dbf0b5..aeb136a76b9 100644
--- a/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js
@@ -25,4 +25,71 @@ describe('JiraConnect store mutations', () => {
});
});
});
+
+ describe('SET_SUBSCRIPTIONS', () => {
+ it('sets subscriptions loading flag', () => {
+ const mockSubscriptions = [{ name: 'test' }];
+ mutations.SET_SUBSCRIPTIONS(localState, mockSubscriptions);
+
+ expect(localState.subscriptions).toBe(mockSubscriptions);
+ });
+ });
+
+ describe('SET_SUBSCRIPTIONS_LOADING', () => {
+ it('sets subscriptions loading flag', () => {
+ mutations.SET_SUBSCRIPTIONS_LOADING(localState, true);
+
+ expect(localState.subscriptionsLoading).toBe(true);
+ });
+ });
+
+ describe('SET_SUBSCRIPTIONS_ERROR', () => {
+ it('sets subscriptions error', () => {
+ mutations.SET_SUBSCRIPTIONS_ERROR(localState, true);
+
+ expect(localState.subscriptionsError).toBe(true);
+ });
+ });
+
+ describe('ADD_SUBSCRIPTION_LOADING', () => {
+ it('sets addSubscriptionLoading', () => {
+ mutations.ADD_SUBSCRIPTION_LOADING(localState, true);
+
+ expect(localState.addSubscriptionLoading).toBe(true);
+ });
+ });
+
+ describe('ADD_SUBSCRIPTION_ERROR', () => {
+ it('sets addSubscriptionError', () => {
+ mutations.ADD_SUBSCRIPTION_ERROR(localState, true);
+
+ expect(localState.addSubscriptionError).toBe(true);
+ });
+ });
+
+ describe('SET_CURRENT_USER', () => {
+ it('sets currentUser', () => {
+ const mockUser = { name: 'root' };
+ mutations.SET_CURRENT_USER(localState, mockUser);
+
+ expect(localState.currentUser).toBe(mockUser);
+ });
+ });
+
+ describe('SET_CURRENT_USER_ERROR', () => {
+ it('sets currentUserError', () => {
+ mutations.SET_CURRENT_USER_ERROR(localState, true);
+
+ expect(localState.currentUserError).toBe(true);
+ });
+ });
+
+ describe('SET_ACCESS_TOKEN', () => {
+ it('sets accessToken', () => {
+ const mockAccessToken = 'asdf1234';
+ mutations.SET_ACCESS_TOKEN(localState, mockAccessToken);
+
+ expect(localState.accessToken).toBe(mockAccessToken);
+ });
+ });
});
diff --git a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js
index ce8e482cc16..92ce3925a90 100644
--- a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js
+++ b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js
@@ -21,6 +21,7 @@ describe('Job Status Token', () => {
value: {
data: '',
},
+ cursorPosition: 'start',
};
const createComponent = () => {
diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js
index 9abe66b4696..fc308766ab9 100644
--- a/spec/frontend/jobs/components/job_app_spec.js
+++ b/spec/frontend/jobs/components/job_app_spec.js
@@ -129,7 +129,9 @@ describe('Job App', () => {
const aYearAgo = new Date();
aYearAgo.setFullYear(aYearAgo.getFullYear() - 1);
- return setupAndMount({ jobData: { started: aYearAgo.toISOString() } });
+ return setupAndMount({
+ jobData: { started: aYearAgo.toISOString(), started_at: aYearAgo.toISOString() },
+ });
});
it('should render provided job information', () => {
diff --git a/spec/frontend/jobs/components/stuck_block_spec.js b/spec/frontend/jobs/components/stuck_block_spec.js
index 4db73eaaaec..1580ed45e46 100644
--- a/spec/frontend/jobs/components/stuck_block_spec.js
+++ b/spec/frontend/jobs/components/stuck_block_spec.js
@@ -32,7 +32,7 @@ describe('Stuck Block Job component', () => {
describe('with no runners for project', () => {
beforeEach(() => {
createWrapper({
- hasNoRunnersForProject: true,
+ hasOfflineRunnersForProject: true,
runnersPath: '/root/project/runners#js-runners-settings',
});
});
@@ -53,7 +53,7 @@ describe('Stuck Block Job component', () => {
describe('with tags', () => {
beforeEach(() => {
createWrapper({
- hasNoRunnersForProject: false,
+ hasOfflineRunnersForProject: false,
tags,
runnersPath: '/root/project/runners#js-runners-settings',
});
@@ -81,7 +81,7 @@ describe('Stuck Block Job component', () => {
describe('without active runners', () => {
beforeEach(() => {
createWrapper({
- hasNoRunnersForProject: false,
+ hasOfflineRunnersForProject: false,
runnersPath: '/root/project/runners#js-runners-settings',
});
});
diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
index 263698e94e1..976b128532d 100644
--- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
@@ -1,8 +1,12 @@
import { GlModal } from '@gitlab/ui';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { redirectTo } from '~/lib/utils/url_utility';
import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue';
+import eventHub from '~/jobs/components/table/event_hub';
import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql';
import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql';
import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql';
@@ -15,11 +19,18 @@ import {
cannotRetryJob,
cannotPlayJob,
cannotPlayScheduledJob,
+ retryMutationResponse,
+ playMutationResponse,
+ cancelMutationResponse,
+ unscheduleMutationResponse,
} from '../../../mock_data';
+jest.mock('~/lib/utils/url_utility');
+
+Vue.use(VueApollo);
+
describe('Job actions cell', () => {
let wrapper;
- let mutate;
const findRetryButton = () => wrapper.findByTestId('retry');
const findPlayButton = () => wrapper.findByTestId('play');
@@ -31,29 +42,27 @@ describe('Job actions cell', () => {
const findModal = () => wrapper.findComponent(GlModal);
- const MUTATION_SUCCESS = { data: { JobRetryMutation: { jobId: retryableJob.id } } };
- const MUTATION_SUCCESS_UNSCHEDULE = {
- data: { JobUnscheduleMutation: { jobId: scheduledJob.id } },
- };
- const MUTATION_SUCCESS_PLAY = { data: { JobPlayMutation: { jobId: playableJob.id } } };
- const MUTATION_SUCCESS_CANCEL = { data: { JobCancelMutation: { jobId: cancelableJob.id } } };
+ const playMutationHandler = jest.fn().mockResolvedValue(playMutationResponse);
+ const retryMutationHandler = jest.fn().mockResolvedValue(retryMutationResponse);
+ const unscheduleMutationHandler = jest.fn().mockResolvedValue(unscheduleMutationResponse);
+ const cancelMutationHandler = jest.fn().mockResolvedValue(cancelMutationResponse);
const $toast = {
show: jest.fn(),
};
- const createComponent = (jobType, mutationType = MUTATION_SUCCESS, props = {}) => {
- mutate = jest.fn().mockResolvedValue(mutationType);
+ const createMockApolloProvider = (requestHandlers) => {
+ return createMockApollo(requestHandlers);
+ };
+ const createComponent = (jobType, requestHandlers, props = {}) => {
wrapper = shallowMountExtended(ActionsCell, {
propsData: {
job: jobType,
...props,
},
+ apolloProvider: createMockApolloProvider(requestHandlers),
mocks: {
- $apollo: {
- mutate,
- },
$toast,
},
});
@@ -101,24 +110,59 @@ describe('Job actions cell', () => {
});
it.each`
- button | mutationResult | action | jobType | mutationFile
- ${findPlayButton} | ${MUTATION_SUCCESS_PLAY} | ${'play'} | ${playableJob} | ${JobPlayMutation}
- ${findRetryButton} | ${MUTATION_SUCCESS} | ${'retry'} | ${retryableJob} | ${JobRetryMutation}
- ${findCancelButton} | ${MUTATION_SUCCESS_CANCEL} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation}
- `('performs the $action mutation', ({ button, mutationResult, jobType, mutationFile }) => {
- createComponent(jobType, mutationResult);
+ button | action | jobType | mutationFile | handler | jobId
+ ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${playableJob.id}
+ ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${retryableJob.id}
+ ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} | ${cancelableJob.id}
+ `('performs the $action mutation', async ({ button, jobType, mutationFile, handler, jobId }) => {
+ createComponent(jobType, [[mutationFile, handler]]);
button().vm.$emit('click');
- expect(mutate).toHaveBeenCalledWith({
- mutation: mutationFile,
- variables: {
- id: jobType.id,
- },
- });
+ expect(handler).toHaveBeenCalledWith({ id: jobId });
});
it.each`
+ button | action | jobType | mutationFile | handler
+ ${findUnscheduleButton} | ${'unschedule'} | ${scheduledJob} | ${JobUnscheduleMutation} | ${unscheduleMutationHandler}
+ ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler}
+ `(
+ 'the mutation action $action emits the jobActionPerformed event',
+ async ({ button, jobType, mutationFile, handler }) => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ createComponent(jobType, [[mutationFile, handler]]);
+
+ button().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('jobActionPerformed');
+ expect(redirectTo).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each`
+ button | action | jobType | mutationFile | handler | redirectLink
+ ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${'/root/project/-/jobs/1986'}
+ ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${'/root/project/-/jobs/1985'}
+ `(
+ 'the mutation action $action redirects to the job',
+ async ({ button, jobType, mutationFile, handler, redirectLink }) => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ createComponent(jobType, [[mutationFile, handler]]);
+
+ button().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(redirectTo).toHaveBeenCalledWith(redirectLink);
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each`
button | action | jobType
${findPlayButton} | ${'play'} | ${playableJob}
${findRetryButton} | ${'retry'} | ${retryableJob}
@@ -152,20 +196,17 @@ describe('Job actions cell', () => {
});
it('unschedules a job', () => {
- createComponent(scheduledJob, MUTATION_SUCCESS_UNSCHEDULE);
+ createComponent(scheduledJob, [[JobUnscheduleMutation, unscheduleMutationHandler]]);
findUnscheduleButton().vm.$emit('click');
- expect(mutate).toHaveBeenCalledWith({
- mutation: JobUnscheduleMutation,
- variables: {
- id: scheduledJob.id,
- },
+ expect(unscheduleMutationHandler).toHaveBeenCalledWith({
+ id: scheduledJob.id,
});
});
it('shows the play job confirmation modal', async () => {
- createComponent(scheduledJob, MUTATION_SUCCESS);
+ createComponent(scheduledJob);
findPlayScheduledJobButton().vm.$emit('click');
diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js
index 27b6c04eded..4676635cce0 100644
--- a/spec/frontend/jobs/mock_data.js
+++ b/spec/frontend/jobs/mock_data.js
@@ -1928,3 +1928,75 @@ export const CIJobConnectionExistingCache = {
};
export const mockFailedSearchToken = { type: 'status', value: { data: 'FAILED', operator: '=' } };
+
+export const retryMutationResponse = {
+ data: {
+ jobRetry: {
+ job: {
+ __typename: 'CiJob',
+ id: '"gid://gitlab/Ci::Build/1985"',
+ detailedStatus: {
+ detailsPath: '/root/project/-/jobs/1985',
+ id: 'pending-1985-1985',
+ __typename: 'DetailedStatus',
+ },
+ },
+ errors: [],
+ __typename: 'JobRetryPayload',
+ },
+ },
+};
+
+export const playMutationResponse = {
+ data: {
+ jobPlay: {
+ job: {
+ __typename: 'CiJob',
+ id: '"gid://gitlab/Ci::Build/1986"',
+ detailedStatus: {
+ detailsPath: '/root/project/-/jobs/1986',
+ id: 'pending-1986-1986',
+ __typename: 'DetailedStatus',
+ },
+ },
+ errors: [],
+ __typename: 'JobRetryPayload',
+ },
+ },
+};
+
+export const cancelMutationResponse = {
+ data: {
+ jobCancel: {
+ job: {
+ __typename: 'CiJob',
+ id: '"gid://gitlab/Ci::Build/1987"',
+ detailedStatus: {
+ detailsPath: '/root/project/-/jobs/1987',
+ id: 'pending-1987-1987',
+ __typename: 'DetailedStatus',
+ },
+ },
+ errors: [],
+ __typename: 'JobRetryPayload',
+ },
+ },
+};
+
+export const unscheduleMutationResponse = {
+ data: {
+ jobUnschedule: {
+ job: {
+ __typename: 'CiJob',
+ id: '"gid://gitlab/Ci::Build/1988"',
+ detailedStatus: {
+ detailsPath: '/root/project/-/jobs/1988',
+ id: 'pending-1988-1988',
+ __typename: 'DetailedStatus',
+ },
+ },
+ errors: [],
+ __typename: 'JobRetryPayload',
+ },
+ },
+};
diff --git a/spec/frontend/jobs/store/getters_spec.js b/spec/frontend/jobs/store/getters_spec.js
index f26c0cf00fd..c13b051c672 100644
--- a/spec/frontend/jobs/store/getters_spec.js
+++ b/spec/frontend/jobs/store/getters_spec.js
@@ -10,16 +10,18 @@ describe('Job Store Getters', () => {
describe('headerTime', () => {
describe('when the job has started key', () => {
- it('returns started key', () => {
+ it('returns started_at value', () => {
const started = '2018-08-31T16:20:49.023Z';
+ const startedAt = '2018-08-31T16:20:49.023Z';
+ localState.job.started_at = startedAt;
localState.job.started = started;
- expect(getters.headerTime(localState)).toEqual(started);
+ expect(getters.headerTime(localState)).toEqual(startedAt);
});
});
describe('when the job does not have started key', () => {
- it('returns created_at key', () => {
+ it('returns created_at value', () => {
const created = '2018-08-31T16:20:49.023Z';
localState.job.created_at = created;
@@ -58,7 +60,7 @@ describe('Job Store Getters', () => {
describe('shouldRenderTriggeredLabel', () => {
describe('when started equals null', () => {
it('returns false', () => {
- localState.job.started = null;
+ localState.job.started_at = null;
expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(false);
});
@@ -66,7 +68,7 @@ describe('Job Store Getters', () => {
describe('when started equals string', () => {
it('returns true', () => {
- localState.job.started = '2018-08-31T16:20:49.023Z';
+ localState.job.started_at = '2018-08-31T16:20:49.023Z';
expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(true);
});
@@ -206,7 +208,7 @@ describe('Job Store Getters', () => {
});
});
- describe('hasRunnersForProject', () => {
+ describe('hasOfflineRunnersForProject', () => {
describe('with available and offline runners', () => {
it('returns true', () => {
localState.job.runners = {
@@ -214,7 +216,7 @@ describe('Job Store Getters', () => {
online: false,
};
- expect(getters.hasRunnersForProject(localState)).toEqual(true);
+ expect(getters.hasOfflineRunnersForProject(localState)).toEqual(true);
});
});
@@ -225,7 +227,7 @@ describe('Job Store Getters', () => {
online: false,
};
- expect(getters.hasRunnersForProject(localState)).toEqual(false);
+ expect(getters.hasOfflineRunnersForProject(localState)).toEqual(false);
});
});
@@ -236,7 +238,7 @@ describe('Job Store Getters', () => {
online: true,
};
- expect(getters.hasRunnersForProject(localState)).toEqual(false);
+ expect(getters.hasOfflineRunnersForProject(localState)).toEqual(false);
});
});
});
diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js
index 47a94a4dcde..34325dad6a1 100644
--- a/spec/frontend/lib/dompurify_spec.js
+++ b/spec/frontend/lib/dompurify_spec.js
@@ -73,6 +73,16 @@ describe('~/lib/dompurify', () => {
expect(sanitize('<p><gl-emoji>💯</gl-emoji></p>')).toBe('<p><gl-emoji>💯</gl-emoji></p>');
});
+ it("doesn't allow style tags", () => {
+ // removes style tags
+ expect(sanitize('<style>p {width:50%;}</style>')).toBe('');
+ expect(sanitize('<style type="text/css">p {width:50%;}</style>')).toBe('');
+ // removes mstyle tag (this can removed later by disallowing math tags)
+ expect(sanitize('<math><mstyle displaystyle="true"></mstyle></math>')).toBe('<math></math>');
+ // removes link tag (this is DOMPurify's default behavior)
+ expect(sanitize('<link rel="stylesheet" href="styles.css">')).toBe('');
+ });
+
describe.each`
type | gon
${'root'} | ${rootGon}
diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js
index 5c72b5a51a7..c9a480e9943 100644
--- a/spec/frontend/lib/gfm/index_spec.js
+++ b/spec/frontend/lib/gfm/index_spec.js
@@ -33,14 +33,16 @@ describe('gfm', () => {
});
it('returns the result of executing the renderer function', async () => {
+ const rendered = { value: 'rendered tree' };
+
const result = await render({
markdown: '<strong>This is bold text</strong>',
renderer: () => {
- return 'rendered tree';
+ return rendered;
},
});
- expect(result).toBe('rendered tree');
+ expect(result).toEqual(rendered);
});
});
});
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index 763a9bd30fe..8e499844406 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -283,6 +283,75 @@ describe('common_utils', () => {
});
});
+ describe('insertText', () => {
+ let textArea;
+
+ beforeAll(() => {
+ textArea = document.createElement('textarea');
+ document.querySelector('body').appendChild(textArea);
+ textArea.value = 'two';
+ textArea.setSelectionRange(0, 0);
+ textArea.focus();
+ });
+
+ afterAll(() => {
+ textArea.parentNode.removeChild(textArea);
+ });
+
+ describe('using execCommand', () => {
+ beforeAll(() => {
+ document.execCommand = jest.fn(() => true);
+ });
+
+ it('inserts the text', () => {
+ commonUtils.insertText(textArea, 'one');
+
+ expect(document.execCommand).toHaveBeenCalledWith('insertText', false, 'one');
+ });
+
+ it('removes selected text', () => {
+ textArea.setSelectionRange(0, textArea.value.length);
+
+ commonUtils.insertText(textArea, '');
+
+ expect(document.execCommand).toHaveBeenCalledWith('delete');
+ });
+ });
+
+ describe('using fallback', () => {
+ beforeEach(() => {
+ document.execCommand = jest.fn(() => false);
+ jest.spyOn(textArea, 'dispatchEvent');
+ textArea.value = 'two';
+ textArea.setSelectionRange(0, 0);
+ });
+
+ it('inserts the text', () => {
+ commonUtils.insertText(textArea, 'one');
+
+ expect(textArea.value).toBe('onetwo');
+ expect(textArea.dispatchEvent).toHaveBeenCalled();
+ });
+
+ it('replaces the selection', () => {
+ textArea.setSelectionRange(0, textArea.value.length);
+
+ commonUtils.insertText(textArea, 'one');
+
+ expect(textArea.value).toBe('one');
+ expect(textArea.selectionStart).toBe(textArea.value.length);
+ });
+
+ it('removes selected text', () => {
+ textArea.setSelectionRange(0, textArea.value.length);
+
+ commonUtils.insertText(textArea, '');
+
+ expect(textArea.value).toBe('');
+ });
+ });
+ });
+
describe('normalizedHeaders', () => {
it('should upperCase all the header keys to keep them consistent', () => {
const apiHeaders = {
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 7a64b654baa..8d989350173 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -308,7 +308,9 @@ describe('datefix', () => {
});
describe('parsePikadayDate', () => {
- // removed because of https://gitlab.com/gitlab-org/gitlab-foss/issues/39834
+ it('should return a UTC date', () => {
+ expect(datetimeUtility.parsePikadayDate('2020-01-29')).toEqual(new Date(2020, 0, 29));
+ });
});
describe('pikadayToString', () => {
diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js
index 2f240f25d2a..88dac449527 100644
--- a/spec/frontend/lib/utils/dom_utils_spec.js
+++ b/spec/frontend/lib/utils/dom_utils_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import {
addClassIfElementExists,
canScrollUp,
@@ -6,6 +7,7 @@ import {
isElementVisible,
isElementHidden,
getParents,
+ getParentByTagName,
setAttributes,
} from '~/lib/utils/dom_utils';
@@ -23,10 +25,14 @@ describe('DOM Utils', () => {
let parentElement;
beforeEach(() => {
- setFixtures(fixture);
+ setHTMLFixture(fixture);
parentElement = document.querySelector('.parent');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('adds class if element exists', () => {
const childElement = parentElement.querySelector('.child');
@@ -126,10 +132,14 @@ describe('DOM Utils', () => {
let element;
beforeEach(() => {
- setFixtures('<div data-foo-bar data-baz data-qux="">');
+ setHTMLFixture('<div data-foo-bar data-baz data-qux="">');
element = document.querySelector('[data-foo-bar]');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('throws if not given an element', () => {
expect(() => parseBooleanDataAttributes(null, ['baz'])).toThrow();
});
@@ -210,6 +220,21 @@ describe('DOM Utils', () => {
});
});
+ describe('getParentByTagName', () => {
+ const el = document.createElement('div');
+ el.innerHTML = '<p><span><strong><mark>hello world';
+
+ it.each`
+ tagName | parent
+ ${'strong'} | ${el.querySelector('strong')}
+ ${'span'} | ${el.querySelector('span')}
+ ${'p'} | ${el.querySelector('p')}
+ ${'pre'} | ${undefined}
+ `('gets a parent by tag name', ({ tagName, parent }) => {
+ expect(getParentByTagName(el.querySelector('mark'), tagName)).toBe(parent);
+ });
+ });
+
describe('setAttributes', () => {
it('sets multiple attribues on element', () => {
const div = document.createElement('div');
diff --git a/spec/frontend/lib/utils/file_upload_spec.js b/spec/frontend/lib/utils/file_upload_spec.js
index ff11107ea60..f63af2fe0a4 100644
--- a/spec/frontend/lib/utils/file_upload_spec.js
+++ b/spec/frontend/lib/utils/file_upload_spec.js
@@ -1,8 +1,9 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import fileUpload, { getFilename, validateImageName } from '~/lib/utils/file_upload';
describe('File upload', () => {
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<form>
<button class="js-button" type="button">Click me!</button>
<input type="text" class="js-input" />
@@ -11,6 +12,10 @@ describe('File upload', () => {
`);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('when there is a matching button and input', () => {
beforeEach(() => {
fileUpload('.js-button', '.js-input');
diff --git a/spec/frontend/lib/utils/mock_data.js b/spec/frontend/lib/utils/mock_data.js
index df1f79529e7..49a2af8b307 100644
--- a/spec/frontend/lib/utils/mock_data.js
+++ b/spec/frontend/lib/utils/mock_data.js
@@ -3,3 +3,45 @@ export const faviconDataUrl =
export const overlayDataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAA85JREFUWAntVllIVGEUPv/9b46O41KplYN7PeRkti8TjQlhCUGh3MmeQugpIsGKAi2soIcIooiohxYKK2daqDAlIpIiWwxtQaJcaHE0d5tMrbn37z9XRqfR0TvVW56Hudf//uec72zfEWBCJjIwkYGJDPzvGSD/KgExN3Oi2Q+2DJgSDYQEMwItVGH1iZGmJw/Si1y+/PwVAMYYib22MYc/8hVQFgKDEfYoId0KYzagAQebsos/ewMZoeB9wdffcTYpQSaCTWHKoqSQaDk7zkIt0+aCUR8BelEHrf3dUNv9AcqbnsHtT5UKB/hTASh0SLYjnjb/CIDRJi0XiFAaJOpCD8zLpdb4NB66b1OfelthX815dtdRRfiti2aAXLvVLiMQ6olGyztGDkSo4JGGXk8/QFdGpYzpHG2GBQTDhtgVhPEaVbbVpvI6GJz22rv4TcAfrYI1x7Rj5MWWAppomKFVVb2302SFzUkZHAbkG+0b1+Gh77yNYjrmqnWTrLBLRxdvBWv8qlFujH/kYjJYyvLkj71t78zAUvzMAMnHhpN4zf9UREJhd8omyssxu1IgazQDwDnHUcNuH6vhPIE1fmuBzHt74Hn7W89jWGtcAjoaIDOFrdcMYJBkgOCoaRF0Lj0oglddDbCj6tRvKjphEpgjkzEQs2YAKsNxMzjn3nKurhzK+Ly7xe28ua8TwgMMcHJZnvvT0BPtEEKM4tDJ+C8GvIIk4ylINIXVZ0EUKJxYuh3mhCeokbudl6TtVc88dfBdLwbyaWB6zQCYQJpBYSrDGQxBQ/ZWRM2B+VNmQnVnHWx7elyNuL2/R336co7KyJR8CL9oLgEuFlREevWUkEl6uGwpVEG4FBm0OEf9N10NMgPlvWYAuNVwsWDKvcUNYsHUWTCZ13ysyFEXe6TO6aC8CUr9IiK+A05TQrc8yjwmxARHeeMAPlfQJw+AQRwu0YhL/GDXi9NwufG+S8dYkuYMqIb4SsWthotlNMOUCOM6r+G9cqXxPmd1dqrBav/o1zJy2l5/NUjJA/VORwYuFnOUaTQcPs9wMqwV++Xv8oADxKAcZ8nLPr8AoGW+xR6HSqYk3GodAz2QNj0V+Gr26dT9ASNH5239Pf0gktVNWZca8ZvfAFBprWS6hSu1pqt++Y0PD+WIwDAhIWQGtzvSHDbcodfFUFB9hg1Gjs5LXqIdFL+acFBl+FddqYwdxsWC3I70OvgfUaA65zhq2O2c8VxYcyIGFTVlXegYtvCXANCQZJMobjVcLMjtSK/IcEgyOOe8Ve5w7ryKDefp2P3+C/5ohv8HZmVLAAAAAElFTkSuQmCC';
+
+const absoluteUrls = [
+ 'http://example.org',
+ 'http://example.org:8080',
+ 'https://example.org',
+ 'https://example.org:8080',
+ 'https://192.168.1.1',
+];
+
+const rootRelativeUrls = ['/relative/link'];
+
+const relativeUrls = ['./relative/link', '../relative/link'];
+
+const urlsWithoutHost = ['http://', 'https://', 'https:https:https:'];
+
+/* eslint-disable no-script-url */
+const nonHttpUrls = [
+ 'javascript:',
+ 'javascript:alert("XSS")',
+ 'jav\tascript:alert("XSS");',
+ ' &#14; javascript:alert("XSS");',
+ 'ftp://192.168.1.1',
+ 'file:///',
+ 'file:///etc/hosts',
+];
+/* eslint-enable no-script-url */
+
+// javascript:alert('XSS')
+const encodedJavaScriptUrls = [
+ '&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041',
+ '&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;',
+ '&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29',
+ '\\u006A\\u0061\\u0076\\u0061\\u0073\\u0063\\u0072\\u0069\\u0070\\u0074\\u003A\\u0061\\u006C\\u0065\\u0072\\u0074\\u0028\\u0027\\u0058\\u0053\\u0053\\u0027\\u0029',
+];
+
+export const safeUrls = [...absoluteUrls, ...rootRelativeUrls];
+export const unsafeUrls = [
+ ...relativeUrls,
+ ...urlsWithoutHost,
+ ...nonHttpUrls,
+ ...encodedJavaScriptUrls,
+];
diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js
index 6a880a0f354..632a8904578 100644
--- a/spec/frontend/lib/utils/navigation_utility_spec.js
+++ b/spec/frontend/lib/utils/navigation_utility_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import findAndFollowLink from '~/lib/utils/navigation_utility';
import * as navigationUtils from '~/lib/utils/navigation_utility';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -8,11 +9,13 @@ describe('findAndFollowLink', () => {
it('visits a link when the selector exists', () => {
const href = '/some/path';
- setFixtures(`<a class="my-shortcut" href="${href}">link</a>`);
+ setHTMLFixture(`<a class="my-shortcut" href="${href}">link</a>`);
findAndFollowLink('.my-shortcut');
expect(visitUrl).toHaveBeenCalledWith(href);
+
+ resetHTMLFixture();
});
it('does not throw an exception when the selector does not exist', () => {
diff --git a/spec/frontend/lib/utils/resize_observer_spec.js b/spec/frontend/lib/utils/resize_observer_spec.js
index 6560562f204..c88ba73ebc6 100644
--- a/spec/frontend/lib/utils/resize_observer_spec.js
+++ b/spec/frontend/lib/utils/resize_observer_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { contentTop } from '~/lib/utils/common_utils';
import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
@@ -19,7 +20,7 @@ describe('ResizeObserver Utility', () => {
jest.spyOn(document.documentElement, 'scrollTo');
- setFixtures(`<div id="content-body"><div id="note_1234">note to scroll to</div></div>`);
+ setHTMLFixture(`<div id="content-body"><div id="note_1234">note to scroll to</div></div>`);
const target = document.querySelector('#note_1234');
@@ -28,6 +29,7 @@ describe('ResizeObserver Utility', () => {
afterEach(() => {
contentTop.mockReset();
+ resetHTMLFixture();
});
describe('Observer behavior', () => {
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index 103305f0797..d1bca3c73b6 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -1,5 +1,10 @@
import $ from 'jquery';
-import { insertMarkdownText, keypressNoteText } from '~/lib/utils/text_markdown';
+import {
+ insertMarkdownText,
+ keypressNoteText,
+ compositionStartNoteText,
+ compositionEndNoteText,
+} from '~/lib/utils/text_markdown';
import '~/lib/utils/jquery_at_who';
describe('init markdown', () => {
@@ -9,6 +14,9 @@ describe('init markdown', () => {
textArea = document.createElement('textarea');
document.querySelector('body').appendChild(textArea);
textArea.focus();
+
+ // needed for the underlying insertText to work
+ document.execCommand = jest.fn(() => false);
});
afterAll(() => {
@@ -172,7 +180,9 @@ describe('init markdown', () => {
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
beforeEach(() => {
- gon.features = { markdownContinueLists: true };
+ textArea.addEventListener('keydown', keypressNoteText);
+ textArea.addEventListener('compositionstart', compositionStartNoteText);
+ textArea.addEventListener('compositionend', compositionEndNoteText);
});
it.each`
@@ -203,7 +213,6 @@ describe('init markdown', () => {
textArea.value = text;
textArea.setSelectionRange(text.length, text.length);
- textArea.addEventListener('keydown', keypressNoteText);
textArea.dispatchEvent(enterEvent);
expect(textArea.value).toEqual(expected);
@@ -231,7 +240,6 @@ describe('init markdown', () => {
textArea.value = text;
textArea.setSelectionRange(text.length, text.length);
- textArea.addEventListener('keydown', keypressNoteText);
textArea.dispatchEvent(enterEvent);
expect(textArea.value.substr(0, textArea.selectionStart)).toEqual(expected);
@@ -251,7 +259,6 @@ describe('init markdown', () => {
textArea.value = text;
textArea.setSelectionRange(text.length, text.length);
- textArea.addEventListener('keydown', keypressNoteText);
textArea.dispatchEvent(enterEvent);
expect(textArea.value).toEqual(expected);
@@ -267,23 +274,25 @@ describe('init markdown', () => {
textArea.value = text;
textArea.setSelectionRange(add_at, add_at);
- textArea.addEventListener('keydown', keypressNoteText);
textArea.dispatchEvent(enterEvent);
expect(textArea.value).toEqual(expected);
},
);
- it('does nothing if feature flag disabled', () => {
- gon.features = { markdownContinueLists: false };
-
- const text = '- item';
- const expected = '- item';
+ it('does not duplicate a line item for IME characters', () => {
+ const text = '- 日本語';
+ const expected = '- 日本語\n- ';
+ textArea.dispatchEvent(new CompositionEvent('compositionstart'));
textArea.value = text;
+
+ // Press enter to end composition
+ textArea.dispatchEvent(enterEvent);
+ textArea.dispatchEvent(new CompositionEvent('compositionend'));
textArea.setSelectionRange(text.length, text.length);
- textArea.addEventListener('keydown', keypressNoteText);
+ // Press enter to make new line
textArea.dispatchEvent(enterEvent);
expect(textArea.value).toEqual(expected);
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 7608cff4c9e..81cf4bd293b 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -1,6 +1,7 @@
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as urlUtils from '~/lib/utils/url_utility';
+import { safeUrls, unsafeUrls } from './mock_data';
const shas = {
valid: [
@@ -575,48 +576,6 @@ describe('URL utility', () => {
});
describe('isSafeUrl', () => {
- const absoluteUrls = [
- 'http://example.org',
- 'http://example.org:8080',
- 'https://example.org',
- 'https://example.org:8080',
- 'https://192.168.1.1',
- ];
-
- const rootRelativeUrls = ['/relative/link'];
-
- const relativeUrls = ['./relative/link', '../relative/link'];
-
- const urlsWithoutHost = ['http://', 'https://', 'https:https:https:'];
-
- /* eslint-disable no-script-url */
- const nonHttpUrls = [
- 'javascript:',
- 'javascript:alert("XSS")',
- 'jav\tascript:alert("XSS");',
- ' &#14; javascript:alert("XSS");',
- 'ftp://192.168.1.1',
- 'file:///',
- 'file:///etc/hosts',
- ];
- /* eslint-enable no-script-url */
-
- // javascript:alert('XSS')
- const encodedJavaScriptUrls = [
- '&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041',
- '&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;',
- '&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29',
- '\\u006A\\u0061\\u0076\\u0061\\u0073\\u0063\\u0072\\u0069\\u0070\\u0074\\u003A\\u0061\\u006C\\u0065\\u0072\\u0074\\u0028\\u0027\\u0058\\u0053\\u0053\\u0027\\u0029',
- ];
-
- const safeUrls = [...absoluteUrls, ...rootRelativeUrls];
- const unsafeUrls = [
- ...relativeUrls,
- ...urlsWithoutHost,
- ...nonHttpUrls,
- ...encodedJavaScriptUrls,
- ];
-
describe('with URL constructor support', () => {
it.each(safeUrls)('returns true for %s', (url) => {
expect(urlUtils.isSafeURL(url)).toBe(true);
@@ -628,6 +587,16 @@ describe('URL utility', () => {
});
});
+ describe('sanitizeUrl', () => {
+ it.each(safeUrls)('returns the url for %s', (url) => {
+ expect(urlUtils.sanitizeUrl(url)).toBe(url);
+ });
+
+ it.each(unsafeUrls)('returns `about:blank` for %s', (url) => {
+ expect(urlUtils.sanitizeUrl(url)).toBe('about:blank');
+ });
+ });
+
describe('getNormalizedURL', () => {
it.each`
url | base | result
diff --git a/spec/frontend/lib/utils/users_cache_spec.js b/spec/frontend/lib/utils/users_cache_spec.js
index 30bdddd8e73..d35ba20f570 100644
--- a/spec/frontend/lib/utils/users_cache_spec.js
+++ b/spec/frontend/lib/utils/users_cache_spec.js
@@ -228,4 +228,29 @@ describe('UsersCache', () => {
expect(userStatus).toBe(dummyUserStatus);
});
});
+
+ describe('updateById', () => {
+ describe('when the user is not cached', () => {
+ it('does nothing and returns undefined', () => {
+ expect(UsersCache.updateById(dummyUserId, { name: 'root' })).toBe(undefined);
+ expect(UsersCache.internalStorage).toStrictEqual({});
+ });
+ });
+
+ describe('when the user is cached', () => {
+ const updatedName = 'has two farms';
+ beforeEach(() => {
+ UsersCache.internalStorage[dummyUserId] = dummyUser;
+ });
+
+ it('updates the user only with the new data', async () => {
+ UsersCache.updateById(dummyUserId, { name: updatedName });
+
+ expect(await UsersCache.retrieveById(dummyUserId)).toStrictEqual({
+ username: dummyUser.username,
+ name: updatedName,
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/listbox/index_spec.js b/spec/frontend/listbox/index_spec.js
index 45659a0e523..07c6cca535a 100644
--- a/spec/frontend/listbox/index_spec.js
+++ b/spec/frontend/listbox/index_spec.js
@@ -3,7 +3,7 @@ import { getAllByRole, getByRole } from '@testing-library/dom';
import { GlDropdown } from '@gitlab/ui';
import { createWrapper } from '@vue/test-utils';
import { initListbox, parseAttributes } from '~/listbox';
-import { getFixture, setHTMLFixture } from 'helpers/fixtures';
+import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
jest.mock('~/lib/utils/url_utility');
@@ -63,6 +63,10 @@ describe('initListbox', () => {
await nextTick();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('returns an instance', () => {
expect(instance).not.toBe(null);
});
diff --git a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js b/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js
index d98d7d05c92..f667a590a36 100644
--- a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js
+++ b/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js
@@ -11,7 +11,10 @@ describe('TokenWithLoadingState', () => {
const initWrapper = (props = {}, options) => {
wrapper = shallowMount(TokenWithLoadingState, {
- propsData: props,
+ propsData: {
+ cursorPosition: 'start',
+ ...props,
+ },
...options,
});
};
diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js
index d4d950e99ba..2f1626a7044 100644
--- a/spec/frontend/members/components/table/role_dropdown_spec.js
+++ b/spec/frontend/members/components/table/role_dropdown_spec.js
@@ -4,8 +4,6 @@ import { within } from '@testing-library/dom';
import { mount, createWrapper } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-import waitForPromises from 'helpers/wait_for_promises';
-import { BV_DROPDOWN_SHOW } from '~/lib/utils/constants';
import RoleDropdown from '~/members/components/table/role_dropdown.vue';
import { MEMBER_TYPES } from '~/members/constants';
import { member } from '../../mock_data';
@@ -70,13 +68,10 @@ describe('RoleDropdown', () => {
});
describe('when dropdown is open', () => {
- beforeEach((done) => {
+ beforeEach(() => {
createComponent();
- findDropdownToggle().trigger('click');
- wrapper.vm.$root.$on(BV_DROPDOWN_SHOW, () => {
- done();
- });
+ return findDropdownToggle().trigger('click');
});
it('renders all valid roles', () => {
@@ -95,14 +90,14 @@ describe('RoleDropdown', () => {
});
describe('when dropdown item is selected', () => {
- it('does nothing if the item selected was already selected', () => {
- getDropdownItemByText('Owner').trigger('click');
+ it('does nothing if the item selected was already selected', async () => {
+ await getDropdownItemByText('Owner').trigger('click');
expect(actions.updateMemberRole).not.toHaveBeenCalled();
});
- it('calls `updateMemberRole` Vuex action', () => {
- getDropdownItemByText('Developer').trigger('click');
+ it('calls `updateMemberRole` Vuex action', async () => {
+ await getDropdownItemByText('Developer').trigger('click');
expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), {
memberId: member.id,
@@ -111,21 +106,19 @@ describe('RoleDropdown', () => {
});
it('displays toast when successful', async () => {
- getDropdownItemByText('Developer').trigger('click');
+ await getDropdownItemByText('Developer').trigger('click');
- await waitForPromises();
+ await nextTick();
expect($toast.show).toHaveBeenCalledWith('Role updated successfully.');
});
it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => {
- getDropdownItemByText('Developer').trigger('click');
-
- await nextTick();
+ await getDropdownItemByText('Developer').trigger('click');
expect(findDropdown().props('disabled')).toBe(true);
- await waitForPromises();
+ await nextTick();
expect(findDropdown().props('disabled')).toBe(false);
});
diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js
index 1b6a0f9e977..7cee6576b53 100644
--- a/spec/frontend/merge_conflicts/store/actions_spec.js
+++ b/spec/frontend/merge_conflicts/store/actions_spec.js
@@ -1,6 +1,6 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import Cookies from 'js-cookie';
+import Cookies from '~/lib/utils/cookies';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
@@ -11,7 +11,7 @@ import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflict
jest.mock('~/flash.js');
jest.mock('~/merge_conflicts/utils');
-jest.mock('js-cookie');
+jest.mock('~/lib/utils/cookies');
describe('merge conflicts actions', () => {
let mock;
diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js
index 9229b353685..bcf64204c7a 100644
--- a/spec/frontend/merge_request_spec.js
+++ b/spec/frontend/merge_request_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'spec/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
@@ -11,7 +12,7 @@ describe('MergeRequest', () => {
let mock;
beforeEach(() => {
- loadFixtures('merge_requests/merge_request_with_task_list.html');
+ loadHTMLFixture('merge_requests/merge_request_with_task_list.html');
jest.spyOn(axios, 'patch');
mock = new MockAdapter(axios);
@@ -26,6 +27,7 @@ describe('MergeRequest', () => {
afterEach(() => {
mock.restore();
+ resetHTMLFixture();
});
it('modifies the Markdown field', async () => {
@@ -103,7 +105,7 @@ describe('MergeRequest', () => {
describe('hideCloseButton', () => {
describe('merge request of current_user', () => {
beforeEach(() => {
- loadFixtures('merge_requests/merge_request_of_current_user.html');
+ loadHTMLFixture('merge_requests/merge_request_of_current_user.html');
test.el = document.querySelector('.js-issuable-actions');
MergeRequest.hideCloseButton();
});
diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js
index 5c24a070342..ccbc61ea658 100644
--- a/spec/frontend/merge_request_tabs_spec.js
+++ b/spec/frontend/merge_request_tabs_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initMrPage from 'helpers/init_vue_mr_page_helper';
import axios from '~/lib/utils/axios_utils';
import MergeRequestTabs from '~/merge_request_tabs';
@@ -79,7 +80,7 @@ describe('MergeRequestTabs', () => {
let tabUrl;
beforeEach(() => {
- loadFixtures('merge_requests/merge_request_with_task_list.html');
+ loadHTMLFixture('merge_requests/merge_request_with_task_list.html');
tabUrl = $('.commits-tab a').attr('href');
@@ -97,6 +98,10 @@ describe('MergeRequestTabs', () => {
};
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('meta click', () => {
let metakeyEvent;
diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
index 28039321428..a93035cc53a 100644
--- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
@@ -17,6 +17,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
primarybuttontext=""
secondarybuttonlink=""
secondarybuttontext=""
+ showicon="true"
title="Feature deprecation"
variant="warning"
>
diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
index 7bd062b81f1..1f9eb03b5d4 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -65,6 +65,7 @@ describe('Dashboard Panel', () => {
},
store,
mocks,
+ provide: { glFeatures: { monitorLogging: true } },
...options,
});
};
@@ -379,6 +380,21 @@ describe('Dashboard Panel', () => {
expect(findViewLogsLink().attributes('href')).toMatch(mockLogsHref);
});
+ describe(':monitor_logging feature flag', () => {
+ it.each`
+ flagState | logsState | expected
+ ${true} | ${'shows'} | ${true}
+ ${false} | ${'hides'} | ${false}
+ `('$logsState logs when flag state is $flagState', async ({ flagState, expected }) => {
+ createWrapper({}, { provide: { glFeatures: { monitorLogging: flagState } } });
+ state.logsPath = mockLogsPath;
+ state.timeRange = mockTimeRange;
+ await nextTick();
+
+ expect(findViewLogsLink().exists()).toBe(expected);
+ });
+ });
+
it('it is overridden when a datazoom event is received', async () => {
state.logsPath = mockLogsPath;
state.timeRange = mockTimeRange;
@@ -488,15 +504,7 @@ describe('Dashboard Panel', () => {
store.registerModule(mockNamespace, monitoringDashboard);
store.state.embedGroup.modules.push(mockNamespace);
- wrapper = shallowMount(DashboardPanel, {
- propsData: {
- graphData,
- settingsPath: dashboardProps.settingsPath,
- namespace: mockNamespace,
- },
- store,
- mocks,
- });
+ createWrapper({ namespace: mockNamespace });
});
it('handles namespaced time range and logs path state', async () => {
diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js
index 66b28a8c0dc..e4f4b3fa5b5 100644
--- a/spec/frontend/new_branch_spec.js
+++ b/spec/frontend/new_branch_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import NewBranchForm from '~/new_branch_form';
describe('Branch', () => {
@@ -18,11 +19,15 @@ describe('Branch', () => {
}
beforeEach(() => {
- loadFixtures('branches/new_branch.html');
+ loadHTMLFixture('branches/new_branch.html');
$('form').on('submit', (e) => e.preventDefault());
testContext.form = new NewBranchForm($('.js-create-branch-form'), []);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it("can't start with a dot", () => {
fillNameWith('.foo');
expectToHaveError("can't start with '.'");
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index a605edc4357..fb42e4d1d84 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -248,13 +248,21 @@ describe('issue_comment_form component', () => {
describe('textarea', () => {
describe('general', () => {
- it('should render textarea with placeholder', () => {
- mountComponent({ mountFunction: mount });
+ it.each`
+ noteType | confidential | placeholder
+ ${'comment'} | ${false} | ${'Write a comment or drag your files here…'}
+ ${'internal note'} | ${true} | ${'Write an internal note or drag your files here…'}
+ `(
+ 'should render textarea with placeholder for $noteType',
+ ({ confidential, placeholder }) => {
+ mountComponent({
+ mountFunction: mount,
+ initialData: { noteIsConfidential: confidential },
+ });
- expect(findTextArea().attributes('placeholder')).toBe(
- 'Write a comment or drag your files here…',
- );
- });
+ expect(findTextArea().attributes('placeholder')).toBe(placeholder);
+ },
+ );
it('should make textarea disabled while requesting', async () => {
mountComponent({ mountFunction: mount });
@@ -380,6 +388,20 @@ describe('issue_comment_form component', () => {
expect(findCloseReopenButton().text()).toBe('Close issue');
});
+ it.each`
+ confidential | buttonText
+ ${false} | ${'Comment'}
+ ${true} | ${'Add internal note'}
+ `('renders comment button with text "$buttonText"', ({ confidential, buttonText }) => {
+ mountComponent({
+ mountFunction: mount,
+ noteableData: createNotableDataMock({ confidential }),
+ initialData: { noteIsConfidential: confidential },
+ });
+
+ expect(findCommentButton().text()).toBe(buttonText);
+ });
+
it('should render comment button as disabled', () => {
mountComponent();
diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js
index 8ac6144e5c8..cabf551deba 100644
--- a/spec/frontend/notes/components/comment_type_dropdown_spec.js
+++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js
@@ -28,18 +28,42 @@ describe('CommentTypeDropdown component', () => {
wrapper.destroy();
});
- it('Should label action button "Comment" and correct dropdown item checked when selected', () => {
+ it.each`
+ isInternalNote | buttonText
+ ${false} | ${COMMENT_FORM.comment}
+ ${true} | ${COMMENT_FORM.internalComment}
+ `(
+ 'Should label action button as "$buttonText" for comment when `isInternalNote` is $isInternalNote',
+ ({ isInternalNote, buttonText }) => {
+ mountComponent({ props: { noteType: constants.COMMENT, isInternalNote } });
+
+ expect(findCommentGlDropdown().props()).toMatchObject({ text: buttonText });
+ },
+ );
+
+ it('Should set correct dropdown item checked when comment is selected', () => {
mountComponent({ props: { noteType: constants.COMMENT } });
- expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.comment });
expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: true });
expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: false });
});
- it('Should label action button "Start Thread" and correct dropdown item option checked when selected', () => {
+ it.each`
+ isInternalNote | buttonText
+ ${false} | ${COMMENT_FORM.startThread}
+ ${true} | ${COMMENT_FORM.startInternalThread}
+ `(
+ 'Should label action button as "$buttonText" for discussion when `isInternalNote` is $isInternalNote',
+ ({ isInternalNote, buttonText }) => {
+ mountComponent({ props: { noteType: constants.DISCUSSION, isInternalNote } });
+
+ expect(findCommentGlDropdown().props()).toMatchObject({ text: buttonText });
+ },
+ );
+
+ it('Should set correct dropdown item option checked when discussion is selected', () => {
mountComponent({ props: { noteType: constants.DISCUSSION } });
- expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.startThread });
expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: false });
expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: true });
});
diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js
index a856d002d2e..f016cef18e6 100644
--- a/spec/frontend/notes/components/discussion_counter_spec.js
+++ b/spec/frontend/notes/components/discussion_counter_spec.js
@@ -45,7 +45,7 @@ describe('DiscussionCounter component', () => {
describe('has no discussions', () => {
it('does not render', () => {
- wrapper = shallowMount(DiscussionCounter, { store });
+ wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } });
expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(false);
});
@@ -55,7 +55,7 @@ describe('DiscussionCounter component', () => {
it('does not render', () => {
store.commit(types.ADD_OR_UPDATE_DISCUSSIONS, [{ ...discussionMock, resolvable: false }]);
store.dispatch('updateResolvableDiscussionsCounts');
- wrapper = shallowMount(DiscussionCounter, { store });
+ wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } });
expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(false);
});
@@ -75,20 +75,34 @@ describe('DiscussionCounter component', () => {
it('renders', () => {
updateStore();
- wrapper = shallowMount(DiscussionCounter, { store });
+ wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } });
expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(true);
});
it.each`
- title | resolved | isActive | groupLength
- ${'not allResolved'} | ${false} | ${false} | ${3}
- ${'allResolved'} | ${true} | ${true} | ${1}
- `('renders correctly if $title', ({ resolved, isActive, groupLength }) => {
+ blocksMerge | color
+ ${true} | ${'gl-bg-orange-50'}
+ ${false} | ${'gl-bg-gray-50'}
+ `(
+ 'changes background color to $color if blocksMerge is $blocksMerge',
+ ({ blocksMerge, color }) => {
+ updateStore();
+ store.state.unresolvedDiscussionsCount = 1;
+ wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge } });
+
+ expect(wrapper.find('[data-testid="discussions-counter-text"]').classes()).toContain(color);
+ },
+ );
+
+ it.each`
+ title | resolved | groupLength
+ ${'not allResolved'} | ${false} | ${4}
+ ${'allResolved'} | ${true} | ${1}
+ `('renders correctly if $title', ({ resolved, groupLength }) => {
updateStore({ resolvable: true, resolved });
- wrapper = shallowMount(DiscussionCounter, { store });
+ wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } });
- expect(wrapper.find(`.is-active`).exists()).toBe(isActive);
expect(wrapper.findAll(GlButton)).toHaveLength(groupLength);
});
});
@@ -99,7 +113,7 @@ describe('DiscussionCounter component', () => {
const discussion = { ...discussionMock, expanded };
store.commit(types.ADD_OR_UPDATE_DISCUSSIONS, [discussion]);
store.dispatch('updateResolvableDiscussionsCounts');
- wrapper = shallowMount(DiscussionCounter, { store });
+ wrapper = shallowMount(DiscussionCounter, { store, propsData: { blocksMerge: true } });
toggleAllButton = wrapper.find('.toggle-all-discussions-btn');
};
@@ -117,26 +131,26 @@ describe('DiscussionCounter component', () => {
updateStoreWithExpanded(true);
expect(wrapper.vm.allExpanded).toBe(true);
- expect(toggleAllButton.props('icon')).toBe('angle-up');
+ expect(toggleAllButton.props('icon')).toBe('collapse');
toggleAllButton.vm.$emit('click');
await nextTick();
expect(wrapper.vm.allExpanded).toBe(false);
- expect(toggleAllButton.props('icon')).toBe('angle-down');
+ expect(toggleAllButton.props('icon')).toBe('expand');
});
it('expands all discussions if collapsed', async () => {
updateStoreWithExpanded(false);
expect(wrapper.vm.allExpanded).toBe(false);
- expect(toggleAllButton.props('icon')).toBe('angle-down');
+ expect(toggleAllButton.props('icon')).toBe('expand');
toggleAllButton.vm.$emit('click');
await nextTick();
expect(wrapper.vm.allExpanded).toBe(true);
- expect(toggleAllButton.props('icon')).toBe('angle-up');
+ expect(toggleAllButton.props('icon')).toBe('collapse');
});
});
});
diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js
index 63f3cd865d5..378dcb97fab 100644
--- a/spec/frontend/notes/components/note_body_spec.js
+++ b/spec/frontend/notes/components/note_body_spec.js
@@ -1,9 +1,10 @@
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { suggestionCommitMessage } from '~/diffs/store/getters';
-import noteBody from '~/notes/components/note_body.vue';
+import NoteBody from '~/notes/components/note_body.vue';
+import NoteAwardsList from '~/notes/components/note_awards_list.vue';
+import NoteForm from '~/notes/components/note_form.vue';
import createStore from '~/notes/stores';
import notes from '~/notes/stores/modules/index';
@@ -11,68 +12,89 @@ import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import { noteableDataMock, notesDataMock, note } from '../mock_data';
+const createComponent = ({
+ props = {},
+ noteableData = noteableDataMock,
+ notesData = notesDataMock,
+ store = null,
+} = {}) => {
+ let mockStore;
+
+ if (!store) {
+ mockStore = createStore();
+
+ mockStore.dispatch('setNoteableData', noteableData);
+ mockStore.dispatch('setNotesData', notesData);
+ }
+
+ return shallowMount(NoteBody, {
+ store: mockStore || store,
+ propsData: {
+ note,
+ canEdit: true,
+ canAwardEmoji: true,
+ isEditing: false,
+ ...props,
+ },
+ });
+};
+
describe('issue_note_body component', () => {
- let store;
- let vm;
+ let wrapper;
beforeEach(() => {
- const Component = Vue.extend(noteBody);
-
- store = createStore();
- store.dispatch('setNoteableData', noteableDataMock);
- store.dispatch('setNotesData', notesDataMock);
-
- vm = new Component({
- store,
- propsData: {
- note,
- canEdit: true,
- canAwardEmoji: true,
- },
- }).$mount();
+ wrapper = createComponent();
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('should render the note', () => {
- expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
+ expect(wrapper.find('.note-text').html()).toContain(note.note_html);
});
it('should render awards list', () => {
- expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull();
- expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull();
+ expect(wrapper.findComponent(NoteAwardsList).exists()).toBe(true);
});
describe('isEditing', () => {
- beforeEach(async () => {
- vm.isEditing = true;
- await nextTick();
+ beforeEach(() => {
+ wrapper = createComponent({ props: { isEditing: true } });
});
it('renders edit form', () => {
- expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull();
+ expect(wrapper.findComponent(NoteForm).exists()).toBe(true);
+ });
+
+ it.each`
+ confidential | buttonText
+ ${false} | ${'Save comment'}
+ ${true} | ${'Save internal note'}
+ `('renders save button with text "$buttonText"', ({ confidential, buttonText }) => {
+ wrapper = createComponent({ props: { note: { ...note, confidential }, isEditing: true } });
+
+ expect(wrapper.findComponent(NoteForm).props('saveButtonTitle')).toBe(buttonText);
});
it('adds autosave', () => {
const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`;
- expect(vm.autosave.key).toEqual(autosaveKey);
+ // While we discourage testing wrapper props
+ // here we aren't testing a component prop
+ // but instead an instance object property
+ // which is defined in `app/assets/javascripts/notes/mixins/autosave.js`
+ expect(wrapper.vm.autosave.key).toEqual(autosaveKey);
});
});
describe('commitMessage', () => {
- let wrapper;
-
- Vue.use(Vuex);
-
beforeEach(() => {
const notesStore = notes();
notesStore.state.notes = {};
- store = new Vuex.Store({
+ const store = new Vuex.Store({
modules: {
notes: notesStore,
diffs: {
@@ -98,9 +120,9 @@ describe('issue_note_body component', () => {
},
});
- wrapper = shallowMount(noteBody, {
+ wrapper = createComponent({
store,
- propsData: {
+ props: {
note: { ...note, suggestions: [12345] },
canEdit: true,
file: { file_path: 'abc' },
diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js
index b709141f4ac..252c24d1117 100644
--- a/spec/frontend/notes/components/note_form_spec.js
+++ b/spec/frontend/notes/components/note_form_spec.js
@@ -6,7 +6,7 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave';
import NoteForm from '~/notes/components/note_form.vue';
import createStore from '~/notes/stores';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import { noteableDataMock, notesDataMock, discussionMock } from '../mock_data';
+import { noteableDataMock, notesDataMock, discussionMock, note } from '../mock_data';
jest.mock('~/lib/utils/autosave');
@@ -45,8 +45,6 @@ describe('issue_note_form component', () => {
noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.',
noteId: '545',
};
-
- gon.features = { markdownContinueLists: true };
});
afterEach(() => {
@@ -116,6 +114,23 @@ describe('issue_note_form component', () => {
expect(textarea.attributes('data-supports-quick-actions')).toBe('true');
});
+ it.each`
+ confidential | placeholder
+ ${false} | ${'Write a comment or drag your files here…'}
+ ${true} | ${'Write an internal note or drag your files here…'}
+ `(
+ 'should set correct textarea placeholder text when discussion confidentiality is $confidential',
+ ({ confidential, placeholder }) => {
+ props.note = {
+ ...note,
+ confidential,
+ };
+ wrapper = createComponentWrapper();
+
+ expect(wrapper.find('textarea').attributes('placeholder')).toBe(placeholder);
+ },
+ );
+
it('should link to markdown docs', () => {
const { markdownDocsPath } = notesDataMock;
const markdownField = wrapper.find(MarkdownField);
diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js
index 3513b562e0a..310a470aa18 100644
--- a/spec/frontend/notes/components/note_header_spec.js
+++ b/spec/frontend/notes/components/note_header_spec.js
@@ -21,7 +21,7 @@ describe('NoteHeader component', () => {
const findActionText = () => wrapper.find({ ref: 'actionText' });
const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' });
const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' });
- const findConfidentialIndicator = () => wrapper.findByTestId('confidentialIndicator');
+ const findConfidentialIndicator = () => wrapper.findByTestId('internalNoteIndicator');
const findSpinner = () => wrapper.find({ ref: 'spinner' });
const findAuthorStatus = () => wrapper.find({ ref: 'authorStatus' });
@@ -297,7 +297,7 @@ describe('NoteHeader component', () => {
createComponent({ isConfidential: true, noteableType: 'issue' });
expect(findConfidentialIndicator().attributes('title')).toBe(
- 'This comment is confidential and only visible to project members',
+ 'This internal note will always remain confidential',
);
});
});
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index e227af88d3f..413ee815906 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import setWindowLocation from 'helpers/set_window_location_helper';
import { setTestTimeout } from 'helpers/timeout';
import waitForPromises from 'helpers/wait_for_promises';
@@ -92,13 +93,17 @@ describe('note_app', () => {
describe('set data', () => {
beforeEach(() => {
- setFixtures('<div class="js-discussions-count"></div>');
+ setHTMLFixture('<div class="js-discussions-count"></div>');
axiosMock.onAny().reply(200, []);
wrapper = mountComponent();
return waitForDiscussionsRequest();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should set notes data', () => {
expect(store.state.notesData).toEqual(mockData.notesDataMock);
});
@@ -122,13 +127,17 @@ describe('note_app', () => {
describe('render', () => {
beforeEach(() => {
- setFixtures('<div class="js-discussions-count"></div>');
+ setHTMLFixture('<div class="js-discussions-count"></div>');
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
return waitForDiscussionsRequest();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should render list of notes', () => {
const note =
mockData.INDIVIDUAL_NOTE_RESPONSE_MAP.GET[
@@ -160,7 +169,7 @@ describe('note_app', () => {
describe('render with comments disabled', () => {
beforeEach(() => {
- setFixtures('<div class="js-discussions-count"></div>');
+ setHTMLFixture('<div class="js-discussions-count"></div>');
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
store.state.commentsDisabled = true;
@@ -168,6 +177,10 @@ describe('note_app', () => {
return waitForDiscussionsRequest();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should not render form when commenting is disabled', () => {
expect(wrapper.find('.js-main-target-form').exists()).toBe(false);
});
@@ -179,7 +192,7 @@ describe('note_app', () => {
describe('timeline view', () => {
beforeEach(() => {
- setFixtures('<div class="js-discussions-count"></div>');
+ setHTMLFixture('<div class="js-discussions-count"></div>');
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
store.state.commentsDisabled = false;
@@ -189,6 +202,10 @@ describe('note_app', () => {
return waitForDiscussionsRequest();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should not render comments form', () => {
expect(wrapper.find('.js-main-target-form').exists()).toBe(false);
});
@@ -196,12 +213,15 @@ describe('note_app', () => {
describe('while fetching data', () => {
beforeEach(() => {
- setFixtures('<div class="js-discussions-count"></div>');
+ setHTMLFixture('<div class="js-discussions-count"></div>');
axiosMock.onAny().reply(200, []);
wrapper = mountComponent();
});
- afterEach(() => waitForDiscussionsRequest());
+ afterEach(() => {
+ waitForDiscussionsRequest();
+ resetHTMLFixture();
+ });
it('renders skeleton notes', () => {
expect(wrapper.find('.animation-container').exists()).toBe(true);
diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js
index 7193475c96a..40b124b9029 100644
--- a/spec/frontend/notes/deprecated_notes_spec.js
+++ b/spec/frontend/notes/deprecated_notes_spec.js
@@ -3,6 +3,7 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { createSpyObj } from 'helpers/jest_helpers';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
@@ -33,7 +34,7 @@ gl.utils.disableButtonIfEmptyField = () => {};
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('Old Notes (~/deprecated_notes.js)', () => {
beforeEach(() => {
- loadFixtures(fixture);
+ loadHTMLFixture(fixture);
// Re-declare this here so that test_setup.js#beforeEach() doesn't
// overwrite it.
@@ -50,12 +51,14 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => {
setTestTimeoutOnce(4000);
});
- afterEach(() => {
+ afterEach(async () => {
// The Notes component sets a polling interval. Clear it after every run.
// Make sure to use jest.runOnlyPendingTimers() instead of runAllTimers().
jest.clearAllTimers();
- return axios.waitForAll().finally(() => mockAxios.restore());
+ await axios.waitForAll().finally(() => mockAxios.restore());
+
+ resetHTMLFixture();
});
it('loads the Notes class into the DOM', () => {
@@ -629,7 +632,7 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => {
let $notesContainer;
beforeEach(() => {
- loadFixtures('commit/show.html');
+ loadHTMLFixture('commit/show.html');
mockAxios.onPost(NOTES_POST_PATH).reply(200, note);
new Notes('', []);
diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js
index aba80789a01..35b3dec6298 100644
--- a/spec/frontend/notes/mixins/discussion_navigation_spec.js
+++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js
@@ -59,6 +59,7 @@ describe('Discussion navigation mixin', () => {
diffs: {
namespaced: true,
actions: { scrollToFile },
+ state: { diffFiles: [] },
},
},
});
diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js
index a4aeeda48d8..c7a6ca5eae3 100644
--- a/spec/frontend/notes/mock_data.js
+++ b/spec/frontend/notes/mock_data.js
@@ -1171,7 +1171,7 @@ export const discussion1 = {
resolved: false,
active: true,
diff_file: {
- file_path: 'about.md',
+ file_identifier_hash: 'discfile1',
},
position: {
new_line: 50,
@@ -1189,7 +1189,7 @@ export const resolvedDiscussion1 = {
resolvable: true,
resolved: true,
diff_file: {
- file_path: 'about.md',
+ file_identifier_hash: 'discfile1',
},
position: {
new_line: 50,
@@ -1208,7 +1208,7 @@ export const discussion2 = {
resolved: false,
active: true,
diff_file: {
- file_path: 'README.md',
+ file_identifier_hash: 'discfile2',
},
position: {
new_line: null,
@@ -1227,7 +1227,7 @@ export const discussion3 = {
active: true,
resolved: false,
diff_file: {
- file_path: 'README.md',
+ file_identifier_hash: 'discfile3',
},
position: {
new_line: 21,
@@ -1240,6 +1240,12 @@ export const discussion3 = {
],
};
+export const authoritativeDiscussionFile = {
+ id: 'abc',
+ file_identifier_hash: 'discfile1',
+ order: 0,
+};
+
export const unresolvableDiscussion = {
resolvable: false,
};
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index 75e7756cd6b..ecb213590ad 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -1,4 +1,5 @@
import AxiosMockAdapter from 'axios-mock-adapter';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
import Api from '~/api';
@@ -51,7 +52,7 @@ describe('Actions Notes Store', () => {
axiosMock = new AxiosMockAdapter(axios);
// This is necessary as we query Close issue button at the top of issue page when clicking bottom button
- setFixtures(
+ setHTMLFixture(
'<div class="detail-page-header-actions"><button class="btn-close btn-grouped"></button></div>',
);
});
@@ -59,6 +60,7 @@ describe('Actions Notes Store', () => {
afterEach(() => {
resetStore(store);
axiosMock.restore();
+ resetHTMLFixture();
});
describe('setNotesData', () => {
@@ -252,7 +254,9 @@ describe('Actions Notes Store', () => {
jest.advanceTimersByTime(time);
}
- return new Promise((resolve) => requestAnimationFrame(resolve));
+ return new Promise((resolve) => {
+ requestAnimationFrame(resolve);
+ });
};
const advanceXMoreIntervals = async (number) => {
const timeoutLength = pollInterval * number;
diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js
index 9a11fdba508..6d078dcefcf 100644
--- a/spec/frontend/notes/stores/getters_spec.js
+++ b/spec/frontend/notes/stores/getters_spec.js
@@ -12,6 +12,7 @@ import {
discussion2,
discussion3,
resolvedDiscussion1,
+ authoritativeDiscussionFile,
unresolvableDiscussion,
draftComments,
draftReply,
@@ -26,6 +27,23 @@ const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({
});
const asDraftDiscussion = (x) => ({ ...x, individual_note: true });
+const createRootState = () => {
+ return {
+ diffs: {
+ diffFiles: [
+ { ...authoritativeDiscussionFile },
+ {
+ ...authoritativeDiscussionFile,
+ ...{ id: 'abc2', file_identifier_hash: 'discfile2', order: 1 },
+ },
+ {
+ ...authoritativeDiscussionFile,
+ ...{ id: 'abc3', file_identifier_hash: 'discfile3', order: 2 },
+ },
+ ],
+ },
+ };
+};
describe('Getters Notes Store', () => {
let state;
@@ -226,20 +244,84 @@ describe('Getters Notes Store', () => {
const localGetters = {
allResolvableDiscussions: [discussion3, discussion1, discussion2],
};
+ const rootState = createRootState();
- expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([
+ expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters, rootState)).toEqual([
'abc1',
'abc2',
'abc3',
]);
});
+ // This is the same test as above, but it exercises the sorting algorithm
+ // for a "strange" Diff File ordering. The intent is to ensure that even if lots
+ // of shuffling has to occur, everything still works
+
+ it('should return all discussions IDs in unusual diff order', () => {
+ const localGetters = {
+ allResolvableDiscussions: [discussion3, discussion1, discussion2],
+ };
+ const rootState = {
+ diffs: {
+ diffFiles: [
+ // 2 is first, but should sort 2nd
+ {
+ ...authoritativeDiscussionFile,
+ ...{ id: 'abc2', file_identifier_hash: 'discfile2', order: 1 },
+ },
+ // 1 is second, but should sort 3rd
+ { ...authoritativeDiscussionFile, ...{ order: 2 } },
+ // 3 is third, but should sort 1st
+ {
+ ...authoritativeDiscussionFile,
+ ...{ id: 'abc3', file_identifier_hash: 'discfile3', order: 0 },
+ },
+ ],
+ },
+ };
+
+ expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters, rootState)).toEqual([
+ 'abc3',
+ 'abc2',
+ 'abc1',
+ ]);
+ });
+
+ it("should use the discussions array order if the files don't have explicit order values", () => {
+ const localGetters = {
+ allResolvableDiscussions: [discussion3, discussion1, discussion2], // This order is used!
+ };
+ const auth1 = { ...authoritativeDiscussionFile };
+ const auth2 = {
+ ...authoritativeDiscussionFile,
+ ...{ id: 'abc2', file_identifier_hash: 'discfile2' },
+ };
+ const auth3 = {
+ ...authoritativeDiscussionFile,
+ ...{ id: 'abc3', file_identifier_hash: 'discfile3' },
+ };
+ const rootState = {
+ diffs: { diffFiles: [auth2, auth1, auth3] }, // This order is not used!
+ };
+
+ delete auth1.order;
+ delete auth2.order;
+ delete auth3.order;
+
+ expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters, rootState)).toEqual([
+ 'abc3',
+ 'abc1',
+ 'abc2',
+ ]);
+ });
+
it('should return empty array if all discussions have been resolved', () => {
const localGetters = {
allResolvableDiscussions: [resolvedDiscussion1],
};
+ const rootState = createRootState();
- expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([]);
+ expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters, rootState)).toEqual([]);
});
});
diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js
index 3187cbf6547..1fa0e0aa8f6 100644
--- a/spec/frontend/oauth_remember_me_spec.js
+++ b/spec/frontend/oauth_remember_me_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me';
describe('OAuthRememberMe', () => {
@@ -7,11 +8,15 @@ describe('OAuthRememberMe', () => {
};
beforeEach(() => {
- loadFixtures('static/oauth_remember_me.html');
+ loadHTMLFixture('static/oauth_remember_me.html');
new OAuthRememberMe({ container: $('#oauth-container') }).bindEvents();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('adds the "remember_me" query parameter to all OAuth login buttons', () => {
$('#oauth-container #remember_me').click();
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
index a8d0d15007c..ca666e38291 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
@@ -1,7 +1,7 @@
import { GlDropdownItem, GlIcon, GlDropdown } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -28,7 +28,6 @@ import { imageTagsCountMock } from '../../mock_data';
describe('Details Header', () => {
let wrapper;
let apolloProvider;
- let localVue;
const defaultImage = {
name: 'foo',
@@ -64,28 +63,18 @@ describe('Details Header', () => {
const mountComponent = ({
propsData = { image: defaultImage },
resolver = jest.fn().mockResolvedValue(imageTagsCountMock()),
- $apollo = undefined,
} = {}) => {
- const mocks = {};
+ Vue.use(VueApollo);
- if ($apollo) {
- mocks.$apollo = $apollo;
- } else {
- localVue = createLocalVue();
- localVue.use(VueApollo);
-
- const requestHandlers = [[getContainerRepositoryMetadata, resolver]];
- apolloProvider = createMockApollo(requestHandlers);
- }
+ const requestHandlers = [[getContainerRepositoryMetadata, resolver]];
+ apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(component, {
- localVue,
apolloProvider,
propsData,
directives: {
GlTooltip: createMockDirective(),
},
- mocks,
stubs: {
TitleArea,
GlDropdown,
@@ -98,7 +87,6 @@ describe('Details Header', () => {
// if we want to mix createMockApollo and manual mocks we need to reset everything
wrapper.destroy();
apolloProvider = undefined;
- localVue = undefined;
wrapper = null;
});
@@ -194,10 +182,7 @@ describe('Details Header', () => {
describe('metadata items', () => {
describe('tags count', () => {
it('displays "-- tags" while loading', async () => {
- // here we are forced to mock apollo because `waitForMetadataItems` waits
- // for two ticks, de facto allowing the promise to resolve, so there is
- // no way to catch the component as both rendered and in loading state
- mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } });
+ mountComponent();
await waitForMetadataItems();
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
index e8ddad2d8ca..af5723267f4 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
@@ -1,8 +1,8 @@
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { helpPagePath } from '~/helpers/help_page_helper';
import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue';
import {
- CLEANUP_TIMED_OUT_ERROR_MESSAGE,
CLEANUP_STATUS_SCHEDULED,
CLEANUP_STATUS_ONGOING,
CLEANUP_STATUS_UNFINISHED,
@@ -17,12 +17,20 @@ describe('cleanup_status', () => {
const findMainIcon = () => wrapper.findByTestId('main-icon');
const findExtraInfoIcon = () => wrapper.findByTestId('extra-info');
+ const findPopover = () => wrapper.findComponent(GlPopover);
+
+ const cleanupPolicyHelpPage = helpPagePath(
+ 'user/packages/container_registry/reduce_container_registry_storage.html',
+ { anchor: 'how-the-cleanup-policy-works' },
+ );
const mountComponent = (propsData = { status: SCHEDULED_STATUS }) => {
wrapper = shallowMountExtended(CleanupStatus, {
propsData,
- directives: {
- GlTooltip: createMockDirective(),
+ stubs: {
+ GlLink,
+ GlPopover,
+ GlSprintf,
},
});
};
@@ -43,7 +51,7 @@ describe('cleanup_status', () => {
mountComponent({ status });
expect(findMainIcon().exists()).toBe(visible);
- expect(wrapper.text()).toBe(text);
+ expect(wrapper.text()).toContain(text);
},
);
@@ -53,12 +61,6 @@ describe('cleanup_status', () => {
expect(findMainIcon().exists()).toBe(true);
});
-
- it(`has the orange class when the status is ${UNFINISHED_STATUS}`, () => {
- mountComponent({ status: UNFINISHED_STATUS });
-
- expect(findMainIcon().classes('gl-text-orange-500')).toBe(true);
- });
});
describe('extra info icon', () => {
@@ -76,12 +78,18 @@ describe('cleanup_status', () => {
},
);
- it(`has a tooltip`, () => {
- mountComponent({ status: UNFINISHED_STATUS });
+ it(`has a popover with a learn more link and a time frame for the next run`, () => {
+ jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
- const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip');
+ mountComponent({
+ status: UNFINISHED_STATUS,
+ expirationPolicy: { next_run: '2063-04-08T01:44:03Z' },
+ });
- expect(tooltip.value.title).toBe(CLEANUP_TIMED_OUT_ERROR_MESSAGE);
+ expect(findPopover().exists()).toBe(true);
+ expect(findPopover().text()).toContain('The cleanup will continue within 4 days. Learn more');
+ expect(findPopover().findComponent(GlLink).exists()).toBe(true);
+ expect(findPopover().findComponent(GlLink).attributes('href')).toBe(cleanupPolicyHelpPage);
});
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
index 7d09c09d03b..f811468550d 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
@@ -4,7 +4,6 @@ import { nextTick } from 'vue';
import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue';
import {
CONTAINER_REGISTRY_TITLE,
- LIST_INTRO_TEXT,
EXPIRATION_POLICY_DISABLED_TEXT,
SET_UP_CLEANUP,
} from '~/packages_and_registries/container_registry/explorer/constants';
@@ -135,9 +134,7 @@ describe('registry_header', () => {
it('is correctly bound to title_area props', () => {
mountComponent({ helpPagePath: 'foo' });
- expect(findTitleArea().props('infoMessages')).toEqual([
- { text: LIST_INTRO_TEXT, link: 'foo' },
- ]);
+ expect(findTitleArea().props('infoMessages')).toEqual([]);
});
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js
new file mode 100644
index 00000000000..5063759a620
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/utils_spec.js
@@ -0,0 +1,21 @@
+import { timeTilRun } from '~/packages_and_registries/container_registry/explorer/utils';
+
+describe('Container registry utilities', () => {
+ describe('timeTilRun', () => {
+ beforeEach(() => {
+ jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
+ });
+
+ it('should return a human readable time', () => {
+ const result = timeTilRun('2063-04-08T01:44:03Z');
+
+ expect(result).toBe('4 days');
+ });
+
+ it('should return an empty string with null times', () => {
+ const result = timeTilRun(null);
+
+ expect(result).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
index dbe9793fb8c..fe4a2c06f1c 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
@@ -9,7 +9,7 @@ import {
GlSprintf,
GlEmptyState,
} from '@gitlab/ui';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import MockAdapter from 'axios-mock-adapter';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -47,7 +47,6 @@ describe('DependencyProxyApp', () => {
const provideDefaults = {
groupPath: 'gitlab-org',
groupId: dummyGrouptId,
- dependencyProxyAvailable: true,
noManifestsIllustration: 'noManifestsIllustration',
};
@@ -74,7 +73,6 @@ describe('DependencyProxyApp', () => {
});
}
- const findProxyNotAvailableAlert = () => wrapper.findByTestId('proxy-not-available');
const findClipBoardButton = () => wrapper.findComponent(ClipboardButton);
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
@@ -103,59 +101,22 @@ describe('DependencyProxyApp', () => {
mock.restore();
});
- describe('when the dependency proxy is not available', () => {
- const createComponentArguments = {
- provide: { ...provideDefaults, dependencyProxyAvailable: false },
- };
-
- it('renders an info alert', () => {
- createComponent(createComponentArguments);
-
- expect(findProxyNotAvailableAlert().text()).toBe(
- DependencyProxyApp.i18n.proxyNotAvailableText,
- );
- });
-
- it('does not render the main area', () => {
- createComponent(createComponentArguments);
-
- expect(findMainArea().exists()).toBe(false);
- });
-
- it('does not call the graphql endpoint', async () => {
- resolver = jest.fn().mockResolvedValue(proxyDetailsQuery());
- createComponent({ ...createComponentArguments });
-
- await waitForPromises();
-
- expect(resolver).not.toHaveBeenCalled();
- });
-
- it('hides the clear cache dropdown list', () => {
- createComponent(createComponentArguments);
-
- expect(findClearCacheDropdownList().exists()).toBe(false);
- });
- });
-
describe('when the dependency proxy is available', () => {
describe('when is loading', () => {
- it('renders the skeleton loader', () => {
+ beforeEach(() => {
createComponent();
+ });
+ it('renders the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
- it('does not show the main section', () => {
- createComponent();
-
- expect(findMainArea().exists()).toBe(false);
+ it('does not render a form group with label', () => {
+ expect(findFormGroup().exists()).toBe(false);
});
- it('does not render the info alert', () => {
- createComponent();
-
- expect(findProxyNotAvailableAlert().exists()).toBe(false);
+ it('does not show the main section', () => {
+ expect(findMainArea().exists()).toBe(false);
});
});
@@ -166,10 +127,6 @@ describe('DependencyProxyApp', () => {
return waitForPromises();
});
- it('does not render the info alert', () => {
- expect(findProxyNotAvailableAlert().exists()).toBe(false);
- });
-
it('renders the main area', () => {
expect(findMainArea().exists()).toBe(true);
});
@@ -193,7 +150,7 @@ describe('DependencyProxyApp', () => {
});
});
- it('from group has a description with proxy count', () => {
+ it('form group has a description with proxy count', () => {
expect(findProxyCountText().text()).toBe('Contains 2 blobs of images (1024 Bytes)');
});
@@ -257,6 +214,28 @@ describe('DependencyProxyApp', () => {
});
});
+ describe('triggering page event on list', () => {
+ beforeEach(async () => {
+ findManifestList().vm.$emit('next-page');
+
+ await nextTick();
+ });
+
+ it('re-renders the skeleton loader', () => {
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('renders form group with label', () => {
+ expect(findFormGroup().attributes('label')).toEqual(
+ expect.stringMatching(DependencyProxyApp.i18n.proxyImagePrefix),
+ );
+ });
+
+ it('does not show the main section', () => {
+ expect(findMainArea().exists()).toBe(false);
+ });
+ });
+
it('shows the clear cache dropdown list', () => {
expect(findClearCacheDropdownList().exists()).toBe(true);
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js
index b7cbd875497..be3236d1f9c 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js
@@ -3,6 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Component from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
+import { MANIFEST_PENDING_DESTRUCTION_STATUS } from '~/packages_and_registries/dependency_proxy/constants';
import { proxyManifests } from 'jest/packages_and_registries/dependency_proxy/mock_data';
describe('Manifest Row', () => {
@@ -26,34 +27,63 @@ describe('Manifest Row', () => {
const findListItem = () => wrapper.findComponent(ListItem);
const findCachedMessages = () => wrapper.findByTestId('cached-message');
const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip);
-
- beforeEach(() => {
- createComponent();
- });
+ const findStatus = () => wrapper.findByTestId('status');
afterEach(() => {
wrapper.destroy();
});
- it('has a list item', () => {
- expect(findListItem().exists()).toBe(true);
- });
+ describe('With a manifest on the DEFAULT status', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- it('displays the name', () => {
- expect(wrapper.text()).toContain('alpine');
- });
+ it('has a list item', () => {
+ expect(findListItem().exists()).toBe(true);
+ });
- it('displays the version', () => {
- expect(wrapper.text()).toContain('latest');
- });
+ it('displays the name', () => {
+ expect(wrapper.text()).toContain('alpine');
+ });
- it('displays the cached time', () => {
- expect(findCachedMessages().text()).toContain('Cached');
+ it('displays the version', () => {
+ expect(wrapper.text()).toContain('latest');
+ });
+
+ it('displays the cached time', () => {
+ expect(findCachedMessages().text()).toContain('Cached');
+ });
+
+ it('has a time ago tooltip component', () => {
+ expect(findTimeAgoTooltip().props()).toMatchObject({
+ time: defaultProps.manifest.createdAt,
+ });
+ });
+
+ it('does not have a status element displayed', () => {
+ expect(findStatus().exists()).toBe(false);
+ });
});
- it('has a time ago tooltip component', () => {
- expect(findTimeAgoTooltip().props()).toMatchObject({
- time: defaultProps.manifest.createdAt,
+ describe('With a manifest on the PENDING_DESTRUCTION_STATUS', () => {
+ const pendingDestructionManifest = {
+ manifest: {
+ ...defaultProps.manifest,
+ status: MANIFEST_PENDING_DESTRUCTION_STATUS,
+ },
+ };
+
+ beforeEach(() => {
+ createComponent(pendingDestructionManifest);
+ });
+
+ it('has a list item', () => {
+ expect(findListItem().exists()).toBe(true);
+ });
+
+ it('has a status element displayed', () => {
+ expect(findStatus().exists()).toBe(true);
+ expect(findStatus().text()).toBe('Scheduled for deletion');
});
});
});
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
index 2aa427bc6af..37c8eb669ba 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
@@ -8,8 +8,18 @@ export const proxyData = () => ({
export const proxySettings = (extend = {}) => ({ enabled: true, ...extend });
export const proxyManifests = () => [
- { id: 'proxy-1', createdAt: '2021-09-22T09:45:28Z', imageName: 'alpine:latest' },
- { id: 'proxy-2', createdAt: '2021-09-21T09:45:28Z', imageName: 'alpine:stable' },
+ {
+ id: 'proxy-1',
+ createdAt: '2021-09-22T09:45:28Z',
+ imageName: 'alpine:latest',
+ status: 'DEFAULT',
+ },
+ {
+ id: 'proxy-2',
+ createdAt: '2021-09-21T09:45:28Z',
+ imageName: 'alpine:stable',
+ status: 'DEFAULT',
+ },
];
export const pagination = (extend) => ({
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
index 519014bb9cf..fdddc131412 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
@@ -29,12 +29,6 @@ exports[`PackageTitle renders with tags 1`] = `
<div
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3"
>
- <gl-icon-stub
- class="gl-mr-3"
- name="eye"
- size="16"
- />
-
<span
data-testid="sub-header"
>
@@ -127,12 +121,6 @@ exports[`PackageTitle renders without tags 1`] = `
<div
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3"
>
- <gl-icon-stub
- class="gl-mr-3"
- name="eye"
- size="16"
- />
-
<span
data-testid="sub-header"
>
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
index 5da9cfffaae..d306f7834f0 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon, GlSprintf } from '@gitlab/ui';
+import { GlSprintf } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { nextTick } from 'vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -46,7 +46,6 @@ describe('PackageTitle', () => {
const findPackageRef = () => wrapper.findByTestId('package-ref');
const findPackageTags = () => wrapper.findComponent(PackageTags);
const findPackageBadges = () => wrapper.findAllByTestId('tag-badge');
- const findSubHeaderIcon = () => wrapper.findComponent(GlIcon);
const findSubHeaderText = () => wrapper.findByTestId('sub-header');
const findSubHeaderTimeAgo = () => wrapper.findComponent(TimeAgoTooltip);
@@ -120,12 +119,6 @@ describe('PackageTitle', () => {
});
describe('sub-header', () => {
- it('has the eye icon', async () => {
- await createComponent();
-
- expect(findSubHeaderIcon().props('name')).toBe('eye');
- });
-
it('has a text showing version', async () => {
await createComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
index 18a99f70756..031afa62890 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
@@ -38,8 +38,6 @@ exports[`packages_list_row renders 1`] = `
</router-link-stub>
<!---->
-
- <!---->
</div>
<!---->
@@ -98,16 +96,35 @@ exports[`packages_list_row renders 1`] = `
<div
class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1"
>
- <gl-button-stub
- aria-label="Remove package"
- buttontextclasses=""
- category="secondary"
- data-testid="action-delete"
- icon="remove"
+ <gl-dropdown-stub
+ category="tertiary"
+ clearalltext="Clear all"
+ clearalltextclass="gl-px-5"
+ data-testid="delete-dropdown"
+ headertext=""
+ hideheaderborder="true"
+ highlighteditemstitle="Selected"
+ highlighteditemstitleclass="gl-px-5"
+ icon="ellipsis_v"
+ no-caret=""
size="medium"
- title="Remove package"
- variant="danger"
- />
+ text="More actions"
+ textsronly="true"
+ variant="default"
+ >
+ <gl-dropdown-item-stub
+ avatarurl=""
+ data-testid="action-delete"
+ iconcolor=""
+ iconname=""
+ iconrightarialabel=""
+ iconrightname=""
+ secondarytext=""
+ variant="danger"
+ >
+ Delete package
+ </gl-dropdown-item-stub>
+ </gl-dropdown-stub>
</div>
</div>
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
index 12a3eaa3873..c16c09b5326 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
@@ -28,12 +28,12 @@ describe('packages_list_row', () => {
const packageWithoutTags = { ...packageData(), project: packageProject() };
const packageWithTags = { ...packageWithoutTags, tags: { nodes: packageTags() } };
+ const packageCannotDestroy = { ...packageData(), canDestroy: false };
const findPackageTags = () => wrapper.find(PackageTags);
const findPackagePath = () => wrapper.find(PackagePath);
- const findDeleteButton = () => wrapper.findByTestId('action-delete');
+ const findDeleteDropdown = () => wrapper.findByTestId('action-delete');
const findPackageIconAndName = () => wrapper.find(PackageIconAndName);
- const findListItem = () => wrapper.findComponent(ListItem);
const findPackageLink = () => wrapper.findByTestId('details-link');
const findWarningIcon = () => wrapper.findByTestId('warning-icon');
const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos');
@@ -102,22 +102,25 @@ describe('packages_list_row', () => {
});
describe('delete button', () => {
+ it('does not exist when package cannot be destroyed', () => {
+ mountComponent({ packageEntity: packageCannotDestroy });
+
+ expect(findDeleteDropdown().exists()).toBe(false);
+ });
+
it('exists and has the correct props', () => {
mountComponent({ packageEntity: packageWithoutTags });
- expect(findDeleteButton().exists()).toBe(true);
- expect(findDeleteButton().attributes()).toMatchObject({
- icon: 'remove',
- category: 'secondary',
+ expect(findDeleteDropdown().exists()).toBe(true);
+ expect(findDeleteDropdown().attributes()).toMatchObject({
variant: 'danger',
- title: 'Remove package',
});
});
it('emits the packageToDelete event when the delete button is clicked', async () => {
mountComponent({ packageEntity: packageWithoutTags });
- findDeleteButton().vm.$emit('click');
+ findDeleteDropdown().vm.$emit('click');
await nextTick();
expect(wrapper.emitted('packageToDelete')).toBeTruthy();
@@ -130,10 +133,6 @@ describe('packages_list_row', () => {
mountComponent({ packageEntity: { ...packageWithoutTags, status: PACKAGE_ERROR_STATUS } });
});
- it('list item has a disabled prop', () => {
- expect(findListItem().props('disabled')).toBe(true);
- });
-
it('details link is disabled', () => {
expect(findPackageLink().props('event')).toBe('');
});
@@ -141,14 +140,14 @@ describe('packages_list_row', () => {
it('has a warning icon', () => {
const icon = findWarningIcon();
const tooltip = getBinding(icon.element, 'gl-tooltip');
- expect(icon.props('icon')).toBe('warning');
+ expect(icon.props('name')).toBe('warning');
expect(tooltip.value).toMatchObject({
title: 'Invalid Package: failed metadata extraction',
});
});
- it('delete button does not exist', () => {
- expect(findDeleteButton().exists()).toBe(false);
+ it('has a delete dropdown', () => {
+ expect(findDeleteDropdown().exists()).toBe(true);
});
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
index 97978dee909..660f00a2b31 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
@@ -1,4 +1,4 @@
-import { GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
@@ -21,6 +21,12 @@ describe('packages_list', () => {
id: 'gid://gitlab/Packages::Package/112',
name: 'second-package',
};
+ const errorPackage = {
+ ...packageData(),
+ id: 'gid://gitlab/Packages::Package/121',
+ status: 'ERROR',
+ name: 'error package',
+ };
const defaultProps = {
list: [firstPackage, secondPackage],
@@ -40,6 +46,7 @@ describe('packages_list', () => {
const findPackageListDeleteModal = () => wrapper.findComponent(GlModalStub);
const findEmptySlot = () => wrapper.findComponent(EmptySlotStub);
const findPackagesListRow = () => wrapper.findComponent(PackagesListRow);
+ const findErrorPackageAlert = () => wrapper.findComponent(GlAlert);
const mountComponent = (props) => {
wrapper = shallowMountExtended(PackagesList, {
@@ -109,6 +116,12 @@ describe('packages_list', () => {
expect(findPackageListDeleteModal().exists()).toBe(true);
});
+
+ it('does not have an error alert displayed', () => {
+ mountComponent();
+
+ expect(findErrorPackageAlert().exists()).toBe(false);
+ });
});
describe('when the user can destroy the package', () => {
@@ -140,6 +153,32 @@ describe('packages_list', () => {
});
});
+ describe('when an error package is present', () => {
+ beforeEach(() => {
+ mountComponent({ list: [firstPackage, errorPackage] });
+
+ return nextTick();
+ });
+
+ it('should display an alert message', () => {
+ expect(findErrorPackageAlert().exists()).toBe(true);
+ expect(findErrorPackageAlert().props('title')).toBe(
+ 'There was an error publishing a error package package',
+ );
+ expect(findErrorPackageAlert().text()).toBe(
+ 'There was a timeout and the package was not published. Delete this package and try again.',
+ );
+ });
+
+ it('should display the deletion modal when clicked on the confirm button', async () => {
+ findErrorPackageAlert().vm.$emit('primaryAction');
+
+ await nextTick();
+
+ expect(findPackageListDeleteModal().text()).toContain(errorPackage.name);
+ });
+ });
+
describe('when the list is empty', () => {
beforeEach(() => {
mountComponent({ list: [] });
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js
index e992ba12faa..23e5c7330d5 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js
@@ -37,7 +37,7 @@ describe('PackageTitle', () => {
expect(findTitleArea().props()).toMatchObject({
title: PackageTitle.i18n.LIST_TITLE_TEXT,
- infoMessages: [{ text: PackageTitle.i18n.LIST_INTRO_TEXT, link: 'foo' }],
+ infoMessages: [],
});
});
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js
index 26b2f3b359f..d0c111bae2d 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js
@@ -11,7 +11,10 @@ describe('packages_filter', () => {
const mountComponent = ({ attrs, listeners } = {}) => {
wrapper = shallowMount(component, {
- attrs,
+ attrs: {
+ cursorPosition: 'start',
+ ...attrs,
+ },
listeners,
});
};
diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
index 94f56e5c979..22754d31f93 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
@@ -6,11 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import component from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue';
-import {
- DEPENDENCY_PROXY_HEADER,
- DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
- DEPENDENCY_PROXY_DOCS_PATH,
-} from '~/packages_and_registries/settings/group/constants';
+import { DEPENDENCY_PROXY_HEADER } from '~/packages_and_registries/settings/group/constants';
import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql';
import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql';
@@ -91,8 +87,6 @@ describe('DependencyProxySettings', () => {
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
const findSettingsTitles = () => wrapper.findComponent(SettingsTitles);
- const findDescription = () => wrapper.findByTestId('description');
- const findDescriptionLink = () => wrapper.findByTestId('description-link');
const findEnableProxyToggle = () => wrapper.findByTestId('dependency-proxy-setting-toggle');
const findEnableTtlPoliciesToggle = () =>
wrapper.findByTestId('dependency-proxy-ttl-policies-toggle');
@@ -126,21 +120,6 @@ describe('DependencyProxySettings', () => {
expect(wrapper.text()).toContain(DEPENDENCY_PROXY_HEADER);
});
- it('has the correct description text', () => {
- mountComponent();
-
- expect(findDescription().text()).toMatchInterpolatedText(DEPENDENCY_PROXY_SETTINGS_DESCRIPTION);
- });
-
- it('has the correct link', () => {
- mountComponent();
-
- expect(findDescriptionLink().attributes()).toMatchObject({
- href: DEPENDENCY_PROXY_DOCS_PATH,
- });
- expect(findDescriptionLink().text()).toBe('Learn more');
- });
-
describe('enable toggle', () => {
it('exists', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
index 5c30074a6af..635195ff0a4 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
@@ -28,7 +28,6 @@ describe('Group Settings App', () => {
const defaultProvide = {
defaultExpanded: false,
groupPath: 'foo_group_path',
- dependencyProxyAvailable: true,
};
const mountComponent = ({
@@ -140,15 +139,4 @@ describe('Group Settings App', () => {
});
});
});
-
- describe('when the dependency proxy is not available', () => {
- beforeEach(() => {
- mountComponent({ provide: { ...defaultProvide, dependencyProxyAvailable: false } });
- return waitForApolloQueryAndRender();
- });
-
- it('the setting block is hidden', () => {
- expect(findDependencyProxySettings().exists()).toBe(false);
- });
- });
});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js
index 266f953c3e0..465e6dc73e2 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js
@@ -1,6 +1,6 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { GlCard, GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs';
@@ -14,8 +14,6 @@ import expirationPolicyQuery from '~/packages_and_registries/settings/project/gr
import Tracking from '~/tracking';
import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data';
-const localVue = createLocalVue();
-
describe('Settings Form', () => {
let wrapper;
let fakeApollo;
@@ -59,7 +57,6 @@ describe('Settings Form', () => {
data,
config,
provide = defaultProvidedValues,
- mocks,
} = {}) => {
wrapper = shallowMount(component, {
stubs: {
@@ -77,7 +74,6 @@ describe('Settings Form', () => {
$toast: {
show: jest.fn(),
},
- ...mocks,
},
...config,
});
@@ -88,7 +84,7 @@ describe('Settings Form', () => {
mutationResolver,
queryPayload = expirationPolicyPayload(),
} = {}) => {
- localVue.use(VueApollo);
+ Vue.use(VueApollo);
const requestHandlers = [
[updateContainerExpirationPolicyMutation, mutationResolver],
@@ -120,7 +116,6 @@ describe('Settings Form', () => {
value,
},
config: {
- localVue,
apolloProvider: fakeApollo,
},
});
@@ -356,8 +351,8 @@ describe('Settings Form', () => {
});
it('parses the error messages', async () => {
- const mutate = jest.fn().mockRejectedValue({
- graphQLErrors: [
+ const mutate = jest.fn().mockResolvedValue({
+ errors: [
{
extensions: {
problems: [{ path: ['nameRegexKeep'], message: 'baz' }],
@@ -365,7 +360,9 @@ describe('Settings Form', () => {
},
],
});
- mountComponent({ mocks: { $apollo: { mutate } } });
+ mountComponentWithApollo({
+ mutationResolver: mutate,
+ });
await submitForm();
diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js
index 9df69124d66..dfb3e87a342 100644
--- a/spec/frontend/pager_spec.js
+++ b/spec/frontend/pager_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
@@ -26,12 +27,14 @@ describe('pager', () => {
const originalHref = window.location.href;
beforeEach(() => {
- setFixtures('<div class="content_list"></div><div class="loading"></div>');
+ setHTMLFixture('<div class="content_list"></div><div class="loading"></div>');
jest.spyOn($.fn, 'endlessScroll').mockImplementation();
});
afterEach(() => {
window.history.replaceState({}, null, originalHref);
+
+ resetHTMLFixture();
});
it('should get initial offset from query parameter', () => {
@@ -57,7 +60,7 @@ describe('pager', () => {
}
beforeEach(() => {
- setFixtures(
+ setHTMLFixture(
'<div class="content_list" data-href="/some_list"></div><div class="loading"></div>',
);
jest.spyOn(axios, 'get');
@@ -65,6 +68,10 @@ describe('pager', () => {
Pager.init();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('shows loader while loading next page', async () => {
mockSuccess();
@@ -135,7 +142,11 @@ describe('pager', () => {
const href = `${TEST_HOST}/some_list.json`;
beforeEach(() => {
- setFixtures(`<div class="content_list" data-href="${href}"></div>`);
+ setHTMLFixture(`<div class="content_list" data-href="${href}"></div>`);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('should use data-href attribute', () => {
@@ -154,7 +165,11 @@ describe('pager', () => {
describe('no data-href attribute attribute provided from list element', () => {
beforeEach(() => {
- setFixtures(`<div class="content_list"></div>`);
+ setHTMLFixture(`<div class="content_list"></div>`);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('should use current url', () => {
@@ -190,7 +205,7 @@ describe('pager', () => {
describe('when `container` is visible', () => {
it('makes API request', () => {
- setFixtures(
+ setHTMLFixture(
`<div id="js-pager"><div class="content_list" data-href="${href}"></div></div>`,
);
@@ -199,12 +214,14 @@ describe('pager', () => {
endlessScrollCallback();
expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object));
+
+ resetHTMLFixture();
});
});
describe('when `container` is not visible', () => {
it('does not make API request', () => {
- setFixtures(
+ setHTMLFixture(
`<div id="js-pager" style="display: none;"><div class="content_list" data-href="${href}"></div></div>`,
);
@@ -213,6 +230,8 @@ describe('pager', () => {
endlessScrollCallback();
expect(axios.get).not.toHaveBeenCalled();
+
+ resetHTMLFixture();
});
});
});
diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
index 71c9da238b4..6edfe9641b9 100644
--- a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
+++ b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import '~/lib/utils/text_utility';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import AbuseReports from '~/pages/admin/abuse_reports/abuse_reports';
describe('Abuse Reports', () => {
@@ -15,11 +15,15 @@ describe('Abuse Reports', () => {
$messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first();
beforeEach(() => {
- loadFixtures(FIXTURE);
+ loadHTMLFixture(FIXTURE);
new AbuseReports(); // eslint-disable-line no-new
$messages = $('.abuse-reports .message');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should truncate long messages', () => {
const $longMessage = findMessage('LONG MESSAGE');
diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
index 3a4f93d4464..542eb2f3ab8 100644
--- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
+++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initUserInternalRegexPlaceholder, {
PLACEHOLDER_USER_EXTERNAL_DEFAULT_FALSE,
PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE,
@@ -10,12 +11,16 @@ describe('AccountAndLimits', () => {
let $userInternalRegex;
beforeEach(() => {
- loadFixtures(FIXTURE);
+ loadHTMLFixture(FIXTURE);
initUserInternalRegexPlaceholder();
$userDefaultExternal = $('#application_setting_user_default_external');
$userInternalRegex = document.querySelector('#application_setting_user_default_internal_regex');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('Changing of userInternalRegex when userDefaultExternal', () => {
it('is unchecked', () => {
expect($userDefaultExternal.prop('checked')).toBeFalsy();
diff --git a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
index 4140b985682..3a52c243867 100644
--- a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
+++ b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
@@ -2,6 +2,7 @@ import initSetHelperText, {
HELPER_TEXT_SERVICE_PING_DISABLED,
HELPER_TEXT_SERVICE_PING_ENABLED,
} from '~/pages/admin/application_settings/metrics_and_profiling/usage_statistics';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
describe('UsageStatistics', () => {
const FIXTURE = 'application_settings/usage.html';
@@ -11,7 +12,7 @@ describe('UsageStatistics', () => {
let servicePingFeaturesHelperText;
beforeEach(() => {
- loadFixtures(FIXTURE);
+ loadHTMLFixture(FIXTURE);
initSetHelperText();
servicePingCheckBox = document.getElementById('application_setting_usage_ping_enabled');
servicePingFeaturesCheckBox = document.getElementById(
@@ -21,6 +22,10 @@ describe('UsageStatistics', () => {
servicePingFeaturesHelperText = document.getElementById('service_ping_features_helper_text');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
const expectEnabledservicePingFeaturesCheckBox = () => {
expect(servicePingFeaturesCheckBox.classList.contains('gl-cursor-not-allowed')).toBe(false);
expect(servicePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_ENABLED);
diff --git a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js
index f10b202f4d7..909349569a8 100644
--- a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js
+++ b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import Api from '~/api';
import NamespaceSelect from '~/pages/admin/projects/components/namespace_select.vue';
@@ -26,7 +27,7 @@ describe('Dropdown select component', () => {
};
beforeEach(() => {
- setFixtures('<div class="test-container"></div>');
+ setHTMLFixture('<div class="test-container"></div>');
jest.spyOn(Api, 'namespaces').mockImplementation((_, callback) =>
callback([
@@ -36,6 +37,10 @@ describe('Dropdown select component', () => {
);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('creates a hidden input if fieldName is provided', () => {
mountDropdown({ fieldName: 'namespace-input' });
diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
index ae53afa7fba..3a9b59f291c 100644
--- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js
+++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
@@ -19,7 +20,7 @@ describe('Todos', () => {
let mock;
beforeEach(() => {
- loadFixtures('todos/todos.html');
+ loadHTMLFixture('todos/todos.html');
todoItem = document.querySelector('.todos-list .todo');
mock = new MockAdapter(axios);
@@ -27,6 +28,10 @@ describe('Todos', () => {
});
afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ afterEach(() => {
mock.restore();
});
diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
index 43c48617800..a850b1655f7 100644
--- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
+++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
@@ -3,6 +3,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -60,6 +61,8 @@ describe('BulkImportsHistoryApp', () => {
wrapper = mountFn(BulkImportsHistoryApp);
}
+ const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
+
const originalApiVersion = gon.api_version;
beforeAll(() => {
gon.api_version = 'v4';
@@ -137,6 +140,20 @@ describe('BulkImportsHistoryApp', () => {
);
});
+ it('sets up the local storage sync correctly', async () => {
+ const NEW_PAGE_SIZE = 4;
+
+ mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ createComponent();
+ await axios.waitForAll();
+ mock.resetHistory();
+
+ wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE);
+ await axios.waitForAll();
+
+ expect(findLocalStorageSync().props('value')).toBe(NEW_PAGE_SIZE);
+ });
+
it('renders correct url for destination group when relative_url is empty', async () => {
mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
createComponent({ shallow: false });
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
index 269c7467c8b..005b8968383 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
+++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
@@ -178,7 +178,8 @@ exports[`Learn GitLab renders correctly 1`] = `
data-track-action="click_link"
data-track-label="Start a free Ultimate trial"
href="http://example.com/"
- target="_self"
+ rel="noopener noreferrer"
+ target="_blank"
>
Start a free Ultimate trial
@@ -209,7 +210,8 @@ exports[`Learn GitLab renders correctly 1`] = `
data-track-action="click_link"
data-track-label="Add code owners"
href="http://example.com/"
- target="_self"
+ rel="noopener noreferrer"
+ target="_blank"
>
Add code owners
@@ -240,7 +242,8 @@ exports[`Learn GitLab renders correctly 1`] = `
data-track-action="click_link"
data-track-label="Add merge request approval"
href="http://example.com/"
- target="_self"
+ rel="noopener noreferrer"
+ target="_blank"
>
Add merge request approval
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
index b8ebf2a1430..d9aff37f703 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
@@ -1,8 +1,11 @@
+import { GlPopover, GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { stubExperiments } from 'helpers/experimentation_helper';
import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper';
import eventHub from '~/invite_members/event_hub';
import LearnGitlabSectionLink from '~/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue';
+import { ACTION_LABELS } from '~/pages/projects/learn_gitlab/constants';
const defaultAction = 'gitWrite';
const defaultProps = {
@@ -10,6 +13,7 @@ const defaultProps = {
description: 'Some description',
url: 'https://example.com',
completed: false,
+ enabled: true,
};
const openInNewTabProps = {
@@ -26,16 +30,21 @@ describe('Learn GitLab Section Link', () => {
});
const createWrapper = (action = defaultAction, props = {}) => {
- wrapper = mount(LearnGitlabSectionLink, {
- propsData: { action, value: { ...defaultProps, ...props } },
- });
+ wrapper = extendedWrapper(
+ mount(LearnGitlabSectionLink, {
+ propsData: { action, value: { ...defaultProps, ...props } },
+ }),
+ );
};
const openInviteMembesrModalLink = () =>
wrapper.find('[data-testid="invite-for-help-continuous-onboarding-experiment-link"]');
const findUncompletedLink = () => wrapper.find('[data-testid="uncompleted-learn-gitlab-link"]');
-
+ const findDisabledLink = () => wrapper.findByTestId('disabled-learn-gitlab-link');
+ const findPopoverTrigger = () => wrapper.findByTestId('contact-admin-popover-trigger');
+ const findPopover = () => wrapper.findComponent(GlPopover);
+ const findPopoverLink = () => findPopover().findComponent(GlLink);
const videoTutorialLink = () => wrapper.find('[data-testid="video-tutorial-link"]');
it('renders no icon when not completed', () => {
@@ -62,6 +71,36 @@ describe('Learn GitLab Section Link', () => {
expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true);
});
+ describe('disabled links', () => {
+ beforeEach(() => {
+ createWrapper('trialStarted', { enabled: false });
+ });
+
+ it('renders text without a link', () => {
+ expect(findDisabledLink().exists()).toBe(true);
+ expect(findDisabledLink().text()).toBe(ACTION_LABELS.trialStarted.title);
+ expect(findDisabledLink().attributes('href')).toBeUndefined();
+ });
+
+ it('renders a popover trigger with question icon', () => {
+ expect(findPopoverTrigger().exists()).toBe(true);
+ expect(findPopoverTrigger().props('icon')).toBe('question-o');
+ });
+
+ it('renders a popover', () => {
+ expect(findPopoverTrigger().attributes('id')).toBe(findPopover().props('target'));
+ expect(findPopover().props()).toMatchObject({
+ placement: 'top',
+ triggers: 'hover focus',
+ });
+ });
+
+ it('renders a link inside the popover', () => {
+ expect(findPopoverLink().exists()).toBe(true);
+ expect(findPopoverLink().attributes('href')).toBe(defaultProps.url);
+ });
+ });
+
describe('links marked with openInNewTab', () => {
beforeEach(() => {
createWrapper('securityScanEnabled', openInNewTabProps);
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js
index 5f1aff99578..0f63c243342 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js
@@ -1,6 +1,6 @@
import { GlProgressBar, GlAlert } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import Cookies from 'js-cookie';
+import Cookies from '~/lib/utils/cookies';
import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue';
import eventHub from '~/invite_members/event_hub';
import { INVITE_MODAL_OPEN_COOKIE } from '~/pages/projects/learn_gitlab/constants';
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
index 5dc64097d81..1c29c68d2a9 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
@@ -3,47 +3,56 @@ export const testActions = {
url: 'http://example.com/',
completed: true,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
},
userAdded: {
url: 'http://example.com/',
completed: true,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
},
pipelineCreated: {
url: 'http://example.com/',
completed: false,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
},
trialStarted: {
url: 'http://example.com/',
completed: false,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
},
codeOwnersEnabled: {
url: 'http://example.com/',
completed: false,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
},
requiredMrApprovalsEnabled: {
url: 'http://example.com/',
completed: false,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
},
mergeRequestCreated: {
url: 'http://example.com/',
completed: false,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
},
securityScanEnabled: {
url: 'https://docs.gitlab.com/ee/foobar/',
completed: false,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
openInNewTab: true,
},
issueCreated: {
url: 'http://example.com/',
completed: false,
svg: 'http://example.com/images/illustration.svg',
+ enabled: true,
},
};
diff --git a/spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js b/spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js
index ea49111760b..5c186441817 100644
--- a/spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js
+++ b/spec/frontend/pages/projects/merge_requests/edit/check_form_state_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'jest/__helpers__/fixtures';
import initCheckFormState from '~/pages/projects/merge_requests/edit/check_form_state';
describe('Check form state', () => {
@@ -7,7 +8,7 @@ describe('Check form state', () => {
let setDialogContent;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<form class="merge-request-form">
<input type="text" name="test" id="form-input"/>
</form>`);
@@ -22,6 +23,8 @@ describe('Check form state', () => {
afterEach(() => {
beforeUnloadEvent.preventDefault.mockRestore();
setDialogContent.mockRestore();
+
+ resetHTMLFixture();
});
it('shows confirmation dialog when there are unsaved changes', () => {
diff --git a/spec/frontend/pages/projects/pages_domains/form_spec.js b/spec/frontend/pages/projects/pages_domains/form_spec.js
index 55336596f30..e437121acd2 100644
--- a/spec/frontend/pages/projects/pages_domains/form_spec.js
+++ b/spec/frontend/pages/projects/pages_domains/form_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initForm from '~/pages/projects/pages_domains/form';
const ENABLED_UNLESS_AUTO_SSL_CLASS = 'js-enabled-unless-auto-ssl';
@@ -17,7 +18,7 @@ describe('Page domains form', () => {
const findUnlessAutoSsl = () => document.querySelector(`.${SHOW_UNLESS_AUTO_SSL_CLASS}`);
const create = () => {
- setFixtures(`
+ setHTMLFixture(`
<form>
<span
class="${SSL_TOGGLE_CLASS}"
@@ -31,6 +32,10 @@ describe('Page domains form', () => {
`);
};
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('instantiates the toggle', () => {
create();
initForm();
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
index c28a03b35d7..ca7f70f4434 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
@@ -1,7 +1,7 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Cookies from 'js-cookie';
import { nextTick } from 'vue';
+import Cookies from '~/lib/utils/cookies';
import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue';
const cookieKey = 'pipeline_schedules_callout_dismissed';
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
index b700c255e8c..42eeff89bf4 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import TimezoneDropdown, {
formatUtcOffset,
formatTimezone,
@@ -25,13 +26,17 @@ describe('Timezone Dropdown', () => {
describe('Initialize', () => {
describe('with dropdown already loaded', () => {
beforeEach(() => {
- loadFixtures('pipeline_schedules/edit.html');
+ loadHTMLFixture('pipeline_schedules/edit.html');
$wrapper = $('.dropdown');
$inputEl = $('#schedule_cron_timezone');
$inputEl.val('');
$dropdownEl = $('.js-timezone-dropdown');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('can take an $inputEl in the constructor', () => {
initTimezoneDropdown();
@@ -86,7 +91,7 @@ describe('Timezone Dropdown', () => {
describe('without dropdown loaded', () => {
beforeEach(() => {
- loadFixtures('pipeline_schedules/edit.html');
+ loadHTMLFixture('pipeline_schedules/edit.html');
$wrapper = $('.dropdown');
$inputEl = $('#schedule_cron_timezone');
$dropdownEl = $('.js-timezone-dropdown');
diff --git a/spec/frontend/pages/search/show/refresh_counts_spec.js b/spec/frontend/pages/search/show/refresh_counts_spec.js
index 81c9bf74308..6f14f0c70bd 100644
--- a/spec/frontend/pages/search/show/refresh_counts_spec.js
+++ b/spec/frontend/pages/search/show/refresh_counts_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import axios from '~/lib/utils/axios_utils';
import refreshCounts from '~/pages/search/show/refresh_counts';
@@ -18,7 +19,11 @@ describe('pages/search/show/refresh_counts', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
- setFixtures(fixture);
+ setHTMLFixture(fixture);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
afterEach(() => {
diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
index a29db961452..4c4a0fbea11 100644
--- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
+++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment';
describe('preserve_url_fragment', () => {
@@ -7,7 +8,11 @@ describe('preserve_url_fragment', () => {
};
beforeEach(() => {
- loadFixtures('sessions/new.html');
+ loadHTMLFixture('sessions/new.html');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('adds the url fragment to the login form actions', () => {
diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
index 601fcfedbe0..f736ce46f9b 100644
--- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
+++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
@@ -1,3 +1,4 @@
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import AccessorUtilities from '~/lib/utils/accessor';
import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer';
@@ -19,11 +20,15 @@ describe('SigninTabsMemoizer', () => {
}
beforeEach(() => {
- loadFixtures(fixtureTemplate);
+ loadHTMLFixture(fixtureTemplate);
jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('does nothing if no tab was previously selected', () => {
createMemoizer();
diff --git a/spec/frontend/pdf/index_spec.js b/spec/frontend/pdf/index_spec.js
index 1ae77a62675..2b0932493bb 100644
--- a/spec/frontend/pdf/index_spec.js
+++ b/spec/frontend/pdf/index_spec.js
@@ -14,18 +14,8 @@ const Component = Vue.extend(PDFLab);
describe('PDF component', () => {
let vm;
- const checkLoaded = (done) => {
- if (vm.loading) {
- setTimeout(() => {
- checkLoaded(done);
- }, 100);
- } else {
- done();
- }
- };
-
describe('without PDF data', () => {
- beforeEach((done) => {
+ beforeEach(() => {
vm = new Component({
propsData: {
pdf: '',
@@ -33,8 +23,6 @@ describe('PDF component', () => {
});
vm.$mount();
-
- checkLoaded(done);
});
it('does not render', () => {
@@ -43,7 +31,7 @@ describe('PDF component', () => {
});
describe('with PDF data', () => {
- beforeEach((done) => {
+ beforeEach(() => {
vm = new Component({
propsData: {
pdf,
@@ -51,8 +39,6 @@ describe('PDF component', () => {
});
vm.$mount();
-
- checkLoaded(done);
});
it('renders pdf component', () => {
diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js
index 91cb46002be..6c1cbfa70a1 100644
--- a/spec/frontend/performance_bar/index_spec.js
+++ b/spec/frontend/performance_bar/index_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import axios from '~/lib/utils/axios_utils';
import '~/performance_bar/components/performance_bar_app.vue';
import performanceBar from '~/performance_bar';
@@ -11,7 +12,7 @@ describe('performance bar wrapper', () => {
let vm;
beforeEach(() => {
- setFixtures('<div id="js-peek"></div>');
+ setHTMLFixture('<div id="js-peek"></div>');
const peekWrapper = document.getElementById('js-peek');
performance.getEntriesByType = jest.fn().mockReturnValue([]);
@@ -49,6 +50,7 @@ describe('performance bar wrapper', () => {
vm.$destroy();
document.getElementById('js-peek').remove();
mock.restore();
+ resetHTMLFixture();
});
describe('addRequest', () => {
diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
index 59bd71b0e60..bec6c2a8d0c 100644
--- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
+++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
@@ -71,7 +71,7 @@ describe('Pipeline Editor | Commit Form', () => {
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: mockCommitMessage,
- targetBranch: mockDefaultBranch,
+ sourceBranch: mockDefaultBranch,
openMergeRequest: false,
},
]);
@@ -127,7 +127,7 @@ describe('Pipeline Editor | Commit Form', () => {
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: anotherMessage,
- targetBranch: anotherBranch,
+ sourceBranch: anotherBranch,
openMergeRequest: true,
},
]);
diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
index e24de832d6d..a61796dbed2 100644
--- a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
+++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
@@ -1,19 +1,58 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue';
import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
+import FileTreePopover from '~/pipeline_editor/components/popovers/file_tree_popover.vue';
+import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql';
+import {
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
+} from '~/pipeline_editor/constants';
+
+Vue.use(VueApollo);
describe('Pipeline editor file nav', () => {
let wrapper;
- const createComponent = ({ provide = {} } = {}) => {
- wrapper = shallowMount(PipelineEditorFileNav, {
- provide: {
- ...provide,
+ const mockApollo = createMockApollo();
+
+ const createComponent = ({
+ appStatus = EDITOR_APP_STATUS_VALID,
+ isNewCiConfigFile = false,
+ pipelineEditorFileTree = false,
+ } = {}) => {
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ app: {
+ __typename: 'PipelineEditorApp',
+ status: appStatus,
+ },
},
});
+
+ wrapper = extendedWrapper(
+ shallowMount(PipelineEditorFileNav, {
+ apolloProvider: mockApollo,
+ provide: {
+ glFeatures: {
+ pipelineEditorFileTree,
+ },
+ },
+ propsData: {
+ isNewCiConfigFile,
+ },
+ }),
+ );
};
const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher);
+ const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle');
+ const findPopoverContainer = () => wrapper.findComponent(FileTreePopover);
afterEach(() => {
wrapper.destroy();
@@ -27,5 +66,91 @@ describe('Pipeline editor file nav', () => {
it('renders the branch switcher', () => {
expect(findBranchSwitcher().exists()).toBe(true);
});
+
+ it('does not render the file tree button', () => {
+ expect(findFileTreeBtn().exists()).toBe(false);
+ });
+
+ it('does not render the file tree popover', () => {
+ expect(findPopoverContainer().exists()).toBe(false);
+ });
+ });
+
+ describe('with pipelineEditorFileTree feature flag ON', () => {
+ describe('when editor is in the empty state', () => {
+ beforeEach(() => {
+ createComponent({
+ appStatus: EDITOR_APP_STATUS_EMPTY,
+ isNewCiConfigFile: false,
+ pipelineEditorFileTree: true,
+ });
+ });
+
+ it('does not render the file tree button', () => {
+ expect(findFileTreeBtn().exists()).toBe(false);
+ });
+
+ it('does not render the file tree popover', () => {
+ expect(findPopoverContainer().exists()).toBe(false);
+ });
+ });
+
+ describe('when user is about to create their config file for the first time', () => {
+ beforeEach(() => {
+ createComponent({
+ appStatus: EDITOR_APP_STATUS_VALID,
+ isNewCiConfigFile: true,
+ pipelineEditorFileTree: true,
+ });
+ });
+
+ it('does not render the file tree button', () => {
+ expect(findFileTreeBtn().exists()).toBe(false);
+ });
+
+ it('does not render the file tree popover', () => {
+ expect(findPopoverContainer().exists()).toBe(false);
+ });
+ });
+
+ describe('when app is in a global loading state', () => {
+ it('renders the file tree button with a loading icon', () => {
+ createComponent({
+ appStatus: EDITOR_APP_STATUS_LOADING,
+ isNewCiConfigFile: false,
+ pipelineEditorFileTree: true,
+ });
+
+ expect(findFileTreeBtn().exists()).toBe(true);
+ expect(findFileTreeBtn().attributes('loading')).toBe('true');
+ });
+ });
+
+ describe('when editor has a non-empty config file open', () => {
+ beforeEach(() => {
+ createComponent({
+ appStatus: EDITOR_APP_STATUS_VALID,
+ isNewCiConfigFile: false,
+ pipelineEditorFileTree: true,
+ });
+ });
+
+ it('renders the file tree button', () => {
+ expect(findFileTreeBtn().exists()).toBe(true);
+ expect(findFileTreeBtn().props('icon')).toBe('file-tree');
+ });
+
+ it('renders the file tree popover', () => {
+ expect(findPopoverContainer().exists()).toBe(true);
+ });
+
+ it('file tree button emits toggle-file-tree event', () => {
+ expect(wrapper.emitted('toggle-file-tree')).toBe(undefined);
+
+ findFileTreeBtn().vm.$emit('click');
+
+ expect(wrapper.emitted('toggle-file-tree')).toHaveLength(1);
+ });
+ });
});
});
diff --git a/spec/frontend/pipeline_editor/components/file-tree/container_spec.js b/spec/frontend/pipeline_editor/components/file-tree/container_spec.js
new file mode 100644
index 00000000000..04a93e8db25
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/file-tree/container_spec.js
@@ -0,0 +1,138 @@
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { createMockDirective } from 'helpers/vue_mock_directive';
+import PipelineEditorFileTreeContainer from '~/pipeline_editor/components/file_tree/container.vue';
+import PipelineEditorFileTreeItem from '~/pipeline_editor/components/file_tree/file_item.vue';
+import { FILE_TREE_TIP_DISMISSED_KEY } from '~/pipeline_editor/constants';
+import { mockCiConfigPath, mockIncludes, mockIncludesHelpPagePath } from '../../mock_data';
+
+describe('Pipeline editor file nav', () => {
+ let wrapper;
+
+ const createComponent = ({ includes = mockIncludes, stubs } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(PipelineEditorFileTreeContainer, {
+ provide: {
+ ciConfigPath: mockCiConfigPath,
+ includesHelpPagePath: mockIncludesHelpPagePath,
+ },
+ propsData: {
+ includes,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ stubs,
+ }),
+ );
+ };
+
+ const findTip = () => wrapper.findComponent(GlAlert);
+ const findCurrentConfigFilename = () => wrapper.findByTestId('current-config-filename');
+ const fileTreeItems = () => wrapper.findAll(PipelineEditorFileTreeItem);
+
+ afterEach(() => {
+ localStorage.clear();
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent({ stubs: { GlAlert } });
+ });
+
+ it('renders config file as a file item', () => {
+ expect(findCurrentConfigFilename().text()).toBe(mockCiConfigPath);
+ });
+ });
+
+ describe('when includes list is empty', () => {
+ describe('when dismiss state is not saved in local storage', () => {
+ beforeEach(() => {
+ createComponent({
+ includes: [],
+ stubs: { GlAlert },
+ });
+ });
+
+ it('does not render filenames', () => {
+ expect(fileTreeItems().exists()).toBe(false);
+ });
+
+ it('renders alert tip', async () => {
+ expect(findTip().exists()).toBe(true);
+ });
+
+ it('renders learn more link', async () => {
+ expect(findTip().props('secondaryButtonLink')).toBe(mockIncludesHelpPagePath);
+ });
+
+ it('can dismiss the tip', async () => {
+ expect(findTip().exists()).toBe(true);
+
+ findTip().vm.$emit('dismiss');
+ await nextTick();
+
+ expect(findTip().exists()).toBe(false);
+ });
+ });
+
+ describe('when dismiss state is saved in local storage', () => {
+ beforeEach(() => {
+ localStorage.setItem(FILE_TREE_TIP_DISMISSED_KEY, 'true');
+ createComponent({
+ includes: [],
+ stubs: { GlAlert },
+ });
+ });
+
+ it('does not render alert tip', async () => {
+ expect(findTip().exists()).toBe(false);
+ });
+ });
+
+ describe('when component receives new props with includes files', () => {
+ beforeEach(() => {
+ createComponent({ includes: [] });
+ });
+
+ it('hides tip and renders list of files', async () => {
+ expect(findTip().exists()).toBe(true);
+ expect(fileTreeItems()).toHaveLength(0);
+
+ await wrapper.setProps({ includes: mockIncludes });
+
+ expect(findTip().exists()).toBe(false);
+ expect(fileTreeItems()).toHaveLength(mockIncludes.length);
+ });
+ });
+ });
+
+ describe('when there are includes files', () => {
+ beforeEach(() => {
+ createComponent({ stubs: { GlAlert } });
+ });
+
+ it('does not render alert tip', () => {
+ expect(findTip().exists()).toBe(false);
+ });
+
+ it('renders the list of files', () => {
+ expect(fileTreeItems()).toHaveLength(mockIncludes.length);
+ });
+
+ describe('when component receives new props with empty includes', () => {
+ it('shows tip and does not render list of files', async () => {
+ expect(findTip().exists()).toBe(false);
+ expect(fileTreeItems()).toHaveLength(mockIncludes.length);
+
+ await wrapper.setProps({ includes: [] });
+
+ expect(findTip().exists()).toBe(true);
+ expect(fileTreeItems()).toHaveLength(0);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js b/spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js
new file mode 100644
index 00000000000..f12ac14c6be
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js
@@ -0,0 +1,52 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import PipelineEditorFileTreeItem from '~/pipeline_editor/components/file_tree/file_item.vue';
+import { mockIncludesWithBlob, mockDefaultIncludes } from '../../mock_data';
+
+describe('Pipeline editor file nav', () => {
+ let wrapper;
+
+ const createComponent = ({ file = mockDefaultIncludes } = {}) => {
+ wrapper = shallowMount(PipelineEditorFileTreeItem, {
+ propsData: {
+ file,
+ },
+ });
+ };
+
+ const fileIcon = () => wrapper.findComponent(FileIcon);
+ const link = () => wrapper.findComponent(GlLink);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders file icon', () => {
+ expect(fileIcon().exists()).toBe(true);
+ });
+
+ it('renders file name', () => {
+ expect(wrapper.text()).toBe(mockDefaultIncludes.location);
+ });
+
+ it('links to raw path by default', () => {
+ expect(link().attributes('href')).toBe(mockDefaultIncludes.raw);
+ });
+ });
+
+ describe('when file has blob link', () => {
+ beforeEach(() => {
+ createComponent({ file: mockIncludesWithBlob });
+ });
+
+ it('links to blob path', () => {
+ expect(link().attributes('href')).toBe(mockIncludesWithBlob.blob);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
index 6dffb7e5470..d159a20a8d6 100644
--- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
+++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
@@ -3,7 +3,7 @@ import { shallowMount, mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import setWindowLocation from 'helpers/set_window_location_helper';
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
-import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue';
+import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
diff --git a/spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js b/spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js
new file mode 100644
index 00000000000..98ce3f6ea40
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js
@@ -0,0 +1,56 @@
+import { nextTick } from 'vue';
+import { GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import FileTreePopover from '~/pipeline_editor/components/popovers/file_tree_popover.vue';
+import { FILE_TREE_POPOVER_DISMISSED_KEY } from '~/pipeline_editor/constants';
+import { mockIncludesHelpPagePath } from '../../mock_data';
+
+describe('FileTreePopover component', () => {
+ let wrapper;
+
+ const findPopover = () => wrapper.findComponent(GlPopover);
+ const findLink = () => findPopover().findComponent(GlLink);
+
+ const createComponent = ({ stubs } = {}) => {
+ wrapper = shallowMount(FileTreePopover, {
+ provide: {
+ includesHelpPagePath: mockIncludesHelpPagePath,
+ },
+ stubs,
+ });
+ };
+
+ afterEach(() => {
+ localStorage.clear();
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(async () => {
+ createComponent({ stubs: { GlSprintf } });
+ });
+
+ it('renders dismissable popover', async () => {
+ expect(findPopover().exists()).toBe(true);
+
+ findPopover().vm.$emit('close-button-clicked');
+ await nextTick();
+
+ expect(findPopover().exists()).toBe(false);
+ });
+
+ it('renders learn more link', () => {
+ expect(findLink().exists()).toBe(true);
+ expect(findLink().attributes('href')).toBe(mockIncludesHelpPagePath);
+ });
+ });
+
+ describe('when popover has already been dismissed before', () => {
+ it('does not render popover', async () => {
+ localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true');
+ createComponent();
+
+ expect(findPopover().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js b/spec/frontend/pipeline_editor/components/popovers/walkthrough_popover_spec.js
index a9ce89ff521..8d172a8462a 100644
--- a/spec/frontend/pipeline_editor/components/walkthrough_popover_spec.js
+++ b/spec/frontend/pipeline_editor/components/popovers/walkthrough_popover_spec.js
@@ -1,6 +1,6 @@
import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
-import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover.vue';
+import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
Vue.config.ignoredElements = ['gl-emoji'];
diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js
index f02f6870653..560b2820fae 100644
--- a/spec/frontend/pipeline_editor/mock_data.js
+++ b/spec/frontend/pipeline_editor/mock_data.js
@@ -9,6 +9,7 @@ export const mockNewBranch = 'new-branch';
export const mockNewMergeRequestPath = '/-/merge_requests/new';
export const mockCommitSha = 'aabbccdd';
export const mockCommitNextSha = 'eeffgghh';
+export const mockIncludesHelpPagePath = '/-/includes/help';
export const mockLintHelpPagePath = '/-/lint-help';
export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot';
export const mockYmlHelpPagePath = '/-/yml-help';
@@ -82,12 +83,46 @@ const mockJobFields = {
__typename: 'CiConfigJob',
};
+export const mockIncludesWithBlob = {
+ location: 'test-include.yml',
+ type: 'local',
+ blob:
+ 'http://gdk.test:3000/root/upstream/-/blob/dd54f00bb3645f8ddce7665d2ffb3864540399cb/test-include.yml',
+ raw:
+ 'http://gdk.test:3000/root/upstream/-/raw/dd54f00bb3645f8ddce7665d2ffb3864540399cb/test-include.yml',
+ __typename: 'CiConfigInclude',
+};
+
+export const mockDefaultIncludes = {
+ location: 'npm.gitlab-ci.yml',
+ type: 'template',
+ blob: null,
+ raw:
+ 'https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/npm.gitlab-ci.yml',
+ __typename: 'CiConfigInclude',
+};
+
+export const mockIncludes = [
+ mockDefaultIncludes,
+ mockIncludesWithBlob,
+ {
+ location: 'a_really_really_long_name_for_includes_file.yml',
+ type: 'local',
+ blob:
+ 'http://gdk.test:3000/root/upstream/-/blob/dd54f00bb3645f8ddce7665d2ffb3864540399cb/a_really_really_long_name_for_includes_file.yml',
+ raw:
+ 'http://gdk.test:3000/root/upstream/-/raw/dd54f00bb3645f8ddce7665d2ffb3864540399cb/a_really_really_long_name_for_includes_file.yml',
+ __typename: 'CiConfigInclude',
+ },
+];
+
// Mock result of the graphql query at:
// app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql
export const mockCiConfigQueryResponse = {
data: {
ciConfig: {
errors: [],
+ includes: mockIncludes,
mergedYaml: mockCiYml,
status: CI_CONFIG_STATUS_VALID,
stages: {
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
index 98e2c17967c..bf0f7fd8c9f 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
@@ -6,10 +6,17 @@ import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header
import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue';
import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
+import PipelineEditorFileTree from '~/pipeline_editor/components/file_tree/container.vue';
import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue';
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
-import { MERGED_TAB, VISUALIZE_TAB, CREATE_TAB, LINT_TAB } from '~/pipeline_editor/constants';
+import {
+ MERGED_TAB,
+ VISUALIZE_TAB,
+ CREATE_TAB,
+ LINT_TAB,
+ FILE_TREE_DISPLAY_KEY,
+} from '~/pipeline_editor/constants';
import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue';
import { mockLintResponse, mockCiYml } from './mock_data';
@@ -47,11 +54,14 @@ describe('Pipeline editor home wrapper', () => {
const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav);
const findModal = () => wrapper.findComponent(GlModal);
const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer);
+ const findPipelineEditorFileTree = () => wrapper.findComponent(PipelineEditorFileTree);
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
+ const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle');
const findHelpBtn = () => wrapper.findByTestId('drawer-toggle');
afterEach(() => {
+ localStorage.clear();
wrapper.destroy();
});
@@ -230,4 +240,89 @@ describe('Pipeline editor home wrapper', () => {
expect(findPipelineEditorDrawer().props('isVisible')).toBe(false);
});
});
+
+ describe('file tree', () => {
+ const toggleFileTree = async () => {
+ findFileTreeBtn().vm.$emit('click');
+ await nextTick();
+ };
+
+ describe('with pipelineEditorFileTree feature flag OFF', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('hides the file tree', () => {
+ expect(findFileTreeBtn().exists()).toBe(false);
+ expect(findPipelineEditorFileTree().exists()).toBe(false);
+ });
+ });
+
+ describe('with pipelineEditorFileTree feature flag ON', () => {
+ describe('button toggle', () => {
+ beforeEach(() => {
+ createComponent({
+ glFeatures: {
+ pipelineEditorFileTree: true,
+ },
+ stubs: {
+ GlButton,
+ PipelineEditorFileNav,
+ },
+ });
+ });
+
+ it('shows button toggle', () => {
+ expect(findFileTreeBtn().exists()).toBe(true);
+ });
+
+ it('toggles the drawer on button click', async () => {
+ await toggleFileTree();
+
+ expect(findPipelineEditorFileTree().exists()).toBe(true);
+
+ await toggleFileTree();
+
+ expect(findPipelineEditorFileTree().exists()).toBe(false);
+ });
+
+ it('sets the display state in local storage', async () => {
+ await toggleFileTree();
+
+ expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('true');
+
+ await toggleFileTree();
+
+ expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('false');
+ });
+ });
+
+ describe('when file tree display state is saved in local storage', () => {
+ beforeEach(() => {
+ localStorage.setItem(FILE_TREE_DISPLAY_KEY, 'true');
+ createComponent({
+ glFeatures: { pipelineEditorFileTree: true },
+ stubs: { PipelineEditorFileNav },
+ });
+ });
+
+ it('shows the file tree by default', () => {
+ expect(findPipelineEditorFileTree().exists()).toBe(true);
+ });
+ });
+
+ describe('when file tree display state is not saved in local storage', () => {
+ beforeEach(() => {
+ createComponent({
+ glFeatures: { pipelineEditorFileTree: true },
+ stubs: { PipelineEditorFileNav },
+ });
+ });
+
+ it('hides the file tree by default', () => {
+ expect(findPipelineEditorFileTree().exists()).toBe(false);
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/pipeline_wizard/components/commit_spec.js b/spec/frontend/pipeline_wizard/components/commit_spec.js
index 6496850b028..c987accbb0d 100644
--- a/spec/frontend/pipeline_wizard/components/commit_spec.js
+++ b/spec/frontend/pipeline_wizard/components/commit_spec.js
@@ -8,7 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import createCommitMutation from '~/pipeline_wizard/queries/create_commit.graphql';
import getFileMetadataQuery from '~/pipeline_wizard/queries/get_file_meta.graphql';
import RefSelector from '~/ref/components/ref_selector.vue';
-import flushPromises from 'helpers/flush_promises';
+import waitForPromises from 'helpers/wait_for_promises';
import {
createCommitMutationErrorResult,
createCommitMutationResult,
@@ -107,7 +107,7 @@ describe('Pipeline Wizard - Commit Page', () => {
it('does not show a load error if call is successful', async () => {
createComponent({ projectPath, filename });
- await flushPromises();
+ await waitForPromises();
expect(wrapper.findByTestId('load-error').exists()).not.toBe(true);
});
@@ -117,7 +117,7 @@ describe('Pipeline Wizard - Commit Page', () => {
{ defaultBranch: branch, projectPath, filename },
createMockApollo([[getFileMetadataQuery, () => fileQueryErrorResult]]),
);
- await flushPromises();
+ await waitForPromises();
expect(wrapper.findByTestId('load-error').exists()).toBe(true);
expect(wrapper.findByTestId('load-error').text()).toBe(i18n.errors.loadError);
});
@@ -131,9 +131,9 @@ describe('Pipeline Wizard - Commit Page', () => {
describe('successful commit', () => {
beforeEach(async () => {
createComponent();
- await flushPromises();
+ await waitForPromises();
await getButtonWithLabel(__('Commit')).trigger('click');
- await flushPromises();
+ await waitForPromises();
});
it('will not show an error', async () => {
@@ -159,9 +159,9 @@ describe('Pipeline Wizard - Commit Page', () => {
describe('failed commit', () => {
beforeEach(async () => {
createComponent({}, getMockApollo({ commitHasError: true }));
- await flushPromises();
+ await waitForPromises();
await getButtonWithLabel(__('Commit')).trigger('click');
- await flushPromises();
+ await waitForPromises();
});
it('will show an error', async () => {
@@ -229,7 +229,7 @@ describe('Pipeline Wizard - Commit Page', () => {
}),
);
- await flushPromises();
+ await waitForPromises();
consoleSpy = jest.spyOn(console, 'error');
@@ -243,7 +243,7 @@ describe('Pipeline Wizard - Commit Page', () => {
}
await Vue.nextTick();
- await flushPromises();
+ await waitForPromises();
});
afterAll(() => {
diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
index 2d2e5db598a..724ec7366d3 100644
--- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
+++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
@@ -11,6 +11,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "6",
+ "kind": "BUILD",
"name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
"needs": Array [],
"previousStageJobsOrNeeds": Array [],
@@ -53,6 +54,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "11",
+ "kind": "BUILD",
"name": "build_b",
"needs": Array [],
"previousStageJobsOrNeeds": Array [],
@@ -95,6 +97,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "16",
+ "kind": "BUILD",
"name": "build_c",
"needs": Array [],
"previousStageJobsOrNeeds": Array [],
@@ -137,6 +140,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "21",
+ "kind": "BUILD",
"name": "build_d 1/3",
"needs": Array [],
"previousStageJobsOrNeeds": Array [],
@@ -163,6 +167,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "24",
+ "kind": "BUILD",
"name": "build_d 2/3",
"needs": Array [],
"previousStageJobsOrNeeds": Array [],
@@ -189,6 +194,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "27",
+ "kind": "BUILD",
"name": "build_d 3/3",
"needs": Array [],
"previousStageJobsOrNeeds": Array [],
@@ -231,6 +237,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "59",
+ "kind": "BUILD",
"name": "test_c",
"needs": Array [],
"previousStageJobsOrNeeds": Array [],
@@ -275,6 +282,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "34",
+ "kind": "BUILD",
"name": "test_a",
"needs": Array [
"build_c",
@@ -325,6 +333,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "42",
+ "kind": "BUILD",
"name": "test_b 1/2",
"needs": Array [
"build_d 3/3",
@@ -363,6 +372,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "67",
+ "kind": "BUILD",
"name": "test_b 2/2",
"needs": Array [
"build_d 3/3",
@@ -417,6 +427,7 @@ Array [
Object {
"__typename": "CiJob",
"id": "53",
+ "kind": "BUILD",
"name": "test_d",
"needs": Array [
"build_b",
diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js
new file mode 100644
index 00000000000..3b5632a8a4e
--- /dev/null
+++ b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js
@@ -0,0 +1,87 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import FailedJobsApp from '~/pipelines/components/jobs/failed_jobs_app.vue';
+import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue';
+import GetFailedJobsQuery from '~/pipelines/graphql/queries/get_failed_jobs.query.graphql';
+import { mockFailedJobsQueryResponse, mockFailedJobsSummaryData } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+jest.mock('~/flash');
+
+describe('Failed Jobs App', () => {
+ let wrapper;
+ let resolverSpy;
+
+ const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
+ const findJobsTable = () => wrapper.findComponent(FailedJobsTable);
+
+ const createMockApolloProvider = (resolver) => {
+ const requestHandlers = [[GetFailedJobsQuery, resolver]];
+
+ return createMockApollo(requestHandlers);
+ };
+
+ const createComponent = (resolver, failedJobsSummaryData = mockFailedJobsSummaryData) => {
+ wrapper = shallowMount(FailedJobsApp, {
+ provide: {
+ fullPath: 'root/ci-project',
+ pipelineIid: 1,
+ },
+ propsData: {
+ failedJobsSummary: failedJobsSummaryData,
+ },
+ apolloProvider: createMockApolloProvider(resolver),
+ });
+ };
+
+ beforeEach(() => {
+ resolverSpy = jest.fn().mockResolvedValue(mockFailedJobsQueryResponse);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('loading spinner', () => {
+ beforeEach(() => {
+ createComponent(resolverSpy);
+ });
+
+ it('displays loading spinner when fetching failed jobs', () => {
+ expect(findLoadingSpinner().exists()).toBe(true);
+ });
+
+ it('hides loading spinner after the failed jobs have been fetched', async () => {
+ await waitForPromises();
+
+ expect(findLoadingSpinner().exists()).toBe(false);
+ });
+ });
+
+ it('displays the failed jobs table', async () => {
+ createComponent(resolverSpy);
+
+ await waitForPromises();
+
+ expect(findJobsTable().exists()).toBe(true);
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+
+ it('handles query fetch error correctly', async () => {
+ resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+
+ createComponent(resolverSpy);
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem fetching the failed jobs.',
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js
new file mode 100644
index 00000000000..b597a3bf4b0
--- /dev/null
+++ b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js
@@ -0,0 +1,117 @@
+import { GlButton, GlLink, GlTableLite } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import createFlash from '~/flash';
+import { redirectTo } from '~/lib/utils/url_utility';
+import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue';
+import RetryFailedJobMutation from '~/pipelines/graphql/mutations/retry_failed_job.mutation.graphql';
+import {
+ successRetryMutationResponse,
+ failedRetryMutationResponse,
+ mockPreparedFailedJobsData,
+ mockPreparedFailedJobsDataNoPermission,
+} from '../../mock_data';
+
+jest.mock('~/flash');
+jest.mock('~/lib/utils/url_utility');
+
+Vue.use(VueApollo);
+
+describe('Failed Jobs Table', () => {
+ let wrapper;
+
+ const successRetryMutationHandler = jest.fn().mockResolvedValue(successRetryMutationResponse);
+ const failedRetryMutationHandler = jest.fn().mockResolvedValue(failedRetryMutationResponse);
+
+ const findJobsTable = () => wrapper.findComponent(GlTableLite);
+ const findRetryButton = () => wrapper.findComponent(GlButton);
+ const findJobLink = () => wrapper.findComponent(GlLink);
+ const findJobLog = () => wrapper.findByTestId('job-log');
+
+ const createMockApolloProvider = (resolver) => {
+ const requestHandlers = [[RetryFailedJobMutation, resolver]];
+ return createMockApollo(requestHandlers);
+ };
+
+ const createComponent = (resolver, failedJobsData = mockPreparedFailedJobsData) => {
+ wrapper = mountExtended(FailedJobsTable, {
+ propsData: {
+ failedJobs: failedJobsData,
+ },
+ apolloProvider: createMockApolloProvider(resolver),
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays the failed jobs table', () => {
+ createComponent();
+
+ expect(findJobsTable().exists()).toBe(true);
+ });
+
+ it('calls the retry failed job mutation correctly', () => {
+ createComponent(successRetryMutationHandler);
+
+ findRetryButton().trigger('click');
+
+ expect(successRetryMutationHandler).toHaveBeenCalledWith({
+ id: mockPreparedFailedJobsData[0].id,
+ });
+ });
+
+ it('redirects to the new job after the mutation', async () => {
+ const {
+ data: {
+ jobRetry: { job },
+ },
+ } = successRetryMutationResponse;
+
+ createComponent(successRetryMutationHandler);
+
+ findRetryButton().trigger('click');
+
+ await waitForPromises();
+
+ expect(redirectTo).toHaveBeenCalledWith(job.detailedStatus.detailsPath);
+ });
+
+ it('shows error message if the retry failed job mutation fails', async () => {
+ createComponent(failedRetryMutationHandler);
+
+ findRetryButton().trigger('click');
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem retrying the failed job.',
+ });
+ });
+
+ it('hides the job log and retry button if a user does not have permission', () => {
+ createComponent([[]], mockPreparedFailedJobsDataNoPermission);
+
+ expect(findJobLog().exists()).toBe(false);
+ expect(findRetryButton().exists()).toBe(false);
+ });
+
+ it('displays the job log and retry button if a user has permission', () => {
+ createComponent();
+
+ expect(findJobLog().exists()).toBe(true);
+ expect(findRetryButton().exists()).toBe(true);
+ });
+
+ it('job name links to the correct job', () => {
+ createComponent();
+
+ expect(findJobLink().attributes('href')).toBe(
+ mockPreparedFailedJobsData[0].detailedStatus.detailsPath,
+ );
+ });
+});
diff --git a/spec/frontend/pipelines/components/jobs/utils_spec.js b/spec/frontend/pipelines/components/jobs/utils_spec.js
new file mode 100644
index 00000000000..720446cfda3
--- /dev/null
+++ b/spec/frontend/pipelines/components/jobs/utils_spec.js
@@ -0,0 +1,14 @@
+import { prepareFailedJobs } from '~/pipelines/components/jobs/utils';
+import {
+ mockFailedJobsData,
+ mockFailedJobsSummaryData,
+ mockPreparedFailedJobsData,
+} from '../../mock_data';
+
+describe('Utils', () => {
+ it('prepares failed jobs data correctly', () => {
+ expect(prepareFailedJobs(mockFailedJobsData, mockFailedJobsSummaryData)).toEqual(
+ mockPreparedFailedJobsData,
+ );
+ });
+});
diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js
index e18c3edbad9..89002ee47a8 100644
--- a/spec/frontend/pipelines/components/pipeline_tabs_spec.js
+++ b/spec/frontend/pipelines/components/pipeline_tabs_spec.js
@@ -21,14 +21,19 @@ describe('The Pipeline Tabs', () => {
const findPipelineApp = () => wrapper.findComponent(PipelineGraphWrapper);
const findTestsApp = () => wrapper.findComponent(TestReports);
+ const defaultProvide = {
+ defaultTabValue: '',
+ };
+
const createComponent = (propsData = {}) => {
wrapper = extendedWrapper(
shallowMount(PipelineTabs, {
propsData,
+ provide: {
+ ...defaultProvide,
+ },
stubs: {
- Dag: { template: '<div id="dag"/>' },
JobsApp: { template: '<div class="jobs" />' },
- PipelineGraph: { template: '<div id="graph" />' },
TestReports: { template: '<div id="tests" />' },
},
}),
diff --git a/spec/frontend/pipelines/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js
index 606fdc9cac1..6531a15ab8e 100644
--- a/spec/frontend/pipelines/empty_state/ci_templates_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js
@@ -13,34 +13,35 @@ describe('CI Templates', () => {
let wrapper;
let trackingSpy;
- const createWrapper = () => {
- return shallowMountExtended(CiTemplates, {
+ const createWrapper = (propsData = {}) => {
+ wrapper = shallowMountExtended(CiTemplates, {
provide: {
pipelineEditorPath,
suggestedCiTemplates,
},
+ propsData,
});
};
const findTemplateDescription = () => wrapper.findByTestId('template-description');
const findTemplateLink = () => wrapper.findByTestId('template-link');
+ const findTemplateNames = () => wrapper.findAllByTestId('template-name');
const findTemplateName = () => wrapper.findByTestId('template-name');
const findTemplateLogo = () => wrapper.findByTestId('template-logo');
- beforeEach(() => {
- wrapper = createWrapper();
- });
-
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('renders template list', () => {
- it('renders all suggested templates', () => {
- const content = wrapper.text();
+ beforeEach(() => {
+ createWrapper();
+ });
- expect(content).toContain('Android', 'Bash', 'C++');
+ it('renders all suggested templates', () => {
+ expect(findTemplateNames().length).toBe(3);
+ expect(wrapper.text()).toContain('Android', 'Bash', 'C++');
});
it('has the correct template name', () => {
@@ -53,9 +54,13 @@ describe('CI Templates', () => {
);
});
+ it('has the link button enabled', () => {
+ expect(findTemplateLink().props('disabled')).toBe(false);
+ });
+
it('has the description of the template', () => {
expect(findTemplateDescription().text()).toBe(
- 'CI/CD template to test and deploy your Android project.',
+ 'Continuous integration and deployment template to test and deploy your Android project.',
);
});
@@ -64,8 +69,30 @@ describe('CI Templates', () => {
});
});
+ describe('filtering the templates', () => {
+ beforeEach(() => {
+ createWrapper({ filterTemplates: ['Bash'] });
+ });
+
+ it('renders only the filtered templates', () => {
+ expect(findTemplateNames()).toHaveLength(1);
+ expect(findTemplateName().text()).toBe('Bash');
+ });
+ });
+
+ describe('disabling the templates', () => {
+ beforeEach(() => {
+ createWrapper({ disabled: true });
+ });
+
+ it('has the link button disabled', () => {
+ expect(findTemplateLink().props('disabled')).toBe(true);
+ });
+ });
+
describe('tracking', () => {
beforeEach(() => {
+ createWrapper();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js
new file mode 100644
index 00000000000..0c2938921d6
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js
@@ -0,0 +1,138 @@
+import '~/commons';
+import { nextTick } from 'vue';
+import { GlPopover, GlButton } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import IosTemplates from '~/pipelines/components/pipelines_list/empty_state/ios_templates.vue';
+import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue';
+
+const pipelineEditorPath = '/-/ci/editor';
+const registrationToken = 'SECRET_TOKEN';
+const iOSTemplateName = 'iOS-Fastlane';
+
+describe('iOS Templates', () => {
+ let wrapper;
+
+ const createWrapper = (providedPropsData = {}) => {
+ return shallowMountExtended(IosTemplates, {
+ provide: {
+ pipelineEditorPath,
+ iosRunnersAvailable: true,
+ ...providedPropsData,
+ },
+ propsData: {
+ registrationToken,
+ },
+ stubs: {
+ GlButton,
+ },
+ });
+ };
+
+ const findIosTemplate = () => wrapper.findComponent(CiTemplates);
+ const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal);
+ const findRunnerInstructionsPopover = () => wrapper.findComponent(GlPopover);
+ const findRunnerSetupTodoEmoji = () => wrapper.findByTestId('runner-setup-marked-todo');
+ const findRunnerSetupCompletedEmoji = () => wrapper.findByTestId('runner-setup-marked-completed');
+ const findSetupRunnerLink = () => wrapper.findByText('Set up a runner');
+ const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link');
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when ios runners are not available', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({ iosRunnersAvailable: false });
+ });
+
+ describe('the runner setup section', () => {
+ it('marks the section as todo', () => {
+ expect(findRunnerSetupTodoEmoji().isVisible()).toBe(true);
+ expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(false);
+ });
+
+ it('renders the setup runner link', () => {
+ expect(findSetupRunnerLink().exists()).toBe(true);
+ });
+
+ it('renders the runner instructions modal with a popover once clicked', async () => {
+ findSetupRunnerLink().element.parentElement.click();
+
+ await nextTick();
+
+ expect(findRunnerInstructionsModal().exists()).toBe(true);
+ expect(findRunnerInstructionsModal().props('registrationToken')).toBe(registrationToken);
+ expect(findRunnerInstructionsModal().props('defaultPlatformName')).toBe('osx');
+
+ findRunnerInstructionsModal().vm.$emit('shown');
+
+ await nextTick();
+
+ expect(findRunnerInstructionsPopover().exists()).toBe(true);
+ });
+ });
+
+ describe('the configure pipeline section', () => {
+ it('has a disabled link button', () => {
+ expect(configurePipelineLink().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('the ios-Fastlane template', () => {
+ it('renders the template', () => {
+ expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]);
+ });
+
+ it('has a disabled link button', () => {
+ expect(findIosTemplate().props('disabled')).toBe(true);
+ });
+ });
+ });
+
+ describe('when ios runners are available', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+
+ describe('the runner setup section', () => {
+ it('marks the section as completed', () => {
+ expect(findRunnerSetupTodoEmoji().isVisible()).toBe(false);
+ expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(true);
+ });
+
+ it('does not render the setup runner link', () => {
+ expect(findSetupRunnerLink().exists()).toBe(false);
+ });
+ });
+
+ describe('the configure pipeline section', () => {
+ it('has an enabled link button', () => {
+ expect(configurePipelineLink().props('disabled')).toBe(false);
+ });
+
+ it('links to the pipeline editor with the right template', () => {
+ expect(configurePipelineLink().attributes('href')).toBe(
+ `${pipelineEditorPath}?template=${iOSTemplateName}`,
+ );
+ });
+ });
+
+ describe('the ios-Fastlane template', () => {
+ it('renders the template', () => {
+ expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]);
+ });
+
+ it('has an enabled link button', () => {
+ expect(findIosTemplate().props('disabled')).toBe(false);
+ });
+
+ it('links to the pipeline editor with the right template', () => {
+ expect(configurePipelineLink().attributes('href')).toBe(
+ `${pipelineEditorPath}?template=${iOSTemplateName}`,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js
index 14860f20317..b537c81da3f 100644
--- a/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js
@@ -16,6 +16,7 @@ import {
} from '~/pipeline_editor/constants';
const pipelineEditorPath = '/-/ci/editor';
+const ciRunnerSettingsPath = '/-/settings/ci_cd';
jest.mock('~/experimentation/experiment_tracking');
@@ -27,8 +28,10 @@ describe('Pipelines CI Templates', () => {
return shallowMountExtended(PipelinesCiTemplates, {
provide: {
pipelineEditorPath,
+ ciRunnerSettingsPath,
+ anyRunnersAvailable: true,
+ ...propsData,
},
- propsData,
stubs,
});
};
diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js
index 93bc8faa51b..6d0e99ff63e 100644
--- a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js
@@ -1,6 +1,7 @@
import { GlDropdown } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
import axios from '~/lib/utils/axios_utils';
import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue';
import eventHub from '~/pipelines/event_hub';
@@ -48,11 +49,12 @@ describe('Pipelines stage component', () => {
mock.restore();
});
+ const findCiActionBtn = () => wrapper.find('.js-ci-action');
+ const findCiIcon = () => wrapper.findComponent(CiIcon);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownToggle = () => wrapper.find('button.dropdown-toggle');
const findDropdownMenu = () =>
wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]');
- const findCiActionBtn = () => wrapper.find('.js-ci-action');
const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]');
const openStageDropdown = () => {
@@ -74,7 +76,7 @@ describe('Pipelines stage component', () => {
it('should render a dropdown with the status icon', () => {
expect(findDropdown().exists()).toBe(true);
expect(findDropdownToggle().exists()).toBe(true);
- expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true);
+ expect(findCiIcon().exists()).toBe(true);
});
});
diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js
index 46dad4a035c..0abf7f59717 100644
--- a/spec/frontend/pipelines/empty_state_spec.js
+++ b/spec/frontend/pipelines/empty_state_spec.js
@@ -1,7 +1,11 @@
import '~/commons';
-import { mount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import { GlEmptyState } from '@gitlab/ui';
+import { stubExperiments } from 'helpers/experimentation_helper';
import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue';
+import IosTemplates from '~/pipelines/components/pipelines_list/empty_state/ios_templates.vue';
describe('Pipelines Empty State', () => {
let wrapper;
@@ -9,44 +13,68 @@ describe('Pipelines Empty State', () => {
const findIllustration = () => wrapper.find('img');
const findButton = () => wrapper.find('a');
const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates);
+ const iosTemplates = () => wrapper.findComponent(IosTemplates);
const createWrapper = (props = {}) => {
- wrapper = mount(EmptyState, {
+ wrapper = shallowMount(EmptyState, {
provide: {
pipelineEditorPath: '',
suggestedCiTemplates: [],
+ anyRunnersAvailable: true,
+ ciRunnerSettingsPath: '',
},
propsData: {
emptyStateSvgPath: 'foo.svg',
canSetCi: true,
...props,
},
+ stubs: {
+ GlEmptyState,
+ GitlabExperiment,
+ },
});
};
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
describe('when user can configure CI', () => {
- beforeEach(() => {
- createWrapper({}, mount);
- });
+ describe('when the ios_specific_templates experiment is active', () => {
+ beforeEach(() => {
+ stubExperiments({ ios_specific_templates: 'candidate' });
+ createWrapper();
+ });
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
+ it('should render the iOS templates', () => {
+ expect(iosTemplates().exists()).toBe(true);
+ });
+
+ it('should not render the CI/CD templates', () => {
+ expect(pipelinesCiTemplates().exists()).toBe(false);
+ });
});
- it('should render the CI/CD templates', () => {
- expect(pipelinesCiTemplates().exists()).toBe(true);
+ describe('when the ios_specific_templates experiment is inactive', () => {
+ beforeEach(() => {
+ stubExperiments({ ios_specific_templates: 'control' });
+ createWrapper();
+ });
+
+ it('should render the CI/CD templates', () => {
+ expect(pipelinesCiTemplates().exists()).toBe(true);
+ });
+
+ it('should not render the iOS templates', () => {
+ expect(iosTemplates().exists()).toBe(false);
+ });
});
});
describe('when user cannot configure CI', () => {
beforeEach(() => {
- createWrapper({ canSetCi: false }, mount);
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
+ createWrapper({ canSetCi: false });
});
it('should render empty state SVG', () => {
diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
index cb7073fb5f5..49d64c6eac0 100644
--- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
@@ -16,7 +16,7 @@ import {
} from '~/performance/constants';
import * as perfUtils from '~/performance/utils';
import {
- IID_FAILURE,
+ ACTION_FAILURE,
LAYER_VIEW,
STAGE_VIEW,
VIEW_TYPE_KEY,
@@ -188,7 +188,9 @@ describe('Pipeline graph wrapper', () => {
it('displays the no iid alert', () => {
expect(getAlert().exists()).toBe(true);
- expect(getAlert().text()).toBe(wrapper.vm.$options.errorTexts[IID_FAILURE]);
+ expect(getAlert().text()).toBe(
+ 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.',
+ );
});
it('does not display the graph', () => {
@@ -196,6 +198,27 @@ describe('Pipeline graph wrapper', () => {
});
});
+ describe('when there is an error with an action in the graph', () => {
+ beforeEach(async () => {
+ createComponentWithApollo();
+ await waitForPromises();
+ await getGraph().vm.$emit('error', { type: ACTION_FAILURE });
+ });
+
+ it('does not display the loading icon', () => {
+ expect(getLoadingIcon().exists()).toBe(false);
+ });
+
+ it('displays the action error alert', () => {
+ expect(getAlert().exists()).toBe(true);
+ expect(getAlert().text()).toBe('An error occurred while performing this action.');
+ });
+
+ it('displays the graph', () => {
+ expect(getGraph().exists()).toBe(true);
+ });
+ });
+
describe('when refresh action is emitted', () => {
beforeEach(async () => {
createComponentWithApollo();
diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js
index 23e7ed7ebb4..4f0da09fec6 100644
--- a/spec/frontend/pipelines/graph/job_item_spec.js
+++ b/spec/frontend/pipelines/graph/job_item_spec.js
@@ -1,89 +1,34 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { GlBadge } from '@gitlab/ui';
import JobItem from '~/pipelines/components/graph/job_item.vue';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import {
+ delayedJob,
+ mockJob,
+ mockJobWithoutDetails,
+ mockJobWithUnauthorizedAction,
+ triggerJob,
+} from './mock_data';
describe('pipeline graph job item', () => {
let wrapper;
- const findJobWithoutLink = () => wrapper.find('[data-testid="job-without-link"]');
- const findJobWithLink = () => wrapper.find('[data-testid="job-with-link"]');
- const findActionComponent = () => wrapper.find('[data-testid="ci-action-component"]');
+ const findJobWithoutLink = () => wrapper.findByTestId('job-without-link');
+ const findJobWithLink = () => wrapper.findByTestId('job-with-link');
+ const findActionComponent = () => wrapper.findByTestId('ci-action-component');
+ const findBadge = () => wrapper.findComponent(GlBadge);
const createWrapper = (propsData) => {
- wrapper = mount(JobItem, {
- propsData,
- });
+ wrapper = extendedWrapper(
+ mount(JobItem, {
+ propsData,
+ }),
+ );
};
const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500';
- const delayedJob = {
- __typename: 'CiJob',
- name: 'delayed job',
- scheduledAt: '2015-07-03T10:01:00.000Z',
- needs: [],
- status: {
- __typename: 'DetailedStatus',
- icon: 'status_scheduled',
- tooltip: 'delayed manual action (%{remainingTime})',
- hasDetails: true,
- detailsPath: '/root/kinder-pipe/-/jobs/5339',
- group: 'scheduled',
- action: {
- __typename: 'StatusAction',
- icon: 'time-out',
- title: 'Unschedule',
- path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule',
- buttonTitle: 'Unschedule job',
- },
- },
- };
-
- const mockJob = {
- id: 4256,
- name: 'test',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- tooltip: 'passed',
- group: 'success',
- detailsPath: '/root/ci-mock/builds/4256',
- hasDetails: true,
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/root/ci-mock/builds/4256/retry',
- method: 'post',
- },
- },
- };
- const mockJobWithoutDetails = {
- id: 4257,
- name: 'job_without_details',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- detailsPath: '/root/ci-mock/builds/4257',
- hasDetails: false,
- },
- };
- const mockJobWithUnauthorizedAction = {
- id: 4258,
- name: 'stop-environment',
- status: {
- icon: 'status_manual',
- label: 'manual stop action (not allowed)',
- tooltip: 'manual action',
- group: 'manual',
- detailsPath: '/root/ci-mock/builds/4258',
- hasDetails: true,
- action: null,
- },
- };
-
afterEach(() => {
wrapper.destroy();
});
@@ -148,13 +93,25 @@ describe('pipeline graph job item', () => {
});
});
- it('should render provided class name', () => {
- createWrapper({
- job: mockJob,
- cssClassJobName: 'css-class-job-name',
+ describe('job style', () => {
+ beforeEach(() => {
+ createWrapper({
+ job: mockJob,
+ cssClassJobName: 'css-class-job-name',
+ });
+ });
+
+ it('should render provided class name', () => {
+ expect(wrapper.find('a').classes()).toContain('css-class-job-name');
+ });
+
+ it('does not show a badge on the job item', () => {
+ expect(findBadge().exists()).toBe(false);
});
- expect(wrapper.find('a').classes()).toContain('css-class-job-name');
+ it('does not apply the trigger job class', () => {
+ expect(findJobWithLink().classes()).not.toContain('gl-rounded-lg');
+ });
});
describe('status label', () => {
@@ -201,34 +158,51 @@ describe('pipeline graph job item', () => {
});
});
- describe('trigger job highlighting', () => {
- it.each`
- job | jobName | expanded | link
- ${mockJob} | ${mockJob.name} | ${true} | ${true}
- ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false}
- `(
- `trigger job should stay highlighted when downstream is expanded`,
- ({ job, jobName, expanded, link }) => {
- createWrapper({ job, pipelineExpanded: { jobName, expanded } });
- const findJobEl = link ? findJobWithLink : findJobWithoutLink;
-
- expect(findJobEl().classes()).toContain(triggerActiveClass);
- },
- );
+ describe('trigger job', () => {
+ describe('card', () => {
+ beforeEach(() => {
+ createWrapper({ job: triggerJob });
+ });
- it.each`
- job | jobName | expanded | link
- ${mockJob} | ${mockJob.name} | ${false} | ${true}
- ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false}
- `(
- `trigger job should not be highlighted when downstream is not expanded`,
- ({ job, jobName, expanded, link }) => {
- createWrapper({ job, pipelineExpanded: { jobName, expanded } });
- const findJobEl = link ? findJobWithLink : findJobWithoutLink;
-
- expect(findJobEl().classes()).not.toContain(triggerActiveClass);
- },
- );
+ it('shows a badge on the job item', () => {
+ expect(findBadge().exists()).toBe(true);
+ expect(findBadge().text()).toBe('Trigger job');
+ });
+
+ it('applies a rounded corner style instead of the usual pill shape', () => {
+ expect(findJobWithoutLink().classes()).toContain('gl-rounded-lg');
+ });
+ });
+
+ describe('highlighting', () => {
+ it.each`
+ job | jobName | expanded | link
+ ${mockJob} | ${mockJob.name} | ${true} | ${true}
+ ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false}
+ `(
+ `trigger job should stay highlighted when downstream is expanded`,
+ ({ job, jobName, expanded, link }) => {
+ createWrapper({ job, pipelineExpanded: { jobName, expanded } });
+ const findJobEl = link ? findJobWithLink : findJobWithoutLink;
+
+ expect(findJobEl().classes()).toContain(triggerActiveClass);
+ },
+ );
+
+ it.each`
+ job | jobName | expanded | link
+ ${mockJob} | ${mockJob.name} | ${false} | ${true}
+ ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false}
+ `(
+ `trigger job should not be highlighted when downstream is not expanded`,
+ ({ job, jobName, expanded, link }) => {
+ createWrapper({ job, pipelineExpanded: { jobName, expanded } });
+ const findJobEl = link ? findJobWithLink : findJobWithoutLink;
+
+ expect(findJobEl().classes()).not.toContain(triggerActiveClass);
+ },
+ );
+ });
});
describe('job classes', () => {
diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
index d800a8c341e..06fd970778c 100644
--- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
@@ -1,11 +1,21 @@
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants';
+import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants';
import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue';
+import { PIPELINE_GRAPHQL_TYPE } from '~/pipelines/constants';
+import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
+import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
import mockPipeline from './linked_pipelines_mock_data';
+Vue.use(VueApollo);
+
describe('Linked pipeline', () => {
let wrapper;
@@ -27,22 +37,30 @@ describe('Linked pipeline', () => {
};
const findButton = () => wrapper.find(GlButton);
- const findDownstreamPipelineTitle = () => wrapper.find('[data-testid="downstream-title"]');
- const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]');
+ const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline');
+ const findCardTooltip = () => wrapper.findComponent(GlTooltip);
+ const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title');
+ const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button');
const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]');
- const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]');
-
- const createWrapper = (propsData, data = []) => {
- wrapper = mount(LinkedPipelineComponent, {
- propsData,
- data() {
- return {
- ...data,
- };
- },
- });
+ const findPipelineLabel = () => wrapper.findByTestId('downstream-pipeline-label');
+ const findPipelineLink = () => wrapper.findByTestId('pipelineLink');
+ const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline');
+
+ const createWrapper = ({ propsData, downstreamRetryAction = false }) => {
+ const mockApollo = createMockApollo();
+
+ wrapper = extendedWrapper(
+ mount(LinkedPipelineComponent, {
+ propsData,
+ provide: {
+ glFeatures: {
+ downstreamRetryAction,
+ },
+ },
+ apolloProvider: mockApollo,
+ }),
+ );
};
afterEach(() => {
@@ -59,7 +77,7 @@ describe('Linked pipeline', () => {
};
beforeEach(() => {
- createWrapper(props);
+ createWrapper({ propsData: props });
});
it('should render the project name', () => {
@@ -84,18 +102,13 @@ describe('Linked pipeline', () => {
expect(wrapper.text()).toContain(`#${props.pipeline.id}`);
});
- it('should correctly compute the tooltip text', () => {
- expect(wrapper.vm.tooltipText).toContain(mockPipeline.project.name);
- expect(wrapper.vm.tooltipText).toContain(mockPipeline.status.label);
- expect(wrapper.vm.tooltipText).toContain(mockPipeline.sourceJob.name);
- expect(wrapper.vm.tooltipText).toContain(mockPipeline.id);
- });
+ it('adds the card tooltip text to the DOM', () => {
+ expect(findCardTooltip().exists()).toBe(true);
- it('should render the tooltip text as the title attribute', () => {
- const titleAttr = findLinkedPipeline().attributes('title');
-
- expect(titleAttr).toContain(mockPipeline.project.name);
- expect(titleAttr).toContain(mockPipeline.status.label);
+ expect(findCardTooltip().text()).toContain(mockPipeline.project.name);
+ expect(findCardTooltip().text()).toContain(mockPipeline.status.label);
+ expect(findCardTooltip().text()).toContain(mockPipeline.sourceJob.name);
+ expect(findCardTooltip().text()).toContain(mockPipeline.id);
});
it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => {
@@ -105,7 +118,7 @@ describe('Linked pipeline', () => {
describe('upstream pipelines', () => {
beforeEach(() => {
- createWrapper(upstreamProps);
+ createWrapper({ propsData: upstreamProps });
});
it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => {
@@ -123,45 +136,246 @@ describe('Linked pipeline', () => {
});
describe('downstream pipelines', () => {
- beforeEach(() => {
- createWrapper(downstreamProps);
- });
-
- it('parent/child label container should exist', () => {
- expect(findPipelineLabel().exists()).toBe(true);
- });
-
- it('should display child label when pipeline project id is the same as triggered pipeline project id', () => {
- expect(findPipelineLabel().exists()).toBe(true);
- });
-
- it('should have the name of the trigger job on the card when it is a child pipeline', () => {
- expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name);
- });
-
- it('downstream pipeline should contain the correct link', () => {
- expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path);
+ describe('styling', () => {
+ beforeEach(() => {
+ createWrapper({ propsData: downstreamProps });
+ });
+
+ it('parent/child label container should exist', () => {
+ expect(findPipelineLabel().exists()).toBe(true);
+ });
+
+ it('should display child label when pipeline project id is the same as triggered pipeline project id', () => {
+ expect(findPipelineLabel().exists()).toBe(true);
+ });
+
+ it('should have the name of the trigger job on the card when it is a child pipeline', () => {
+ expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name);
+ });
+
+ it('downstream pipeline should contain the correct link', () => {
+ expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path);
+ });
+
+ it('applies the flex-row css class to the card', () => {
+ expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row');
+ expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row-reverse');
+ });
});
- it('applies the flex-row css class to the card', () => {
- expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row');
- expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row-reverse');
+ describe('action button', () => {
+ describe('with the `downstream_retry_action` flag on', () => {
+ describe('with permissions', () => {
+ describe('on an upstream', () => {
+ describe('when retryable', () => {
+ beforeEach(() => {
+ const retryablePipeline = {
+ ...upstreamProps,
+ pipeline: { ...mockPipeline, retryable: true },
+ };
+
+ createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true });
+ });
+
+ it('does not show the retry or cancel button', () => {
+ expect(findCancelButton().exists()).toBe(false);
+ expect(findRetryButton().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('on a downstream', () => {
+ describe('when retryable', () => {
+ beforeEach(() => {
+ const retryablePipeline = {
+ ...downstreamProps,
+ pipeline: { ...mockPipeline, retryable: true },
+ };
+
+ createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true });
+ });
+
+ it('shows only the retry button', () => {
+ expect(findCancelButton().exists()).toBe(false);
+ expect(findRetryButton().exists()).toBe(true);
+ });
+
+ it('hides the card tooltip when the action button tooltip is hovered', async () => {
+ expect(findCardTooltip().exists()).toBe(true);
+
+ await findRetryButton().trigger('mouseover');
+
+ expect(findCardTooltip().exists()).toBe(false);
+ });
+
+ describe('and the retry button is clicked', () => {
+ describe('on success', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
+ jest.spyOn(wrapper.vm, '$emit');
+ await findRetryButton().trigger('click');
+ });
+
+ it('calls the retry mutation ', () => {
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: RetryPipelineMutation,
+ variables: {
+ id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id),
+ },
+ });
+ });
+
+ it('emits the refreshPipelineGraph event', () => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph');
+ });
+ });
+
+ describe('on failure', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] });
+ jest.spyOn(wrapper.vm, '$emit');
+ await findRetryButton().trigger('click');
+ });
+
+ it('emits an error event', () => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', {
+ type: ACTION_FAILURE,
+ });
+ });
+ });
+ });
+ });
+
+ describe('when cancelable', () => {
+ beforeEach(() => {
+ const cancelablePipeline = {
+ ...downstreamProps,
+ pipeline: { ...mockPipeline, cancelable: true },
+ };
+
+ createWrapper({ propsData: cancelablePipeline, downstreamRetryAction: true });
+ });
+
+ it('shows only the cancel button ', () => {
+ expect(findCancelButton().exists()).toBe(true);
+ expect(findRetryButton().exists()).toBe(false);
+ });
+
+ it('hides the card tooltip when the action button tooltip is hovered', async () => {
+ expect(findCardTooltip().exists()).toBe(true);
+
+ await findCancelButton().trigger('mouseover');
+
+ expect(findCardTooltip().exists()).toBe(false);
+ });
+
+ describe('and the cancel button is clicked', () => {
+ describe('on success', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
+ jest.spyOn(wrapper.vm, '$emit');
+ await findCancelButton().trigger('click');
+ });
+
+ it('calls the cancel mutation', () => {
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: CancelPipelineMutation,
+ variables: {
+ id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id),
+ },
+ });
+ });
+ it('emits the refreshPipelineGraph event', () => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph');
+ });
+ });
+ describe('on failure', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] });
+ jest.spyOn(wrapper.vm, '$emit');
+ await findCancelButton().trigger('click');
+ });
+ it('emits an error event', () => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', {
+ type: ACTION_FAILURE,
+ });
+ });
+ });
+ });
+ });
+
+ describe('when both cancellable and retryable', () => {
+ beforeEach(() => {
+ const pipelineWithTwoActions = {
+ ...downstreamProps,
+ pipeline: { ...mockPipeline, cancelable: true, retryable: true },
+ };
+
+ createWrapper({ propsData: pipelineWithTwoActions, downstreamRetryAction: true });
+ });
+
+ it('only shows the cancel button', () => {
+ expect(findRetryButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('without permissions', () => {
+ beforeEach(() => {
+ const pipelineWithTwoActions = {
+ ...downstreamProps,
+ pipeline: {
+ ...mockPipeline,
+ cancelable: true,
+ retryable: true,
+ userPermissions: { updatePipeline: false },
+ },
+ };
+
+ createWrapper({ propsData: pipelineWithTwoActions });
+ });
+
+ it('does not show any action button', () => {
+ expect(findRetryButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with the `downstream_retry_action` flag off', () => {
+ beforeEach(() => {
+ const pipelineWithTwoActions = {
+ ...downstreamProps,
+ pipeline: { ...mockPipeline, cancelable: true, retryable: true },
+ };
+
+ createWrapper({ propsData: pipelineWithTwoActions });
+ });
+ it('does not show any action button', () => {
+ expect(findRetryButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
});
});
describe('expand button', () => {
it.each`
- pipelineType | anglePosition | borderClass | expanded
- ${downstreamProps} | ${'angle-right'} | ${'gl-border-l-1!'} | ${false}
- ${downstreamProps} | ${'angle-left'} | ${'gl-border-l-1!'} | ${true}
- ${upstreamProps} | ${'angle-left'} | ${'gl-border-r-1!'} | ${false}
- ${upstreamProps} | ${'angle-right'} | ${'gl-border-r-1!'} | ${true}
+ pipelineType | anglePosition | buttonBorderClasses | expanded
+ ${downstreamProps} | ${'angle-right'} | ${'gl-border-l-0!'} | ${false}
+ ${downstreamProps} | ${'angle-left'} | ${'gl-border-l-0!'} | ${true}
+ ${upstreamProps} | ${'angle-left'} | ${'gl-border-r-0!'} | ${false}
+ ${upstreamProps} | ${'angle-right'} | ${'gl-border-r-0!'} | ${true}
`(
- '$pipelineType.columnTitle pipeline button icon should be $anglePosition with $borderClass if expanded state is $expanded',
- ({ pipelineType, anglePosition, borderClass, expanded }) => {
- createWrapper({ ...pipelineType, expanded });
+ '$pipelineType.columnTitle pipeline button icon should be $anglePosition with $buttonBorderClasses if expanded state is $expanded',
+ ({ pipelineType, anglePosition, buttonBorderClasses, expanded }) => {
+ createWrapper({ propsData: { ...pipelineType, expanded } });
expect(findExpandButton().props('icon')).toBe(anglePosition);
- expect(findExpandButton().classes()).toContain(borderClass);
+ expect(findExpandButton().classes()).toContain(buttonBorderClasses);
},
);
});
@@ -176,7 +390,7 @@ describe('Linked pipeline', () => {
};
beforeEach(() => {
- createWrapper(props);
+ createWrapper({ propsData: props });
});
it('loading icon is visible', () => {
@@ -194,7 +408,7 @@ describe('Linked pipeline', () => {
};
beforeEach(() => {
- createWrapper(props);
+ createWrapper({ propsData: props });
});
it('emits `pipelineClicked` event', () => {
diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
index 1673065e09c..46000711110 100644
--- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
@@ -67,7 +67,6 @@ describe('Linked Pipelines Column', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
describe('it renders correctly', () => {
diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js
index 955b70cbd3b..f7f5738e46d 100644
--- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js
+++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js
@@ -2,6 +2,11 @@ export default {
__typename: 'Pipeline',
id: 195,
iid: '5',
+ retryable: false,
+ cancelable: false,
+ userPermissions: {
+ updatePipeline: true,
+ },
path: '/root/elemenohpee/-/pipelines/195',
status: {
__typename: 'DetailedStatus',
diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js
index 0cf7dc507f4..6124d67af09 100644
--- a/spec/frontend/pipelines/graph/mock_data.js
+++ b/spec/frontend/pipelines/graph/mock_data.js
@@ -1,4 +1,5 @@
import { unwrapPipelineData } from '~/pipelines/components/graph/utils';
+import { BUILD_KIND, BRIDGE_KIND } from '~/pipelines/components/graph/constants';
export const mockPipelineResponse = {
data: {
@@ -50,6 +51,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '6',
+ kind: BUILD_KIND,
name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
scheduledAt: null,
status: {
@@ -101,6 +103,7 @@ export const mockPipelineResponse = {
__typename: 'CiJob',
id: '11',
name: 'build_b',
+ kind: BUILD_KIND,
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
@@ -151,6 +154,7 @@ export const mockPipelineResponse = {
__typename: 'CiJob',
id: '16',
name: 'build_c',
+ kind: BUILD_KIND,
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
@@ -200,6 +204,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '21',
+ kind: BUILD_KIND,
name: 'build_d 1/3',
scheduledAt: null,
status: {
@@ -232,6 +237,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '24',
+ kind: BUILD_KIND,
name: 'build_d 2/3',
scheduledAt: null,
status: {
@@ -264,6 +270,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '27',
+ kind: BUILD_KIND,
name: 'build_d 3/3',
scheduledAt: null,
status: {
@@ -329,6 +336,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '34',
+ kind: BUILD_KIND,
name: 'test_a',
scheduledAt: null,
status: {
@@ -413,6 +421,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '42',
+ kind: BUILD_KIND,
name: 'test_b 1/2',
scheduledAt: null,
status: {
@@ -499,6 +508,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '67',
+ kind: BUILD_KIND,
name: 'test_b 2/2',
scheduledAt: null,
status: {
@@ -603,6 +613,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '59',
+ kind: BUILD_KIND,
name: 'test_c',
scheduledAt: null,
status: {
@@ -646,6 +657,7 @@ export const mockPipelineResponse = {
{
__typename: 'CiJob',
id: '53',
+ kind: BUILD_KIND,
name: 'test_d',
scheduledAt: null,
status: {
@@ -699,6 +711,11 @@ export const downstream = {
id: 175,
iid: '31',
path: '/root/elemenohpee/-/pipelines/175',
+ retryable: true,
+ cancelable: false,
+ userPermissions: {
+ updatePipeline: true,
+ },
status: {
id: '70',
group: 'success',
@@ -724,6 +741,11 @@ export const downstream = {
id: 181,
iid: '27',
path: '/root/abcd-dag/-/pipelines/181',
+ retryable: true,
+ cancelable: false,
+ userPermissions: {
+ updatePipeline: true,
+ },
status: {
id: '72',
group: 'success',
@@ -752,6 +774,11 @@ export const upstream = {
id: 161,
iid: '24',
path: '/root/abcd-dag/-/pipelines/161',
+ retryable: true,
+ cancelable: false,
+ userPermissions: {
+ updatePipeline: true,
+ },
status: {
id: '74',
group: 'success',
@@ -786,6 +813,11 @@ export const wrappedPipelineReturn = {
updatePipeline: true,
},
downstream: {
+ retryable: true,
+ cancelable: false,
+ userPermissions: {
+ updatePipeline: true,
+ },
__typename: 'PipelineConnection',
nodes: [],
},
@@ -793,6 +825,11 @@ export const wrappedPipelineReturn = {
id: 'gid://gitlab/Ci::Pipeline/174',
iid: '37',
path: '/root/elemenohpee/-/pipelines/174',
+ retryable: true,
+ cancelable: false,
+ userPermissions: {
+ updatePipeline: true,
+ },
__typename: 'Pipeline',
status: {
__typename: 'DetailedStatus',
@@ -846,6 +883,7 @@ export const wrappedPipelineReturn = {
{
__typename: 'CiJob',
id: '83',
+ kind: BUILD_KIND,
name: 'build_n',
scheduledAt: null,
needs: {
@@ -916,3 +954,87 @@ export const mockCalloutsResponse = (mappedCallouts) => ({
},
},
});
+
+export const delayedJob = {
+ __typename: 'CiJob',
+ kind: BUILD_KIND,
+ name: 'delayed job',
+ scheduledAt: '2015-07-03T10:01:00.000Z',
+ needs: [],
+ status: {
+ __typename: 'DetailedStatus',
+ icon: 'status_scheduled',
+ tooltip: 'delayed manual action (%{remainingTime})',
+ hasDetails: true,
+ detailsPath: '/root/kinder-pipe/-/jobs/5339',
+ group: 'scheduled',
+ action: {
+ __typename: 'StatusAction',
+ icon: 'time-out',
+ title: 'Unschedule',
+ path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule',
+ buttonTitle: 'Unschedule job',
+ },
+ },
+};
+
+export const mockJob = {
+ id: 4256,
+ name: 'test',
+ kind: BUILD_KIND,
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ tooltip: 'passed',
+ group: 'success',
+ detailsPath: '/root/ci-mock/builds/4256',
+ hasDetails: true,
+ action: {
+ icon: 'retry',
+ title: 'Retry',
+ path: '/root/ci-mock/builds/4256/retry',
+ method: 'post',
+ },
+ },
+};
+
+export const mockJobWithoutDetails = {
+ id: 4257,
+ name: 'job_without_details',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ detailsPath: '/root/ci-mock/builds/4257',
+ hasDetails: false,
+ },
+};
+
+export const mockJobWithUnauthorizedAction = {
+ id: 4258,
+ name: 'stop-environment',
+ status: {
+ icon: 'status_manual',
+ label: 'manual stop action (not allowed)',
+ tooltip: 'manual action',
+ group: 'manual',
+ detailsPath: '/root/ci-mock/builds/4258',
+ hasDetails: true,
+ action: null,
+ },
+};
+
+export const triggerJob = {
+ id: 4259,
+ name: 'trigger',
+ kind: BRIDGE_KIND,
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ action: null,
+ },
+};
diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js
index be422fac92c..2c6d126e12c 100644
--- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js
+++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import { parseData } from '~/pipelines/components/parsing_utils';
import { createJobsHash } from '~/pipelines/utils';
@@ -42,7 +42,7 @@ describe('Links Inner component', () => {
// We create fixture so that each job has an empty div that represent
// the JobPill in the DOM. Each `JobPill` would have different coordinates,
// so we increment their coordinates on each iteration to simulate different positions.
- const setFixtures = ({ stages }) => {
+ const setHTMLFixtureLocal = ({ stages }) => {
const jobs = createJobsHash(stages);
const arrayOfJobs = Object.keys(jobs);
@@ -82,6 +82,7 @@ describe('Links Inner component', () => {
afterEach(() => {
jest.restoreAllMocks();
wrapper.destroy();
+ resetHTMLFixture();
});
describe('basic SVG creation', () => {
@@ -124,7 +125,7 @@ describe('Links Inner component', () => {
describe('with one need', () => {
beforeEach(() => {
- setFixtures(pipelineData);
+ setHTMLFixtureLocal(pipelineData);
createComponent({ pipelineData: pipelineData.stages });
});
@@ -143,7 +144,7 @@ describe('Links Inner component', () => {
describe('with a parallel need', () => {
beforeEach(() => {
- setFixtures(parallelNeedData);
+ setHTMLFixtureLocal(parallelNeedData);
createComponent({ pipelineData: parallelNeedData.stages });
});
@@ -162,7 +163,7 @@ describe('Links Inner component', () => {
describe('with same stage needs', () => {
beforeEach(() => {
- setFixtures(sameStageNeeds);
+ setHTMLFixtureLocal(sameStageNeeds);
createComponent({ pipelineData: sameStageNeeds.stages });
});
@@ -181,7 +182,7 @@ describe('Links Inner component', () => {
describe('with a large number of needs', () => {
beforeEach(() => {
- setFixtures(largePipelineData);
+ setHTMLFixtureLocal(largePipelineData);
createComponent({ pipelineData: largePipelineData.stages });
});
@@ -200,7 +201,7 @@ describe('Links Inner component', () => {
describe('interactions', () => {
beforeEach(() => {
- setFixtures(largePipelineData);
+ setHTMLFixtureLocal(largePipelineData);
createComponent({ pipelineData: largePipelineData.stages });
});
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index c4639bd8e16..5cc11adf696 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -1,4 +1,4 @@
-import { GlModal, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlModal, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
@@ -21,6 +21,7 @@ describe('Pipeline details header', () => {
let glModalDirective;
let mutate = jest.fn();
+ const findAlert = () => wrapper.find(GlAlert);
const findDeleteModal = () => wrapper.find(GlModal);
const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]');
@@ -121,6 +122,22 @@ describe('Pipeline details header', () => {
it('should render retry action tooltip', () => {
expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY);
});
+
+ it('should display error message on failure', async () => {
+ const failureMessage = 'failure message';
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: {
+ pipelineRetry: {
+ errors: [failureMessage],
+ },
+ },
+ });
+
+ findRetryButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(findAlert().text()).toBe(failureMessage);
+ });
});
describe('Retry action failed', () => {
@@ -156,6 +173,22 @@ describe('Pipeline details header', () => {
variables: { id: mockRunningPipelineHeader.id },
});
});
+
+ it('should display error message on failure', async () => {
+ const failureMessage = 'failure message';
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: {
+ pipelineCancel: {
+ errors: [failureMessage],
+ },
+ },
+ });
+
+ findCancelButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(findAlert().text()).toBe(failureMessage);
+ });
});
describe('Delete action', () => {
@@ -179,6 +212,22 @@ describe('Pipeline details header', () => {
variables: { id: mockFailedPipelineHeader.id },
});
});
+
+ it('should display error message on failure', async () => {
+ const failureMessage = 'failure message';
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: {
+ pipelineDestroy: {
+ errors: [failureMessage],
+ },
+ },
+ });
+
+ findDeleteModal().vm.$emit('ok');
+ await waitForPromises();
+
+ expect(findAlert().text()).toBe(failureMessage);
+ });
});
describe('Permissions', () => {
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
index 59d4e808b32..57d1511d859 100644
--- a/spec/frontend/pipelines/mock_data.js
+++ b/spec/frontend/pipelines/mock_data.js
@@ -1141,3 +1141,218 @@ export const mockPipelineBranch = () => {
viewType: 'root',
};
};
+
+export const mockFailedJobsQueryResponse = {
+ data: {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ __typename: 'Pipeline',
+ id: 'gid://gitlab/Ci::Pipeline/300',
+ jobs: {
+ __typename: 'CiJobConnection',
+ nodes: [
+ {
+ __typename: 'CiJob',
+ status: 'FAILED',
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'failed-1848-1848',
+ detailsPath: '/root/ci-project/-/jobs/1848',
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ tooltip: 'failed - (script failure)',
+ action: {
+ __typename: 'StatusAction',
+ id: 'Ci::Build-failed-1848',
+ buttonTitle: 'Retry this job',
+ icon: 'retry',
+ method: 'post',
+ path: '/root/ci-project/-/jobs/1848/retry',
+ title: 'Retry',
+ },
+ },
+ id: 'gid://gitlab/Ci::Build/1848',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/358',
+ name: 'build',
+ },
+ name: 'wait_job',
+ retryable: true,
+ userPermissions: {
+ __typename: 'JobPermissions',
+ readBuild: true,
+ updateBuild: true,
+ },
+ },
+ {
+ __typename: 'CiJob',
+ status: 'FAILED',
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'failed-1710-1710',
+ detailsPath: '/root/ci-project/-/jobs/1710',
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ tooltip: 'failed - (script failure) (retried)',
+ action: null,
+ },
+ id: 'gid://gitlab/Ci::Build/1710',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/358',
+ name: 'build',
+ },
+ name: 'wait_job',
+ retryable: false,
+ userPermissions: {
+ __typename: 'JobPermissions',
+ readBuild: true,
+ updateBuild: true,
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+};
+
+export const mockFailedJobsSummaryData = [
+ {
+ id: 1848,
+ failure: null,
+ failure_summary:
+ '<span>Pulling docker image node:latest ...<br/></span><span>Using docker image sha256:738d733448be00c72cb6618b7a06a1424806c6d239d8885e92f9b1e8727092b5 for node:latest with digest node@sha256:e5b7b349d517159246070bf14242027a9e220ffa8bd98a67ba1495d969c06c01 ...<br/></span><div class="section-start" data-timestamp="1651175313" data-section="prepare-script" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-prepare-script">Preparing environment</span><span class="section section-header js-s-prepare-script"><br/></span><span class="section line js-s-prepare-script">Running on runner-kvkqh24-project-20-concurrent-0 via 0706719b1b8d...<br/></span><div class="section-end" data-section="prepare-script"></div><div class="section-start" data-timestamp="1651175313" data-section="get-sources" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-get-sources">Getting source from Git repository</span><span class="section section-header js-s-get-sources"><br/></span><span class="term-fg-l-green term-bold section line js-s-get-sources">Fetching changes with git depth set to 50...</span><span class="section line js-s-get-sources"><br/>Reinitialized existing Git repository in /builds/root/ci-project/.git/<br/>fatal: couldn\'t find remote ref refs/heads/test<br/></span><div class="section-end" data-section="get-sources"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit code 1<br/></span>',
+ },
+];
+
+export const mockFailedJobsData = [
+ {
+ normalizedId: 1848,
+ __typename: 'CiJob',
+ status: 'FAILED',
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'failed-1848-1848',
+ detailsPath: '/root/ci-project/-/jobs/1848',
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ tooltip: 'failed - (script failure)',
+ action: {
+ __typename: 'StatusAction',
+ id: 'Ci::Build-failed-1848',
+ buttonTitle: 'Retry this job',
+ icon: 'retry',
+ method: 'post',
+ path: '/root/ci-project/-/jobs/1848/retry',
+ title: 'Retry',
+ },
+ },
+ id: 'gid://gitlab/Ci::Build/1848',
+ stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' },
+ name: 'wait_job',
+ retryable: true,
+ userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true },
+ },
+ {
+ normalizedId: 1710,
+ __typename: 'CiJob',
+ status: 'FAILED',
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'failed-1710-1710',
+ detailsPath: '/root/ci-project/-/jobs/1710',
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ tooltip: 'failed - (script failure) (retried)',
+ action: null,
+ },
+ id: 'gid://gitlab/Ci::Build/1710',
+ stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' },
+ name: 'wait_job',
+ retryable: false,
+ userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true },
+ },
+];
+
+export const mockPreparedFailedJobsData = [
+ {
+ __typename: 'CiJob',
+ _showDetails: true,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ action: {
+ __typename: 'StatusAction',
+ buttonTitle: 'Retry this job',
+ icon: 'retry',
+ id: 'Ci::Build-failed-1848',
+ method: 'post',
+ path: '/root/ci-project/-/jobs/1848/retry',
+ title: 'Retry',
+ },
+ detailsPath: '/root/ci-project/-/jobs/1848',
+ group: 'failed',
+ icon: 'status_failed',
+ id: 'failed-1848-1848',
+ label: 'failed',
+ text: 'failed',
+ tooltip: 'failed - (script failure)',
+ },
+ failure: null,
+ failureSummary:
+ '<span>Pulling docker image node:latest ...<br/></span><span>Using docker image sha256:738d733448be00c72cb6618b7a06a1424806c6d239d8885e92f9b1e8727092b5 for node:latest with digest node@sha256:e5b7b349d517159246070bf14242027a9e220ffa8bd98a67ba1495d969c06c01 ...<br/></span><div class="section-start" data-timestamp="1651175313" data-section="prepare-script" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-prepare-script">Preparing environment</span><span class="section section-header js-s-prepare-script"><br/></span><span class="section line js-s-prepare-script">Running on runner-kvkqh24-project-20-concurrent-0 via 0706719b1b8d...<br/></span><div class="section-end" data-section="prepare-script"></div><div class="section-start" data-timestamp="1651175313" data-section="get-sources" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-get-sources">Getting source from Git repository</span><span class="section section-header js-s-get-sources"><br/></span><span class="term-fg-l-green term-bold section line js-s-get-sources">Fetching changes with git depth set to 50...</span><span class="section line js-s-get-sources"><br/>Reinitialized existing Git repository in /builds/root/ci-project/.git/<br/>fatal: couldn\'t find remote ref refs/heads/test<br/></span><div class="section-end" data-section="get-sources"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit code 1<br/></span>',
+ id: 'gid://gitlab/Ci::Build/1848',
+ name: 'wait_job',
+ normalizedId: 1848,
+ retryable: true,
+ stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' },
+ status: 'FAILED',
+ userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true },
+ },
+];
+
+export const mockPreparedFailedJobsDataNoPermission = [
+ {
+ ...mockPreparedFailedJobsData[0],
+ userPermissions: { __typename: 'JobPermissions', readBuild: false, updateBuild: false },
+ },
+];
+
+export const successRetryMutationResponse = {
+ data: {
+ jobRetry: {
+ job: {
+ __typename: 'CiJob',
+ id: '"gid://gitlab/Ci::Build/1985"',
+ detailedStatus: {
+ detailsPath: '/root/project/-/jobs/1985',
+ id: 'pending-1985-1985',
+ __typename: 'DetailedStatus',
+ },
+ },
+ errors: [],
+ __typename: 'JobRetryPayload',
+ },
+ },
+};
+
+export const failedRetryMutationResponse = {
+ data: {
+ jobRetry: {
+ job: {},
+ errors: ['New Error'],
+ __typename: 'JobRetryPayload',
+ },
+ },
+};
diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js
index 5816bc06fe3..d6b13da3c3a 100644
--- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js
+++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js
@@ -1,4 +1,5 @@
-import { createJobsHash, generateJobNeedsDict } from '~/pipelines/utils';
+import { createJobsHash, generateJobNeedsDict, getPipelineDefaultTab } from '~/pipelines/utils';
+import { TAB_QUERY_PARAM, validPipelineTabNames } from '~/pipelines/constants';
describe('utils functions', () => {
const jobName1 = 'build_1';
@@ -169,4 +170,21 @@ describe('utils functions', () => {
});
});
});
+
+ describe('getPipelineDefaultTab', () => {
+ const baseUrl = 'http://gitlab.com/user/multi-projects-small/-/pipelines/332/';
+ it('returns null if there was no `tab` params', () => {
+ expect(getPipelineDefaultTab(baseUrl)).toBe(null);
+ });
+
+ it('returns null if there was no valid tab param', () => {
+ expect(getPipelineDefaultTab(`${baseUrl}?${TAB_QUERY_PARAM}=invalid`)).toBe(null);
+ });
+
+ it('returns the correct tab name if present', () => {
+ validPipelineTabNames.forEach((tabName) => {
+ expect(getPipelineDefaultTab(`${baseUrl}?${TAB_QUERY_PARAM}=${tabName}`)).toBe(tabName);
+ });
+ });
+ });
});
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index d2b30c93746..de9f394db43 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -82,6 +82,8 @@ describe('Pipelines', () => {
provide: {
pipelineEditorPath: '',
suggestedCiTemplates: [],
+ ciRunnerSettingsPath: paths.ciRunnerSettingsPath,
+ anyRunnersAvailable: true,
},
propsData: {
store: new Store(),
diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js
index d5acb115bc1..74a9d8c354f 100644
--- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js
@@ -82,17 +82,16 @@ describe('Actions TestReports Store', () => {
);
});
- it('should create flash on API error', async () => {
+ it('should call SET_SUITE_ERROR on error', () => {
const index = 0;
- await testAction(
+ return testAction(
actions.fetchTestSuite,
index,
{ ...state, testReports, suiteEndpoint: null },
- [],
+ [{ type: types.SET_SUITE_ERROR, payload: expect.any(Error) }],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
);
- expect(createFlash).toHaveBeenCalled();
});
describe('when we already have the suite data', () => {
diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
index f2dbeec6a06..6ab479a257c 100644
--- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
@@ -1,6 +1,9 @@
import testReports from 'test_fixtures/pipelines/test_report.json';
import * as types from '~/pipelines/stores/test_reports/mutation_types';
import mutations from '~/pipelines/stores/test_reports/mutations';
+import createFlash from '~/flash';
+
+jest.mock('~/flash.js');
describe('Mutations TestReports Store', () => {
let mockState;
@@ -44,6 +47,24 @@ describe('Mutations TestReports Store', () => {
});
});
+ describe('set suite error', () => {
+ it('should set the error message in state if provided', () => {
+ const message = 'Test report artifacts have expired';
+
+ mutations[types.SET_SUITE_ERROR](mockState, {
+ response: { data: { errors: message } },
+ });
+
+ expect(mockState.errorMessage).toBe(message);
+ });
+
+ it('should show a flash message otherwise', () => {
+ mutations[types.SET_SUITE_ERROR](mockState, {});
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+
describe('set selected suite index', () => {
it('should set selectedSuiteIndex', () => {
const selectedSuiteIndex = 0;
diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
index 97241e14129..dc72fa31ace 100644
--- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
@@ -1,12 +1,13 @@
import { GlButton, GlFriendlyWrap, GlLink, GlPagination } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import testReports from 'test_fixtures/pipelines/test_report.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue';
import { TestStatus } from '~/pipelines/constants';
import * as getters from '~/pipelines/stores/test_reports/getters';
import { formatFilePath } from '~/pipelines/stores/test_reports/utils';
+import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from '~/pipelines/stores/test_reports/constants';
import skippedTestCases from './mock_data';
Vue.use(Vuex);
@@ -23,13 +24,14 @@ describe('Test reports suite table', () => {
const testCases = testSuite.test_cases;
const blobPath = '/test/blob/path';
- const noCasesMessage = () => wrapper.find('.js-no-test-cases');
- const allCaseRows = () => wrapper.findAll('.js-case-row');
- const findCaseRowAtIndex = (index) => wrapper.findAll('.js-case-row').at(index);
+ const noCasesMessage = () => wrapper.findByTestId('no-test-cases');
+ const artifactsExpiredMessage = () => wrapper.findByTestId('artifacts-expired');
+ const allCaseRows = () => wrapper.findAllByTestId('test-case-row');
+ const findCaseRowAtIndex = (index) => wrapper.findAllByTestId('test-case-row').at(index);
const findLinkForRow = (row) => row.find(GlLink);
const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`);
- const createComponent = (suite = testSuite, perPage = 20) => {
+ const createComponent = ({ suite = testSuite, perPage = 20, errorMessage } = {}) => {
store = new Vuex.Store({
state: {
blobPath,
@@ -41,11 +43,12 @@ describe('Test reports suite table', () => {
page: 1,
perPage,
},
+ errorMessage,
},
getters,
});
- wrapper = shallowMount(SuiteTable, {
+ wrapper = shallowMountExtended(SuiteTable, {
store,
stubs: { GlFriendlyWrap },
});
@@ -55,12 +58,18 @@ describe('Test reports suite table', () => {
wrapper.destroy();
});
- describe('should not render', () => {
- beforeEach(() => createComponent([]));
+ it('should render a message when there are no test cases', () => {
+ createComponent({ suite: [] });
- it('a table when there are no test cases', () => {
- expect(noCasesMessage().exists()).toBe(true);
- });
+ expect(noCasesMessage().exists()).toBe(true);
+ expect(artifactsExpiredMessage().exists()).toBe(false);
+ });
+
+ it('should render a message when artifacts have expired', () => {
+ createComponent({ suite: [], errorMessage: ARTIFACTS_EXPIRED_ERROR_MESSAGE });
+
+ expect(noCasesMessage().exists()).toBe(true);
+ expect(artifactsExpiredMessage().exists()).toBe(true);
});
describe('when a test suite is supplied', () => {
@@ -102,7 +111,7 @@ describe('Test reports suite table', () => {
const perPage = 2;
beforeEach(() => {
- createComponent(testSuite, perPage);
+ createComponent({ testSuite, perPage });
});
it('renders one page of test cases', () => {
@@ -117,11 +126,13 @@ describe('Test reports suite table', () => {
describe('when a test case classname property is null', () => {
it('still renders all test cases', () => {
createComponent({
- ...testSuite,
- test_cases: testSuite.test_cases.map((testCase) => ({
- ...testCase,
- classname: null,
- })),
+ testSuite: {
+ ...testSuite,
+ test_cases: testSuite.test_cases.map((testCase) => ({
+ ...testCase,
+ classname: null,
+ })),
+ },
});
expect(allCaseRows()).toHaveLength(testCases.length);
@@ -131,11 +142,13 @@ describe('Test reports suite table', () => {
describe('when a test case name property is null', () => {
it('still renders all test cases', () => {
createComponent({
- ...testSuite,
- test_cases: testSuite.test_cases.map((testCase) => ({
- ...testCase,
- name: null,
- })),
+ testSuite: {
+ ...testSuite,
+ test_cases: testSuite.test_cases.map((testCase) => ({
+ ...testCase,
+ name: null,
+ })),
+ },
});
expect(allCaseRows()).toHaveLength(testCases.length);
diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
index 42ae154fb5e..ba478363d04 100644
--- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
@@ -34,6 +34,7 @@ describe('Pipeline Branch Name Token', () => {
value: {
data: '',
},
+ cursorPosition: 'start',
};
const optionsWithDefaultBranchName = (options) => {
diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js
index 684d2d0664a..b8abf2c1727 100644
--- a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js
@@ -20,6 +20,7 @@ describe('Pipeline Source Token', () => {
value: {
data: '',
},
+ cursorPosition: 'start',
};
const createComponent = () => {
diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
index 1db736ba01e..2c5fa8b00e2 100644
--- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js
@@ -20,6 +20,7 @@ describe('Pipeline Status Token', () => {
value: {
data: '',
},
+ cursorPosition: 'start',
};
const createComponent = () => {
diff --git a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js
index b03dbb73b95..596a9218c39 100644
--- a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js
@@ -29,6 +29,7 @@ describe('Pipeline Branch Name Token', () => {
value: {
data: '',
},
+ cursorPosition: 'start',
};
const createComponent = (options, data) => {
diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
index 7ddbbb3b005..397dbdf95a9 100644
--- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
@@ -24,6 +24,7 @@ describe('Pipeline Trigger Author Token', () => {
value: {
data: '',
},
+ cursorPosition: 'start',
};
const createComponent = (data) => {
diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js
index 40e7d27edc8..b8d5a1a61f3 100644
--- a/spec/frontend/project_select_combo_button_spec.js
+++ b/spec/frontend/project_select_combo_button_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import ProjectSelectComboButton from '~/project_select_combo_button';
const fixturePath = 'static/project_select_combo_button.html';
@@ -22,16 +23,25 @@ describe('Project Select Combo Button', () => {
name: 'My Other Cool Project',
url: 'http://myothercoolproject.com',
},
+ vulnerableProject: {
+ name: 'Self XSS',
+ // eslint-disable-next-line no-script-url
+ url: 'javascript:alert(1)',
+ },
localStorageKey: 'group-12345-new-issue-recent-project',
relativePath: 'issues/new',
};
- loadFixtures(fixturePath);
+ loadHTMLFixture(fixturePath);
testContext.newItemBtn = document.querySelector('.js-new-project-item-link');
testContext.projectSelectInput = document.querySelector('.project-item-select');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('on page load when localStorage is empty', () => {
beforeEach(() => {
testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput);
@@ -99,6 +109,25 @@ describe('Project Select Combo Button', () => {
});
});
+ describe('after selecting a vulnerable project', () => {
+ beforeEach(() => {
+ testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput);
+
+ // mock the effect of selecting an item from the projects dropdown (select2)
+ $('.project-item-select')
+ .val(JSON.stringify(testContext.defaults.vulnerableProject))
+ .trigger('change');
+ });
+
+ it('newItemBtn href is correctly sanitized', () => {
+ expect(testContext.newItemBtn.getAttribute('href')).toBe('about:blank');
+ });
+
+ afterEach(() => {
+ window.localStorage.clear();
+ });
+ });
+
describe('deriveTextVariants', () => {
beforeEach(() => {
testContext.mockExecutionContext = {
diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js
index 4e567ab030e..d11090cba8a 100644
--- a/spec/frontend/projects/commits/components/author_select_spec.js
+++ b/spec/frontend/projects/commits/components/author_select_spec.js
@@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownSectionHeader, GlSearchBoxByType, GlDropdownItem
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import * as urlUtility from '~/lib/utils/url_utility';
import AuthorSelect from '~/projects/commits/components/author_select.vue';
import { createStore } from '~/projects/commits/store';
@@ -30,7 +31,7 @@ describe('Author Select', () => {
let wrapper;
const createComponent = () => {
- setFixtures(`
+ setHTMLFixture(`
<div class="js-project-commits-show">
<input id="commits-search" type="text" />
<div id="commits-list"></div>
@@ -54,6 +55,7 @@ describe('Author Select', () => {
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
const findDropdownContainer = () => wrapper.find({ ref: 'dropdownContainer' });
diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
index 26a3b27d958..736d149f06d 100644
--- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
@@ -31,6 +31,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
+ arialabel=""
dismisslabel="Close"
footer-class="gl-bg-gray-10 gl-p-5"
modalclass=""
@@ -49,6 +50,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = `
primarybuttontext=""
secondarybuttonlink=""
secondarybuttontext=""
+ showicon="true"
title=""
variant="danger"
>
diff --git a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
index 2d1039a8743..26495fbcf83 100644
--- a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
@@ -43,6 +43,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = `
primarybuttontext=""
secondarybuttonlink=""
secondarybuttontext=""
+ showicon="true"
title=""
variant="danger"
>
diff --git a/spec/frontend/projects/new/components/deployment_target_select_spec.js b/spec/frontend/projects/new/components/deployment_target_select_spec.js
index 1c443879dc3..f3b22d4a1b9 100644
--- a/spec/frontend/projects/new/components/deployment_target_select_spec.js
+++ b/spec/frontend/projects/new/components/deployment_target_select_spec.js
@@ -1,6 +1,7 @@
import { GlFormGroup, GlFormSelect, GlFormText, GlLink, GlSprintf } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mockTracking } from 'helpers/tracking_helper';
import DeploymentTargetSelect from '~/projects/new/components/deployment_target_select.vue';
import {
@@ -32,7 +33,7 @@ describe('Deployment target select', () => {
};
const createForm = () => {
- setFixtures(`
+ setHTMLFixture(`
<form id="${NEW_PROJECT_FORM}">
</form>
`);
@@ -47,6 +48,7 @@ describe('Deployment target select', () => {
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
it('renders the correct label', () => {
diff --git a/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js
index 31ddbc80ae4..42259a5c392 100644
--- a/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js
+++ b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js
@@ -1,5 +1,6 @@
import { GlPopover, GlFormInputGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import NewProjectPushTipPopover from '~/projects/new/components/new_project_push_tip_popover.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -31,12 +32,13 @@ describe('New project push tip popover', () => {
};
beforeEach(() => {
- setFixtures(`<a id="${targetId}"></a>`);
+ setHTMLFixture(`<a id="${targetId}"></a>`);
buildWrapper();
});
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
it('renders popover that targets the specified target', () => {
diff --git a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js
index cafb3f231bd..7bb289408b8 100644
--- a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js
@@ -1,8 +1,8 @@
import { nextTick } from 'vue';
-import { GlSegmentedControl } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CiCdAnalyticsAreaChart from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue';
import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue';
+import SegmentedControlButtonGroup from '~/vue_shared/components/segmented_control_button_group.vue';
import { transformedAreaChartData, chartOptions } from '../mock_data';
const DEFAULT_PROPS = {
@@ -48,7 +48,7 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', (
});
const findMetricsSlot = () => wrapper.findByTestId('metrics-slot');
- const findSegmentedControl = () => wrapper.findComponent(GlSegmentedControl);
+ const findSegmentedControl = () => wrapper.findComponent(SegmentedControlButtonGroup);
describe('segmented control', () => {
beforeEach(() => {
@@ -56,7 +56,7 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', (
});
it('should default to the first chart', () => {
- expect(findSegmentedControl().props('checked')).toBe(0);
+ expect(findSegmentedControl().props('value')).toBe(0);
});
it('should use the title and index as values', () => {
diff --git a/spec/frontend/projects/project_import_gitlab_project_spec.js b/spec/frontend/projects/project_import_gitlab_project_spec.js
index aaf8a81f626..76621ba9c06 100644
--- a/spec/frontend/projects/project_import_gitlab_project_spec.js
+++ b/spec/frontend/projects/project_import_gitlab_project_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import projectImportGitlab from '~/projects/project_import_gitlab_project';
describe('Import Gitlab project', () => {
@@ -7,7 +8,7 @@ describe('Import Gitlab project', () => {
const setTestFixtures = (url) => {
window.history.pushState({}, null, url);
- setFixtures(`
+ setHTMLFixture(`
<input class="js-path-name" />
<input class="js-project-name" />
`);
@@ -21,6 +22,7 @@ describe('Import Gitlab project', () => {
afterEach(() => {
window.history.pushState({}, null, '');
+ resetHTMLFixture();
});
describe('project name', () => {
diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js
index d2936cb9efe..fe325343da8 100644
--- a/spec/frontend/projects/project_new_spec.js
+++ b/spec/frontend/projects/project_new_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import projectNew from '~/projects/project_new';
@@ -8,7 +9,7 @@ describe('New Project', () => {
let $projectName;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class='toggle-import-form'>
<div class='import-url-data'>
<div class="form-group">
@@ -33,6 +34,10 @@ describe('New Project', () => {
$projectName = $('#project_name');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('deriveProjectPathFromUrl', () => {
const dummyImportUrl = `${TEST_HOST}/dummy/import/url.git`;
diff --git a/spec/frontend/projects/projects_filterable_list_spec.js b/spec/frontend/projects/projects_filterable_list_spec.js
index a41e8b7bc09..f217efa411e 100644
--- a/spec/frontend/projects/projects_filterable_list_spec.js
+++ b/spec/frontend/projects/projects_filterable_list_spec.js
@@ -1,4 +1,4 @@
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import ProjectsFilterableList from '~/projects/projects_filterable_list';
describe('ProjectsFilterableList', () => {
@@ -20,6 +20,10 @@ describe('ProjectsFilterableList', () => {
List = new ProjectsFilterableList(form, filter, holder);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('getFilterEndpoint', () => {
it('updates converts getPagePath for projects', () => {
jest.spyOn(List, 'getPagePath').mockReturnValue('blah/projects?');
diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js
index 236968a3736..65b01172e7e 100644
--- a/spec/frontend/projects/settings/access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/access_dropdown_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import AccessDropdown from '~/projects/settings/access_dropdown';
import { LEVEL_TYPES } from '~/projects/settings/constants';
@@ -7,7 +8,7 @@ describe('AccessDropdown', () => {
let dropdown;
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div id="dummy-dropdown">
<span class="dropdown-toggle-text"></span>
</div>
@@ -28,6 +29,10 @@ describe('AccessDropdown', () => {
dropdown = new AccessDropdown(options);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('toggleLabel', () => {
let $dropdownToggleText;
const dummyItems = [
diff --git a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js
index dbea94cbd53..8b8e7d1454d 100644
--- a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js
+++ b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js
@@ -1,11 +1,11 @@
-import { GlTokenSelector, GlToken } from '@gitlab/ui';
+import { GlAvatarLabeled, GlTokenSelector, GlToken } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import TopicsTokenSelector from '~/projects/settings/topics/components/topics_token_selector.vue';
const mockTopics = [
- { id: 1, name: 'topic1', avatarUrl: 'avatar.com/topic1.png' },
- { id: 2, name: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' },
+ { id: 1, name: 'topic1', title: 'Topic 1', avatarUrl: 'avatar.com/topic1.png' },
+ { id: 2, name: 'GitLab', title: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' },
];
describe('TopicsTokenSelector', () => {
@@ -38,6 +38,8 @@ describe('TopicsTokenSelector', () => {
const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]');
+ const findAllAvatars = () => wrapper.findAllComponents(GlAvatarLabeled).wrappers;
+
const setTokenSelectorInputValue = (value) => {
const tokenSelectorInput = findTokenSelectorInput();
@@ -81,6 +83,13 @@ describe('TopicsTokenSelector', () => {
expect(tokenWrapper.text()).toBe(selected[index].name);
});
});
+
+ it('passes topic title to the avatar', async () => {
+ createComponent();
+ const avatars = findAllAvatars();
+
+ mockTopics.map((topic, index) => expect(avatars[index].text()).toBe(topic.title));
+ });
});
describe('when enter key is pressed', () => {
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
index 57e515723e5..aac1a418142 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
@@ -165,8 +165,12 @@ describe('ServiceDeskSetting', () => {
describe('save button', () => {
it('renders a save button to save a template', () => {
wrapper = createComponent();
+ const saveButton = findButton();
- expect(findButton().text()).toContain('Save changes');
+ expect(saveButton.text()).toContain('Save changes');
+ expect(saveButton.props()).toMatchObject({
+ variant: 'confirm',
+ });
});
it('emits a save event with the chosen template when the save button is clicked', async () => {
diff --git a/spec/frontend/prometheus_alerts/components/reset_key_spec.js b/spec/frontend/prometheus_alerts/components/reset_key_spec.js
deleted file mode 100644
index dc5fdb1dffc..00000000000
--- a/spec/frontend/prometheus_alerts/components/reset_key_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import { GlModal } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
-import waitForPromises from 'helpers/wait_for_promises';
-import axios from '~/lib/utils/axios_utils';
-import ResetKey from '~/prometheus_alerts/components/reset_key.vue';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-
-describe('ResetKey', () => {
- let mock;
- let vm;
-
- const propsData = {
- initialAuthorizationKey: 'abcd1234',
- changeKeyUrl: '/updateKeyUrl',
- notifyUrl: '/root/autodevops-deploy/prometheus/alerts/notify.json',
- learnMoreUrl: '/learnMore',
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- setFixtures('<div class="flash-container"></div><div id="reset-key"></div>');
- });
-
- afterEach(() => {
- mock.restore();
- vm.destroy();
- });
-
- describe('authorization key exists', () => {
- beforeEach(() => {
- propsData.initialAuthorizationKey = 'abcd1234';
- vm = shallowMount(ResetKey, {
- propsData,
- });
- });
-
- it('shows fields and buttons', () => {
- expect(vm.find('#notify-url').attributes('value')).toEqual(propsData.notifyUrl);
- expect(vm.find('#authorization-key').attributes('value')).toEqual(
- propsData.initialAuthorizationKey,
- );
-
- expect(vm.findAll(ClipboardButton).length).toBe(2);
- expect(vm.find('.js-reset-auth-key').text()).toEqual('Reset key');
- });
-
- it('reset updates key', async () => {
- mock.onPost(propsData.changeKeyUrl).replyOnce(200, { token: 'newToken' });
-
- vm.find(GlModal).vm.$emit('ok');
-
- await nextTick();
- await waitForPromises();
- expect(vm.vm.authorizationKey).toEqual('newToken');
- expect(vm.find('#authorization-key').attributes('value')).toEqual('newToken');
- });
-
- it('reset key failure shows error', async () => {
- mock.onPost(propsData.changeKeyUrl).replyOnce(500);
-
- vm.find(GlModal).vm.$emit('ok');
-
- await nextTick();
- await waitForPromises();
- expect(vm.find('#authorization-key').attributes('value')).toEqual(
- propsData.initialAuthorizationKey,
- );
-
- expect(document.querySelector('.flash-container').innerText.trim()).toEqual(
- 'Failed to reset key. Please try again.',
- );
- });
- });
-
- describe('authorization key has not been set', () => {
- beforeEach(() => {
- propsData.initialAuthorizationKey = '';
- vm = shallowMount(ResetKey, {
- propsData,
- });
- });
-
- it('shows Generate Key button', () => {
- expect(vm.find('.js-reset-auth-key').text()).toEqual('Generate key');
- expect(vm.find('#authorization-key').attributes('value')).toEqual('');
- });
-
- it('Generate key button triggers key change', async () => {
- mock.onPost(propsData.changeKeyUrl).replyOnce(200, { token: 'newToken' });
-
- vm.find('.js-reset-auth-key').vm.$emit('click');
-
- await waitForPromises();
- expect(vm.find('#authorization-key').attributes('value')).toEqual('newToken');
- });
- });
-});
diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
index 20593351ee5..473327bf5e1 100644
--- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import axios from '~/lib/utils/axios_utils';
import PANEL_STATE from '~/prometheus_metrics/constants';
import CustomMetrics from '~/prometheus_metrics/custom_metrics';
@@ -15,11 +16,12 @@ describe('PrometheusMetrics', () => {
mock.onGet(customMetricsEndpoint).reply(200, {
metrics,
});
- loadFixtures(FIXTURE);
+ loadHTMLFixture(FIXTURE);
});
afterEach(() => {
mock.restore();
+ resetHTMLFixture();
});
describe('Custom Metrics', () => {
diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
index ee74e28ba23..1151c0b3769 100644
--- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import PANEL_STATE from '~/prometheus_metrics/constants';
@@ -9,7 +10,7 @@ describe('PrometheusMetrics', () => {
const FIXTURE = 'services/prometheus/prometheus_service.html';
beforeEach(() => {
- loadFixtures(FIXTURE);
+ loadHTMLFixture(FIXTURE);
});
describe('constructor', () => {
@@ -19,6 +20,10 @@ describe('PrometheusMetrics', () => {
prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should initialize wrapper element refs on class object', () => {
expect(prometheusMetrics.$wrapper).toBeDefined();
expect(prometheusMetrics.$monitoredMetricsPanel).toBeDefined();
diff --git a/spec/frontend/protected_branches/protected_branch_create_spec.js b/spec/frontend/protected_branches/protected_branch_create_spec.js
index b3de2d5e031..4b634c52b01 100644
--- a/spec/frontend/protected_branches/protected_branch_create_spec.js
+++ b/spec/frontend/protected_branches/protected_branch_create_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import ProtectedBranchCreate from '~/protected_branches/protected_branch_create';
const FORCE_PUSH_TOGGLE_TESTID = 'force-push-toggle';
@@ -21,7 +22,7 @@ describe('ProtectedBranchCreate', () => {
codeOwnerToggleChecked = false,
hasLicense = true,
} = {}) => {
- setFixtures(`
+ setHTMLFixture(`
<form class="js-new-protected-branch">
<span
class="js-force-push-toggle"
@@ -40,6 +41,10 @@ describe('ProtectedBranchCreate', () => {
return new ProtectedBranchCreate({ hasLicense });
};
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('when license supports code owner approvals', () => {
it('instantiates the code owner toggle', () => {
create();
diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js
index 959ca6ecde2..d842e00d850 100644
--- a/spec/frontend/protected_branches/protected_branch_edit_spec.js
+++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -33,7 +34,7 @@ describe('ProtectedBranchEdit', () => {
codeOwnerToggleChecked = false,
hasLicense = true,
} = {}) => {
- setFixtures(`<div id="wrap" data-url="${TEST_URL}">
+ setHTMLFixture(`<div id="wrap" data-url="${TEST_URL}">
<span
class="js-force-push-toggle"
data-label="Toggle allowed to force push"
@@ -51,6 +52,7 @@ describe('ProtectedBranchEdit', () => {
afterEach(() => {
mock.restore();
+ resetHTMLFixture();
});
describe('when license supports code owner approvals', () => {
@@ -76,7 +78,11 @@ describe('ProtectedBranchEdit', () => {
describe('when toggles are not available in the DOM on page load', () => {
beforeEach(() => {
create({ hasLicense: true });
- setFixtures('');
+ setHTMLFixture('');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('does not instantiate the force push toggle', () => {
diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js
index 16f0d7fb075..80d7c941660 100644
--- a/spec/frontend/read_more_spec.js
+++ b/spec/frontend/read_more_spec.js
@@ -1,10 +1,15 @@
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initReadMore from '~/read_more';
describe('Read more click-to-expand functionality', () => {
const fixtureName = 'projects/overview.html';
beforeEach(() => {
- loadFixtures(fixtureName);
+ loadHTMLFixture(fixtureName);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
describe('expands target element', () => {
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index 0a0a683b56d..80be27c92ff 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter';
import { merge } from 'lodash';
import Vuex from 'vuex';
import { nextTick } from 'vue';
+import { GlFormCheckbox } from '@gitlab/ui';
import originalRelease from 'test_fixtures/api/releases/release.json';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
@@ -11,6 +12,7 @@ import * as commonUtils from '~/lib/utils/common_utils';
import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
const originalMilestones = originalRelease.milestones;
const releasesPagePath = 'path/to/releases/page';
@@ -47,6 +49,7 @@ describe('Release edit/new component', () => {
links: [],
},
}),
+ formattedReleaseNotes: () => 'these notes are formatted',
};
const store = new Vuex.Store(
@@ -129,6 +132,11 @@ describe('Release edit/new component', () => {
expect(wrapper.find('#release-notes').element.value).toBe(release.description);
});
+ it('sets the preview text to be the formatted release notes', () => {
+ const notes = getters.formattedReleaseNotes();
+ expect(wrapper.findComponent(MarkdownField).props('textareaValue')).toBe(notes);
+ });
+
it('renders the "Save changes" button as type="submit"', () => {
expect(findSubmitButton().attributes('type')).toBe('submit');
});
@@ -195,6 +203,10 @@ describe('Release edit/new component', () => {
it('renders the submit button with the text "Create release"', () => {
expect(findSubmitButton().text()).toBe('Create release');
});
+
+ it('renders a checkbox to include release notes', () => {
+ expect(wrapper.find(GlFormCheckbox).exists()).toBe(true);
+ });
});
describe('when editing an existing release', () => {
diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js
index c13b513f87e..9f500c318ea 100644
--- a/spec/frontend/releases/components/tag_field_new_spec.js
+++ b/spec/frontend/releases/components/tag_field_new_spec.js
@@ -1,5 +1,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import { __ } from '~/locale';
import TagFieldNew from '~/releases/components/tag_field_new.vue';
@@ -14,6 +16,7 @@ const NONEXISTENT_TAG_NAME = 'nonexistent-tag';
describe('releases/components/tag_field_new', () => {
let store;
let wrapper;
+ let mock;
let RefSelectorStub;
const createComponent = (
@@ -65,11 +68,14 @@ describe('releases/components/tag_field_new', () => {
links: [],
},
};
+
+ mock = new MockAdapter(axios);
+ gon.api_version = 'v4';
});
afterEach(() => {
wrapper.destroy();
- wrapper = null;
+ mock.restore();
});
const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]');
@@ -114,9 +120,14 @@ describe('releases/components/tag_field_new', () => {
expect(store.state.editNew.release.tagName).toBe(updatedTagName);
});
- it('shows the "Create from" field', () => {
+ it('hides the "Create from" field', () => {
expect(findCreateFromFormGroup().exists()).toBe(false);
});
+
+ it('fetches the release notes for the tag', () => {
+ const expectedUrl = `/api/v4/projects/1234/repository/tags/${updatedTagName}`;
+ expect(mock.history.get).toContainEqual(expect.objectContaining({ url: expectedUrl }));
+ });
});
});
@@ -177,6 +188,18 @@ describe('releases/components/tag_field_new', () => {
await expectValidationMessageToBe('hidden');
});
+
+ it('displays a validation error if the tag has an associated release', async () => {
+ findTagNameDropdown().vm.$emit('input', 'vTest');
+ findTagNameDropdown().vm.$emit('hide');
+
+ store.state.editNew.existingRelease = {};
+
+ await expectValidationMessageToBe('shown');
+ expect(findTagNameFormGroup().text()).toContain(
+ __('Selected tag is already in use. Choose another option.'),
+ );
+ });
});
describe('when the user has interacted with the component and the value is empty', () => {
@@ -185,6 +208,7 @@ describe('releases/components/tag_field_new', () => {
findTagNameDropdown().vm.$emit('hide');
await expectValidationMessageToBe('shown');
+ expect(findTagNameFormGroup().text()).toContain(__('Tag name is required.'));
});
});
});
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index d8329fb82b1..41653f62ebf 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -1,8 +1,10 @@
import { cloneDeep } from 'lodash';
import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json';
import testAction from 'helpers/vuex_action_helper';
+import { getTag } from '~/api/tags_api';
import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
import { ASSET_LINK_TYPE } from '~/releases/constants';
import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql';
import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql';
@@ -12,6 +14,8 @@ import * as types from '~/releases/stores/modules/edit_new/mutation_types';
import createState from '~/releases/stores/modules/edit_new/state';
import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
+jest.mock('~/api/tags_api');
+
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
@@ -567,4 +571,46 @@ describe('Release edit/new actions', () => {
});
});
});
+
+ describe('fetchTagNotes', () => {
+ const tagName = 'v8.0.0';
+
+ it('saves the tag notes on succes', async () => {
+ const tag = { message: 'this is a tag' };
+ getTag.mockResolvedValue({ data: tag });
+
+ await testAction(
+ actions.fetchTagNotes,
+ tagName,
+ state,
+ [
+ { type: types.REQUEST_TAG_NOTES },
+ { type: types.RECEIVE_TAG_NOTES_SUCCESS, payload: tag },
+ ],
+ [],
+ );
+
+ expect(getTag).toHaveBeenCalledWith(state.projectId, tagName);
+ });
+ it('creates a flash on error', async () => {
+ error = new Error();
+ getTag.mockRejectedValue(error);
+
+ await testAction(
+ actions.fetchTagNotes,
+ tagName,
+ state,
+ [
+ { type: types.REQUEST_TAG_NOTES },
+ { type: types.RECEIVE_TAG_NOTES_ERROR, payload: error },
+ ],
+ [],
+ );
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: s__('Release|Unable to fetch the tag notes.'),
+ });
+ expect(getTag).toHaveBeenCalledWith(state.projectId, tagName);
+ });
+ });
});
diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js
index c32969c131e..c42c6c00f56 100644
--- a/spec/frontend/releases/stores/modules/detail/getters_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js
@@ -1,3 +1,4 @@
+import { s__ } from '~/locale';
import * as getters from '~/releases/stores/modules/edit_new/getters';
describe('Release edit/new getters', () => {
@@ -145,6 +146,8 @@ describe('Release edit/new getters', () => {
],
},
},
+ // tag has an existing release
+ existingRelease: {},
};
actualErrors = getters.validationErrors(state);
@@ -158,6 +161,14 @@ describe('Release edit/new getters', () => {
expect(actualErrors).toMatchObject(expectedErrors);
});
+ it('returns a validation error if the tag has an existing release', () => {
+ const expectedErrors = {
+ existingRelease: true,
+ };
+
+ expect(actualErrors).toMatchObject(expectedErrors);
+ });
+
it('returns a validation error if links share a URL', () => {
const expectedErrors = {
assets: {
@@ -369,4 +380,25 @@ describe('Release edit/new getters', () => {
expect(actualVariables).toEqual(expectedVariables);
});
});
+
+ describe('formattedReleaseNotes', () => {
+ it.each`
+ description | includeTagNotes | tagNotes | included
+ ${'release notes'} | ${true} | ${'tag notes'} | ${true}
+ ${'release notes'} | ${true} | ${''} | ${false}
+ ${'release notes'} | ${false} | ${'tag notes'} | ${false}
+ `(
+ 'should include tag notes=$included when includeTagNotes=$includeTagNotes and tagNotes=$tagNotes',
+ ({ description, includeTagNotes, tagNotes, included }) => {
+ const state = { release: { description }, includeTagNotes, tagNotes };
+
+ const text = `### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`;
+ if (included) {
+ expect(getters.formattedReleaseNotes(state)).toContain(text);
+ } else {
+ expect(getters.formattedReleaseNotes(state)).not.toContain(text);
+ }
+ },
+ );
+ });
});
diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
index 24dcedb3580..85844831e0b 100644
--- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
@@ -237,4 +237,41 @@ describe('Release edit/new mutations', () => {
expect(state.release.assets.links).not.toContainEqual(linkToRemove);
});
});
+ describe(`${types.REQUEST_TAG_NOTES}`, () => {
+ it('sets isFetchingTagNotes to true', () => {
+ state.isFetchingTagNotes = false;
+ mutations[types.REQUEST_TAG_NOTES](state);
+ expect(state.isFetchingTagNotes).toBe(true);
+ });
+ });
+ describe(`${types.RECEIVE_TAG_NOTES_SUCCESS}`, () => {
+ it('sets the tag notes in the state', () => {
+ state.isFetchingTagNotes = true;
+ const message = 'tag notes';
+
+ mutations[types.RECEIVE_TAG_NOTES_SUCCESS](state, { message, release });
+ expect(state.tagNotes).toBe(message);
+ expect(state.isFetchingTagNotes).toBe(false);
+ expect(state.existingRelease).toBe(release);
+ });
+ });
+ describe(`${types.RECEIVE_TAG_NOTES_ERROR}`, () => {
+ it('sets tag notes to empty', () => {
+ const message = 'there was an error';
+ state.isFetchingTagNotes = true;
+ state.tagNotes = 'tag notes';
+
+ mutations[types.RECEIVE_TAG_NOTES_ERROR](state, { message });
+ expect(state.tagNotes).toBe('');
+ expect(state.isFetchingTagNotes).toBe(false);
+ });
+ });
+ describe(`${types.UPDATE_INCLUDE_TAG_NOTES}`, () => {
+ it('sets whether or not to include the tag notes', () => {
+ state.includeTagNotes = false;
+
+ mutations[types.UPDATE_INCLUDE_TAG_NOTES](state, true);
+ expect(state.includeTagNotes).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/reports/codequality_report/store/getters_spec.js b/spec/frontend/reports/codequality_report/store/getters_spec.js
index b5f6edf85eb..646903390ff 100644
--- a/spec/frontend/reports/codequality_report/store/getters_spec.js
+++ b/spec/frontend/reports/codequality_report/store/getters_spec.js
@@ -61,8 +61,8 @@ describe('Codequality reports store getters', () => {
it.each`
resolvedIssues | newIssues | expectedText
${0} | ${0} | ${'No changes to code quality'}
- ${0} | ${1} | ${'Code quality degraded'}
- ${2} | ${0} | ${'Code quality improved'}
+ ${0} | ${1} | ${'Code quality degraded due to 1 new issue'}
+ ${2} | ${0} | ${'Code quality improved due to 2 resolved issues'}
${1} | ${2} | ${'Code quality scanning detected 3 changes in merged results'}
`(
'returns a summary containing $resolvedIssues resolved issues and $newIssues new issues',
diff --git a/spec/frontend/reports/components/report_link_spec.js b/spec/frontend/reports/components/report_link_spec.js
index fc21515ded6..2ed0617a598 100644
--- a/spec/frontend/reports/components/report_link_spec.js
+++ b/spec/frontend/reports/components/report_link_spec.js
@@ -1,69 +1,56 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import component from '~/reports/components/report_link.vue';
+import { shallowMount } from '@vue/test-utils';
+import ReportLink from '~/reports/components/report_link.vue';
-describe('report link', () => {
- let vm;
-
- const Component = Vue.extend(component);
+describe('app/assets/javascripts/reports/components/report_link.vue', () => {
+ let wrapper;
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
- describe('With url', () => {
- it('renders link', () => {
- vm = mountComponent(Component, {
- issue: {
- path: 'Gemfile.lock',
- urlPath: '/Gemfile.lock',
- },
- });
+ const defaultProps = {
+ issue: {},
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ReportLink, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ describe('When an issue prop has a $urlPath property', () => {
+ it('render a link that will take the user to the $urlPath', () => {
+ createComponent({ issue: { path: 'Gemfile.lock', urlPath: '/Gemfile.lock' } });
- expect(vm.$el.textContent.trim()).toContain('in');
- expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('/Gemfile.lock');
- expect(vm.$el.querySelector('a').textContent.trim()).toEqual('Gemfile.lock');
+ expect(wrapper.text()).toContain('in');
+ expect(wrapper.find('a').attributes('href')).toBe('/Gemfile.lock');
+ expect(wrapper.find('a').text()).toContain('Gemfile.lock');
});
});
- describe('Without url', () => {
+ describe('When an issue prop has no $urlPath property', () => {
it('does not render link', () => {
- vm = mountComponent(Component, {
- issue: {
- path: 'Gemfile.lock',
- },
- });
+ createComponent({ issue: { path: 'Gemfile.lock' } });
- expect(vm.$el.querySelector('a')).toBeNull();
- expect(vm.$el.textContent.trim()).toContain('in');
- expect(vm.$el.textContent.trim()).toContain('Gemfile.lock');
+ expect(wrapper.find('a').exists()).toBe(false);
+ expect(wrapper.text()).toContain('in');
+ expect(wrapper.text()).toContain('Gemfile.lock');
});
});
- describe('with line', () => {
- it('renders line number', () => {
- vm = mountComponent(Component, {
- issue: {
- path: 'Gemfile.lock',
- urlPath: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
- line: 22,
- },
- });
+ describe('When an issue prop has a $line property', () => {
+ it('render a line number', () => {
+ createComponent({ issue: { path: 'Gemfile.lock', urlPath: '/Gemfile.lock', line: 22 } });
- expect(vm.$el.querySelector('a').textContent.trim()).toContain('Gemfile.lock:22');
+ expect(wrapper.find('a').text()).toContain('Gemfile.lock:22');
});
});
- describe('without line', () => {
- it('does not render line number', () => {
- vm = mountComponent(Component, {
- issue: {
- path: 'Gemfile.lock',
- urlPath: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
- },
- });
+ describe('When an issue prop does not have a $line property', () => {
+ it('does not render a line number', () => {
+ createComponent({ issue: { urlPath: '/Gemfile.lock' } });
- expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(':22');
+ expect(wrapper.find('a').text()).not.toContain(':22');
});
});
});
diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
index fea937b905f..4732d68c8c6 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -2,13 +2,13 @@
exports[`Repository last commit component renders commit widget 1`] = `
<div
- class="well-segment commit gl-p-5 gl-w-full"
+ class="well-segment commit gl-p-5 gl-w-full gl-display-flex"
>
<user-avatar-link-stub
- class="avatar-cell"
+ class="gl-my-2 gl-mr-4"
imgalt=""
- imgcssclasses=""
- imgsize="40"
+ imgcssclasses="gl-mr-0!"
+ imgsize="32"
imgsrc="https://test.com"
linkhref="/test"
tooltipplacement="top"
@@ -55,7 +55,11 @@ exports[`Repository last commit component renders commit widget 1`] = `
</div>
<div
- class="commit-actions flex-row"
+ class="gl-flex-grow-1"
+ />
+
+ <div
+ class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row"
>
<!---->
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index 2f6de03b73d..2ab4afbffbe 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -26,6 +26,7 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import httpStatusCodes from '~/lib/utils/http_status';
import LineHighlighter from '~/blob/line_highlighter';
+import { LEGACY_FILE_TYPES } from '~/repository/constants';
import {
simpleViewerMock,
richViewerMock,
@@ -195,6 +196,14 @@ describe('Blob content viewer component', () => {
expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl);
});
+ it.each(LEGACY_FILE_TYPES)(
+ 'loads the legacy viewer when a file type is identified as legacy',
+ async (type) => {
+ await createComponent({ blob: { ...simpleViewerMock, fileType: type, webPath: type } });
+ expect(mockAxios.history.get[0].url).toBe(`${type}?format=json&viewer=simple`);
+ },
+ );
+
it('loads the LineHighlighter', async () => {
mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test');
await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } });
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js
index eef66045573..40b32904589 100644
--- a/spec/frontend/repository/components/breadcrumbs_spec.js
+++ b/spec/frontend/repository/components/breadcrumbs_spec.js
@@ -147,7 +147,7 @@ describe('Repository breadcrumbs component', () => {
describe('renders the new directory modal', () => {
beforeEach(() => {
- factory('/', { canEditTree: true });
+ factory('some_dir', { canEditTree: true, newDirPath: 'root/master' });
});
it('does not render the modal while loading', () => {
expect(findNewDirectoryModal().exists()).toBe(false);
@@ -161,6 +161,7 @@ describe('Repository breadcrumbs component', () => {
await nextTick();
expect(findNewDirectoryModal().exists()).toBe(true);
+ expect(findNewDirectoryModal().props('path')).toBe('root/master/some_dir');
});
});
});
diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js
index d1f861669a0..5847842f5a6 100644
--- a/spec/frontend/right_sidebar_spec.js
+++ b/spec/frontend/right_sidebar_spec.js
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
-import '~/commons/bootstrap';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import axios from '~/lib/utils/axios_utils';
import Sidebar from '~/right_sidebar';
@@ -30,7 +30,7 @@ describe('RightSidebar', () => {
let mock;
beforeEach(() => {
- loadFixtures(fixtureName);
+ loadHTMLFixture(fixtureName);
mock = new MockAdapter(axios);
new Sidebar(); // eslint-disable-line no-new
$aside = $('.right-sidebar');
@@ -44,6 +44,8 @@ describe('RightSidebar', () => {
afterEach(() => {
mock.restore();
+
+ resetHTMLFixture();
});
it('should expand/collapse the sidebar when arrow is clicked', () => {
diff --git a/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js
index d121c6be218..8a34cb14d8b 100644
--- a/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js
+++ b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js
@@ -7,17 +7,20 @@ import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/runner/components/runner_header.vue';
-import runnerQuery from '~/runner/graphql/details/runner.query.graphql';
+import RunnerUpdateForm from '~/runner/components/runner_update_form.vue';
+import runnerFormQuery from '~/runner/graphql/edit/runner_form.query.graphql';
import AdminRunnerEditApp from '~//runner/admin_runner_edit/admin_runner_edit_app.vue';
import { captureException } from '~/runner/sentry_utils';
-import { runnerData } from '../mock_data';
+import { runnerFormData } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
-const mockRunnerGraphqlId = runnerData.data.runner.id;
+const mockRunner = runnerFormData.data.runner;
+const mockRunnerGraphqlId = mockRunner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
+const mockRunnerPath = `/admin/runners/${mockRunnerId}`;
Vue.use(VueApollo);
@@ -26,12 +29,14 @@ describe('AdminRunnerEditApp', () => {
let mockRunnerQuery;
const findRunnerHeader = () => wrapper.findComponent(RunnerHeader);
+ const findRunnerUpdateForm = () => wrapper.findComponent(RunnerUpdateForm);
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(AdminRunnerEditApp, {
- apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]),
+ apolloProvider: createMockApollo([[runnerFormQuery, mockRunnerQuery]]),
propsData: {
runnerId: mockRunnerId,
+ runnerPath: mockRunnerPath,
...props,
},
});
@@ -40,7 +45,7 @@ describe('AdminRunnerEditApp', () => {
};
beforeEach(() => {
- mockRunnerQuery = jest.fn().mockResolvedValue(runnerData);
+ mockRunnerQuery = jest.fn().mockResolvedValue(runnerFormData);
});
afterEach(() => {
@@ -68,6 +73,26 @@ describe('AdminRunnerEditApp', () => {
expect(findRunnerHeader().text()).toContain(`shared`);
});
+ it('displays a loading runner form', () => {
+ createComponentWithApollo();
+
+ expect(findRunnerUpdateForm().props()).toMatchObject({
+ runner: null,
+ loading: true,
+ runnerPath: mockRunnerPath,
+ });
+ });
+
+ it('displays the runner form', async () => {
+ await createComponentWithApollo();
+
+ expect(findRunnerUpdateForm().props()).toMatchObject({
+ runner: mockRunner,
+ loading: false,
+ runnerPath: mockRunnerPath,
+ });
+ });
+
describe('When there is an error', () => {
beforeEach(async () => {
mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!'));
diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js
index f994ff24c21..07259ec3538 100644
--- a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js
+++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js
@@ -3,24 +3,30 @@ import { mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { redirectTo } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/runner/components/runner_header.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
+import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
-import runnerQuery from '~/runner/graphql/details/runner.query.graphql';
+import runnerQuery from '~/runner/graphql/show/runner.query.graphql';
import AdminRunnerShowApp from '~/runner/admin_runner_show/admin_runner_show_app.vue';
import { captureException } from '~/runner/sentry_utils';
+import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage';
import { runnerData } from '../mock_data';
+jest.mock('~/runner/local_storage_alert/save_alert_to_local_storage');
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
+jest.mock('~/lib/utils/url_utility');
const mockRunner = runnerData.data.runner;
const mockRunnerGraphqlId = mockRunner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
+const mockRunnersPath = '/admin/runners';
Vue.use(VueApollo);
@@ -29,6 +35,7 @@ describe('AdminRunnerShowApp', () => {
let mockRunnerQuery;
const findRunnerHeader = () => wrapper.findComponent(RunnerHeader);
+ const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton);
const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton);
const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton);
@@ -45,6 +52,7 @@ describe('AdminRunnerShowApp', () => {
apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]),
propsData: {
runnerId: mockRunnerId,
+ runnersPath: mockRunnersPath,
...props,
},
});
@@ -75,6 +83,7 @@ describe('AdminRunnerShowApp', () => {
it('displays the runner edit and pause buttons', async () => {
expect(findRunnerEditButton().exists()).toBe(true);
expect(findRunnerPauseButton().exists()).toBe(true);
+ expect(findRunnerDeleteButton().exists()).toBe(true);
});
it('shows basic runner details', async () => {
@@ -82,6 +91,9 @@ describe('AdminRunnerShowApp', () => {
Last contact Never contacted
Version 1.0.0
IP Address 127.0.0.1
+ Executor None
+ Architecture None
+ Platform darwin
Configuration Runs untagged jobs
Maximum job timeout None
Tags None`.replace(/\s+/g, ' ');
@@ -108,6 +120,42 @@ describe('AdminRunnerShowApp', () => {
});
});
+ describe('when runner cannot be deleted', () => {
+ beforeEach(async () => {
+ mockRunnerQueryResult({
+ userPermissions: {
+ deleteRunner: false,
+ },
+ });
+
+ await createComponent({
+ mountFn: mount,
+ });
+ });
+
+ it('does not display the runner edit and pause buttons', () => {
+ expect(findRunnerDeleteButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when runner is deleted', () => {
+ beforeEach(async () => {
+ await createComponent({
+ mountFn: mount,
+ });
+ });
+
+ it('redirects to the runner list page', () => {
+ findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' });
+
+ expect(saveAlertToLocalStorage).toHaveBeenCalledWith({
+ message: 'Runner deleted',
+ variant: VARIANT_SUCCESS,
+ });
+ expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath);
+ });
+ });
+
describe('when runner does not have an edit url ', () => {
beforeEach(async () => {
mockRunnerQueryResult({
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index 2ef856c90ab..405813be4e3 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -35,6 +35,8 @@ import {
PARAM_KEY_STATUS,
PARAM_KEY_TAG,
STATUS_ONLINE,
+ STATUS_OFFLINE,
+ STATUS_STALE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql';
@@ -52,6 +54,7 @@ import {
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockRunners = runnersData.data.runners.nodes;
+const mockRunnersCount = runnersCountData.data.runners.count;
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
@@ -124,18 +127,6 @@ describe('AdminRunnersApp', () => {
wrapper.destroy();
});
- it('shows total runner counts', async () => {
- createComponent({ mountFn: mountExtended });
-
- await waitForPromises();
-
- const stats = findRunnerStats().text();
-
- expect(stats).toMatch('Online runners 4');
- expect(stats).toMatch('Offline runners 4');
- expect(stats).toMatch('Stale runners 4');
- });
-
it('shows the runner tabs with a runner count for each type', async () => {
mockRunnersCountQuery.mockImplementation(({ type }) => {
let count;
@@ -197,6 +188,24 @@ describe('AdminRunnersApp', () => {
expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE);
});
+ it('shows total runner counts', async () => {
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({
+ status: STATUS_ONLINE,
+ });
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({
+ status: STATUS_OFFLINE,
+ });
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({
+ status: STATUS_STALE,
+ });
+
+ expect(findRunnerStats().props()).toMatchObject({
+ onlineRunnersCount: mockRunnersCount,
+ offlineRunnersCount: mockRunnersCount,
+ staleRunnersCount: mockRunnersCount,
+ });
+ });
+
it('shows the runners list', () => {
expect(findRunnerList().props('runners')).toEqual(mockRunners);
});
@@ -329,13 +338,30 @@ describe('AdminRunnersApp', () => {
first: RUNNER_PAGE_SIZE,
});
});
+
+ it('fetches count results for requested status', () => {
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({
+ type: INSTANCE_TYPE,
+ status: STATUS_ONLINE,
+ tagList: ['tag1'],
+ });
+
+ expect(findRunnerStats().props()).toMatchObject({
+ onlineRunnersCount: mockRunnersCount,
+ });
+ });
});
describe('when a filter is selected by the user', () => {
beforeEach(() => {
+ mockRunnersCountQuery.mockClear();
+
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
- filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }],
+ filters: [
+ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } },
+ { type: PARAM_KEY_TAG, value: { data: 'tag1', operator: '=' } },
+ ],
sort: CREATED_ASC,
});
});
@@ -343,17 +369,45 @@ describe('AdminRunnersApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
- url: 'http://test.host/admin/runners?status[]=ONLINE&sort=CREATED_ASC',
+ url: 'http://test.host/admin/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC',
});
});
it('requests the runners with filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: STATUS_ONLINE,
+ tagList: ['tag1'],
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
});
+
+ it('fetches count results for requested status', () => {
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({
+ tagList: ['tag1'],
+ status: STATUS_ONLINE,
+ });
+
+ expect(findRunnerStats().props()).toMatchObject({
+ onlineRunnersCount: mockRunnersCount,
+ });
+ });
+
+ it('skips fetching count results for status that were not in filter', () => {
+ expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({
+ tagList: ['tag1'],
+ status: STATUS_OFFLINE,
+ });
+ expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({
+ tagList: ['tag1'],
+ status: STATUS_STALE,
+ });
+
+ expect(findRunnerStats().props()).toMatchObject({
+ offlineRunnersCount: null,
+ staleRunnersCount: null,
+ });
+ });
});
it('when runners have not loaded, shows a loading state', () => {
diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js
index 5cd93df9967..81c2788f084 100644
--- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js
+++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js
@@ -35,6 +35,16 @@ describe('RegistrationDropdown', () => {
const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input');
const findTokenResetDropdownItem = () =>
wrapper.findComponent(RegistrationTokenResetDropdownItem);
+ const findModalContent = () =>
+ createWrapper(document.body)
+ .find('[data-testid="runner-instructions-modal"]')
+ .text()
+ .replace(/[\n\t\s]+/g, ' ');
+
+ const openModal = async () => {
+ await findRegistrationInstructionsDropdownItem().trigger('click');
+ await waitForPromises();
+ };
const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => {
wrapper = extendedWrapper(
@@ -49,6 +59,25 @@ describe('RegistrationDropdown', () => {
);
};
+ const createComponentWithModal = () => {
+ Vue.use(VueApollo);
+
+ const requestHandlers = [
+ [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)],
+ [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)],
+ ];
+
+ createComponent(
+ {
+ // Mock load modal contents from API
+ apolloProvider: createMockApollo(requestHandlers),
+ // Use `attachTo` to find the modal
+ attachTo: document.body,
+ },
+ mount,
+ );
+ };
+
it.each`
type | text
${INSTANCE_TYPE} | ${'Register an instance runner'}
@@ -76,29 +105,10 @@ describe('RegistrationDropdown', () => {
});
describe('When the dropdown item is clicked', () => {
- Vue.use(VueApollo);
-
- const requestHandlers = [
- [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)],
- [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)],
- ];
-
- const findModalInBody = () =>
- createWrapper(document.body).find('[data-testid="runner-instructions-modal"]');
-
beforeEach(async () => {
- createComponent(
- {
- // Mock load modal contents from API
- apolloProvider: createMockApollo(requestHandlers),
- // Use `attachTo` to find the modal
- attachTo: document.body,
- },
- mount,
- );
-
- await findRegistrationInstructionsDropdownItem().trigger('click');
- await waitForPromises();
+ createComponentWithModal({}, mount);
+
+ await openModal();
});
afterEach(() => {
@@ -106,9 +116,7 @@ describe('RegistrationDropdown', () => {
});
it('opens the modal with contents', () => {
- const modalText = findModalInBody()
- .text()
- .replace(/[\n\t\s]+/g, ' ');
+ const modalText = findModalContent();
expect(modalText).toContain('Install a runner');
@@ -153,15 +161,34 @@ describe('RegistrationDropdown', () => {
});
});
- it('Updates the token when it gets reset', async () => {
+ describe('When token is reset', () => {
const newToken = 'mock1';
- createComponent({}, mount);
- expect(findRegistrationTokenInput().props('value')).not.toBe(newToken);
+ const resetToken = async () => {
+ findTokenResetDropdownItem().vm.$emit('tokenReset', newToken);
+ await nextTick();
+ };
+
+ it('Updates token in input', async () => {
+ createComponent({}, mount);
+
+ expect(findRegistrationTokenInput().props('value')).not.toBe(newToken);
+
+ await resetToken();
+
+ expect(findRegistrationToken().props('value')).toBe(newToken);
+ });
- findTokenResetDropdownItem().vm.$emit('tokenReset', newToken);
- await nextTick();
+ it('Updates token in modal', async () => {
+ createComponentWithModal({}, mount);
- expect(findRegistrationToken().props('value')).toBe(newToken);
+ await openModal();
+
+ expect(findModalContent()).toContain(mockToken);
+
+ await resetToken();
+
+ expect(findModalContent()).toContain(newToken);
+ });
});
});
diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js
index 3eb257607b4..b11c749d0a7 100644
--- a/spec/frontend/runner/components/runner_delete_button_spec.js
+++ b/spec/frontend/runner/components/runner_delete_button_spec.js
@@ -118,6 +118,12 @@ describe('RunnerDeleteButton', () => {
expect(findBtn().attributes('aria-label')).toBe(undefined);
});
+ it('Passes other attributes to the button', () => {
+ createComponent({ props: { category: 'secondary' } });
+
+ expect(findBtn().props('category')).toBe('secondary');
+ });
+
describe(`Before the delete button is clicked`, () => {
it('The mutation has not been called', () => {
expect(runnerDeleteHandler).toHaveBeenCalledTimes(0);
diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js
index 6bf4a52a799..162d21febfd 100644
--- a/spec/frontend/runner/components/runner_details_spec.js
+++ b/spec/frontend/runner/components/runner_details_spec.js
@@ -77,6 +77,9 @@ describe('RunnerDetails', () => {
${'Last contact'} | ${{ contactedAt: null }} | ${'Never contacted'}
${'Version'} | ${{ version: '12.3' }} | ${'12.3'}
${'Version'} | ${{ version: null }} | ${'None'}
+ ${'Executor'} | ${{ executorName: 'shell' }} | ${'shell'}
+ ${'Architecture'} | ${{ architectureName: 'amd64' }} | ${'amd64'}
+ ${'Platform'} | ${{ platformName: 'darwin' }} | ${'darwin'}
${'IP Address'} | ${{ ipAddress: '127.0.0.1' }} | ${'127.0.0.1'}
${'IP Address'} | ${{ ipAddress: null }} | ${'None'}
${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: true }} | ${'Protected, Runs untagged jobs'}
diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js
index 9e40e911448..8ac5685a0dd 100644
--- a/spec/frontend/runner/components/runner_jobs_spec.js
+++ b/spec/frontend/runner/components/runner_jobs_spec.js
@@ -11,7 +11,7 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue';
import { captureException } from '~/runner/sentry_utils';
import { I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/runner/constants';
-import runnerJobsQuery from '~/runner/graphql/details/runner_jobs.query.graphql';
+import runnerJobsQuery from '~/runner/graphql/show/runner_jobs.query.graphql';
import { runnerData, runnerJobsData } from '../mock_data';
diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js
index 62ebc6539e2..04627e2307b 100644
--- a/spec/frontend/runner/components/runner_projects_spec.js
+++ b/spec/frontend/runner/components/runner_projects_spec.js
@@ -16,7 +16,7 @@ import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import { captureException } from '~/runner/sentry_utils';
-import runnerProjectsQuery from '~/runner/graphql/details/runner_projects.query.graphql';
+import runnerProjectsQuery from '~/runner/graphql/show/runner_projects.query.graphql';
import { runnerData, runnerProjectsData } from '../mock_data';
diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js
index b071791e39f..3037364d941 100644
--- a/spec/frontend/runner/components/runner_update_form_spec.js
+++ b/spec/frontend/runner/components/runner_update_form_spec.js
@@ -1,10 +1,11 @@
import Vue, { nextTick } from 'vue';
-import { GlForm } from '@gitlab/ui';
+import { GlForm, GlSkeletonLoader } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { redirectTo } from '~/lib/utils/url_utility';
import RunnerUpdateForm from '~/runner/components/runner_update_form.vue';
import {
INSTANCE_TYPE,
@@ -13,14 +14,18 @@ import {
ACCESS_LEVEL_REF_PROTECTED,
ACCESS_LEVEL_NOT_PROTECTED,
} from '~/runner/constants';
-import runnerUpdateMutation from '~/runner/graphql/details/runner_update.mutation.graphql';
+import runnerUpdateMutation from '~/runner/graphql/edit/runner_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
-import { runnerData } from '../mock_data';
+import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage';
+import { runnerFormData } from '../mock_data';
+jest.mock('~/runner/local_storage_alert/save_alert_to_local_storage');
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
+jest.mock('~/lib/utils/url_utility');
-const mockRunner = runnerData.data.runner;
+const mockRunner = runnerFormData.data.runner;
+const mockRunnerPath = '/admin/runners/1';
Vue.use(VueApollo);
@@ -33,8 +38,7 @@ describe('RunnerUpdateForm', () => {
const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected');
const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged');
const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked');
-
- const findIpInput = () => wrapper.findByTestId('runner-field-ip-address').find('input');
+ const findFields = () => wrapper.findAll('[data-testid^="runner-field"');
const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input');
const findMaxJobTimeoutInput = () =>
@@ -53,7 +57,6 @@ describe('RunnerUpdateForm', () => {
: ACCESS_LEVEL_NOT_PROTECTED,
runUntagged: findRunUntaggedCheckbox().element.checked,
locked: findLockedCheckbox().element?.checked || false,
- ipAddress: findIpInput().element.value,
maximumTimeout: findMaxJobTimeoutInput().element.value || null,
tagList: findTagsInput().element.value.split(',').filter(Boolean),
});
@@ -62,6 +65,7 @@ describe('RunnerUpdateForm', () => {
wrapper = mountExtended(RunnerUpdateForm, {
propsData: {
runner: mockRunner,
+ runnerPath: mockRunnerPath,
...props,
},
apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]),
@@ -74,12 +78,13 @@ describe('RunnerUpdateForm', () => {
input: expect.objectContaining(submittedRunner),
});
- expect(createAlert).toHaveBeenLastCalledWith({
- message: expect.stringContaining('saved'),
- variant: VARIANT_SUCCESS,
- });
-
- expect(findSubmitDisabledAttr()).toBeUndefined();
+ expect(saveAlertToLocalStorage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.any(String),
+ variant: VARIANT_SUCCESS,
+ }),
+ );
+ expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath);
};
beforeEach(() => {
@@ -122,27 +127,19 @@ describe('RunnerUpdateForm', () => {
await submitFormAndWait();
// Some read-only fields are not submitted
- const {
- __typename,
- ipAddress,
- runnerType,
- createdAt,
- status,
- editAdminUrl,
- contactedAt,
- userPermissions,
- version,
- groups,
- jobCount,
- ...submitted
- } = mockRunner;
+ const { __typename, shortSha, runnerType, createdAt, status, ...submitted } = mockRunner;
expectToHaveSubmittedRunnerContaining(submitted);
});
describe('When data is being loaded', () => {
beforeEach(() => {
- createComponent({ props: { runner: null } });
+ createComponent({ props: { loading: true } });
+ });
+
+ it('Form skeleton is shown', () => {
+ expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true);
+ expect(findFields()).toHaveLength(0);
});
it('Form cannot be submitted', () => {
@@ -151,11 +148,12 @@ describe('RunnerUpdateForm', () => {
it('Form is updated when data loads', async () => {
wrapper.setProps({
- runner: mockRunner,
+ loading: false,
});
await nextTick();
+ expect(findFields()).not.toHaveLength(0);
expect(mockRunner).toMatchObject(getFieldsModel());
});
});
@@ -273,8 +271,11 @@ describe('RunnerUpdateForm', () => {
expect(createAlert).toHaveBeenLastCalledWith({
message: mockErrorMsg,
});
- expect(captureException).not.toHaveBeenCalled();
expect(findSubmitDisabledAttr()).toBeUndefined();
+
+ expect(captureException).not.toHaveBeenCalled();
+ expect(saveAlertToLocalStorage).not.toHaveBeenCalled();
+ expect(redirectTo).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js
index 02348bf737a..52bd51a974b 100644
--- a/spec/frontend/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -30,7 +30,10 @@ import {
PROJECT_TYPE,
PARAM_KEY_PAUSED,
PARAM_KEY_STATUS,
+ PARAM_KEY_TAG,
STATUS_ONLINE,
+ STATUS_OFFLINE,
+ STATUS_STALE,
RUNNER_PAGE_SIZE,
I18N_EDIT,
} from '~/runner/constants';
@@ -53,7 +56,7 @@ Vue.use(GlToast);
const mockGroupFullPath = 'group1';
const mockRegistrationToken = 'AABBCC';
const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges;
-const mockGroupRunnersLimitedCount = mockGroupRunnersEdges.length;
+const mockGroupRunnersCount = mockGroupRunnersEdges.length;
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
@@ -94,7 +97,7 @@ describe('GroupRunnersApp', () => {
propsData: {
registrationToken: mockRegistrationToken,
groupFullPath: mockGroupFullPath,
- groupRunnersLimitedCount: mockGroupRunnersLimitedCount,
+ groupRunnersLimitedCount: mockGroupRunnersCount,
...props,
},
provide: {
@@ -115,15 +118,24 @@ describe('GroupRunnersApp', () => {
});
it('shows total runner counts', async () => {
- createComponent({ mountFn: mountExtended });
-
- await waitForPromises();
-
- const stats = findRunnerStats().text();
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
+ groupFullPath: mockGroupFullPath,
+ status: STATUS_ONLINE,
+ });
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
+ groupFullPath: mockGroupFullPath,
+ status: STATUS_OFFLINE,
+ });
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
+ groupFullPath: mockGroupFullPath,
+ status: STATUS_STALE,
+ });
- expect(stats).toMatch('Online runners 2');
- expect(stats).toMatch('Offline runners 2');
- expect(stats).toMatch('Stale runners 2');
+ expect(findRunnerStats().props()).toMatchObject({
+ onlineRunnersCount: mockGroupRunnersCount,
+ offlineRunnersCount: mockGroupRunnersCount,
+ staleRunnersCount: mockGroupRunnersCount,
+ });
});
it('shows the runner tabs with a runner count for each type', async () => {
@@ -281,13 +293,28 @@ describe('GroupRunnersApp', () => {
first: RUNNER_PAGE_SIZE,
});
});
+
+ it('fetches count results for requested status', () => {
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
+ groupFullPath: mockGroupFullPath,
+ type: INSTANCE_TYPE,
+ status: STATUS_ONLINE,
+ });
+
+ expect(findRunnerStats().props()).toMatchObject({
+ onlineRunnersCount: mockGroupRunnersCount,
+ });
+ });
});
describe('when a filter is selected by the user', () => {
beforeEach(async () => {
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
- filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }],
+ filters: [
+ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } },
+ { type: PARAM_KEY_TAG, value: { data: 'tag1', operator: '=' } },
+ ],
sort: CREATED_ASC,
});
@@ -297,7 +324,7 @@ describe('GroupRunnersApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
- url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&sort=CREATED_ASC',
+ url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC',
});
});
@@ -305,10 +332,41 @@ describe('GroupRunnersApp', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ONLINE,
+ tagList: ['tag1'],
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
});
+
+ it('fetches count results for requested status', () => {
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
+ groupFullPath: mockGroupFullPath,
+ tagList: ['tag1'],
+ status: STATUS_ONLINE,
+ });
+
+ expect(findRunnerStats().props()).toMatchObject({
+ onlineRunnersCount: mockGroupRunnersCount,
+ });
+ });
+
+ it('skips fetching count results for status that were not in filter', () => {
+ expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({
+ groupFullPath: mockGroupFullPath,
+ tagList: ['tag1'],
+ status: STATUS_OFFLINE,
+ });
+ expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({
+ groupFullPath: mockGroupFullPath,
+ tagList: ['tag1'],
+ status: STATUS_STALE,
+ });
+
+ expect(findRunnerStats().props()).toMatchObject({
+ offlineRunnersCount: null,
+ staleRunnersCount: null,
+ });
+ });
});
it('when runners have not loaded, shows a loading state', () => {
diff --git a/spec/frontend/runner/local_storage_alert/save_alert_to_local_storage_spec.js b/spec/frontend/runner/local_storage_alert/save_alert_to_local_storage_spec.js
new file mode 100644
index 00000000000..69cda6d6022
--- /dev/null
+++ b/spec/frontend/runner/local_storage_alert/save_alert_to_local_storage_spec.js
@@ -0,0 +1,24 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage';
+import { LOCAL_STORAGE_ALERT_KEY } from '~/runner/local_storage_alert/constants';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+
+const mockAlert = { message: 'Message!' };
+
+describe('saveAlertToLocalStorage', () => {
+ useLocalStorageSpy();
+
+ beforeEach(() => {
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
+ });
+
+ it('saves message to local storage', () => {
+ saveAlertToLocalStorage(mockAlert);
+
+ expect(localStorage.setItem).toHaveBeenCalledTimes(1);
+ expect(localStorage.setItem).toHaveBeenCalledWith(
+ LOCAL_STORAGE_ALERT_KEY,
+ JSON.stringify(mockAlert),
+ );
+ });
+});
diff --git a/spec/frontend/runner/local_storage_alert/show_alert_from_local_storage_spec.js b/spec/frontend/runner/local_storage_alert/show_alert_from_local_storage_spec.js
new file mode 100644
index 00000000000..cabbe642dac
--- /dev/null
+++ b/spec/frontend/runner/local_storage_alert/show_alert_from_local_storage_spec.js
@@ -0,0 +1,40 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+import { showAlertFromLocalStorage } from '~/runner/local_storage_alert/show_alert_from_local_storage';
+import { LOCAL_STORAGE_ALERT_KEY } from '~/runner/local_storage_alert/constants';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import { createAlert } from '~/flash';
+
+jest.mock('~/flash');
+
+describe('showAlertFromLocalStorage', () => {
+ useLocalStorageSpy();
+
+ beforeEach(() => {
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
+ });
+
+ it('retrieves message from local storage and displays it', async () => {
+ const mockAlert = { message: 'Message!' };
+
+ localStorage.getItem.mockReturnValueOnce(JSON.stringify(mockAlert));
+
+ await showAlertFromLocalStorage();
+
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith(mockAlert);
+
+ expect(localStorage.removeItem).toHaveBeenCalledTimes(1);
+ expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY);
+ });
+
+ it.each(['not a json string', null])('does not fail when stored message is %o', async (item) => {
+ localStorage.getItem.mockReturnValueOnce(item);
+
+ await showAlertFromLocalStorage();
+
+ expect(createAlert).not.toHaveBeenCalled();
+
+ expect(localStorage.removeItem).toHaveBeenCalledTimes(1);
+ expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY);
+ });
+});
diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js
index fbe8926124c..1c2333b552c 100644
--- a/spec/frontend/runner/mock_data.js
+++ b/spec/frontend/runner/mock_data.js
@@ -1,5 +1,14 @@
// Fixtures generated by: spec/frontend/fixtures/runner.rb
+// Show runner queries
+import runnerData from 'test_fixtures/graphql/runner/show/runner.query.graphql.json';
+import runnerWithGroupData from 'test_fixtures/graphql/runner/show/runner.query.graphql.with_group.json';
+import runnerProjectsData from 'test_fixtures/graphql/runner/show/runner_projects.query.graphql.json';
+import runnerJobsData from 'test_fixtures/graphql/runner/show/runner_jobs.query.graphql.json';
+
+// Edit runner queries
+import runnerFormData from 'test_fixtures/graphql/runner/edit/runner_form.query.graphql.json';
+
// List queries
import runnersData from 'test_fixtures/graphql/runner/list/admin_runners.query.graphql.json';
import runnersDataPaginated from 'test_fixtures/graphql/runner/list/admin_runners.query.graphql.paginated.json';
@@ -8,25 +17,20 @@ import groupRunnersData from 'test_fixtures/graphql/runner/list/group_runners.qu
import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.paginated.json';
import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runners_count.query.graphql.json';
-// Details queries
-import runnerData from 'test_fixtures/graphql/runner/details/runner.query.graphql.json';
-import runnerWithGroupData from 'test_fixtures/graphql/runner/details/runner.query.graphql.with_group.json';
-import runnerProjectsData from 'test_fixtures/graphql/runner/details/runner_projects.query.graphql.json';
-import runnerJobsData from 'test_fixtures/graphql/runner/details/runner_jobs.query.graphql.json';
-
// Other mock data
export const onlineContactTimeoutSecs = 2 * 60 * 60;
export const staleTimeoutSecs = 5259492; // Ruby's `2.months`
export {
runnersData,
- runnersCountData,
runnersDataPaginated,
+ runnersCountData,
+ groupRunnersData,
+ groupRunnersDataPaginated,
+ groupRunnersCountData,
runnerData,
runnerWithGroupData,
runnerProjectsData,
runnerJobsData,
- groupRunnersData,
- groupRunnersCountData,
- groupRunnersDataPaginated,
+ runnerFormData,
};
diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js
index 7834e76fe48..a3c1458ed26 100644
--- a/spec/frontend/runner/runner_search_utils_spec.js
+++ b/spec/frontend/runner/runner_search_utils_spec.js
@@ -220,13 +220,11 @@ describe('search_params.js', () => {
});
it.each`
- query | updatedQuery
- ${'status[]=NOT_CONNECTED'} | ${'status[]=NEVER_CONTACTED'}
- ${'status[]=NOT_CONNECTED&a=b'} | ${'status[]=NEVER_CONTACTED&a=b'}
- ${'status[]=ACTIVE'} | ${'paused[]=false'}
- ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'}
- ${'status[]=ACTIVE'} | ${'paused[]=false'}
- ${'status[]=PAUSED'} | ${'paused[]=true'}
+ query | updatedQuery
+ ${'status[]=ACTIVE'} | ${'paused[]=false'}
+ ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'}
+ ${'status[]=ACTIVE'} | ${'paused[]=false'}
+ ${'status[]=PAUSED'} | ${'paused[]=true'}
`('updates "$query" to "$updatedQuery"', ({ query, updatedQuery }) => {
const mockUrl = 'http://test.host/admin/runners?';
diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js
index 9fa3bfc1f9a..15cff436076 100644
--- a/spec/frontend/search/highlight_blob_search_result_spec.js
+++ b/spec/frontend/search/highlight_blob_search_result_spec.js
@@ -1,10 +1,15 @@
import setHighlightClass from '~/search/highlight_blob_search_result';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
const fixture = 'search/blob_search_result.html';
const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79
describe('search/highlight_blob_search_result', () => {
- beforeEach(() => loadFixtures(fixture));
+ beforeEach(() => loadHTMLFixture(fixture));
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
it('highlights lines with search term occurrence', () => {
setHighlightClass(searchKeyword);
diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js
index 190f2803324..4639552b4d3 100644
--- a/spec/frontend/search_autocomplete_spec.js
+++ b/spec/frontend/search_autocomplete_spec.js
@@ -1,5 +1,6 @@
import AxiosMockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import axios from '~/lib/utils/axios_utils';
import initSearchAutocomplete from '~/search_autocomplete';
@@ -104,7 +105,7 @@ describe('Search autocomplete dropdown', () => {
};
beforeEach(() => {
- loadFixtures('static/search_autocomplete.html');
+ loadHTMLFixture('static/search_autocomplete.html');
window.gon = {};
window.gon.current_user_id = userId;
@@ -118,6 +119,8 @@ describe('Search autocomplete dropdown', () => {
// Undo what we did to the shared <body>
removeBodyAttributes();
window.gon = {};
+
+ resetHTMLFixture();
});
it('should show Dashboard specific dropdown menu', () => {
diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js
index 9a18cb636b2..d7d46d0d415 100644
--- a/spec/frontend/security_configuration/components/app_spec.js
+++ b/spec/frontend/security_configuration/components/app_spec.js
@@ -1,6 +1,8 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { GlTab, GlTabs, GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
import stubChildren from 'helpers/stub_children';
@@ -18,15 +20,22 @@ import {
LICENSE_COMPLIANCE_DESCRIPTION,
LICENSE_COMPLIANCE_HELP_PATH,
AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY,
+ LICENSE_ULTIMATE,
+ LICENSE_PREMIUM,
+ LICENSE_FREE,
} from '~/security_configuration/components/constants';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import currentLicenseQuery from '~/security_configuration/graphql/current_license.query.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue';
import {
REPORT_TYPE_LICENSE_COMPLIANCE,
REPORT_TYPE_SAST,
} from '~/vue_shared/security_reports/constants';
+import { getCurrentLicensePlanResponse } from '../mock_data';
const upgradePath = '/upgrade';
const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath';
@@ -36,18 +45,33 @@ const projectFullPath = 'namespace/project';
const vulnerabilityTrainingDocsPath = 'user/application_security/vulnerabilities/index';
useLocalStorageSpy();
+Vue.use(VueApollo);
describe('App component', () => {
let wrapper;
let userCalloutDismissSpy;
+ let mockApollo;
const createComponent = ({
shouldShowCallout = true,
- secureVulnerabilityTraining = true,
+ licenseQueryResponse = LICENSE_ULTIMATE,
...propsData
}) => {
userCalloutDismissSpy = jest.fn();
+ mockApollo = createMockApollo([
+ [
+ currentLicenseQuery,
+ jest
+ .fn()
+ .mockResolvedValue(
+ licenseQueryResponse instanceof Error
+ ? licenseQueryResponse
+ : getCurrentLicensePlanResponse(licenseQueryResponse),
+ ),
+ ],
+ ]);
+
wrapper = extendedWrapper(
mount(SecurityConfigurationApp, {
propsData,
@@ -57,10 +81,8 @@ describe('App component', () => {
autoDevopsPath,
projectFullPath,
vulnerabilityTrainingDocsPath,
- glFeatures: {
- secureVulnerabilityTraining,
- },
},
+ apolloProvider: mockApollo,
stubs: {
...stubChildren(SecurityConfigurationApp),
GlLink: false,
@@ -135,14 +157,16 @@ describe('App component', () => {
afterEach(() => {
wrapper.destroy();
+ mockApollo = null;
});
describe('basic structure', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent({
augmentedSecurityFeatures: securityFeaturesMock,
augmentedComplianceFeatures: complianceFeaturesMock,
});
+ await waitForPromises();
});
it('renders main-heading with correct text', () => {
@@ -445,11 +469,12 @@ describe('App component', () => {
});
describe('Vulnerability management', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent({
augmentedSecurityFeatures: securityFeaturesMock,
augmentedComplianceFeatures: complianceFeaturesMock,
});
+ await waitForPromises();
});
it('renders TrainingProviderList component', () => {
@@ -466,23 +491,25 @@ describe('App component', () => {
expect(trainingLink.text()).toBe('Learn more about vulnerability training');
expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath);
});
- });
-
- describe('when secureVulnerabilityTraining feature flag is disabled', () => {
- beforeEach(() => {
- createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
- secureVulnerabilityTraining: false,
- });
- });
- it('renders correct amount of tabs', () => {
- expect(findTabs()).toHaveLength(2);
- });
-
- it('does not render the vulnerability-management tab', () => {
- expect(wrapper.findByTestId('vulnerability-management-tab').exists()).toBe(false);
- });
+ it.each`
+ licenseQueryResponse | display
+ ${LICENSE_ULTIMATE} | ${true}
+ ${LICENSE_PREMIUM} | ${false}
+ ${LICENSE_FREE} | ${false}
+ ${null} | ${true}
+ ${new Error()} | ${true}
+ `(
+ 'displays $display for license $licenseQueryResponse',
+ async ({ licenseQueryResponse, display }) => {
+ createComponent({
+ licenseQueryResponse,
+ augmentedSecurityFeatures: securityFeaturesMock,
+ augmentedComplianceFeatures: complianceFeaturesMock,
+ });
+ await waitForPromises();
+ expect(findVulnerabilityManagementTab().exists()).toBe(display);
+ },
+ );
});
});
diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js
index 18a480bf082..94a36472a1d 100644
--- a/spec/frontend/security_configuration/mock_data.js
+++ b/spec/frontend/security_configuration/mock_data.js
@@ -111,3 +111,12 @@ export const tempProviderLogos = {
svg: `<svg>${[testProviderName[1]]}</svg>`,
},
};
+
+export const getCurrentLicensePlanResponse = (plan) => ({
+ data: {
+ currentLicense: {
+ id: 'gid://gitlab/License/1',
+ plan,
+ },
+ },
+});
diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
index c968c28c811..62a9ff98243 100644
--- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
+++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
@@ -63,6 +63,7 @@ exports[`self monitor component When the self monitor project has not been creat
</div>
<gl-modal-stub
+ arialabel=""
cancel-title="Cancel"
category="primary"
dismisslabel="Close"
diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
deleted file mode 100644
index 0f4dfdf8a75..00000000000
--- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
+++ /dev/null
@@ -1,22 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`EmptyStateComponent should render content 1`] = `
-"<section class=\\"gl-display-flex empty-state gl-text-center gl-flex-direction-column\\">
- <div class=\\"gl-max-w-full\\">
- <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full gl-dark-invert-keep-hue\\"></div>
- </div>
- <div class=\\"gl-max-w-full gl-m-auto\\">
- <div class=\\"gl-mx-auto gl-my-0 gl-p-5\\">
- <h1 class=\\"gl-font-size-h-display gl-line-height-36 h4\\">
- Getting started with serverless
- </h1>
- <p class=\\"gl-mt-3\\">Serverless was <gl-link-stub target=\\"_blank\\" href=\\"https://about.gitlab.com/releases/2021/09/22/gitlab-14-3-released/#gitlab-serverless\\">deprecated</gl-link-stub>. But if you opt to use it, you must install Knative in your Kubernetes cluster first. <gl-link-stub href=\\"/help\\">Learn more.</gl-link-stub>
- </p>
- <div class=\\"gl-display-flex gl-flex-wrap gl-justify-content-center\\">
- <!---->
- <!---->
- </div>
- </div>
- </div>
-</section>"
-`;
diff --git a/spec/frontend/serverless/components/area_spec.js b/spec/frontend/serverless/components/area_spec.js
deleted file mode 100644
index 05c9ee44307..00000000000
--- a/spec/frontend/serverless/components/area_spec.js
+++ /dev/null
@@ -1,121 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Area from '~/serverless/components/area.vue';
-import { mockNormalizedMetrics } from '../mock_data';
-
-describe('Area component', () => {
- const mockWidgets = 'mockWidgets';
- const mockGraphData = mockNormalizedMetrics;
- let areaChart;
-
- beforeEach(() => {
- areaChart = shallowMount(Area, {
- propsData: {
- graphData: mockGraphData,
- containerWidth: 0,
- },
- slots: {
- default: mockWidgets,
- },
- });
- });
-
- afterEach(() => {
- areaChart.destroy();
- });
-
- it('renders chart title', () => {
- expect(areaChart.find({ ref: 'graphTitle' }).text()).toBe(mockGraphData.title);
- });
-
- it('contains graph widgets from slot', () => {
- expect(areaChart.find({ ref: 'graphWidgets' }).text()).toBe(mockWidgets);
- });
-
- describe('methods', () => {
- describe('formatTooltipText', () => {
- const mockDate = mockNormalizedMetrics.queries[0].result[0].values[0].time;
- const generateSeriesData = (type) => ({
- seriesData: [
- {
- componentSubType: type,
- value: [mockDate, 4],
- },
- ],
- value: mockDate,
- });
-
- describe('series is of line type', () => {
- beforeEach(() => {
- areaChart.vm.formatTooltipText(generateSeriesData('line'));
- });
-
- it('formats tooltip title', () => {
- expect(areaChart.vm.tooltipPopoverTitle).toBe('28 Feb 2019, 11:11AM');
- });
-
- it('formats tooltip content', () => {
- expect(areaChart.vm.tooltipPopoverContent).toBe('Invocations (requests): 4');
- });
- });
-
- it('verify default interval value of 1', () => {
- expect(areaChart.vm.getInterval).toBe(1);
- });
- });
-
- describe('onResize', () => {
- const mockWidth = 233;
-
- beforeEach(() => {
- jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({
- width: mockWidth,
- }));
- areaChart.vm.onResize();
- });
-
- it('sets area chart width', () => {
- expect(areaChart.vm.width).toBe(mockWidth);
- });
- });
- });
-
- describe('computed', () => {
- describe('chartData', () => {
- it('utilizes all data points', () => {
- expect(Object.keys(areaChart.vm.chartData)).toEqual(['requests']);
- expect(areaChart.vm.chartData.requests.length).toBe(2);
- });
-
- it('creates valid data', () => {
- const data = areaChart.vm.chartData.requests;
-
- expect(
- data.filter(
- (datum) => new Date(datum.time).getTime() > 0 && typeof datum.value === 'number',
- ).length,
- ).toBe(data.length);
- });
- });
-
- describe('generateSeries', () => {
- it('utilizes correct time data', () => {
- expect(areaChart.vm.generateSeries.data).toEqual([
- ['2019-02-28T11:11:38.756Z', 0],
- ['2019-02-28T11:12:38.756Z', 0],
- ]);
- });
- });
-
- describe('xAxisLabel', () => {
- it('constructs a label for the chart x-axis', () => {
- expect(areaChart.vm.xAxisLabel).toBe('invocations / minute');
- });
- });
-
- describe('yAxisLabel', () => {
- it('constructs a label for the chart y-axis', () => {
- expect(areaChart.vm.yAxisLabel).toBe('Invocations (requests)');
- });
- });
- });
-});
diff --git a/spec/frontend/serverless/components/empty_state_spec.js b/spec/frontend/serverless/components/empty_state_spec.js
deleted file mode 100644
index d63882c2a6d..00000000000
--- a/spec/frontend/serverless/components/empty_state_spec.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { GlEmptyState, GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import EmptyStateComponent from '~/serverless/components/empty_state.vue';
-import { createStore } from '~/serverless/store';
-
-describe('EmptyStateComponent', () => {
- let wrapper;
-
- beforeEach(() => {
- const store = createStore({
- clustersPath: '/clusters',
- helpPath: '/help',
- emptyImagePath: '/image.svg',
- });
- wrapper = shallowMount(EmptyStateComponent, { store, stubs: { GlEmptyState, GlSprintf } });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('should render content', () => {
- expect(wrapper.html()).toMatchSnapshot();
- });
-});
diff --git a/spec/frontend/serverless/components/environment_row_spec.js b/spec/frontend/serverless/components/environment_row_spec.js
deleted file mode 100644
index 944283136d0..00000000000
--- a/spec/frontend/serverless/components/environment_row_spec.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import environmentRowComponent from '~/serverless/components/environment_row.vue';
-
-import { translate } from '~/serverless/utils';
-import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
-
-const createComponent = (env, envName) =>
- shallowMount(environmentRowComponent, {
- propsData: { env, envName },
- }).vm;
-
-describe('environment row component', () => {
- describe('default global cluster case', () => {
- let vm;
-
- beforeEach(() => {
- vm = createComponent(translate(mockServerlessFunctions.functions)['*'], '*');
- });
-
- afterEach(() => vm.$destroy());
-
- it('has the correct envId', () => {
- expect(vm.envId).toEqual('env-global');
- });
-
- it('is open by default', () => {
- expect(vm.isOpenClass).toEqual({ 'is-open': true });
- });
-
- it('generates correct output', () => {
- expect(vm.$el.id).toEqual('env-global');
- expect(vm.$el.classList.contains('is-open')).toBe(true);
- expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
- });
-
- it('opens and closes correctly', () => {
- expect(vm.isOpen).toBe(true);
-
- vm.toggleOpen();
-
- expect(vm.isOpen).toBe(false);
- });
- });
-
- describe('default named cluster case', () => {
- let vm;
-
- beforeEach(() => {
- vm = createComponent(translate(mockServerlessFunctionsDiffEnv.functions).test, 'test');
- });
-
- afterEach(() => vm.$destroy());
-
- it('has the correct envId', () => {
- expect(vm.envId).toEqual('env-test');
- });
-
- it('is open by default', () => {
- expect(vm.isOpenClass).toEqual({ 'is-open': true });
- });
-
- it('generates correct output', () => {
- expect(vm.$el.id).toEqual('env-test');
- expect(vm.$el.classList.contains('is-open')).toBe(true);
- expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
- });
- });
-});
diff --git a/spec/frontend/serverless/components/function_details_spec.js b/spec/frontend/serverless/components/function_details_spec.js
deleted file mode 100644
index 0c9b2498589..00000000000
--- a/spec/frontend/serverless/components/function_details_spec.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
-
-import functionDetailsComponent from '~/serverless/components/function_details.vue';
-import { createStore } from '~/serverless/store';
-
-describe('functionDetailsComponent', () => {
- let component;
- let store;
-
- beforeEach(() => {
- Vue.use(Vuex);
-
- store = createStore({ clustersPath: '/clusters', helpPath: '/help' });
- });
-
- afterEach(() => {
- component.vm.$destroy();
- });
-
- describe('Verify base functionality', () => {
- const serviceStub = {
- name: 'test',
- description: 'a description',
- environment: '*',
- url: 'http://service.com/test',
- namespace: 'test-ns',
- podcount: 0,
- metricsUrl: '/metrics',
- };
-
- it('has a name, description, URL, and no pods loaded', () => {
- component = shallowMount(functionDetailsComponent, {
- store,
- propsData: {
- func: serviceStub,
- hasPrometheus: false,
- },
- });
-
- expect(
- component.vm.$el.querySelector('.serverless-function-name').innerHTML.trim(),
- ).toContain('test');
-
- expect(
- component.vm.$el.querySelector('.serverless-function-description').innerHTML.trim(),
- ).toContain('a description');
-
- expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain(
- 'No pods loaded at this time.',
- );
- });
-
- it('has a pods loaded', () => {
- serviceStub.podcount = 1;
-
- component = shallowMount(functionDetailsComponent, {
- store,
- propsData: {
- func: serviceStub,
- hasPrometheus: false,
- },
- });
-
- expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('1 pod in use');
- });
-
- it('has multiple pods loaded', () => {
- serviceStub.podcount = 3;
-
- component = shallowMount(functionDetailsComponent, {
- store,
- propsData: {
- func: serviceStub,
- hasPrometheus: false,
- },
- });
-
- expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('3 pods in use');
- });
-
- it('can support a missing description', () => {
- serviceStub.description = null;
-
- component = shallowMount(functionDetailsComponent, {
- store,
- propsData: {
- func: serviceStub,
- hasPrometheus: false,
- },
- });
-
- expect(
- component.vm.$el.querySelector('.serverless-function-description').querySelector('div')
- .innerHTML.length,
- ).toEqual(0);
- });
- });
-});
diff --git a/spec/frontend/serverless/components/function_row_spec.js b/spec/frontend/serverless/components/function_row_spec.js
deleted file mode 100644
index 081edd33b3b..00000000000
--- a/spec/frontend/serverless/components/function_row_spec.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import functionRowComponent from '~/serverless/components/function_row.vue';
-import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
-
-import { mockServerlessFunction } from '../mock_data';
-
-describe('functionRowComponent', () => {
- let wrapper;
-
- const createComponent = (func) => {
- wrapper = shallowMount(functionRowComponent, {
- propsData: { func },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('Parses the function details correctly', () => {
- createComponent(mockServerlessFunction);
-
- expect(wrapper.find('b').text()).toBe(mockServerlessFunction.name);
- expect(wrapper.find('span').text()).toBe(mockServerlessFunction.image);
- expect(wrapper.find(Timeago).attributes('time')).not.toBe(null);
- });
-
- it('handles clicks correctly', () => {
- createComponent(mockServerlessFunction);
- const { vm } = wrapper;
-
- expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row
- });
-});
diff --git a/spec/frontend/serverless/components/functions_spec.js b/spec/frontend/serverless/components/functions_spec.js
deleted file mode 100644
index 846fd63e918..00000000000
--- a/spec/frontend/serverless/components/functions_spec.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { GlLoadingIcon, GlAlert, GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import AxiosMockAdapter from 'axios-mock-adapter';
-import Vuex from 'vuex';
-import { TEST_HOST } from 'helpers/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import EmptyState from '~/serverless/components/empty_state.vue';
-import EnvironmentRow from '~/serverless/components/environment_row.vue';
-import functionsComponent from '~/serverless/components/functions.vue';
-import { createStore } from '~/serverless/store';
-import { mockServerlessFunctions } from '../mock_data';
-
-describe('functionsComponent', () => {
- const statusPath = `${TEST_HOST}/statusPath`;
-
- let component;
- let store;
- let axiosMock;
-
- beforeEach(() => {
- axiosMock = new AxiosMockAdapter(axios);
- axiosMock.onGet(statusPath).reply(200);
-
- Vue.use(Vuex);
-
- store = createStore({});
- component = shallowMount(functionsComponent, { store, stubs: { GlSprintf } });
- });
-
- afterEach(() => {
- component.destroy();
- axiosMock.restore();
- });
-
- it('should render deprecation notice', () => {
- expect(component.findComponent(GlAlert).text()).toBe(
- 'Serverless was deprecated in GitLab 14.3.',
- );
- });
-
- it('should render empty state when Knative is not installed', async () => {
- await store.dispatch('receiveFunctionsSuccess', { knative_installed: false });
-
- expect(component.findComponent(EmptyState).exists()).toBe(true);
- });
-
- it('should render a loading component', async () => {
- await store.dispatch('requestFunctionsLoading');
-
- expect(component.findComponent(GlLoadingIcon).exists()).toBe(true);
- });
-
- it('should render empty state when there is no function data', async () => {
- await store.dispatch('receiveFunctionsNoDataSuccess', { knative_installed: true });
-
- expect(
- component.vm.$el
- .querySelector('.empty-state, .js-empty-state')
- .classList.contains('js-empty-state'),
- ).toBe(true);
-
- expect(component.vm.$el.querySelector('.state-title, .text-center').innerHTML.trim()).toEqual(
- 'No functions available',
- );
- });
-
- it('should render functions and a loader when functions are partially fetched', async () => {
- await store.dispatch('receiveFunctionsPartial', {
- ...mockServerlessFunctions,
- knative_installed: 'checking',
- });
-
- expect(component.find('.js-functions-wrapper').exists()).toBe(true);
- expect(component.find('.js-functions-loader').exists()).toBe(true);
- });
-
- it('should render the functions list', async () => {
- store = createStore({ clustersPath: 'clustersPath', helpPath: 'helpPath', statusPath });
-
- await component.vm.$store.dispatch('receiveFunctionsSuccess', mockServerlessFunctions);
-
- await nextTick();
- expect(component.findComponent(EnvironmentRow).exists()).toBe(true);
- });
-});
diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js
deleted file mode 100644
index 1b93fd784e1..00000000000
--- a/spec/frontend/serverless/components/missing_prometheus_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue';
-import { createStore } from '~/serverless/store';
-
-describe('missingPrometheusComponent', () => {
- let wrapper;
-
- const createComponent = (missingData) => {
- const store = createStore({ clustersPath: '/clusters', helpPath: '/help' });
-
- wrapper = shallowMount(missingPrometheusComponent, { store, propsData: { missingData } });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('should render missing prometheus message', () => {
- createComponent(false);
- const { vm } = wrapper;
-
- expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain(
- 'Function invocation metrics require the Prometheus cluster integration.',
- );
-
- expect(wrapper.find(GlButton).attributes('variant')).toBe('success');
- });
-
- it('should render no prometheus data message', () => {
- createComponent(true);
- const { vm } = wrapper;
-
- expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain(
- 'Invocation metrics loading or not available at this time.',
- );
- });
-});
diff --git a/spec/frontend/serverless/components/pod_box_spec.js b/spec/frontend/serverless/components/pod_box_spec.js
deleted file mode 100644
index cf0c14a2cac..00000000000
--- a/spec/frontend/serverless/components/pod_box_spec.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import podBoxComponent from '~/serverless/components/pod_box.vue';
-
-const createComponent = (count) =>
- shallowMount(podBoxComponent, {
- propsData: {
- count,
- },
- }).vm;
-
-describe('podBoxComponent', () => {
- it('should render three boxes', () => {
- const count = 3;
- const vm = createComponent(count);
- const rects = vm.$el.querySelectorAll('rect');
-
- expect(rects.length).toEqual(3);
- expect(parseInt(rects[2].getAttribute('x'), 10)).toEqual(40);
-
- vm.$destroy();
- });
-});
diff --git a/spec/frontend/serverless/components/url_spec.js b/spec/frontend/serverless/components/url_spec.js
deleted file mode 100644
index 8c839577aa0..00000000000
--- a/spec/frontend/serverless/components/url_spec.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import urlComponent from '~/serverless/components/url.vue';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-
-const createComponent = (uri) =>
- shallowMount(Vue.extend(urlComponent), {
- propsData: {
- uri,
- },
- });
-
-describe('urlComponent', () => {
- it('should render correctly', () => {
- const uri = 'http://testfunc.apps.example.com';
- const wrapper = createComponent(uri);
- const { vm } = wrapper;
-
- expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
- expect(wrapper.find(ClipboardButton).attributes('text')).toEqual(uri);
-
- expect(vm.$el.querySelector('[data-testid="url-text-field"]').innerHTML).toContain(uri);
-
- vm.$destroy();
- });
-});
diff --git a/spec/frontend/serverless/mock_data.js b/spec/frontend/serverless/mock_data.js
deleted file mode 100644
index 1816ad62a04..00000000000
--- a/spec/frontend/serverless/mock_data.js
+++ /dev/null
@@ -1,145 +0,0 @@
-export const mockServerlessFunctions = {
- knative_installed: true,
- functions: [
- {
- name: 'testfunc1',
- namespace: 'tm-example',
- environment_scope: '*',
- cluster_id: 46,
- detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
- podcount: null,
- created_at: '2019-02-05T01:01:23Z',
- url: 'http://testfunc1.tm-example.apps.example.com',
- description: 'A test service',
- image: 'knative-test-container-buildtemplate',
- },
- {
- name: 'testfunc2',
- namespace: 'tm-example',
- environment_scope: '*',
- cluster_id: 46,
- detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
- podcount: null,
- created_at: '2019-02-05T01:01:23Z',
- url: 'http://testfunc2.tm-example.apps.example.com',
- description: 'A second test service\nThis one with additional descriptions',
- image: 'knative-test-echo-buildtemplate',
- },
- ],
-};
-
-export const mockServerlessFunctionsDiffEnv = {
- knative_installed: true,
- functions: [
- {
- name: 'testfunc1',
- namespace: 'tm-example',
- environment_scope: '*',
- cluster_id: 46,
- detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
- podcount: null,
- created_at: '2019-02-05T01:01:23Z',
- url: 'http://testfunc1.tm-example.apps.example.com',
- description: 'A test service',
- image: 'knative-test-container-buildtemplate',
- },
- {
- name: 'testfunc2',
- namespace: 'tm-example',
- environment_scope: 'test',
- cluster_id: 46,
- detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
- podcount: null,
- created_at: '2019-02-05T01:01:23Z',
- url: 'http://testfunc2.tm-example.apps.example.com',
- description: 'A second test service\nThis one with additional descriptions',
- image: 'knative-test-echo-buildtemplate',
- },
- ],
-};
-
-export const mockServerlessFunction = {
- name: 'testfunc1',
- namespace: 'tm-example',
- environment_scope: '*',
- cluster_id: 46,
- detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
- podcount: '3',
- created_at: '2019-02-05T01:01:23Z',
- url: 'http://testfunc1.tm-example.apps.example.com',
- description: 'A test service',
- image: 'knative-test-container-buildtemplate',
-};
-
-export const mockMultilineServerlessFunction = {
- name: 'testfunc1',
- namespace: 'tm-example',
- environment_scope: '*',
- cluster_id: 46,
- detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
- podcount: '3',
- created_at: '2019-02-05T01:01:23Z',
- url: 'http://testfunc1.tm-example.apps.example.com',
- description: 'testfunc1\nA test service line\\nWith additional services',
- image: 'knative-test-container-buildtemplate',
-};
-
-export const mockMetrics = {
- success: true,
- last_update: '2019-02-28T19:11:38.926Z',
- metrics: {
- id: 22,
- title: 'Knative function invocations',
- required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'],
- weight: 0,
- y_label: 'Invocations',
- queries: [
- {
- query_range:
- 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))',
- unit: 'requests',
- label: 'invocations / minute',
- result: [
- {
- metric: {},
- values: [
- [1551352298.756, '0'],
- [1551352358.756, '0'],
- ],
- },
- ],
- },
- ],
- },
-};
-
-export const mockNormalizedMetrics = {
- id: 22,
- title: 'Knative function invocations',
- required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'],
- weight: 0,
- y_label: 'Invocations',
- queries: [
- {
- query_range:
- 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))',
- unit: 'requests',
- label: 'invocations / minute',
- result: [
- {
- metric: {},
- values: [
- {
- time: '2019-02-28T11:11:38.756Z',
- value: 0,
- },
- {
- time: '2019-02-28T11:12:38.756Z',
- value: 0,
- },
- ],
- },
- ],
- },
- ],
-};
diff --git a/spec/frontend/serverless/store/actions_spec.js b/spec/frontend/serverless/store/actions_spec.js
deleted file mode 100644
index 5fbecf081a6..00000000000
--- a/spec/frontend/serverless/store/actions_spec.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'helpers/vuex_action_helper';
-import axios from '~/lib/utils/axios_utils';
-import statusCodes from '~/lib/utils/http_status';
-import { fetchFunctions, fetchMetrics } from '~/serverless/store/actions';
-import { mockServerlessFunctions, mockMetrics } from '../mock_data';
-import { adjustMetricQuery } from '../utils';
-
-describe('ServerlessActions', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('fetchFunctions', () => {
- it('should successfully fetch functions', () => {
- const endpoint = '/functions';
- mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions));
-
- return testAction(
- fetchFunctions,
- { functionsPath: endpoint },
- {},
- [],
- [
- { type: 'requestFunctionsLoading' },
- { type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions },
- ],
- );
- });
-
- it('should successfully retry', () => {
- const endpoint = '/functions';
- mock
- .onGet(endpoint)
- .reply(() => new Promise((resolve) => setTimeout(() => resolve(200), Infinity)));
-
- return testAction(
- fetchFunctions,
- { functionsPath: endpoint },
- {},
- [],
- [{ type: 'requestFunctionsLoading' }],
- );
- });
- });
-
- describe('fetchMetrics', () => {
- it('should return no prometheus', () => {
- const endpoint = '/metrics';
- mock.onGet(endpoint).reply(statusCodes.NO_CONTENT);
-
- return testAction(
- fetchMetrics,
- { metricsPath: endpoint, hasPrometheus: false },
- {},
- [],
- [{ type: 'receiveMetricsNoPrometheus' }],
- );
- });
-
- it('should successfully fetch metrics', () => {
- const endpoint = '/metrics';
- mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics));
-
- return testAction(
- fetchMetrics,
- { metricsPath: endpoint, hasPrometheus: true },
- {},
- [],
- [{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }],
- );
- });
- });
-});
diff --git a/spec/frontend/serverless/store/getters_spec.js b/spec/frontend/serverless/store/getters_spec.js
deleted file mode 100644
index e1942bd2759..00000000000
--- a/spec/frontend/serverless/store/getters_spec.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import * as getters from '~/serverless/store/getters';
-import serverlessState from '~/serverless/store/state';
-import { mockServerlessFunctions } from '../mock_data';
-
-describe('Serverless Store Getters', () => {
- let state;
-
- beforeEach(() => {
- state = serverlessState;
- });
-
- describe('hasPrometheusMissingData', () => {
- it('should return false if Prometheus is not installed', () => {
- state.hasPrometheus = false;
-
- expect(getters.hasPrometheusMissingData(state)).toEqual(false);
- });
-
- it('should return false if Prometheus is installed and there is data', () => {
- state.hasPrometheusData = true;
-
- expect(getters.hasPrometheusMissingData(state)).toEqual(false);
- });
-
- it('should return true if Prometheus is installed and there is no data', () => {
- state.hasPrometheus = true;
- state.hasPrometheusData = false;
-
- expect(getters.hasPrometheusMissingData(state)).toEqual(true);
- });
- });
-
- describe('getFunctions', () => {
- it('should translate the raw function array to group the functions per environment scope', () => {
- state.functions = mockServerlessFunctions.functions;
-
- const funcs = getters.getFunctions(state);
-
- expect(Object.keys(funcs)).toContain('*');
- expect(funcs['*'].length).toEqual(2);
- });
- });
-});
diff --git a/spec/frontend/serverless/store/mutations_spec.js b/spec/frontend/serverless/store/mutations_spec.js
deleted file mode 100644
index a1a8f9a2ca7..00000000000
--- a/spec/frontend/serverless/store/mutations_spec.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import * as types from '~/serverless/store/mutation_types';
-import mutations from '~/serverless/store/mutations';
-import { mockServerlessFunctions, mockMetrics } from '../mock_data';
-
-describe('ServerlessMutations', () => {
- describe('Functions List Mutations', () => {
- it('should ensure loading is true', () => {
- const state = {};
-
- mutations[types.REQUEST_FUNCTIONS_LOADING](state);
-
- expect(state.isLoading).toEqual(true);
- });
-
- it('should set proper state once functions are loaded', () => {
- const state = {};
-
- mutations[types.RECEIVE_FUNCTIONS_SUCCESS](state, mockServerlessFunctions);
-
- expect(state.isLoading).toEqual(false);
- expect(state.hasFunctionData).toEqual(true);
- expect(state.functions).toEqual(mockServerlessFunctions.functions);
- });
-
- it('should ensure loading has stopped and hasFunctionData is false when there are no functions available', () => {
- const state = {};
-
- mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, { knative_installed: true });
-
- expect(state.isLoading).toEqual(false);
- expect(state.hasFunctionData).toEqual(false);
- expect(state.functions).toBe(undefined);
- });
-
- it('should ensure loading has stopped, and an error is raised', () => {
- const state = {};
-
- mutations[types.RECEIVE_FUNCTIONS_ERROR](state, 'sample error');
-
- expect(state.isLoading).toEqual(false);
- expect(state.hasFunctionData).toEqual(false);
- expect(state.functions).toBe(undefined);
- expect(state.error).not.toBe(undefined);
- });
- });
-
- describe('Function Details Metrics Mutations', () => {
- it('should ensure isLoading and hasPrometheus data flags indicate data is loaded', () => {
- const state = {};
-
- mutations[types.RECEIVE_METRICS_SUCCESS](state, mockMetrics);
-
- expect(state.isLoading).toEqual(false);
- expect(state.hasPrometheusData).toEqual(true);
- expect(state.graphData).toEqual(mockMetrics);
- });
-
- it('should ensure isLoading and hasPrometheus data flags are cleared indicating no functions available', () => {
- const state = {};
-
- mutations[types.RECEIVE_METRICS_NODATA_SUCCESS](state);
-
- expect(state.isLoading).toEqual(false);
- expect(state.hasPrometheusData).toEqual(false);
- expect(state.graphData).toBe(undefined);
- });
-
- it('should properly indicate an error', () => {
- const state = {};
-
- mutations[types.RECEIVE_METRICS_ERROR](state, 'sample error');
-
- expect(state.hasPrometheusData).toEqual(false);
- expect(state.error).not.toBe(undefined);
- });
-
- it('should properly indicate when prometheus is installed', () => {
- const state = {};
-
- mutations[types.RECEIVE_METRICS_NO_PROMETHEUS](state);
-
- expect(state.hasPrometheus).toEqual(false);
- expect(state.hasPrometheusData).toEqual(false);
- });
- });
-});
diff --git a/spec/frontend/serverless/utils.js b/spec/frontend/serverless/utils.js
deleted file mode 100644
index 7caf7da231e..00000000000
--- a/spec/frontend/serverless/utils.js
+++ /dev/null
@@ -1,17 +0,0 @@
-export const adjustMetricQuery = (data) => {
- const updatedMetric = data.metrics;
-
- const queries = data.metrics.queries.map((query) => ({
- ...query,
- result: query.result.map((result) => ({
- ...result,
- values: result.values.map(([timestamp, value]) => ({
- time: new Date(timestamp * 1000).toISOString(),
- value: Number(value),
- })),
- })),
- }));
-
- updatedMetric.queries = queries;
- return updatedMetric;
-};
diff --git a/spec/frontend/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js
index 3a62cd703ab..d59e1a20b27 100644
--- a/spec/frontend/settings_panels_spec.js
+++ b/spec/frontend/settings_panels_spec.js
@@ -1,9 +1,14 @@
import $ from 'jquery';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initSettingsPanels, { isExpanded } from '~/settings_panels';
describe('Settings Panels', () => {
beforeEach(() => {
- loadFixtures('groups/edit.html');
+ loadHTMLFixture('groups/edit.html');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
describe('initSettingsPane', () => {
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js
index 8b9a11056f2..e859d435f48 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/shortcuts_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import { flatten } from 'lodash';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
const mockMousetrap = {
@@ -21,7 +22,7 @@ describe('Shortcuts', () => {
});
beforeEach(() => {
- loadFixtures(fixtureName);
+ loadHTMLFixture(fixtureName);
jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus');
jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus');
@@ -30,6 +31,10 @@ describe('Shortcuts', () => {
new Shortcuts(); // eslint-disable-line no-new
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
describe('toggleMarkdownPreview', () => {
it('focuses preview button in form', () => {
Shortcuts.toggleMarkdownPreview(
diff --git a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
index 90aae85e1ca..f7437386814 100644
--- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
+++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js
@@ -47,12 +47,10 @@ describe('UncollapsedAssigneeList component', () => {
it('calls the AssigneeAvatarLink with the proper props', () => {
expect(wrapper.find(AssigneeAvatarLink).exists()).toBe(true);
- expect(wrapper.find(AssigneeAvatarLink).props().tooltipPlacement).toEqual('left');
});
it('Shows one user with avatar, username and author name', () => {
expect(wrapper.text()).toContain(user.name);
- expect(wrapper.text()).toContain(`@${user.username}`);
});
});
diff --git a/spec/frontend/sidebar/components/attention_requested_toggle_spec.js b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js
index a9ae23c1624..959fa799eb7 100644
--- a/spec/frontend/sidebar/components/attention_requested_toggle_spec.js
+++ b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js
@@ -68,6 +68,7 @@ describe('Attention require toggle', () => {
{
user: { attention_requested: true, can_update_merge_request: true },
callback: expect.anything(),
+ direction: 'remove',
},
]);
});
@@ -96,9 +97,9 @@ describe('Attention require toggle', () => {
it.each`
type | attentionRequested | tooltip | canUpdateMergeRequest
- ${'reviewer'} | ${true} | ${AttentionRequestedToggle.i18n.removeAttentionRequested} | ${true}
- ${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.attentionRequestedReviewer} | ${true}
- ${'assignee'} | ${false} | ${AttentionRequestedToggle.i18n.attentionRequestedAssignee} | ${true}
+ ${'reviewer'} | ${true} | ${AttentionRequestedToggle.i18n.removeAttentionRequest} | ${true}
+ ${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.addAttentionRequest} | ${true}
+ ${'assignee'} | ${false} | ${AttentionRequestedToggle.i18n.addAttentionRequest} | ${true}
${'reviewer'} | ${true} | ${AttentionRequestedToggle.i18n.attentionRequestedNoPermission} | ${false}
${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.noAttentionRequestedNoPermission} | ${false}
${'assignee'} | ${true} | ${AttentionRequestedToggle.i18n.attentionRequestedNoPermission} | ${false}
diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js
index 8844e1626cd..ab45fdf03bc 100644
--- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js
+++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue';
@@ -60,12 +60,24 @@ describe('Sidebar Confidentiality Content', () => {
it('displays a correct confidential text for issue', () => {
createComponent({ confidential: true });
- expect(findText().text()).toBe('This issue is confidential');
+
+ const alertEl = findText().findComponent(GlAlert);
+
+ expect(alertEl.props()).toMatchObject({
+ showIcon: false,
+ dismissible: false,
+ variant: 'warning',
+ });
+ expect(alertEl.text()).toBe(
+ 'Only project members with at least Reporter role can view or be notified about this issue.',
+ );
});
it('displays a correct confidential text for epic', () => {
createComponent({ confidential: true, issuableType: 'epic' });
- expect(findText().text()).toBe('This epic is confidential');
+ expect(findText().findComponent(GlAlert).text()).toBe(
+ 'Only group members with at least Reporter role can view or be notified about this epic.',
+ );
});
});
});
diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js
index 28a19fb9df6..85d6bc7b782 100644
--- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js
+++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js
@@ -89,7 +89,7 @@ describe('Sidebar Confidentiality Form', () => {
it('renders a message about making an issue confidential', () => {
expect(findWarningMessage().text()).toBe(
- 'You are going to turn on confidentiality. Only team members with at least Reporter access will be able to see and leave comments on the issue.',
+ 'You are going to turn on confidentiality. Only project members with at least Reporter role can view or be notified about this issue.',
);
});
diff --git a/spec/frontend/sidebar/components/crm_contacts_spec.js b/spec/frontend/sidebar/components/crm_contacts_spec.js
index 758cff30e2d..6456829258f 100644
--- a/spec/frontend/sidebar/components/crm_contacts_spec.js
+++ b/spec/frontend/sidebar/components/crm_contacts_spec.js
@@ -33,7 +33,7 @@ describe('Issue crm contacts component', () => {
[issueCrmContactsSubscription, subscriptionHandler],
]);
wrapper = shallowMountExtended(CrmContacts, {
- propsData: { issueId: '123' },
+ propsData: { issueId: '123', groupIssuesPath: '/groups/flightjs/-/issues' },
apolloProvider: fakeApollo,
});
};
@@ -71,8 +71,14 @@ describe('Issue crm contacts component', () => {
await waitForPromises();
expect(wrapper.find('#contact_0').text()).toContain('Someone Important');
+ expect(wrapper.find('#contact_0').attributes('href')).toBe(
+ '/groups/flightjs/-/issues?crm_contact_id=1',
+ );
expect(wrapper.find('#contact_container_0').text()).toContain('si@gitlab.com');
expect(wrapper.find('#contact_1').text()).toContain('Marty McFly');
+ expect(wrapper.find('#contact_1').attributes('href')).toBe(
+ '/groups/flightjs/-/issues?crm_contact_id=5',
+ );
});
it('renders correct results after subscription update', async () => {
@@ -83,5 +89,8 @@ describe('Issue crm contacts component', () => {
contact.forEach((property) => {
expect(wrapper.find('#contact_container_0').text()).toContain(property);
});
+ expect(wrapper.find('#contact_0').attributes('href')).toBe(
+ '/groups/flightjs/-/issues?crm_contact_id=13',
+ );
});
});
diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
index d0792fa7b73..8999f120a0f 100644
--- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
+++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
@@ -42,9 +42,8 @@ describe('UncollapsedReviewerList component', () => {
expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(1);
});
- it('shows one user with avatar, username and author name', () => {
+ it('shows one user with avatar, and author name', () => {
expect(wrapper.text()).toContain(user.name);
- expect(wrapper.text()).toContain(`@root`);
});
it('renders re-request loading icon', async () => {
@@ -84,11 +83,9 @@ describe('UncollapsedReviewerList component', () => {
expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(2);
});
- it('shows both users with avatar, username and author name', () => {
+ it('shows both users with avatar, and author name', () => {
expect(wrapper.text()).toContain(user.name);
- expect(wrapper.text()).toContain(`@root`);
expect(wrapper.text()).toContain(user2.name);
- expect(wrapper.text()).toContain(`@hello-world`);
});
it('renders approval icon', () => {
diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js
index 3f1b3fa8ec1..ba2781118d9 100644
--- a/spec/frontend/sidebar/components/time_tracking/mock_data.js
+++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js
@@ -9,6 +9,7 @@ export const getIssueTimelogsQueryResponse = {
nodes: [
{
__typename: 'Timelog',
+ id: 'gid://gitlab/Timelog/18',
timeSpent: 14400,
user: {
id: 'user-1',
@@ -25,6 +26,7 @@ export const getIssueTimelogsQueryResponse = {
},
{
__typename: 'Timelog',
+ id: 'gid://gitlab/Timelog/20',
timeSpent: 1800,
user: {
id: 'user-2',
@@ -37,6 +39,7 @@ export const getIssueTimelogsQueryResponse = {
},
{
__typename: 'Timelog',
+ id: 'gid://gitlab/Timelog/25',
timeSpent: 14400,
user: {
id: 'user-2',
@@ -68,6 +71,7 @@ export const getMrTimelogsQueryResponse = {
nodes: [
{
__typename: 'Timelog',
+ id: 'gid://gitlab/Timelog/13',
timeSpent: 1800,
user: {
id: 'user-1',
@@ -84,6 +88,7 @@ export const getMrTimelogsQueryResponse = {
},
{
__typename: 'Timelog',
+ id: 'gid://gitlab/Timelog/22',
timeSpent: 3600,
user: {
id: 'user-1',
@@ -96,6 +101,7 @@ export const getMrTimelogsQueryResponse = {
},
{
__typename: 'Timelog',
+ id: 'gid://gitlab/Timelog/64',
timeSpent: 300,
user: {
id: 'user-1',
diff --git a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js
index 7bf7e563a01..8478d3d674d 100644
--- a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js
+++ b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js
@@ -37,6 +37,9 @@ describe('IssuableLockForm', () => {
const createComponent = ({ props = {} }) => {
wrapper = shallowMount(IssuableLockForm, {
store,
+ provide: {
+ fullPath: '',
+ },
propsData: {
isEditable: true,
...props,
diff --git a/spec/frontend/sidebar/reviewers_spec.js b/spec/frontend/sidebar/reviewers_spec.js
index fc24b51287f..351dfc9a6ed 100644
--- a/spec/frontend/sidebar/reviewers_spec.js
+++ b/spec/frontend/sidebar/reviewers_spec.js
@@ -146,7 +146,6 @@ describe('Reviewer component', () => {
const userItems = wrapper.findAll('[data-testid="reviewer"]');
expect(userItems.length).toBe(3);
- expect(userItems.at(0).find('a').attributes('title')).toBe(users[2].name);
});
it('passes the sorted reviewers to the collapsed-reviewer-list', () => {
diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js
index c472a98bf0b..82fb10ab1d2 100644
--- a/spec/frontend/sidebar/sidebar_mediator_spec.js
+++ b/spec/frontend/sidebar/sidebar_mediator_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import * as urlUtility from '~/lib/utils/url_utility';
import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
@@ -8,6 +9,7 @@ import toast from '~/vue_shared/plugins/global_toast';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Mock from './mock_data';
+jest.mock('~/flash');
jest.mock('~/vue_shared/plugins/global_toast');
jest.mock('~/commons/nav/user_merge_requests');
@@ -122,25 +124,39 @@ describe('Sidebar mediator', () => {
});
describe('toggleAttentionRequested', () => {
- let attentionRequiredService;
+ let requestAttentionMock;
+ let removeAttentionRequestMock;
beforeEach(() => {
- attentionRequiredService = jest
- .spyOn(mediator.service, 'toggleAttentionRequested')
+ requestAttentionMock = jest.spyOn(mediator.service, 'requestAttention').mockResolvedValue();
+ removeAttentionRequestMock = jest
+ .spyOn(mediator.service, 'removeAttentionRequest')
.mockResolvedValue();
});
- it('calls attentionRequired service method', async () => {
- mediator.store.reviewers = [{ id: 1, attention_requested: false, username: 'root' }];
+ it.each`
+ attentionIsCurrentlyRequested | serviceMethod
+ ${true} | ${'remove'}
+ ${false} | ${'add'}
+ `(
+ "calls the $serviceMethod service method when the user's attention request is set to $attentionIsCurrentlyRequested",
+ async ({ serviceMethod }) => {
+ const methods = {
+ add: requestAttentionMock,
+ remove: removeAttentionRequestMock,
+ };
+ mediator.store.reviewers = [{ id: 1, attention_requested: false, username: 'root' }];
- await mediator.toggleAttentionRequested('reviewer', {
- user: { id: 1, username: 'root' },
- callback: jest.fn(),
- });
+ await mediator.toggleAttentionRequested('reviewer', {
+ user: { id: 1, username: 'root' },
+ callback: jest.fn(),
+ direction: serviceMethod,
+ });
- expect(attentionRequiredService).toHaveBeenCalledWith(1);
- expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
- });
+ expect(methods[serviceMethod]).toHaveBeenCalledWith(1);
+ expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
+ },
+ );
it.each`
type | method
@@ -172,5 +188,27 @@ describe('Sidebar mediator', () => {
expect(toast).toHaveBeenCalledWith(toastMessage);
},
);
+
+ describe('errors', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(mediator.service, 'removeAttentionRequest')
+ .mockRejectedValueOnce(new Error('Something went wrong'));
+ });
+
+ it('shows an error message', async () => {
+ await mediator.toggleAttentionRequested('reviewer', {
+ user: { id: 1, username: 'root' },
+ callback: jest.fn(),
+ direction: 'remove',
+ });
+
+ expect(createFlash).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Updating the attention request for root failed.',
+ }),
+ );
+ });
+ });
});
});
diff --git a/spec/frontend/single_file_diff_spec.js b/spec/frontend/single_file_diff_spec.js
index 8718152655f..6f42ec47458 100644
--- a/spec/frontend/single_file_diff_spec.js
+++ b/spec/frontend/single_file_diff_spec.js
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import axios from '~/lib/utils/axios_utils';
import SingleFileDiff from '~/single_file_diff';
@@ -15,6 +15,7 @@ describe('SingleFileDiff', () => {
afterEach(() => {
mock.restore();
+ resetHTMLFixture();
});
it('loads diff via axios exactly once for collapsed diffs', async () => {
diff --git a/spec/frontend/smart_interval_spec.js b/spec/frontend/smart_interval_spec.js
index 1a2fd7ff8f1..5dda097ae6a 100644
--- a/spec/frontend/smart_interval_spec.js
+++ b/spec/frontend/smart_interval_spec.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import { assignIn } from 'lodash';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import SmartInterval from '~/smart_interval';
@@ -116,11 +117,15 @@ describe('SmartInterval', () => {
describe('DOM Events', () => {
beforeEach(() => {
// This ensures DOM and DOM events are initialized for these specs.
- setFixtures('<div></div>');
+ setHTMLFixture('<div></div>');
interval = createDefaultSmartInterval();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should pause when page is not visible', () => {
jest.runOnlyPendingTimers();
diff --git a/spec/frontend/snippet/collapsible_input_spec.js b/spec/frontend/snippet/collapsible_input_spec.js
index 3f14a9cd1a1..56e64d136c2 100644
--- a/spec/frontend/snippet/collapsible_input_spec.js
+++ b/spec/frontend/snippet/collapsible_input_spec.js
@@ -1,4 +1,4 @@
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import setupCollapsibleInputs from '~/snippet/collapsible_input';
describe('~/snippet/collapsible_input', () => {
@@ -38,6 +38,10 @@ describe('~/snippet/collapsible_input', () => {
setupCollapsibleInputs();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
const findInput = (el) => el.querySelector('textarea,input');
const findCollapsed = (el) => el.querySelector('.js-collapsed');
const findExpanded = (el) => el.querySelector('.js-expanded');
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
index 2b26c306c68..fec300ddd7e 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
@@ -28,9 +28,9 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
data-uploads-path=""
>
<markdown-header-stub
- data-testid="markdownHeader"
enablepreview="true"
linecontent=""
+ restrictedtoolbaritems=""
suggestionstartindex="0"
/>
@@ -81,6 +81,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
canattachfile="true"
markdowndocspath="help/"
quickactionsdocspath=""
+ showcommenttoolbar="true"
/>
</div>
</div>
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index 9cfe136129a..8a767765149 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -1,5 +1,4 @@
-import { GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlFormGroup, GlLoadingIcon } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import { merge } from 'lodash';
@@ -7,6 +6,7 @@ import VueApollo, { ApolloMutation } from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
import createFlash from '~/flash';
import * as urlUtils from '~/lib/utils/url_utility';
@@ -22,7 +22,6 @@ import {
import CreateSnippetMutation from '~/snippets/mutations/create_snippet.mutation.graphql';
import UpdateSnippetMutation from '~/snippets/mutations/update_snippet.mutation.graphql';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
-import TitleField from '~/vue_shared/components/form/title.vue';
import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils';
jest.mock('~/flash');
@@ -112,19 +111,19 @@ describe('Snippet Edit app', () => {
gon.relative_url_root = originalRelativeUrlRoot;
});
- const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
- const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
- const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
- const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
- const clickSubmitBtn = () => wrapper.find('[data-testid="snippet-edit-form"]').trigger('submit');
+ const findBlobActions = () => wrapper.findComponent(SnippetBlobActionsEdit);
+ const findCancelButton = () => wrapper.findByTestId('snippet-cancel-btn');
+ const clickSubmitBtn = () => wrapper.findByTestId('snippet-edit-form').trigger('submit');
+
const triggerBlobActions = (actions) => findBlobActions().vm.$emit('actions', actions);
const setUploadFilesHtml = (paths) => {
wrapper.vm.$el.innerHTML = paths
.map((path) => `<input name="files[]" value="${path}">`)
.join('');
};
- const setTitle = (val) => wrapper.find(TitleField).vm.$emit('input', val);
- const setDescription = (val) => wrapper.find(SnippetDescriptionEdit).vm.$emit('input', val);
+ const setTitle = (val) => wrapper.findByTestId('snippet-title-input').vm.$emit('input', val);
+ const setDescription = (val) =>
+ wrapper.findComponent(SnippetDescriptionEdit).vm.$emit('input', val);
const createComponent = ({ props = {}, selectedLevel = SNIPPET_VISIBILITY_PRIVATE } = {}) => {
if (wrapper) {
@@ -139,7 +138,7 @@ describe('Snippet Edit app', () => {
];
const apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMount(SnippetEditApp, {
+ wrapper = shallowMountExtended(SnippetEditApp, {
apolloProvider,
stubs: {
ApolloMutation,
@@ -177,7 +176,7 @@ describe('Snippet Edit app', () => {
it('renders loader', () => {
createComponent();
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
});
@@ -193,10 +192,10 @@ describe('Snippet Edit app', () => {
});
it('should render components', () => {
- expect(wrapper.find(TitleField).exists()).toBe(true);
- expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
- expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
- expect(wrapper.find(FormFooterActions).exists()).toBe(true);
+ expect(wrapper.findComponent(GlFormGroup).attributes('label')).toEqual('Title');
+ expect(wrapper.findComponent(SnippetDescriptionEdit).exists()).toBe(true);
+ expect(wrapper.findComponent(SnippetVisibilityEdit).exists()).toBe(true);
+ expect(wrapper.findComponent(FormFooterActions).exists()).toBe(true);
expect(findBlobActions().exists()).toBe(true);
});
@@ -207,25 +206,34 @@ describe('Snippet Edit app', () => {
describe('default', () => {
it.each`
- title | actions | shouldDisable
- ${''} | ${[]} | ${true}
- ${''} | ${[TEST_ACTIONS.VALID]} | ${true}
- ${'foo'} | ${[]} | ${false}
- ${'foo'} | ${[TEST_ACTIONS.VALID]} | ${false}
- ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${true}
- ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${false}
+ title | actions | titleHasErrors | blobActionsHasErrors
+ ${''} | ${[]} | ${true} | ${false}
+ ${''} | ${[TEST_ACTIONS.VALID]} | ${true} | ${false}
+ ${'foo'} | ${[]} | ${false} | ${false}
+ ${'foo'} | ${[TEST_ACTIONS.VALID]} | ${false} | ${false}
+ ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${false} | ${true}
+ ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${false} | ${false}
`(
- 'should handle submit disable (title="$title", actions="$actions", shouldDisable="$shouldDisable")',
- async ({ title, actions, shouldDisable }) => {
+ 'validates correctly (title="$title", actions="$actions", titleHasErrors="$titleHasErrors", blobActionsHasErrors="$blobActionsHasErrors")',
+ async ({ title, actions, titleHasErrors, blobActionsHasErrors }) => {
getSpy.mockResolvedValue(createQueryResponse({ title }));
await createComponentAndLoad();
triggerBlobActions(actions);
+ clickSubmitBtn();
+
await nextTick();
- expect(hasDisabledSubmit()).toBe(shouldDisable);
+ expect(wrapper.findComponent(GlFormGroup).exists()).toBe(true);
+ expect(Boolean(wrapper.findComponent(GlFormGroup).attributes('state'))).toEqual(
+ !titleHasErrors,
+ );
+
+ expect(wrapper.find(SnippetBlobActionsEdit).props('isValid')).toEqual(
+ !blobActionsHasErrors,
+ );
},
);
@@ -262,35 +270,64 @@ describe('Snippet Edit app', () => {
);
describe('form submission handling', () => {
- it.each`
- snippetGid | projectPath | uploadedFiles | input | mutationType
- ${''} | ${'project/path'} | ${[]} | ${{ ...getApiData(), projectPath: 'project/path', uploadedFiles: [] }} | ${'createSnippet'}
- ${''} | ${''} | ${[]} | ${{ ...getApiData(), projectPath: '', uploadedFiles: [] }} | ${'createSnippet'}
- ${''} | ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData(), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }} | ${'createSnippet'}
- ${TEST_SNIPPET_GID} | ${'project/path'} | ${[]} | ${getApiData(createSnippet())} | ${'updateSnippet'}
- ${TEST_SNIPPET_GID} | ${''} | ${[]} | ${getApiData(createSnippet())} | ${'updateSnippet'}
- `(
- 'should submit mutation $mutationType (snippetGid=$snippetGid, projectPath=$projectPath, uploadedFiles=$uploadedFiles)',
- async ({ snippetGid, projectPath, uploadedFiles, mutationType, input }) => {
- await createComponentAndLoad({
- props: {
- snippetGid,
- projectPath,
- },
- });
-
- setUploadFilesHtml(uploadedFiles);
-
- await nextTick();
-
- clickSubmitBtn();
+ describe('when creating a new snippet', () => {
+ it.each`
+ projectPath | uploadedFiles | input
+ ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData({ title: 'Title' }), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }}
+ ${'project/path'} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData({ title: 'Title' }), projectPath: 'project/path', uploadedFiles: TEST_UPLOADED_FILES }}
+ `(
+ 'should submit a createSnippet mutation (projectPath=$projectPath, uploadedFiles=$uploadedFiles)',
+ async ({ projectPath, uploadedFiles, input }) => {
+ await createComponentAndLoad({
+ props: {
+ snippetGid: '',
+ projectPath,
+ },
+ });
+
+ setTitle(input.title);
+ setUploadFilesHtml(uploadedFiles);
+
+ await nextTick();
+
+ clickSubmitBtn();
+
+ expect(mutateSpy).toHaveBeenCalledTimes(1);
+ expect(mutateSpy).toHaveBeenCalledWith('createSnippet', {
+ input,
+ });
+ },
+ );
+ });
- expect(mutateSpy).toHaveBeenCalledTimes(1);
- expect(mutateSpy).toHaveBeenCalledWith(mutationType, {
- input,
- });
- },
- );
+ describe('when updating a snippet', () => {
+ it.each`
+ projectPath | uploadedFiles | input
+ ${''} | ${[]} | ${getApiData(createSnippet())}
+ ${'project/path'} | ${[]} | ${getApiData(createSnippet())}
+ `(
+ 'should submit an updateSnippet mutation (projectPath=$projectPath, uploadedFiles=$uploadedFiles)',
+ async ({ projectPath, uploadedFiles, input }) => {
+ await createComponentAndLoad({
+ props: {
+ snippetGid: TEST_SNIPPET_GID,
+ projectPath,
+ },
+ });
+
+ setUploadFilesHtml(uploadedFiles);
+
+ await nextTick();
+
+ clickSubmitBtn();
+
+ expect(mutateSpy).toHaveBeenCalledTimes(1);
+ expect(mutateSpy).toHaveBeenCalledWith('updateSnippet', {
+ input,
+ });
+ },
+ );
+ });
it('should redirect to snippet view on successful mutation', async () => {
await createComponentAndSubmit();
@@ -298,30 +335,55 @@ describe('Snippet Edit app', () => {
expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL);
});
- it.each`
- snippetGid | projectPath | mutationRes | expectMessage
- ${''} | ${'project/path'} | ${createMutationResponseWithErrors('createSnippet')} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`}
- ${''} | ${''} | ${createMutationResponseWithErrors('createSnippet')} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`}
- ${TEST_SNIPPET_GID} | ${'project/path'} | ${createMutationResponseWithErrors('updateSnippet')} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`}
- ${TEST_SNIPPET_GID} | ${''} | ${createMutationResponseWithErrors('updateSnippet')} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`}
- `(
- 'should flash error with (snippet=$snippetGid, projectPath=$projectPath)',
- async ({ snippetGid, projectPath, mutationRes, expectMessage }) => {
- mutateSpy.mockResolvedValue(mutationRes);
-
- await createComponentAndSubmit({
- props: {
- projectPath,
- snippetGid,
- },
+ describe('when there are errors after creating a new snippet', () => {
+ it.each`
+ projectPath
+ ${'project/path'}
+ ${''}
+ `('should flash error (projectPath=$projectPath)', async ({ projectPath }) => {
+ mutateSpy.mockResolvedValue(createMutationResponseWithErrors('createSnippet'));
+
+ await createComponentAndLoad({
+ props: { projectPath, snippetGid: '' },
});
+ setTitle('Title');
+
+ clickSubmitBtn();
+
+ await waitForPromises();
+
expect(urlUtils.redirectTo).not.toHaveBeenCalled();
expect(createFlash).toHaveBeenCalledWith({
- message: expectMessage,
+ message: `Can't create snippet: ${TEST_MUTATION_ERROR}`,
});
- },
- );
+ });
+ });
+
+ describe('when there are errors after updating a snippet', () => {
+ it.each`
+ projectPath
+ ${'project/path'}
+ ${''}
+ `(
+ 'should flash error with (snippet=$snippetGid, projectPath=$projectPath)',
+ async ({ projectPath }) => {
+ mutateSpy.mockResolvedValue(createMutationResponseWithErrors('updateSnippet'));
+
+ await createComponentAndSubmit({
+ props: {
+ projectPath,
+ snippetGid: TEST_SNIPPET_GID,
+ },
+ });
+
+ expect(urlUtils.redirectTo).not.toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalledWith({
+ message: `Can't update snippet: ${TEST_MUTATION_ERROR}`,
+ });
+ },
+ );
+ });
describe('with apollo network error', () => {
beforeEach(async () => {
@@ -382,6 +444,7 @@ describe('Snippet Edit app', () => {
false,
() => {
triggerBlobActions([testEntries.updated.diff]);
+ setTitle('test');
clickSubmitBtn();
},
],
diff --git a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
index 8174ba5c693..df98312b498 100644
--- a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
@@ -1,6 +1,7 @@
-import { shallowMount } from '@vue/test-utils';
import { times } from 'lodash';
import { nextTick } from 'vue';
+import { GlFormGroup } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue';
import {
@@ -8,6 +9,7 @@ import {
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '~/snippets/constants';
+import { s__ } from '~/locale';
import { testEntries, createBlobFromTestEntry } from '../test_utils';
const TEST_BLOBS = [
@@ -29,7 +31,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
});
};
- const findLabel = () => wrapper.find('label');
+ const findLabel = () => wrapper.findComponent(GlFormGroup);
const findBlobEdits = () => wrapper.findAll(SnippetBlobEdit);
const findBlobsData = () =>
findBlobEdits().wrappers.map((x) => ({
@@ -65,7 +67,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
});
it('renders label', () => {
- expect(findLabel().text()).toBe('Files');
+ expect(findLabel().attributes('label')).toBe('Files');
});
it(`renders delete button (show=true)`, () => {
@@ -280,4 +282,32 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
expect(findAddButton().props('disabled')).toBe(true);
});
});
+
+ describe('isValid prop', () => {
+ const validationMessage = s__(
+ "Snippets|Snippets can't contain empty files. Ensure all files have content, or delete them.",
+ );
+
+ describe('when not present', () => {
+ it('sets the label validation state to true', () => {
+ createComponent();
+
+ const label = findLabel();
+
+ expect(Boolean(label.attributes('state'))).toEqual(true);
+ expect(label.attributes('invalid-feedback')).toEqual(validationMessage);
+ });
+ });
+
+ describe('when present', () => {
+ it('sets the label validation state to the value', () => {
+ createComponent({ isValid: false });
+
+ const label = findLabel();
+
+ expect(Boolean(label.attributes('state'))).toEqual(false);
+ expect(label.attributes('invalid-feedback')).toEqual(validationMessage);
+ });
+ });
+ });
});
diff --git a/spec/frontend/syntax_highlight_spec.js b/spec/frontend/syntax_highlight_spec.js
index 8ad4f8d5c70..1be6c213350 100644
--- a/spec/frontend/syntax_highlight_spec.js
+++ b/spec/frontend/syntax_highlight_spec.js
@@ -1,6 +1,6 @@
/* eslint-disable no-return-assign */
-
import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import syntaxHighlight from '~/syntax_highlight';
describe('Syntax Highlighter', () => {
@@ -20,7 +20,11 @@ describe('Syntax Highlighter', () => {
`('highlight using $desc syntax', ({ fn }) => {
describe('on a js-syntax-highlight element', () => {
beforeEach(() => {
- setFixtures('<div class="js-syntax-highlight"></div>');
+ setHTMLFixture('<div class="js-syntax-highlight"></div>');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
it('applies syntax highlighting', () => {
@@ -33,11 +37,15 @@ describe('Syntax Highlighter', () => {
describe('on a parent element', () => {
beforeEach(() => {
- setFixtures(
+ setHTMLFixture(
'<div class="parent">\n <div class="js-syntax-highlight"></div>\n <div class="foo"></div>\n <div class="js-syntax-highlight"></div>\n</div>',
);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('applies highlighting to all applicable children', () => {
stubUserColorScheme('monokai');
syntaxHighlight(fn('.parent'));
@@ -49,7 +57,7 @@ describe('Syntax Highlighter', () => {
});
it('prevents an infinite loop when no matches exist', () => {
- setFixtures('<div></div>');
+ setHTMLFixture('<div></div>');
const highlight = () => syntaxHighlight(fn('div'));
expect(highlight).not.toThrow();
diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js
index 98617b404ff..67e3d707adb 100644
--- a/spec/frontend/tabs/index_spec.js
+++ b/spec/frontend/tabs/index_spec.js
@@ -1,6 +1,6 @@
import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs';
import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants';
-import { getFixture, setHTMLFixture } from 'helpers/fixtures';
+import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
const tabsFixture = getFixture('tabs/tabs.html');
@@ -93,6 +93,8 @@ describe('GlTabsBehavior', () => {
describe('when given an element', () => {
afterEach(() => {
glTabs.destroy();
+
+ resetHTMLFixture();
});
beforeEach(() => {
@@ -250,6 +252,10 @@ describe('GlTabsBehavior', () => {
glTabs = new GlTabsBehavior(tabsEl);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('connects the panels to their tabs correctly', () => {
findTab('bar').click();
diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js
index fbdb73ae6de..e79c516a694 100644
--- a/spec/frontend/task_list_spec.js
+++ b/spec/frontend/task_list_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import axios from '~/lib/utils/axios_utils';
import TaskList from '~/task_list';
@@ -14,7 +15,7 @@ describe('TaskList', () => {
const createTaskList = () => new TaskList(taskListOptions);
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div class="task-list">
<div class="js-task-list-container">
<ul data-sourcepos="5:1-5:11" class="task-list" dir="auto">
@@ -37,6 +38,10 @@ describe('TaskList', () => {
taskList = createTaskList();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should call init when the class constructed', () => {
jest.spyOn(TaskList.prototype, 'init');
jest.spyOn(TaskList.prototype, 'disable').mockImplementation(() => {});
diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js
index 665bf44fc77..08da3a9a465 100644
--- a/spec/frontend/tracking/tracking_spec.js
+++ b/spec/frontend/tracking/tracking_spec.js
@@ -76,6 +76,18 @@ describe('Tracking', () => {
);
});
+ it('returns `true` if the Snowplow library was called without issues', () => {
+ expect(Tracking.event(TEST_CATEGORY, TEST_ACTION)).toBe(true);
+ });
+
+ it('returns `false` if the Snowplow library throws an error', () => {
+ snowplowSpy.mockImplementation(() => {
+ throw new Error();
+ });
+
+ expect(Tracking.event(TEST_CATEGORY, TEST_ACTION)).toBe(false);
+ });
+
it('allows adding extra data to the default context', () => {
const extra = { foo: 'bar' };
diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js
index 08eb8ae0843..fb5093eb065 100644
--- a/spec/frontend/user_lists/components/user_lists_table_spec.js
+++ b/spec/frontend/user_lists/components/user_lists_table_spec.js
@@ -2,6 +2,7 @@ import { GlModal } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import * as timeago from 'timeago.js';
import { nextTick } from 'vue';
+import { timeagoLanguageCode } from '~/lib/utils/datetime/timeago_utility';
import UserListsTable from '~/user_lists/components/user_lists_table.vue';
import { userList } from 'jest/feature_flags/mock_data';
@@ -31,7 +32,7 @@ describe('User Lists Table', () => {
userList.user_xids.replace(/,/g, ', '),
);
expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago');
- expect(timeago.format).toHaveBeenCalledWith(userList.created_at);
+ expect(timeago.format).toHaveBeenCalledWith(userList.created_at, timeagoLanguageCode);
});
it('should set the title for a tooltip on the created stamp', () => {
diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js
index 745b66fd700..fa598716645 100644
--- a/spec/frontend/user_popovers_spec.js
+++ b/spec/frontend/user_popovers_spec.js
@@ -1,5 +1,13 @@
+import { within } from '@testing-library/dom';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import UsersCache from '~/lib/utils/users_cache';
import initUserPopovers from '~/user_popovers';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/api/user_api', () => ({
+ followUser: jest.fn().mockResolvedValue({}),
+ unfollowUser: jest.fn().mockResolvedValue({}),
+}));
describe('User Popovers', () => {
const fixtureTemplate = 'merge_requests/merge_request_with_mentions.html';
@@ -19,7 +27,7 @@ describe('User Popovers', () => {
return link;
};
- const dummyUser = { name: 'root' };
+ const dummyUser = { name: 'root', username: 'root', is_followed: false };
const dummyUserStatus = { message: 'active' };
let popovers;
@@ -35,7 +43,7 @@ describe('User Popovers', () => {
};
beforeEach(() => {
- loadFixtures(fixtureTemplate);
+ loadHTMLFixture(fixtureTemplate);
const usersCacheSpy = () => Promise.resolve(dummyUser);
jest.spyOn(UsersCache, 'retrieveById').mockImplementation((userId) => usersCacheSpy(userId));
@@ -44,10 +52,15 @@ describe('User Popovers', () => {
jest
.spyOn(UsersCache, 'retrieveStatusById')
.mockImplementation((userId) => userStatusCacheSpy(userId));
+ jest.spyOn(UsersCache, 'updateById');
popovers = initUserPopovers(document.querySelectorAll(selector));
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('initializes a popover for each user link with a user id', () => {
const linksWithUsers = findFixtureLinks();
@@ -115,4 +128,32 @@ describe('User Popovers', () => {
expect(userLink.getAttribute('aria-describedby')).toBe(null);
});
+
+ it('updates toggle follow button and `UsersCache` when toggle follow button is clicked', async () => {
+ const [firstPopover] = popovers;
+ const withinFirstPopover = within(firstPopover.$el);
+ const findFollowButton = () => withinFirstPopover.queryByRole('button', { name: 'Follow' });
+ const findUnfollowButton = () => withinFirstPopover.queryByRole('button', { name: 'Unfollow' });
+
+ const userLink = document.querySelector(selector);
+ triggerEvent('mouseenter', userLink);
+
+ await waitForPromises();
+
+ const { userId } = document.querySelector(selector).dataset;
+
+ triggerEvent('click', findFollowButton());
+
+ await waitForPromises();
+
+ expect(findUnfollowButton()).not.toBe(null);
+ expect(UsersCache.updateById).toHaveBeenCalledWith(userId, { is_followed: true });
+
+ triggerEvent('click', findUnfollowButton());
+
+ await waitForPromises();
+
+ expect(findFollowButton()).not.toBe(null);
+ expect(UsersCache.updateById).toHaveBeenCalledWith(userId, { is_followed: false });
+ });
});
diff --git a/spec/frontend/vue_alerts_spec.js b/spec/frontend/vue_alerts_spec.js
index 1952eea4a01..de2faa09438 100644
--- a/spec/frontend/vue_alerts_spec.js
+++ b/spec/frontend/vue_alerts_spec.js
@@ -1,5 +1,5 @@
import { nextTick } from 'vue';
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import initVueAlerts from '~/vue_alerts';
@@ -40,6 +40,10 @@ describe('VueAlerts', () => {
);
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
const findJsHooks = () => document.querySelectorAll('.js-vue-alert');
const findAlerts = () => document.querySelectorAll('.gl-alert');
const findAlertDismiss = (alert) => alert.querySelector('.gl-dismiss-btn');
diff --git a/spec/frontend/vue_mr_widget/components/added_commit_message_spec.js b/spec/frontend/vue_mr_widget/components/added_commit_message_spec.js
new file mode 100644
index 00000000000..150680caa7e
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/added_commit_message_spec.js
@@ -0,0 +1,31 @@
+import { shallowMount } from '@vue/test-utils';
+import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue';
+
+let wrapper;
+
+function factory(propsData) {
+ wrapper = shallowMount(AddedCommentMessage, {
+ propsData: {
+ isFastForwardEnabled: false,
+ targetBranch: 'main',
+ ...propsData,
+ },
+ provide: {
+ glFeatures: {
+ restructuredMrWidget: true.valueOf,
+ },
+ },
+ });
+}
+
+describe('Widget added commit message', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays changes where not merged when state is closed', () => {
+ factory({ state: 'closed' });
+
+ expect(wrapper.element.outerHTML).toContain('The changes were not merged');
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js
index 98cfc04eb25..5799799ad5e 100644
--- a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js
+++ b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js
@@ -2,16 +2,18 @@ import { generateText } from '~/vue_merge_request_widget/components/extensions/u
describe('generateText', () => {
it.each`
- text | expectedText
- ${'%{strong_start}Hello world%{strong_end}'} | ${'<span class="gl-font-weight-bold">Hello world</span>'}
- ${'%{success_start}Hello world%{success_end}'} | ${'<span class="gl-font-weight-bold gl-text-green-500">Hello world</span>'}
- ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'}
- ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'}
- ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'}
- ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm gl-text-gray-700">Hello world</span>'}
- ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'}
- ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'}
- ${['array']} | ${null}
+ text | expectedText
+ ${'%{strong_start}Hello world%{strong_end}'} | ${'<span class="gl-font-weight-bold">Hello world</span>'}
+ ${'%{success_start}Hello world%{success_end}'} | ${'<span class="gl-font-weight-bold gl-text-green-500">Hello world</span>'}
+ ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'}
+ ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'}
+ ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'}
+ ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm gl-text-gray-700">Hello world</span>'}
+ ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'}
+ ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'}
+ ${{ text: 'Hello world', href: 'http://www.example.com' }} | ${'<a class="gl-text-decoration-underline" href="http://www.example.com">Hello world</a>'}
+ ${{ prependText: 'Hello', text: 'world', href: 'http://www.example.com' }} | ${'Hello <a class="gl-text-decoration-underline" href="http://www.example.com">world</a>'}
+ ${['array']} | ${null}
`('generates $expectedText from $text', ({ text, expectedText }) => {
expect(generateText(text)).toBe(expectedText);
});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
index 5a1f17573d4..ed6dc598845 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
@@ -1,7 +1,5 @@
import { shallowMount, mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import Header from '~/vue_merge_request_widget/components/mr_widget_header.vue';
-import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
describe('MRWidgetHeader', () => {
let wrapper;
@@ -17,16 +15,6 @@ describe('MRWidgetHeader', () => {
gon.relative_url_root = '';
});
- const expectDownloadDropdownItems = () => {
- const downloadEmailPatchesEl = wrapper.find('.js-download-email-patches');
- const downloadPlainDiffEl = wrapper.find('.js-download-plain-diff');
-
- expect(downloadEmailPatchesEl.text().trim()).toBe('Email patches');
- expect(downloadEmailPatchesEl.attributes('href')).toBe('/mr/email-patches');
- expect(downloadPlainDiffEl.text().trim()).toBe('Plain diff');
- expect(downloadPlainDiffEl.attributes('href')).toBe('/mr/plainDiffPath');
- };
-
const commonMrProps = {
divergedCommitsCount: 1,
sourceBranch: 'mr-widget-refactor',
@@ -36,8 +24,6 @@ describe('MRWidgetHeader', () => {
statusPath: 'abc',
};
- const findWebIdeButton = () => wrapper.findComponent(WebIdeLink);
-
describe('computed', () => {
describe('shouldShowCommitsBehindText', () => {
it('return true when there are divergedCommitsCount', () => {
@@ -133,136 +119,6 @@ describe('MRWidgetHeader', () => {
});
});
- describe('with an open merge request', () => {
- const mrDefaultOptions = {
- iid: 1,
- divergedCommitsCount: 12,
- sourceBranch: 'mr-widget-refactor',
- sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
- sourceBranchRemoved: false,
- targetBranchPath: 'foo/bar/commits-path',
- targetBranchTreePath: 'foo/bar/tree/path',
- targetBranch: 'main',
- isOpen: true,
- canPushToSourceBranch: true,
- emailPatchesPath: '/mr/email-patches',
- plainDiffPath: '/mr/plainDiffPath',
- statusPath: 'abc',
- sourceProjectFullPath: 'root/gitlab-ce',
- targetProjectFullPath: 'gitlab-org/gitlab-ce',
- gitpodEnabled: true,
- showGitpodButton: true,
- gitpodUrl: 'http://gitpod.localhost',
- userPreferencesGitpodPath: '/-/profile/preferences#user_gitpod_enabled',
- userProfileEnableGitpodPath: '/-/profile?user%5Bgitpod_enabled%5D=true',
- };
-
- it('renders checkout branch button with modal trigger', () => {
- createComponent({
- mr: { ...mrDefaultOptions },
- });
-
- const button = wrapper.find('.js-check-out-branch');
-
- expect(button.text().trim()).toBe('Check out branch');
- });
-
- it.each([
- [
- 'renders web ide button',
- {
- mrProps: {},
- relativeUrl: '',
- webIdeUrl:
- '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=gitlab-org%2Fgitlab-ce',
- },
- ],
- [
- 'renders web ide button with blank target_project, when mr has same target project',
- {
- mrProps: { targetProjectFullPath: 'root/gitlab-ce' },
- relativeUrl: '',
- webIdeUrl: '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=',
- },
- ],
- [
- 'renders web ide button with relative url',
- {
- mrProps: { iid: 2 },
- relativeUrl: '/gitlab',
- webIdeUrl:
- '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce',
- },
- ],
- ])('%s', async (_, { mrProps, relativeUrl, webIdeUrl }) => {
- gon.relative_url_root = relativeUrl;
- createComponent({
- mr: { ...mrDefaultOptions, ...mrProps },
- });
-
- await nextTick();
-
- expect(findWebIdeButton().props()).toMatchObject({
- showEditButton: false,
- showWebIdeButton: true,
- webIdeText: 'Open in Web IDE',
- gitpodText: 'Open in Gitpod',
- gitpodEnabled: true,
- showGitpodButton: true,
- gitpodUrl: 'http://gitpod.localhost',
- userPreferencesGitpodPath: mrDefaultOptions.userPreferencesGitpodPath,
- userProfileEnableGitpodPath: mrDefaultOptions.userProfileEnableGitpodPath,
- webIdeUrl,
- });
- });
-
- it('does not render web ide button if source branch is removed', async () => {
- createComponent({ mr: { ...mrDefaultOptions, sourceBranchRemoved: true } });
-
- await nextTick();
-
- expect(findWebIdeButton().exists()).toBe(false);
- });
-
- it('renders download dropdown with links', () => {
- createComponent({
- mr: { ...mrDefaultOptions },
- });
-
- expectDownloadDropdownItems();
- });
- });
-
- describe('with a closed merge request', () => {
- beforeEach(() => {
- createComponent({
- mr: {
- divergedCommitsCount: 12,
- sourceBranch: 'mr-widget-refactor',
- sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
- sourceBranchRemoved: false,
- targetBranchPath: 'foo/bar/commits-path',
- targetBranchTreePath: 'foo/bar/tree/path',
- targetBranch: 'main',
- isOpen: false,
- emailPatchesPath: '/mr/email-patches',
- plainDiffPath: '/mr/plainDiffPath',
- statusPath: 'abc',
- },
- });
- });
-
- it('does not render checkout branch button with modal trigger', () => {
- const button = wrapper.find('.js-check-out-branch');
-
- expect(button.exists()).toBe(false);
- });
-
- it('renders download dropdown with links', () => {
- expectDownloadDropdownItems();
- });
- });
-
describe('without diverged commits', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
index 0e364eb6800..da3a323e8ea 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -59,6 +59,7 @@ const createTestMr = (customConfig) => {
mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs',
transitionStateMachine: (transition) => eventHub.$emit('StateMachineValueChanged', transition),
translateStateToMachine: () => this.transitionStateMachine(),
+ state: 'open',
};
Object.assign(mr, customConfig.mr);
@@ -321,7 +322,6 @@ describe('ReadyToMerge', () => {
await waitForPromises();
- expect(wrapper.vm.isMakingRequest).toBeTruthy();
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
expect(eventHub.$emit).toHaveBeenCalledWith('StateMachineValueChanged', {
transition: 'start-auto-merge',
@@ -348,7 +348,6 @@ describe('ReadyToMerge', () => {
await waitForPromises();
- expect(wrapper.vm.isMakingRequest).toBeTruthy();
expect(eventHub.$emit).toHaveBeenCalledWith('FailedToMerge', undefined);
const params = wrapper.vm.service.merge.mock.calls[0][0];
@@ -371,7 +370,6 @@ describe('ReadyToMerge', () => {
await waitForPromises();
- expect(wrapper.vm.isMakingRequest).toBeTruthy();
expect(wrapper.vm.mr.transitionStateMachine).toHaveBeenCalledWith({
transition: 'start-merge',
});
diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js
index 88b8e32bd5d..2bc6860743a 100644
--- a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js
+++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js
@@ -16,6 +16,7 @@ import newErrorsTestReports from 'jest/reports/mock_data/new_errors_report.json'
import newFailedTestReports from 'jest/reports/mock_data/new_failures_report.json';
import successTestReports from 'jest/reports/mock_data/no_failures_report.json';
import resolvedFailures from 'jest/reports/mock_data/resolved_failures.json';
+import recentFailures from 'jest/reports/mock_data/recent_failures_report.json';
const reportWithParsingErrors = failedReport;
reportWithParsingErrors.suites[0].suite_errors = {
@@ -101,6 +102,17 @@ describe('Test report extension', () => {
expect(wrapper.text()).toContain(expectedResult);
});
+ it('displays report level recently failed count', async () => {
+ mockApi(httpStatusCodes.OK, recentFailures);
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toContain(
+ '2 out of 3 failed tests have failed more than once in the last 14 days',
+ );
+ });
+
it('displays a link to the full report', async () => {
mockApi(httpStatusCodes.OK);
createComponent();
@@ -125,10 +137,10 @@ describe('Test report extension', () => {
it('displays summary for each suite', async () => {
await createExpandedWidgetWithData();
- expect(trimText(findAllExtensionListItems().at(0).text())).toBe(
+ expect(trimText(findAllExtensionListItems().at(0).text())).toContain(
'rspec:pg: 1 failed and 2 fixed test results, 8 total tests',
);
- expect(trimText(findAllExtensionListItems().at(1).text())).toBe(
+ expect(trimText(findAllExtensionListItems().at(1).text())).toContain(
'java ant: 1 failed, 3 total tests',
);
});
@@ -145,5 +157,37 @@ describe('Test report extension', () => {
'Base report parsing error: JUnit data parsing failed: string not matched',
);
});
+
+ it('displays suite level recently failed count', async () => {
+ await createExpandedWidgetWithData(recentFailures);
+
+ expect(trimText(findAllExtensionListItems().at(0).text())).toContain(
+ '1 out of 2 failed tests has failed more than once in the last 14 days',
+ );
+ expect(trimText(findAllExtensionListItems().at(1).text())).toContain(
+ '1 out of 1 failed test has failed more than once in the last 14 days',
+ );
+ });
+
+ it('displays the list of failed and fixed tests', async () => {
+ await createExpandedWidgetWithData();
+
+ const firstSuite = trimText(findAllExtensionListItems().at(0).text());
+ const secondSuite = trimText(findAllExtensionListItems().at(1).text());
+
+ expect(firstSuite).toContain('Test#subtract when a is 2 and b is 1 returns correct result');
+ expect(firstSuite).toContain('Test#sum when a is 1 and b is 2 returns summary');
+ expect(firstSuite).toContain('Test#sum when a is 100 and b is 200 returns summary');
+
+ expect(secondSuite).toContain('sumTest');
+ });
+
+ it('displays the test level recently failed count', async () => {
+ await createExpandedWidgetWithData(recentFailures);
+
+ expect(trimText(findAllExtensionListItems().at(0).text())).toContain(
+ 'Failed 8 times in main in the last 14 days',
+ );
+ });
});
});
diff --git a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js
index ea422a57956..6d1b3bb34a5 100644
--- a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js
+++ b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js
@@ -107,7 +107,7 @@ describe('Accessibility extension', () => {
it('displays report list item formatted', () => {
const text = {
newError: trimText(findAllExtensionListItems().at(0).text()),
- resolvedError: findAllExtensionListItems().at(3).text(),
+ resolvedError: trimText(findAllExtensionListItems().at(3).text()),
existingError: trimText(findAllExtensionListItems().at(6).text()),
};
diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js
index 28b3bf5287a..8cbe0630426 100644
--- a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js
+++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js
@@ -3,6 +3,8 @@ import { mount, shallowMount } from '@vue/test-utils';
import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue';
+jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1));
+
describe('ColorPicker', () => {
let wrapper;
@@ -14,10 +16,11 @@ describe('ColorPicker', () => {
const setColor = '#000000';
const invalidText = 'Please enter a valid hex (#RRGGBB or #RGB) color value';
- const label = () => wrapper.find(GlFormGroup).attributes('label');
+ const findGlFormGroup = () => wrapper.find(GlFormGroup);
const colorPreview = () => wrapper.find('[data-testid="color-preview"]');
const colorPicker = () => wrapper.find(GlFormInput);
- const colorInput = () => wrapper.find(GlFormInputGroup).find('input[type="text"]');
+ const colorInput = () => wrapper.find('input[type="color"]');
+ const colorTextInput = () => wrapper.find(GlFormInputGroup).find('input[type="text"]');
const invalidFeedback = () => wrapper.find('.invalid-feedback');
const description = () => wrapper.find(GlFormGroup).attributes('description');
const presetColors = () => wrapper.findAll(GlLink);
@@ -39,13 +42,29 @@ describe('ColorPicker', () => {
it('hides the label if the label is not passed', () => {
createComponent(shallowMount);
- expect(label()).toBe('');
+ expect(findGlFormGroup().attributes('label')).toBe('');
});
it('shows the label if the label is passed', () => {
createComponent(shallowMount, { label: 'test' });
- expect(label()).toBe('test');
+ expect(findGlFormGroup().attributes('label')).toBe('test');
+ });
+
+ describe.each`
+ desc | id
+ ${'with prop id'} | ${'test-id'}
+ ${'without prop id'} | ${undefined}
+ `('$desc', ({ id }) => {
+ beforeEach(() => {
+ createComponent(mount, { id, label: 'test' });
+ });
+
+ it('renders the same `ID` for input and `for` for label', () => {
+ expect(findGlFormGroup().find('label').attributes('for')).toBe(
+ colorInput().attributes('id'),
+ );
+ });
});
});
@@ -55,30 +74,30 @@ describe('ColorPicker', () => {
expect(colorPreview().attributes('style')).toBe(undefined);
expect(colorPicker().attributes('value')).toBe(undefined);
- expect(colorInput().props('value')).toBe('');
+ expect(colorTextInput().props('value')).toBe('');
expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-gray-400');
});
it('has a color set on initialization', () => {
createComponent(mount, { value: setColor });
- expect(colorInput().props('value')).toBe(setColor);
+ expect(colorTextInput().props('value')).toBe(setColor);
});
it('emits input event from component when a color is selected', async () => {
createComponent();
- await colorInput().setValue(setColor);
+ await colorTextInput().setValue(setColor);
expect(wrapper.emitted().input[0]).toStrictEqual([setColor]);
});
it('trims spaces from submitted colors', async () => {
createComponent();
- await colorInput().setValue(` ${setColor} `);
+ await colorTextInput().setValue(` ${setColor} `);
expect(wrapper.emitted().input[0]).toStrictEqual([setColor]);
expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-gray-400');
- expect(colorInput().attributes('class')).not.toContain('is-invalid');
+ expect(colorTextInput().attributes('class')).not.toContain('is-invalid');
});
it('shows invalid feedback when the state is marked as invalid', async () => {
@@ -86,14 +105,14 @@ describe('ColorPicker', () => {
expect(invalidFeedback().text()).toBe(invalidText);
expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-red-500');
- expect(colorInput().attributes('class')).toContain('is-invalid');
+ expect(colorTextInput().attributes('class')).toContain('is-invalid');
});
});
describe('inputs', () => {
it('has color input value entered', async () => {
createComponent();
- await colorInput().setValue(setColor);
+ await colorTextInput().setValue(setColor);
expect(wrapper.emitted().input[0]).toStrictEqual([setColor]);
});
diff --git a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js
new file mode 100644
index 00000000000..9d11fbbaf55
--- /dev/null
+++ b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js
@@ -0,0 +1,52 @@
+import { GlBadge } from '@gitlab/ui';
+
+import { shallowMount } from '@vue/test-utils';
+import { WorkspaceType, IssuableType } from '~/issues/constants';
+
+import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
+
+const createComponent = ({
+ workspaceType = WorkspaceType.project,
+ issuableType = IssuableType.Issue,
+} = {}) =>
+ shallowMount(ConfidentialityBadge, {
+ propsData: {
+ workspaceType,
+ issuableType,
+ },
+ });
+
+describe('ConfidentialityBadge', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ workspaceType | issuableType | expectedTooltip
+ ${WorkspaceType.project} | ${IssuableType.Issue} | ${'Only project members with at least Reporter role can view or be notified about this issue.'}
+ ${WorkspaceType.group} | ${IssuableType.Epic} | ${'Only group members with at least Reporter role can view or be notified about this epic.'}
+ `(
+ 'should render gl-badge with correct tooltip when workspaceType is $workspaceType and issuableType is $issuableType',
+ ({ workspaceType, issuableType, expectedTooltip }) => {
+ wrapper = createComponent({
+ workspaceType,
+ issuableType,
+ });
+
+ const badgeEl = wrapper.findComponent(GlBadge);
+
+ expect(badgeEl.props()).toMatchObject({
+ icon: 'eye-slash',
+ variant: 'warning',
+ });
+ expect(badgeEl.attributes('title')).toBe(expectedTooltip);
+ expect(badgeEl.text()).toBe('Confidential');
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
index f75694bd504..a660643d74f 100644
--- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
@@ -3,6 +3,7 @@ import {
CONFIRM_DANGER_WARNING,
CONFIRM_DANGER_MODAL_BUTTON,
CONFIRM_DANGER_MODAL_ID,
+ CONFIRM_DANGER_MODAL_CANCEL,
} from '~/vue_shared/components/confirm_danger/constants';
import ConfirmDangerModal from '~/vue_shared/components/confirm_danger/confirm_danger_modal.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -10,6 +11,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('Confirm Danger Modal', () => {
const confirmDangerMessage = 'This is a dangerous activity';
const confirmButtonText = 'Confirm button text';
+ const cancelButtonText = 'Cancel button text';
const phrase = 'You must construct additional pylons';
const modalId = CONFIRM_DANGER_MODAL_ID;
@@ -21,6 +23,7 @@ describe('Confirm Danger Modal', () => {
const findDefaultWarning = () => wrapper.findByTestId('confirm-danger-warning');
const findAdditionalMessage = () => wrapper.findByTestId('confirm-danger-message');
const findPrimaryAction = () => findModal().props('actionPrimary');
+ const findCancelAction = () => findModal().props('actionCancel');
const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr];
const createComponent = ({ provide = {} } = {}) =>
@@ -34,7 +37,9 @@ describe('Confirm Danger Modal', () => {
});
beforeEach(() => {
- wrapper = createComponent({ provide: { confirmDangerMessage, confirmButtonText } });
+ wrapper = createComponent({
+ provide: { confirmDangerMessage, confirmButtonText, cancelButtonText },
+ });
});
afterEach(() => {
@@ -54,6 +59,10 @@ describe('Confirm Danger Modal', () => {
expect(findPrimaryActionAttributes('variant')).toBe('danger');
});
+ it('renders the cancel button', () => {
+ expect(findCancelAction().text).toBe(cancelButtonText);
+ });
+
it('renders the correct confirmation phrase', () => {
expect(findConfirmationPhrase().text()).toBe(
`Please type ${phrase} to proceed or close this modal to cancel.`,
@@ -72,6 +81,10 @@ describe('Confirm Danger Modal', () => {
it('renders the default confirm button', () => {
expect(findPrimaryAction().text).toBe(CONFIRM_DANGER_MODAL_BUTTON);
});
+
+ it('renders the default cancel button', () => {
+ expect(findCancelAction().text).toBe(CONFIRM_DANGER_MODAL_CANCEL);
+ });
});
describe('with a valid confirmation phrase', () => {
diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js
index d4b6b987c69..aa41df438d2 100644
--- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js
+++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js
@@ -15,7 +15,7 @@ describe('DateTimePicker', () => {
const dropdownToggle = () => wrapper.find('.dropdown-toggle');
const dropdownMenu = () => wrapper.find('.dropdown-menu');
const cancelButton = () => wrapper.find('[data-testid="cancelButton"]');
- const applyButtonElement = () => wrapper.find('button.btn-success').element;
+ const applyButtonElement = () => wrapper.find('button.btn-confirm').element;
const findQuickRangeItems = () => wrapper.findAll('.dropdown-item');
const createComponent = (props) => {
diff --git a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
index 59653a0ec13..e3d8bfd22ca 100644
--- a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
+++ b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
@@ -6,12 +6,16 @@ import { folder } from './mock_data';
describe('Deploy Board Instance', () => {
let wrapper;
- const createComponent = (props = {}) =>
+ const createComponent = (props = {}, provide) =>
shallowMount(DeployBoardInstance, {
propsData: {
status: 'succeeded',
...props,
},
+ provide: {
+ glFeatures: { monitorLogging: true },
+ ...provide,
+ },
});
describe('as a non-canary deployment', () => {
@@ -95,4 +99,23 @@ describe('Deploy Board Instance', () => {
expect(wrapper.attributes('title')).toEqual('');
});
});
+
+ describe(':monitor_logging feature flag', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ flagState | logsState | expected
+ ${true} | ${'shows'} | ${'/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1'}
+ ${false} | ${'hides'} | ${undefined}
+ `('$logsState log link when flag state is $flagState', async ({ flagState, expected }) => {
+ wrapper = createComponent(
+ { logsPath: folder.logs_path, podName: 'tanuki-1' },
+ { glFeatures: { monitorLogging: flagState } },
+ );
+
+ expect(wrapper.attributes('href')).toEqual(expected);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js
deleted file mode 100644
index 30b8e869aab..00000000000
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import Vue from 'vue';
-
-import mountComponent from 'helpers/vue_mount_component_helper';
-import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
-
-import { mockLabels } from './mock_data';
-
-const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => {
- const Component = Vue.extend(dropdownHiddenInputComponent);
-
- return mountComponent(Component, {
- name,
- value,
- });
-};
-
-describe('DropdownHiddenInputComponent', () => {
- let vm;
-
- beforeEach(() => {
- vm = createComponent();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('template', () => {
- it('renders input element of type `hidden`', () => {
- expect(vm.$el.nodeName).toBe('INPUT');
- expect(vm.$el.getAttribute('type')).toBe('hidden');
- expect(vm.$el.getAttribute('name')).toBe(vm.name);
- expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js
deleted file mode 100644
index b32dbeb8852..00000000000
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import DropdownSearchInputComponent from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
-
-describe('DropdownSearchInputComponent', () => {
- let wrapper;
-
- const defaultProps = {
- placeholderText: 'Search something',
- };
- const buildVM = (propsData = defaultProps) => {
- wrapper = mount(DropdownSearchInputComponent, {
- propsData,
- });
- };
- const findInputEl = () => wrapper.find('.dropdown-input-field');
-
- beforeEach(() => {
- buildVM();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('template', () => {
- it('renders input element with type `search`', () => {
- expect(findInputEl().exists()).toBe(true);
- expect(findInputEl().attributes('type')).toBe('search');
- });
-
- it('renders search icon element', () => {
- expect(wrapper.find('.dropdown-input-search[data-testid="search-icon"]').exists()).toBe(true);
- });
-
- it('displays custom placeholder text', () => {
- expect(findInputEl().attributes('placeholder')).toBe(defaultProps.placeholderText);
- });
-
- it('focuses input element when focused property equals true', async () => {
- const inputEl = findInputEl().element;
-
- jest.spyOn(inputEl, 'focus');
-
- wrapper.setProps({ focused: true });
-
- await nextTick();
- expect(inputEl.focus).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js
index 921091c5b84..5cf891a2e52 100644
--- a/spec/frontend/vue_shared/components/file_finder/index_spec.js
+++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js
@@ -1,5 +1,6 @@
import Mousetrap from 'mousetrap';
import Vue, { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { file } from 'jest/ide/helpers';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import FindFileComponent from '~/vue_shared/components/file_finder/index.vue';
@@ -22,7 +23,11 @@ describe('File finder item spec', () => {
}
beforeEach(() => {
- setFixtures('<div id="app"></div>');
+ setHTMLFixture('<div id="app"></div>');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
});
afterEach(() => {
@@ -105,18 +110,6 @@ describe('File finder item spec', () => {
});
});
- describe('listHeight', () => {
- it('returns 55 when entries exist', () => {
- expect(vm.listHeight).toBe(55);
- });
-
- it('returns 33 when entries dont exist', () => {
- vm.searchText = 'testing 123';
-
- expect(vm.listHeight).toBe(33);
- });
- });
-
describe('filteredBlobsLength', () => {
it('returns length of filtered blobs', () => {
vm.searchText = 'index';
@@ -253,11 +246,9 @@ describe('File finder item spec', () => {
describe('without entries', () => {
it('renders loading text when loading', () => {
- createComponent({
- loading: true,
- });
+ createComponent({ loading: true });
- expect(vm.$el.textContent).toContain('Loading...');
+ expect(vm.$el.querySelector('.gl-spinner')).not.toBe(null);
});
it('renders no files text', () => {
@@ -307,7 +298,7 @@ describe('File finder item spec', () => {
});
it('stops callback in monaco editor', () => {
- setFixtures('<div class="inputarea"></div>');
+ setHTMLFixture('<div class="inputarea"></div>');
expect(
Mousetrap.prototype.stopCallback(null, document.querySelector('.inputarea'), 't'),
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
index b6a181e6a0b..e44bc8771f5 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
@@ -11,7 +11,10 @@ import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
-import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ FILTERED_SEARCH_TERM,
+ SortDirection,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
@@ -68,6 +71,10 @@ const createComponent = ({
describe('FilteredSearchBarRoot', () => {
let wrapper;
+ const findGlButton = () => wrapper.findComponent(GlButton);
+ const findGlDropdown = () => wrapper.findComponent(GlDropdown);
+ const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
+
beforeEach(() => {
wrapper = createComponent({ sortOptions: mockSortOptions });
});
@@ -79,7 +86,7 @@ describe('FilteredSearchBarRoot', () => {
describe('data', () => {
it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props and displays the sort dropdown', () => {
expect(wrapper.vm.filterValue).toEqual([]);
- expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending);
+ expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0]);
expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending);
expect(wrapper.find(GlButtonGroup).exists()).toBe(true);
expect(wrapper.find(GlButton).exists()).toBe(true);
@@ -225,9 +232,7 @@ describe('FilteredSearchBarRoot', () => {
});
it('initializes `recentSearchesPromise` prop with a promise by using `recentSearchesService.fetch()`', () => {
- jest
- .spyOn(wrapper.vm.recentSearchesService, 'fetch')
- .mockReturnValue(new Promise(() => []));
+ jest.spyOn(wrapper.vm.recentSearchesService, 'fetch').mockResolvedValue([]);
wrapper.vm.setupRecentSearch();
@@ -489,4 +494,40 @@ describe('FilteredSearchBarRoot', () => {
expect(sortButtonEl.props('icon')).toBe('sort-highest');
});
});
+
+ describe('watchers', () => {
+ const tokenValue = {
+ id: 'id-1',
+ type: FILTERED_SEARCH_TERM,
+ value: { data: '' },
+ };
+
+ it('syncs filter value', async () => {
+ await wrapper.setProps({ initialFilterValue: [tokenValue], syncFilterAndSort: true });
+
+ expect(findGlFilteredSearch().props('value')).toEqual([tokenValue]);
+ });
+
+ it('does not sync filter value when syncFilterAndSort=false', async () => {
+ await wrapper.setProps({ initialFilterValue: [tokenValue], syncFilterAndSort: false });
+
+ expect(findGlFilteredSearch().props('value')).toEqual([]);
+ });
+
+ it('syncs sort values', async () => {
+ await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: true });
+
+ expect(findGlDropdown().props('text')).toBe('Last updated');
+ expect(findGlButton().props('icon')).toBe('sort-lowest');
+ expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Ascending');
+ });
+
+ it('does not sync sort values when syncFilterAndSort=false', async () => {
+ await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: false });
+
+ expect(findGlDropdown().props('text')).toBe('Created date');
+ expect(findGlButton().props('icon')).toBe('sort-highest');
+ expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Descending');
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index 87066b70023..3f24d5df858 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -51,6 +51,7 @@ function createComponent(options = {}) {
config,
value,
active,
+ cursorPosition: 'start',
},
provide: {
portalName: 'fake target',
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index af8a2a496ea..ca8cd419d87 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -78,6 +78,7 @@ const mockProps = {
suggestionsLoading: false,
defaultSuggestions: DEFAULT_NONE_ANY,
getActiveTokenValue: (labels, data) => labels.find((label) => label.title === data),
+ cursorPosition: 'start',
};
function createComponent({
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
index 7a7db434052..7b495ec9bee 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
@@ -39,6 +39,7 @@ function createComponent(options = {}) {
config,
value,
active,
+ cursorPosition: 'start',
},
provide: {
portalName: 'fake target',
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
index b163563cea4..dcb0d095b1b 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
@@ -45,6 +45,7 @@ function createComponent(options = {}) {
config,
value,
active,
+ cursorPosition: 'start',
},
provide: {
portalName: 'fake target',
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index 52df27c2d00..f03a2e7934f 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -45,6 +45,7 @@ function createComponent(options = {}) {
config,
value,
active,
+ cursorPosition: 'start',
},
provide: {
portalName: 'fake target',
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
index de9ec863dd5..7c545f76c0b 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
@@ -42,6 +42,7 @@ function createComponent(options = {}) {
config,
value,
active,
+ cursorPosition: 'start',
},
provide: {
portalName: 'fake target',
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
index 8be21b35414..4bbbaab9b7a 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
@@ -18,6 +18,7 @@ describe('ReleaseToken', () => {
active: false,
config,
value,
+ cursorPosition: 'start',
},
provide: {
portalName: 'fake target',
diff --git a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js
index b673e5407d4..b180e8c12dd 100644
--- a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js
+++ b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js
@@ -1,7 +1,7 @@
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-import flushPromises from 'helpers/flush_promises';
+import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue';
@@ -43,7 +43,7 @@ describe('GitlabVersionCheck', () => {
describe(`is ${description}`, () => {
beforeEach(async () => {
createComponent(mockResponse);
- await flushPromises(); // Ensure we wrap up the axios call
+ await waitForPromises(); // Ensure we wrap up the axios call
});
it(`does${renders ? '' : ' not'} render GlBadge`, () => {
@@ -61,7 +61,7 @@ describe('GitlabVersionCheck', () => {
describe(`when response is ${mockResponse.res.severity}`, () => {
beforeEach(async () => {
createComponent(mockResponse);
- await flushPromises(); // Ensure we wrap up the axios call
+ await waitForPromises(); // Ensure we wrap up the axios call
});
it(`title is ${expectedUI.title}`, () => {
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index d1c4d777d44..b3376f26a25 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -5,12 +5,14 @@ import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import MarkdownFieldHeader from '~/vue_shared/components/markdown/header.vue';
+import MarkdownToolbar from '~/vue_shared/components/markdown/toolbar.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
const markdownPreviewPath = `${TEST_HOST}/preview`;
const markdownDocsPath = `${TEST_HOST}/docs`;
const textareaValue = 'testing\n123';
const uploadsPath = 'test/uploads';
+const restrictedToolBarItems = ['quote'];
function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) {
expect(writeLink.element.children[0].classList.contains('active')).toBe(isWrite);
@@ -63,6 +65,7 @@ describe('Markdown field component', () => {
textareaValue,
lines,
enablePreview,
+ restrictedToolBarItems,
},
provide: {
glFeatures: {
@@ -81,6 +84,8 @@ describe('Markdown field component', () => {
const getAttachButton = () => subject.find('.button-attach-file');
const clickAttachButton = () => getAttachButton().trigger('click');
const findDropzone = () => subject.find('.div-dropzone');
+ const findMarkdownHeader = () => subject.findComponent(MarkdownFieldHeader);
+ const findMarkdownToolbar = () => subject.findComponent(MarkdownToolbar);
describe('mounted', () => {
const previewHTML = `
@@ -184,9 +189,23 @@ describe('Markdown field component', () => {
assertMarkdownTabs(false, writeLink, previewLink, subject);
});
+
+ it('passes correct props to MarkdownToolbar', () => {
+ expect(findMarkdownToolbar().props()).toEqual({
+ canAttachFile: true,
+ markdownDocsPath,
+ quickActionsDocsPath: '',
+ showCommentToolBar: true,
+ });
+ });
});
describe('markdown buttons', () => {
+ beforeEach(() => {
+ // needed for the underlying insertText to work
+ document.execCommand = jest.fn(() => false);
+ });
+
it('converts single words', async () => {
const textarea = subject.find('textarea').element;
textarea.setSelectionRange(0, 7);
@@ -309,9 +328,7 @@ describe('Markdown field component', () => {
it('escapes new line characters', () => {
createSubject({ lines: [{ rich_text: 'hello world\\n' }] });
- expect(subject.find('[data-testid="markdownHeader"]').props('lineContent')).toBe(
- 'hello world%br',
- );
+ expect(findMarkdownHeader().props('lineContent')).toBe('hello world%br');
});
});
@@ -325,4 +342,12 @@ describe('Markdown field component', () => {
expect(subject.findComponent(MarkdownFieldHeader).props('enablePreview')).toBe(true);
});
+
+ it('passess restricted tool bar items', () => {
+ createSubject();
+
+ expect(subject.findComponent(MarkdownFieldHeader).props('restrictedToolBarItems')).toBe(
+ restrictedToolBarItems,
+ );
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index fa4ca63f910..67222cab247 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -166,4 +166,26 @@ describe('Markdown field header component', () => {
expect(wrapper.findByTestId('preview-tab').exists()).toBe(false);
});
+
+ describe('restricted tool bar items', () => {
+ let defaultCount;
+
+ beforeEach(() => {
+ defaultCount = findToolbarButtons().length;
+ });
+
+ it('restricts items as per input', () => {
+ createWrapper({
+ restrictedToolBarItems: ['quote'],
+ });
+
+ expect(findToolbarButtons().length).toBe(defaultCount - 1);
+ });
+
+ it('shows all items by default', () => {
+ createWrapper();
+
+ expect(findToolbarButtons().length).toBe(defaultCount);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
index 8bff85b0bda..f698794b951 100644
--- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
@@ -33,4 +33,18 @@ describe('toolbar', () => {
expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull();
});
});
+
+ describe('comment tool bar settings', () => {
+ it('does not show comment tool bar div', () => {
+ createMountedWrapper({ showCommentToolBar: false });
+
+ expect(wrapper.find('.comment-toolbar').exists()).toBe(false);
+ });
+
+ it('shows comment tool bar by default', () => {
+ createMountedWrapper();
+
+ expect(wrapper.find('.comment-toolbar').exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap
index 5dd12d9edf5..015049795a1 100644
--- a/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap
+++ b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap
@@ -10,6 +10,7 @@ exports[`Metrics upload item render the metrics image component 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
+ arialabel=""
body-class="gl-pb-0! gl-min-h-6!"
dismisslabel="Close"
modalclass=""
@@ -26,6 +27,7 @@ exports[`Metrics upload item render the metrics image component 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
+ arialabel=""
data-testid="metric-image-edit-modal"
dismisslabel="Close"
modalclass=""
diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js
index 1b93292e37b..6e9abb2bfb3 100644
--- a/spec/frontend/vue_shared/components/registry/list_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js
@@ -101,20 +101,6 @@ describe('list item', () => {
});
});
- describe('disabled prop', () => {
- it('when true applies gl-opacity-5 class', () => {
- mountComponent({ disabled: true });
-
- expect(wrapper.classes('gl-opacity-5')).toBe(true);
- });
-
- it('when false does not apply gl-opacity-5 class', () => {
- mountComponent({ disabled: false });
-
- expect(wrapper.classes('gl-opacity-5')).toBe(false);
- });
- });
-
describe('borders and selection', () => {
it.each`
first | selected | shouldHave | shouldNotHave
diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
index ac313e556fc..8ff49271eb5 100644
--- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
+++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
@@ -4,6 +4,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
<gl-modal-stub
actionprimary="[object Object]"
actionsecondary="[object Object]"
+ arialabel=""
dismisslabel="Close"
modalclass=""
modalid="runner-aws-deployments-modal"
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
index 0da9939e97f..001b6ee4a6f 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
@@ -45,8 +45,10 @@ describe('RunnerInstructionsModal component', () => {
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAlert = () => wrapper.findComponent(GlAlert);
+ const findModal = () => wrapper.findComponent(GlModal);
const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons');
const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton);
+ const findOsxPlatformButton = () => wrapper.find({ ref: 'osx' });
const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item');
const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions');
const findRegisterCommand = () => wrapper.findByTestId('register-command');
@@ -140,6 +142,38 @@ describe('RunnerInstructionsModal component', () => {
expect(instructions).toBe(registerInstructions);
});
});
+
+ describe('when the modal is shown', () => {
+ it('sets the focus on the selected platform', () => {
+ findPlatformButtons().at(0).element.focus = jest.fn();
+
+ findModal().vm.$emit('shown');
+
+ expect(findPlatformButtons().at(0).element.focus).toHaveBeenCalled();
+ });
+ });
+
+ describe('when providing a defaultPlatformName', () => {
+ beforeEach(async () => {
+ createComponent({ props: { defaultPlatformName: 'osx' } });
+ await waitForPromises();
+ });
+
+ it('runner instructions for the default selected platform are requested', () => {
+ expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({
+ platform: 'osx',
+ architecture: 'amd64',
+ });
+ });
+
+ it('sets the focus on the default selected platform', () => {
+ findOsxPlatformButton().element.focus = jest.fn();
+
+ findModal().vm.$emit('shown');
+
+ expect(findOsxPlatformButton().element.focus).toHaveBeenCalled();
+ });
+ });
});
describe('after a platform and architecture are selected', () => {
diff --git a/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js
new file mode 100644
index 00000000000..88445b6684c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js
@@ -0,0 +1,104 @@
+import { GlButtonGroup, GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import SegmentedControlButtonGroup from '~/vue_shared/components/segmented_control_button_group.vue';
+
+const DEFAULT_OPTIONS = [
+ { text: 'Lorem', value: 'abc' },
+ { text: 'Ipsum', value: 'def' },
+ { text: 'Foo', value: 'x', disabled: true },
+ { text: 'Dolar', value: 'ghi' },
+];
+
+describe('~/vue_shared/components/segmented_control_button_group.vue', () => {
+ let wrapper;
+
+ const createComponent = (props = {}, scopedSlots = {}) => {
+ wrapper = shallowMount(SegmentedControlButtonGroup, {
+ propsData: {
+ value: DEFAULT_OPTIONS[0].value,
+ options: DEFAULT_OPTIONS,
+ ...props,
+ },
+ scopedSlots,
+ });
+ };
+
+ const findButtonGroup = () => wrapper.findComponent(GlButtonGroup);
+ const findButtons = () => findButtonGroup().findAllComponents(GlButton);
+ const findButtonsData = () =>
+ findButtons().wrappers.map((x) => ({
+ selected: x.props('selected'),
+ text: x.text(),
+ disabled: x.props('disabled'),
+ }));
+ const findButtonWithText = (text) => findButtons().wrappers.find((x) => x.text() === text);
+
+ const optionsAsButtonData = (options) =>
+ options.map(({ text, disabled = false }) => ({
+ selected: false,
+ text,
+ disabled,
+ }));
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders button group', () => {
+ expect(findButtonGroup().exists()).toBe(true);
+ });
+
+ it('renders buttons', () => {
+ const expectation = optionsAsButtonData(DEFAULT_OPTIONS);
+ expectation[0].selected = true;
+
+ expect(findButtonsData()).toEqual(expectation);
+ });
+
+ describe.each(DEFAULT_OPTIONS.filter((x) => !x.disabled))(
+ 'when button clicked %p',
+ ({ text, value }) => {
+ it('emits input with value', () => {
+ expect(wrapper.emitted('input')).toBeUndefined();
+
+ findButtonWithText(text).vm.$emit('click');
+
+ expect(wrapper.emitted('input')).toEqual([[value]]);
+ });
+ },
+ );
+ });
+
+ const VALUE_TEST_CASES = [0, 1, 3].map((index) => [DEFAULT_OPTIONS[index].value, index]);
+
+ describe.each(VALUE_TEST_CASES)('with value=%s', (value, index) => {
+ it(`renders selected button at ${index}`, () => {
+ createComponent({ value });
+
+ const expectation = optionsAsButtonData(DEFAULT_OPTIONS);
+ expectation[index].selected = true;
+
+ expect(findButtonsData()).toEqual(expectation);
+ });
+ });
+
+ describe('with button-content slot', () => {
+ it('renders button content based on slot', () => {
+ createComponent(
+ {},
+ {
+ 'button-content': `<template #button-content="{ text }">In a slot - {{ text }}</template>`,
+ },
+ );
+
+ expect(findButtonsData().map((x) => x.text)).toEqual(
+ DEFAULT_OPTIONS.map((x) => `In a slot - ${x.text}`),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
index 3ceed670d77..9c29f304c71 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -153,7 +153,11 @@ describe('DropdownContentsCreateView', () => {
});
it('enables a Create button', () => {
- expect(findCreateButton().props('disabled')).toBe(false);
+ expect(findCreateButton().props()).toMatchObject({
+ disabled: false,
+ category: 'primary',
+ variant: 'confirm',
+ });
});
it('renders a loader spinner after Create button click', async () => {
diff --git a/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js b/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js
new file mode 100644
index 00000000000..662c09d02bf
--- /dev/null
+++ b/spec/frontend/vue_shared/components/usage_quotas/usage_banner_spec.js
@@ -0,0 +1,62 @@
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import component from '~/vue_shared/components/usage_quotas/usage_banner.vue';
+
+describe('usage banner', () => {
+ let wrapper;
+
+ const findLeftPrimaryTextSlot = () => wrapper.findByTestId('left-primary-text');
+ const findLeftSecondaryTextSlot = () => wrapper.findByTestId('left-secondary-text');
+ const findRightPrimaryTextSlot = () => wrapper.findByTestId('right-primary-text');
+ const findRightSecondaryTextSlot = () => wrapper.findByTestId('right-secondary-text');
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ const mountComponent = (propsData, slots) => {
+ wrapper = shallowMountExtended(component, {
+ propsData,
+ slots: {
+ 'left-primary-text': '<div data-testid="left-primary-text" />',
+ 'left-secondary-text': '<div data-testid="left-secondary-text" />',
+ 'right-primary-text': '<div data-testid="right-primary-text" />',
+ 'right-secondary-text': '<div data-testid="right-secondary-text" />',
+ ...slots,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe.each`
+ slotName | finderFunction
+ ${'left-primary-text'} | ${findLeftPrimaryTextSlot}
+ ${'left-secondary-text'} | ${findLeftSecondaryTextSlot}
+ ${'right-primary-text'} | ${findRightPrimaryTextSlot}
+ ${'right-secondary-text'} | ${findRightSecondaryTextSlot}
+ `('$slotName slot', ({ finderFunction, slotName }) => {
+ it('exist when the slot is filled', () => {
+ mountComponent();
+
+ expect(finderFunction().exists()).toBe(true);
+ });
+
+ it('does not exist when the slot is empty', () => {
+ mountComponent({}, { [slotName]: '' });
+
+ expect(finderFunction().exists()).toBe(false);
+ });
+ });
+
+ it('should show a skeleton loader component', () => {
+ mountComponent({ loading: true });
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('should not show a skeleton loader component', () => {
+ mountComponent();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
index 3329199a46b..a54f3450633 100644
--- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
+++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
@@ -1,11 +1,22 @@
import { GlSkeletonLoader, GlIcon } from '@gitlab/ui';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import { followUser, unfollowUser } from '~/api/user_api';
+
+jest.mock('~/flash');
+jest.mock('~/api/user_api', () => ({
+ followUser: jest.fn(),
+ unfollowUser: jest.fn(),
+}));
const DEFAULT_PROPS = {
user: {
+ id: 1,
username: 'root',
name: 'Administrator',
location: 'Vienna',
@@ -15,6 +26,7 @@ const DEFAULT_PROPS = {
workInformation: null,
status: null,
pronouns: 'they/them',
+ isFollowed: false,
loaded: true,
},
};
@@ -25,11 +37,13 @@ describe('User Popover Component', () => {
let wrapper;
beforeEach(() => {
- loadFixtures(fixtureTemplate);
+ loadHTMLFixture(fixtureTemplate);
+ gon.features = {};
});
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
const findUserStatus = () => wrapper.findByTestId('user-popover-status');
@@ -37,15 +51,15 @@ describe('User Popover Component', () => {
const findUserName = () => wrapper.find(UserNameWithStatus);
const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link');
const findUserLocalTime = () => wrapper.findByTestId('user-popover-local-time');
+ const findToggleFollowButton = () => wrapper.findByTestId('toggle-follow-button');
- const createWrapper = (props = {}, options = {}) => {
+ const createWrapper = (props = {}) => {
wrapper = mountExtended(UserPopover, {
propsData: {
...DEFAULT_PROPS,
target: findTarget(),
...props,
},
- ...options,
});
};
@@ -289,4 +303,124 @@ describe('User Popover Component', () => {
expect(findUserLocalTime().exists()).toBe(false);
});
});
+
+ describe("when current user doesn't follow the user", () => {
+ beforeEach(() => createWrapper());
+
+ it('renders the Follow button with the correct variant', () => {
+ expect(findToggleFollowButton().text()).toBe('Follow');
+ expect(findToggleFollowButton().props('variant')).toBe('confirm');
+ });
+
+ describe('when clicking', () => {
+ it('follows the user', async () => {
+ followUser.mockResolvedValue({});
+
+ await findToggleFollowButton().trigger('click');
+
+ expect(findToggleFollowButton().props('loading')).toBe(true);
+
+ await axios.waitForAll();
+
+ expect(wrapper.emitted().follow.length).toBe(1);
+ expect(wrapper.emitted().unfollow).toBeFalsy();
+ });
+
+ describe('when an error occurs', () => {
+ beforeEach(() => {
+ followUser.mockRejectedValue({});
+
+ findToggleFollowButton().trigger('click');
+ });
+
+ it('shows an error message', async () => {
+ await axios.waitForAll();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'An error occurred while trying to follow this user, please try again.',
+ error: {},
+ captureError: true,
+ });
+ });
+
+ it('emits no events', async () => {
+ await axios.waitForAll();
+
+ expect(wrapper.emitted().follow).toBe(undefined);
+ expect(wrapper.emitted().unfollow).toBe(undefined);
+ });
+ });
+ });
+ });
+
+ describe('when current user follows the user', () => {
+ beforeEach(() => createWrapper({ user: { ...DEFAULT_PROPS.user, isFollowed: true } }));
+
+ it('renders the Unfollow button with the correct variant', () => {
+ expect(findToggleFollowButton().text()).toBe('Unfollow');
+ expect(findToggleFollowButton().props('variant')).toBe('default');
+ });
+
+ describe('when clicking', () => {
+ it('unfollows the user', async () => {
+ unfollowUser.mockResolvedValue({});
+
+ findToggleFollowButton().trigger('click');
+
+ await axios.waitForAll();
+
+ expect(wrapper.emitted().follow).toBe(undefined);
+ expect(wrapper.emitted().unfollow.length).toBe(1);
+ });
+
+ describe('when an error occurs', () => {
+ beforeEach(async () => {
+ unfollowUser.mockRejectedValue({});
+
+ findToggleFollowButton().trigger('click');
+
+ await axios.waitForAll();
+ });
+
+ it('shows an error message', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'An error occurred while trying to unfollow this user, please try again.',
+ error: {},
+ captureError: true,
+ });
+ });
+
+ it('emits no events', () => {
+ expect(wrapper.emitted().follow).toBe(undefined);
+ expect(wrapper.emitted().unfollow).toBe(undefined);
+ });
+ });
+ });
+ });
+
+ describe('when the current user is the user', () => {
+ beforeEach(() => {
+ gon.current_username = DEFAULT_PROPS.user.username;
+ createWrapper();
+ });
+
+ it("doesn't render the toggle follow button", () => {
+ expect(findToggleFollowButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when API does not support `isFollowed`', () => {
+ beforeEach(() => {
+ const user = {
+ ...DEFAULT_PROPS.user,
+ isFollowed: undefined,
+ };
+
+ createWrapper({ user });
+ });
+
+ it('does not render the toggle follow button', () => {
+ expect(findToggleFollowButton().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js
index 59ce9f086c3..d052c99ec0e 100644
--- a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js
+++ b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js
@@ -1,3 +1,4 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
/**
@@ -10,10 +11,14 @@ describe('AutofocusOnShow directive', () => {
let el;
beforeEach(() => {
- setFixtures('<div id="container" style="display: none;"><input id="inputel"/></div>');
+ setHTMLFixture('<div id="container" style="display: none;"><input id="inputel"/></div>');
el = document.querySelector('#inputel');
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('should bind IntersectionObserver on input element', () => {
jest.spyOn(el, 'focus').mockImplementation(() => {});
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js
index 7dfeced571a..a25f92c9cf2 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import IssuableBulkEditSidebar from '~/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue';
const createComponent = ({ expanded = true } = {}) =>
@@ -22,12 +23,13 @@ describe('IssuableBulkEditSidebar', () => {
let wrapper;
beforeEach(() => {
- setFixtures('<div class="layout-page right-sidebar-collapsed"></div>');
+ setHTMLFixture('<div class="layout-page right-sidebar-collapsed"></div>');
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
describe('watch', () => {
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
index b79dc0bf976..d3e484cf913 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
@@ -36,7 +36,6 @@ describe('IssuableEditForm', () => {
beforeEach(() => {
wrapper = createComponent();
- gon.features = { markdownContinueLists: true };
});
afterEach(() => {
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js
index 1cdd709159f..544db891a13 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js
@@ -1,8 +1,6 @@
import { GlIcon, GlAvatarLabeled } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue';
import { mockIssuableShowProps, mockIssuable } from '../mock_data';
@@ -12,10 +10,17 @@ const issuableHeaderProps = {
...mockIssuableShowProps,
};
-const createComponent = (propsData = issuableHeaderProps, { stubs } = {}) =>
- extendedWrapper(
- shallowMount(IssuableHeader, {
- propsData,
+describe('IssuableHeader', () => {
+ let wrapper;
+
+ const findTaskStatusEl = () => wrapper.findByTestId('task-status');
+
+ const createComponent = (props = {}, { stubs } = {}) => {
+ wrapper = shallowMountExtended(IssuableHeader, {
+ propsData: {
+ ...issuableHeaderProps,
+ ...props,
+ },
slots: {
'status-badge': 'Open',
'header-actions': `
@@ -24,23 +29,18 @@ const createComponent = (propsData = issuableHeaderProps, { stubs } = {}) =>
`,
},
stubs,
- }),
- );
-
-describe('IssuableHeader', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = createComponent();
- });
+ });
+ };
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
describe('computed', () => {
describe('authorId', () => {
it('returns numeric ID from GraphQL ID of `author` prop', () => {
+ createComponent();
expect(wrapper.vm.authorId).toBe(1);
});
});
@@ -48,10 +48,11 @@ describe('IssuableHeader', () => {
describe('handleRightSidebarToggleClick', () => {
beforeEach(() => {
- setFixtures('<button class="js-toggle-right-sidebar-button">Collapse sidebar</button>');
+ setHTMLFixture('<button class="js-toggle-right-sidebar-button">Collapse sidebar</button>');
});
it('dispatches `click` event on sidebar toggle button', () => {
+ createComponent();
wrapper.vm.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
jest.spyOn(wrapper.vm.toggleSidebarButtonEl, 'dispatchEvent').mockImplementation(jest.fn);
@@ -67,20 +68,21 @@ describe('IssuableHeader', () => {
describe('template', () => {
it('renders issuable status icon and text', () => {
+ createComponent();
const statusBoxEl = wrapper.findByTestId('status');
+ const statusIconEl = statusBoxEl.findComponent(GlIcon);
expect(statusBoxEl.exists()).toBe(true);
- expect(statusBoxEl.find(GlIcon).props('name')).toBe(mockIssuableShowProps.statusIcon);
+ expect(statusIconEl.props('name')).toBe(mockIssuableShowProps.statusIcon);
+ expect(statusIconEl.attributes('class')).toBe(mockIssuableShowProps.statusIconClass);
expect(statusBoxEl.text()).toContain('Open');
});
it('renders blocked icon when issuable is blocked', async () => {
- wrapper.setProps({
+ createComponent({
blocked: true,
});
- await nextTick();
-
const blockedEl = wrapper.findByTestId('blocked');
expect(blockedEl.exists()).toBe(true);
@@ -88,12 +90,10 @@ describe('IssuableHeader', () => {
});
it('renders confidential icon when issuable is confidential', async () => {
- wrapper.setProps({
+ createComponent({
confidential: true,
});
- await nextTick();
-
const confidentialEl = wrapper.findByTestId('confidential');
expect(confidentialEl.exists()).toBe(true);
@@ -101,6 +101,7 @@ describe('IssuableHeader', () => {
});
it('renders issuable author avatar', () => {
+ createComponent();
const { username, name, webUrl, avatarUrl } = mockIssuable.author;
const avatarElAttrs = {
'data-user-id': '1',
@@ -120,28 +121,26 @@ describe('IssuableHeader', () => {
expect(avatarEl.find(GlAvatarLabeled).find(GlIcon).exists()).toBe(false);
});
- it('renders tast status text when `taskCompletionStatus` prop is defined', () => {
- let taskStatusEl = wrapper.findByTestId('task-status');
+ it('renders task status text when `taskCompletionStatus` prop is defined', () => {
+ createComponent();
- expect(taskStatusEl.exists()).toBe(true);
- expect(taskStatusEl.text()).toContain('0 of 5 tasks completed');
+ expect(findTaskStatusEl().exists()).toBe(true);
+ expect(findTaskStatusEl().text()).toContain('0 of 5 tasks completed');
+ });
- const wrapperSingleTask = createComponent({
- ...issuableHeaderProps,
+ it('does not render task status text when tasks count is 0', () => {
+ createComponent({
taskCompletionStatus: {
+ count: 0,
completedCount: 0,
- count: 1,
},
});
- taskStatusEl = wrapperSingleTask.findByTestId('task-status');
-
- expect(taskStatusEl.text()).toContain('0 of 1 task completed');
-
- wrapperSingleTask.destroy();
+ expect(findTaskStatusEl().exists()).toBe(false);
});
it('renders sidebar toggle button', () => {
+ createComponent();
const toggleButtonEl = wrapper.findByTestId('sidebar-toggle');
expect(toggleButtonEl.exists()).toBe(true);
@@ -149,6 +148,7 @@ describe('IssuableHeader', () => {
});
it('renders header actions', () => {
+ createComponent();
const actionsEl = wrapper.findByTestId('header-actions');
expect(actionsEl.find('button.js-close').exists()).toBe(true);
@@ -157,9 +157,8 @@ describe('IssuableHeader', () => {
describe('when author exists outside of GitLab', () => {
it("renders 'external-link' icon in avatar label", () => {
- wrapper = createComponent(
+ createComponent(
{
- ...issuableHeaderProps,
author: {
...issuableHeaderProps.author,
webUrl: 'https://jira.com/test-user/author.jpg',
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js
index d1eb1366225..8b027f990a2 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js
@@ -49,6 +49,7 @@ describe('IssuableShowRoot', () => {
const {
statusBadgeClass,
statusIcon,
+ statusIconClass,
enableEdit,
enableAutocomplete,
editFormVisible,
@@ -56,7 +57,7 @@ describe('IssuableShowRoot', () => {
descriptionHelpPath,
taskCompletionStatus,
} = mockIssuableShowProps;
- const { blocked, confidential, createdAt, author } = mockIssuable;
+ const { state, blocked, confidential, createdAt, author } = mockIssuable;
it('renders component container element with class `issuable-show-container`', () => {
expect(wrapper.classes()).toContain('issuable-show-container');
@@ -67,15 +68,17 @@ describe('IssuableShowRoot', () => {
expect(issuableHeader.exists()).toBe(true);
expect(issuableHeader.props()).toMatchObject({
+ issuableState: state,
statusBadgeClass,
statusIcon,
+ statusIconClass,
blocked,
confidential,
createdAt,
author,
taskCompletionStatus,
});
- expect(issuableHeader.find('.issuable-status-box').text()).toContain('Open');
+ expect(issuableHeader.find('.issuable-status-badge').text()).toContain('Open');
expect(issuableHeader.find('.detail-page-header-actions button.js-close').exists()).toBe(
true,
);
diff --git a/spec/frontend/vue_shared/issuable/show/mock_data.js b/spec/frontend/vue_shared/issuable/show/mock_data.js
index f5f3ed58655..32bb9edfe08 100644
--- a/spec/frontend/vue_shared/issuable/show/mock_data.js
+++ b/spec/frontend/vue_shared/issuable/show/mock_data.js
@@ -36,8 +36,9 @@ export const mockIssuableShowProps = {
enableTaskList: true,
enableEdit: true,
showFieldTitle: false,
- statusBadgeClass: 'status-box-open',
- statusIcon: 'issue-open-m',
+ statusBadgeClass: 'issuable-status-badge-open',
+ statusIcon: 'issues',
+ statusIconClass: 'gl-sm-display-none',
taskCompletionStatus: {
completedCount: 0,
count: 5,
diff --git a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
index 47bf3c8ed83..6c9e5f85fa0 100644
--- a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
@@ -1,6 +1,7 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import Cookies from 'js-cookie';
import { nextTick } from 'vue';
+import Cookies from '~/lib/utils/cookies';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import IssuableSidebarRoot from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue';
@@ -9,7 +10,7 @@ import { USER_COLLAPSED_GUTTER_COOKIE } from '~/vue_shared/issuable/sidebar/cons
const MOCK_LAYOUT_PAGE_CLASS = 'layout-page';
const createComponent = () => {
- setFixtures(`<div class="${MOCK_LAYOUT_PAGE_CLASS}"></div>`);
+ setHTMLFixture(`<div class="${MOCK_LAYOUT_PAGE_CLASS}"></div>`);
return shallowMountExtended(IssuableSidebarRoot, {
slots: {
@@ -38,6 +39,7 @@ describe('IssuableSidebarRoot', () => {
afterEach(() => {
wrapper.destroy();
+ resetHTMLFixture();
});
describe('when sidebar is expanded', () => {
diff --git a/spec/frontend/security_configuration/components/section_layout_spec.js b/spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js
index 75da380bbb8..136fe74b0d6 100644
--- a/spec/frontend/security_configuration/components/section_layout_spec.js
+++ b/spec/frontend/vue_shared/security_configuration/components/section_layout_spec.js
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import SectionLayout from '~/security_configuration/components/section_layout.vue';
+import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue';
+import SectionLoader from '~/vue_shared/security_configuration/components/section_loader.vue';
describe('Section Layout component', () => {
let wrapper;
@@ -18,6 +19,7 @@ describe('Section Layout component', () => {
};
const findHeading = () => wrapper.find('h2');
+ const findLoader = () => wrapper.findComponent(SectionLoader);
afterEach(() => {
wrapper.destroy();
@@ -46,4 +48,11 @@ describe('Section Layout component', () => {
});
});
});
+
+ describe('loading state', () => {
+ it('should show loaders when loading', () => {
+ createComponent({ heading: 'testheading', isLoading: true });
+ expect(findLoader().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js
index dac9accbbf5..a9ad675e538 100644
--- a/spec/frontend/vue_shared/security_reports/mock_data.js
+++ b/spec/frontend/vue_shared/security_reports/mock_data.js
@@ -62,7 +62,7 @@ export const mockFindings = [
report_type: 'dependency_scanning',
name: '3rd party CORS request may execute in jquery',
severity: 'high',
- scanner: { external_id: 'retire.js', name: 'Retire.js' },
+ scanner: { external_id: 'gemnasium', name: 'gemnasium' },
identifiers: [
{
external_type: 'cve',
@@ -145,7 +145,7 @@ export const mockFindings = [
name:
'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery',
severity: 'low',
- scanner: { external_id: 'retire.js', name: 'Retire.js' },
+ scanner: { external_id: 'gemnasium', name: 'gemnasium' },
identifiers: [
{
external_type: 'cve',
@@ -227,7 +227,7 @@ export const mockFindings = [
name:
'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery',
severity: 'low',
- scanner: { external_id: 'retire.js', name: 'Retire.js' },
+ scanner: { external_id: 'gemnasium', name: 'gemnasium' },
identifiers: [
{
external_type: 'cve',
diff --git a/spec/frontend/whats_new/components/feature_spec.js b/spec/frontend/whats_new/components/feature_spec.js
index 8f4b4b08f50..b6627c257ff 100644
--- a/spec/frontend/whats_new/components/feature_spec.js
+++ b/spec/frontend/whats_new/components/feature_spec.js
@@ -21,6 +21,7 @@ describe("What's new single feature", () => {
const findReleaseDate = () => wrapper.find('[data-testid="release-date"]');
const findBodyAnchor = () => wrapper.find('[data-testid="body-content"] a');
+ const findImageLink = () => wrapper.find('[data-testid="whats-new-image-link"]');
const createWrapper = ({ feature } = {}) => {
wrapper = shallowMount(Feature, {
@@ -35,18 +36,38 @@ describe("What's new single feature", () => {
it('renders the date', () => {
createWrapper({ feature: exampleFeature });
+
expect(findReleaseDate().text()).toBe('April 22, 2021');
});
- describe('when the published_at is null', () => {
- it("doesn't render the date", () => {
+ it('renders image link', () => {
+ createWrapper({ feature: exampleFeature });
+
+ expect(findImageLink().exists()).toBe(true);
+ expect(findImageLink().find('div').attributes('style')).toBe(
+ `background-image: url(${exampleFeature.image_url});`,
+ );
+ });
+
+ describe('when published_at is null', () => {
+ it('does not render the date', () => {
createWrapper({ feature: { ...exampleFeature, published_at: null } });
+
expect(findReleaseDate().exists()).toBe(false);
});
});
+ describe('when image_url is null', () => {
+ it('does not render image link', () => {
+ createWrapper({ feature: { ...exampleFeature, image_url: null } });
+
+ expect(findImageLink().exists()).toBe(false);
+ });
+ });
+
it('safe-html config allows target attribute on elements', () => {
createWrapper({ feature: exampleFeature });
+
expect(findBodyAnchor().attributes()).toEqual({
href: expect.any(String),
rel: 'noopener noreferrer',
diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js
index ef61462a3c5..dac02ee07bd 100644
--- a/spec/frontend/whats_new/utils/notification_spec.js
+++ b/spec/frontend/whats_new/utils/notification_spec.js
@@ -1,3 +1,4 @@
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { setNotification, getVersionDigest } from '~/whats_new/utils/notification';
@@ -11,12 +12,13 @@ describe('~/whats_new/utils/notification', () => {
const getAppEl = () => wrapper.querySelector('.app');
beforeEach(() => {
- loadFixtures('static/whats_new_notification.html');
+ loadHTMLFixture('static/whats_new_notification.html');
wrapper = document.querySelector('.whats-new-notification-fixture-root');
});
afterEach(() => {
wrapper.remove();
+ resetHTMLFixture();
});
describe('setNotification', () => {
diff --git a/spec/frontend/wikis_spec.js b/spec/frontend/wikis_spec.js
index c4e914bcf34..d8748c65da7 100644
--- a/spec/frontend/wikis_spec.js
+++ b/spec/frontend/wikis_spec.js
@@ -1,5 +1,5 @@
import { escape } from 'lodash';
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import Wikis from '~/pages/shared/wikis/wikis';
import Tracking from '~/tracking';
@@ -21,6 +21,10 @@ describe('Wikis', () => {
Wikis.trackPageView();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('sends the tracking event and context', () => {
expect(Tracking.event).toHaveBeenCalledWith(trackingPage, 'view_wiki_page', {
label: 'view_wiki_page',
diff --git a/spec/frontend/work_items/components/item_state_spec.js b/spec/frontend/work_items/components/item_state_spec.js
new file mode 100644
index 00000000000..79b76f3c061
--- /dev/null
+++ b/spec/frontend/work_items/components/item_state_spec.js
@@ -0,0 +1,54 @@
+import { mount } from '@vue/test-utils';
+import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants';
+import ItemState from '~/work_items/components/item_state.vue';
+
+describe('ItemState', () => {
+ let wrapper;
+
+ const findLabel = () => wrapper.find('label').text();
+ const selectedValue = () => wrapper.find('option:checked').element.value;
+
+ const clickOpen = () => wrapper.findAll('option').at(0).setSelected();
+
+ const createComponent = ({ state = STATE_OPEN, disabled = false } = {}) => {
+ wrapper = mount(ItemState, {
+ propsData: {
+ state,
+ disabled,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders label and dropdown', () => {
+ createComponent();
+
+ expect(findLabel()).toBe('Status');
+ expect(selectedValue()).toBe(STATE_OPEN);
+ });
+
+ it('renders dropdown for closed', () => {
+ createComponent({ state: STATE_CLOSED });
+
+ expect(selectedValue()).toBe(STATE_CLOSED);
+ });
+
+ it('emits changed event', async () => {
+ createComponent({ state: STATE_CLOSED });
+
+ await clickOpen();
+
+ expect(wrapper.emitted('changed')).toEqual([[STATE_OPEN]]);
+ });
+
+ it('does not emits changed event if clicking selected value', async () => {
+ createComponent({ state: STATE_OPEN });
+
+ await clickOpen();
+
+ expect(wrapper.emitted('changed')).toBeUndefined();
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js
index d0e9cfee353..137a0a7326d 100644
--- a/spec/frontend/work_items/components/work_item_actions_spec.js
+++ b/spec/frontend/work_items/components/work_item_actions_spec.js
@@ -1,30 +1,18 @@
import { GlDropdownItem, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import waitForPromises from 'helpers/wait_for_promises';
-import createMockApollo from 'helpers/mock_apollo_helper';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
-import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graphql';
-import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data';
describe('WorkItemActions component', () => {
let wrapper;
let glModalDirective;
- Vue.use(VueApollo);
-
const findModal = () => wrapper.findComponent(GlModal);
const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
- const createComponent = ({
- canUpdate = true,
- deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse),
- } = {}) => {
+ const createComponent = ({ canDelete = true } = {}) => {
glModalDirective = jest.fn();
wrapper = shallowMount(WorkItemActions, {
- apolloProvider: createMockApollo([[deleteWorkItem, deleteWorkItemHandler]]),
- propsData: { workItemId: '123', canUpdate },
+ propsData: { workItemId: '123', canDelete },
directives: {
glModal: {
bind(_, { value }) {
@@ -54,48 +42,17 @@ describe('WorkItemActions component', () => {
expect(glModalDirective).toHaveBeenCalled();
});
- it('calls delete mutation when clicking OK button', () => {
- const deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse);
-
- createComponent({
- deleteWorkItemHandler,
- });
-
- findModal().vm.$emit('ok');
-
- expect(deleteWorkItemHandler).toHaveBeenCalled();
- expect(wrapper.emitted('error')).toBeUndefined();
- });
-
- it('emits event after delete success', async () => {
+ it('emits event when clicking OK button', () => {
createComponent();
findModal().vm.$emit('ok');
- await waitForPromises();
-
- expect(wrapper.emitted('workItemDeleted')).not.toBeUndefined();
- expect(wrapper.emitted('error')).toBeUndefined();
- });
-
- it('emits error event after delete failure', async () => {
- createComponent({
- deleteWorkItemHandler: jest.fn().mockResolvedValue(deleteWorkItemFailureResponse),
- });
-
- findModal().vm.$emit('ok');
-
- await waitForPromises();
-
- expect(wrapper.emitted('error')[0]).toEqual([
- "The resource that you are attempting to access does not exist or you don't have permission to perform this action",
- ]);
- expect(wrapper.emitted('workItemDeleted')).toBeUndefined();
+ expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]);
});
- it('does not render when canUpdate is false', () => {
+ it('does not render when canDelete is false', () => {
createComponent({
- canUpdate: false,
+ canDelete: false,
});
expect(wrapper.html()).toBe('');
diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
index 9f35ccb853b..aaabdbc82d9 100644
--- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
@@ -1,23 +1,57 @@
-import { GlModal } from '@gitlab/ui';
+import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
-import WorkItemActions from '~/work_items/components/work_item_actions.vue';
+import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql';
describe('WorkItemDetailModal component', () => {
let wrapper;
Vue.use(VueApollo);
+ const hideModal = jest.fn();
+ const GlModal = {
+ template: `
+ <div>
+ <slot></slot>
+ </div>
+ `,
+ methods: {
+ hide: hideModal,
+ },
+ };
+
const findModal = () => wrapper.findComponent(GlModal);
- const findWorkItemActions = () => wrapper.findComponent(WorkItemActions);
+ const findAlert = () => wrapper.findComponent(GlAlert);
const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
- const createComponent = ({ visible = true, workItemId = '1', canUpdate = false } = {}) => {
+ const createComponent = ({ workItemId = '1', error = false } = {}) => {
+ const apolloProvider = createMockApollo([
+ [
+ deleteWorkItemFromTaskMutation,
+ jest.fn().mockResolvedValue({
+ data: {
+ workItemDeleteTask: {
+ workItem: { id: 123, descriptionHtml: 'updated work item desc' },
+ errors: [],
+ },
+ },
+ }),
+ ],
+ ]);
+
wrapper = shallowMount(WorkItemDetailModal, {
- propsData: { visible, workItemId, canUpdate },
+ apolloProvider,
+ propsData: { workItemId },
+ data() {
+ return {
+ error,
+ };
+ },
stubs: {
GlModal,
},
@@ -28,31 +62,59 @@ describe('WorkItemDetailModal component', () => {
wrapper.destroy();
});
- describe.each([true, false])('when visible=%s', (visible) => {
- it(`${visible ? 'renders' : 'does not render'} modal`, () => {
- createComponent({ visible });
+ it('renders WorkItemDetail', () => {
+ createComponent();
- expect(findModal().props('visible')).toBe(visible);
+ expect(findWorkItemDetail().props()).toEqual({
+ workItemId: '1',
});
});
- it('renders heading', () => {
+ it('renders alert if there is an error', () => {
+ createComponent({ error: true });
+
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('does not render alert if there is no error', () => {
createComponent();
- expect(wrapper.find('h2').text()).toBe('Work Item');
+ expect(findAlert().exists()).toBe(false);
});
- it('renders WorkItemDetail', () => {
+ it('dismisses the alert on `dismiss` emitted event', async () => {
+ createComponent({ error: true });
+ findAlert().vm.$emit('dismiss');
+ await nextTick();
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('emits `close` event on hiding the modal', () => {
createComponent();
+ findModal().vm.$emit('hide');
- expect(findWorkItemDetail().props()).toEqual({ workItemId: '1' });
+ expect(wrapper.emitted('close')).toBeTruthy();
});
- it('shows work item actions', () => {
- createComponent({
- canUpdate: true,
- });
+ it('emits `workItemUpdated` event on updating work item', () => {
+ createComponent();
+ findWorkItemDetail().vm.$emit('workItemUpdated');
+
+ expect(wrapper.emitted('workItemUpdated')).toBeTruthy();
+ });
+
+ describe('delete work item', () => {
+ it('emits workItemDeleted and closes modal', async () => {
+ createComponent();
+ const newDesc = 'updated work item desc';
+
+ findWorkItemDetail().vm.$emit('deleteWorkItem');
- expect(findWorkItemActions().exists()).toBe(true);
+ await waitForPromises();
+
+ expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]);
+ expect(hideModal).toHaveBeenCalled();
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_state_spec.js b/spec/frontend/work_items/components/work_item_state_spec.js
new file mode 100644
index 00000000000..9e48f56d9e9
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_state_spec.js
@@ -0,0 +1,117 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ItemState from '~/work_items/components/item_state.vue';
+import WorkItemState from '~/work_items/components/work_item_state.vue';
+import {
+ i18n,
+ STATE_OPEN,
+ STATE_CLOSED,
+ STATE_EVENT_CLOSE,
+ STATE_EVENT_REOPEN,
+} from '~/work_items/constants';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data';
+
+describe('WorkItemState component', () => {
+ let wrapper;
+
+ Vue.use(VueApollo);
+
+ const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
+
+ const findItemState = () => wrapper.findComponent(ItemState);
+
+ const createComponent = ({
+ state = STATE_OPEN,
+ mutationHandler = mutationSuccessHandler,
+ } = {}) => {
+ const { id, workItemType } = workItemQueryResponse.data.workItem;
+ wrapper = shallowMount(WorkItemState, {
+ apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
+ propsData: {
+ workItem: {
+ id,
+ state,
+ workItemType,
+ },
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders state', () => {
+ createComponent();
+
+ expect(findItemState().props('state')).toBe(workItemQueryResponse.data.workItem.state);
+ });
+
+ describe('when updating the state', () => {
+ it('calls a mutation', () => {
+ createComponent();
+
+ findItemState().vm.$emit('changed', STATE_CLOSED);
+
+ expect(mutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ id: workItemQueryResponse.data.workItem.id,
+ stateEvent: STATE_EVENT_CLOSE,
+ },
+ });
+ });
+
+ it('calls a mutation with REOPEN', () => {
+ createComponent({
+ state: STATE_CLOSED,
+ });
+
+ findItemState().vm.$emit('changed', STATE_OPEN);
+
+ expect(mutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ id: workItemQueryResponse.data.workItem.id,
+ stateEvent: STATE_EVENT_REOPEN,
+ },
+ });
+ });
+
+ it('emits updated event', async () => {
+ createComponent();
+
+ findItemState().vm.$emit('changed', STATE_CLOSED);
+ await waitForPromises();
+
+ expect(wrapper.emitted('updated')).toEqual([[]]);
+ });
+
+ it('emits an error message when the mutation was unsuccessful', async () => {
+ createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') });
+
+ findItemState().vm.$emit('changed', STATE_CLOSED);
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]);
+ });
+
+ it('tracks editing the state', async () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ createComponent();
+
+ findItemState().vm.$emit('changed', STATE_CLOSED);
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_state', {
+ category: 'workItems:show',
+ label: 'item_state',
+ property: 'type_Task',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js
index 9b1ef2d14e4..19b56362ac0 100644
--- a/spec/frontend/work_items/components/work_item_title_spec.js
+++ b/spec/frontend/work_items/components/work_item_title_spec.js
@@ -1,4 +1,3 @@
-import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -18,15 +17,13 @@ describe('WorkItemTitle component', () => {
const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findItemTitle = () => wrapper.findComponent(ItemTitle);
- const createComponent = ({ loading = false, mutationHandler = mutationSuccessHandler } = {}) => {
+ const createComponent = ({ mutationHandler = mutationSuccessHandler } = {}) => {
const { id, title, workItemType } = workItemQueryResponse.data.workItem;
wrapper = shallowMount(WorkItemTitle, {
apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
propsData: {
- loading,
workItemId: id,
workItemTitle: title,
workItemType: workItemType.name,
@@ -38,32 +35,10 @@ describe('WorkItemTitle component', () => {
wrapper.destroy();
});
- describe('when loading', () => {
- beforeEach(() => {
- createComponent({ loading: true });
- });
-
- it('renders loading spinner', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- });
-
- it('does not render title', () => {
- expect(findItemTitle().exists()).toBe(false);
- });
- });
-
- describe('when loaded', () => {
- beforeEach(() => {
- createComponent({ loading: false });
- });
-
- it('does not render loading spinner', () => {
- expect(findLoadingIcon().exists()).toBe(false);
- });
+ it('renders title', () => {
+ createComponent();
- it('renders title', () => {
- expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title);
- });
+ expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title);
});
describe('when updating the title', () => {
@@ -82,6 +57,15 @@ describe('WorkItemTitle component', () => {
});
});
+ it('emits updated event', async () => {
+ createComponent();
+
+ findItemTitle().vm.$emit('title-changed', 'new title');
+ await waitForPromises();
+
+ expect(wrapper.emitted('updated')).toEqual([[]]);
+ });
+
it('does not call a mutation when the title has not changed', () => {
createComponent();
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index 722e1708c15..f3483550013 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -4,11 +4,17 @@ export const workItemQueryResponse = {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
title: 'Test',
+ state: 'OPEN',
+ description: 'description',
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
name: 'Task',
},
+ userPermissions: {
+ deleteWorkItem: false,
+ updateWorkItem: false,
+ },
},
},
};
@@ -21,11 +27,17 @@ export const updateWorkItemMutationResponse = {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
title: 'Updated title',
+ state: 'OPEN',
+ description: 'description',
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
name: 'Task',
},
+ userPermissions: {
+ deleteWorkItem: false,
+ updateWorkItem: false,
+ },
},
},
},
@@ -39,6 +51,7 @@ export const projectWorkItemTypesQueryResponse = {
nodes: [
{ id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' },
{ id: 'gid://gitlab/WorkItems::Type/2', name: 'Incident' },
+ { id: 'gid://gitlab/WorkItems::Type/3', name: 'Task' },
],
},
},
@@ -53,11 +66,17 @@ export const createWorkItemMutationResponse = {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
title: 'Updated title',
+ state: 'OPEN',
+ description: 'description',
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
name: 'Task',
},
+ userPermissions: {
+ deleteWorkItem: false,
+ updateWorkItem: false,
+ },
},
},
},
@@ -72,6 +91,10 @@ export const createWorkItemFromTaskMutationResponse = {
descriptionHtml: '<p>New description</p>',
id: 'gid://gitlab/WorkItem/13',
__typename: 'WorkItem',
+ userPermissions: {
+ deleteWorkItem: false,
+ updateWorkItem: false,
+ },
},
},
},
diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js
index fb1f1d56356..e89477ed599 100644
--- a/spec/frontend/work_items/pages/create_work_item_spec.js
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -158,6 +158,11 @@ describe('Create work item component', () => {
it('adds padding for content', () => {
expect(findContent().classes('gl-px-5')).toBe(true);
});
+
+ it('defaults type to `Task`', async () => {
+ await waitForPromises();
+ expect(findSelect().attributes('value')).toBe('gid://gitlab/WorkItems::Type/3');
+ });
});
it('displays a loading icon inside dropdown when work items query is loading', () => {
@@ -181,7 +186,7 @@ describe('Create work item component', () => {
});
it('displays a list of work item types', () => {
- expect(findSelect().attributes('options').split(',')).toHaveLength(3);
+ expect(findSelect().attributes('options').split(',')).toHaveLength(4);
});
it('selects a work item type on click', async () => {
diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js
index 1eb6c0145e7..9f87655175c 100644
--- a/spec/frontend/work_items/pages/work_item_detail_spec.js
+++ b/spec/frontend/work_items/pages/work_item_detail_spec.js
@@ -1,10 +1,11 @@
-import { GlAlert } from '@gitlab/ui';
+import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
+import WorkItemState from '~/work_items/components/work_item_state.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
@@ -20,7 +21,9 @@ describe('WorkItemDetail component', () => {
const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const findAlert = () => wrapper.findComponent(GlAlert);
+ const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
+ const findWorkItemState = () => wrapper.findComponent(WorkItemState);
const createComponent = ({
workItemId = workItemQueryResponse.data.workItem.id,
@@ -55,8 +58,10 @@ describe('WorkItemDetail component', () => {
createComponent();
});
- it('renders WorkItemTitle in loading state', () => {
- expect(findWorkItemTitle().props('loading')).toBe(true);
+ it('renders skeleton loader', () => {
+ expect(findSkeleton().exists()).toBe(true);
+ expect(findWorkItemState().exists()).toBe(false);
+ expect(findWorkItemTitle().exists()).toBe(false);
});
});
@@ -66,8 +71,10 @@ describe('WorkItemDetail component', () => {
return waitForPromises();
});
- it('does not render WorkItemTitle in loading state', () => {
- expect(findWorkItemTitle().props('loading')).toBe(false);
+ it('does not render skeleton', () => {
+ expect(findSkeleton().exists()).toBe(false);
+ expect(findWorkItemState().exists()).toBe(true);
+ expect(findWorkItemTitle().exists()).toBe(true);
});
});
@@ -82,6 +89,7 @@ describe('WorkItemDetail component', () => {
it('shows an error message when WorkItemTitle emits an `error` event', async () => {
createComponent();
+ await waitForPromises();
findWorkItemTitle().vm.$emit('error', i18n.updateError);
await waitForPromises();
@@ -96,4 +104,18 @@ describe('WorkItemDetail component', () => {
issuableId: workItemQueryResponse.data.workItem.id,
});
});
+
+ it('emits workItemUpdated event when fields updated', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ findWorkItemState().vm.$emit('updated');
+
+ expect(wrapper.emitted('workItemUpdated')).toEqual([[]]);
+
+ findWorkItemTitle().vm.$emit('updated');
+
+ expect(wrapper.emitted('workItemUpdated')).toEqual([[], []]);
+ });
});
diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js
index 2803724b9af..85096392e84 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -1,21 +1,45 @@
+import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { visitUrl } from '~/lib/utils/url_utility';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
+import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graphql';
+import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+}));
Vue.use(VueApollo);
describe('Work items root component', () => {
let wrapper;
+ const issuesListPath = '/-/issues';
+ const mockToastShow = jest.fn();
const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
+ const findAlert = () => wrapper.findComponent(GlAlert);
- const createComponent = () => {
+ const createComponent = ({
+ deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse),
+ } = {}) => {
wrapper = shallowMount(WorkItemsRoot, {
+ apolloProvider: createMockApollo([[deleteWorkItem, deleteWorkItemHandler]]),
+ provide: {
+ issuesListPath,
+ },
propsData: {
id: '1',
},
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
+ },
});
};
@@ -26,6 +50,38 @@ describe('Work items root component', () => {
it('renders WorkItemDetail', () => {
createComponent();
- expect(findWorkItemDetail().props()).toEqual({ workItemId: 'gid://gitlab/WorkItem/1' });
+ expect(findWorkItemDetail().props()).toEqual({
+ workItemId: 'gid://gitlab/WorkItem/1',
+ });
+ });
+
+ it('deletes work item when deleteWorkItem event emitted', async () => {
+ const deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse);
+
+ createComponent({
+ deleteWorkItemHandler,
+ });
+
+ findWorkItemDetail().vm.$emit('deleteWorkItem');
+
+ await waitForPromises();
+
+ expect(deleteWorkItemHandler).toHaveBeenCalled();
+ expect(mockToastShow).toHaveBeenCalled();
+ expect(visitUrl).toHaveBeenCalledWith(issuesListPath);
+ });
+
+ it('shows alert if delete fails', async () => {
+ const deleteWorkItemHandler = jest.fn().mockRejectedValue(deleteWorkItemFailureResponse);
+
+ createComponent({
+ deleteWorkItemHandler,
+ });
+
+ findWorkItemDetail().vm.$emit('deleteWorkItem');
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
});
});
diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js
index 7e68c5e4f0e..99dcd886f7b 100644
--- a/spec/frontend/work_items/router_spec.js
+++ b/spec/frontend/work_items/router_spec.js
@@ -17,6 +17,7 @@ describe('Work items router', () => {
router,
provide: {
fullPath: 'full-path',
+ issuesListPath: 'full-path/-/issues',
},
mocks: {
$apollo: {
diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js
index 44684619fae..a88910b2613 100644
--- a/spec/frontend/zen_mode_spec.js
+++ b/spec/frontend/zen_mode_spec.js
@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import Dropzone from 'dropzone';
import $ from 'jquery';
import Mousetrap from 'mousetrap';
+import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import GLForm from '~/gl_form';
import * as utils from '~/lib/utils/common_utils';
import ZenMode from '~/zen_mode';
@@ -33,7 +34,7 @@ describe('ZenMode', () => {
mock = new MockAdapter(axios);
mock.onGet().reply(200);
- loadFixtures(fixtureName);
+ loadHTMLFixture(fixtureName);
const form = $('.js-new-note-form');
new GLForm(form); // eslint-disable-line no-new
@@ -45,8 +46,10 @@ describe('ZenMode', () => {
// Set this manually because we can't actually scroll the window
zen.scroll_position = 456;
+ });
- gon.features = { markdownContinueLists: true };
+ afterEach(() => {
+ resetHTMLFixture();
});
describe('enabling dropzone', () => {
diff --git a/spec/frontend_integration/fly_out_nav_browser_spec.js b/spec/frontend_integration/fly_out_nav_browser_spec.js
index ef2afa20528..47f3c6a0ac2 100644
--- a/spec/frontend_integration/fly_out_nav_browser_spec.js
+++ b/spec/frontend_integration/fly_out_nav_browser_spec.js
@@ -184,19 +184,21 @@ describe('Fly out sidebar navigation', () => {
mockBoundingRects();
});
- it('shows sub-items after 0ms if no menu is open', (done) => {
+ it('shows sub-items after 0ms if no menu is open', () => {
const subItems = findSubItems();
mouseEnterTopItems(el);
expect(getHideSubItemsInterval()).toBe(0);
- setTimeout(() => {
- expect(subItems.style.display).toBe('block');
- done();
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ expect(subItems.style.display).toBe('block');
+ resolve();
+ });
});
});
- it('shows sub-items after 300ms if a menu is currently open', (done) => {
+ it('shows sub-items after 300ms if a menu is currently open', () => {
const subItems = findSubItems();
documentMouseMove({
@@ -213,10 +215,11 @@ describe('Fly out sidebar navigation', () => {
mouseEnterTopItems(el, 0);
- setTimeout(() => {
- expect(subItems.style.display).toBe('block');
-
- done();
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ expect(subItems.style.display).toBe('block');
+ resolve();
+ });
});
});
});
diff --git a/spec/frontend_integration/ide/helpers/ide_helper.js b/spec/frontend_integration/ide/helpers/ide_helper.js
index 00ce39a5598..8c5ff816c74 100644
--- a/spec/frontend_integration/ide/helpers/ide_helper.js
+++ b/spec/frontend_integration/ide/helpers/ide_helper.js
@@ -24,13 +24,19 @@ export const switchLeftSidebarTab = (name) => {
export const getStatusBar = () => document.querySelector('.ide-status-bar');
export const waitForMonacoEditor = () =>
- new Promise((resolve) => monacoEditor.onDidCreateEditor(resolve));
+ new Promise((resolve) => {
+ monacoEditor.onDidCreateEditor(resolve);
+ });
export const waitForEditorDispose = (instance) =>
- new Promise((resolve) => instance.onDidDispose(resolve));
+ new Promise((resolve) => {
+ instance.onDidDispose(resolve);
+ });
export const waitForEditorModelChange = (instance) =>
- new Promise((resolve) => instance.onDidChangeModel(resolve));
+ new Promise((resolve) => {
+ instance.onDidChangeModel(resolve);
+ });
export const findMonacoEditor = () =>
screen.findAllByLabelText(/Editor content;/).then(([x]) => x.closest('.monaco-editor'));
diff --git a/spec/frontend_integration/ide/ide_integration_spec.js b/spec/frontend_integration/ide/ide_integration_spec.js
index aad9b9e526c..a002ce91deb 100644
--- a/spec/frontend_integration/ide/ide_integration_spec.js
+++ b/spec/frontend_integration/ide/ide_integration_spec.js
@@ -1,4 +1,5 @@
import { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout';
import waitForPromises from 'helpers/wait_for_promises';
import { waitForText } from 'helpers/wait_for_text';
@@ -17,13 +18,14 @@ describe('WebIDE', () => {
// For some reason these tests were timing out in CI.
// We will investigate in https://gitlab.com/gitlab-org/gitlab/-/issues/298714
setTestTimeout(20000);
- setFixtures('<div class="webide-container"></div>');
+ setHTMLFixture('<div class="webide-container"></div>');
container = document.querySelector('.webide-container');
});
afterEach(() => {
vm.$destroy();
vm = null;
+ resetHTMLFixture();
});
it('user commits changes', async () => {
diff --git a/spec/frontend_integration/ide/user_opens_file_spec.js b/spec/frontend_integration/ide/user_opens_file_spec.js
index 2cb3363ef85..c3131f6ad45 100644
--- a/spec/frontend_integration/ide/user_opens_file_spec.js
+++ b/spec/frontend_integration/ide/user_opens_file_spec.js
@@ -1,4 +1,5 @@
import { screen } from '@testing-library/dom';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useOverclockTimers } from 'test_helpers/utils/overclock_timers';
import * as ideHelper from './helpers/ide_helper';
import startWebIDE from './helpers/start';
@@ -10,7 +11,7 @@ describe('IDE: User opens a file in the Web IDE', () => {
let container;
beforeEach(async () => {
- setFixtures('<div class="webide-container"></div>');
+ setHTMLFixture('<div class="webide-container"></div>');
container = document.querySelector('.webide-container');
vm = startWebIDE(container);
@@ -21,6 +22,7 @@ describe('IDE: User opens a file in the Web IDE', () => {
afterEach(() => {
vm.$destroy();
vm = null;
+ resetHTMLFixture();
});
describe('user opens a directory', () => {
diff --git a/spec/frontend_integration/ide/user_opens_ide_spec.js b/spec/frontend_integration/ide/user_opens_ide_spec.js
index c9d78d1de8f..b2b85452451 100644
--- a/spec/frontend_integration/ide/user_opens_ide_spec.js
+++ b/spec/frontend_integration/ide/user_opens_ide_spec.js
@@ -1,4 +1,5 @@
import { screen } from '@testing-library/dom';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { useOverclockTimers } from 'test_helpers/utils/overclock_timers';
import * as ideHelper from './helpers/ide_helper';
import startWebIDE from './helpers/start';
@@ -10,13 +11,14 @@ describe('IDE: User opens IDE', () => {
let container;
beforeEach(() => {
- setFixtures('<div class="webide-container"></div>');
+ setHTMLFixture('<div class="webide-container"></div>');
container = document.querySelector('.webide-container');
});
afterEach(() => {
vm.$destroy();
vm = null;
+ resetHTMLFixture();
});
it('shows loading indicator while the IDE is loading', async () => {
diff --git a/spec/frontend_integration/ide/user_opens_mr_spec.js b/spec/frontend_integration/ide/user_opens_mr_spec.js
index 3ffc5169351..084aae9f297 100644
--- a/spec/frontend_integration/ide/user_opens_mr_spec.js
+++ b/spec/frontend_integration/ide/user_opens_mr_spec.js
@@ -1,4 +1,5 @@
import { basename } from 'path';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { getMergeRequests, getMergeRequestWithChanges } from 'test_helpers/fixtures';
import { useOverclockTimers } from 'test_helpers/utils/overclock_timers';
import * as ideHelper from './helpers/ide_helper';
@@ -19,7 +20,7 @@ describe('IDE: User opens Merge Request', () => {
changes = getRelevantChanges();
- setFixtures('<div class="webide-container"></div>');
+ setHTMLFixture('<div class="webide-container"></div>');
container = document.querySelector('.webide-container');
vm = startWebIDE(container, { mrId });
@@ -31,6 +32,7 @@ describe('IDE: User opens Merge Request', () => {
afterEach(() => {
vm.$destroy();
vm = null;
+ resetHTMLFixture();
});
const findAllTabs = () => Array.from(document.querySelectorAll('.multi-file-tab'));
diff --git a/spec/frontend_integration/lib/utils/browser_spec.js b/spec/frontend_integration/lib/utils/browser_spec.js
index 6c72e29076d..c9e99af2889 100644
--- a/spec/frontend_integration/lib/utils/browser_spec.js
+++ b/spec/frontend_integration/lib/utils/browser_spec.js
@@ -1,4 +1,5 @@
import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import * as commonUtils from '~/lib/utils/common_utils';
describe('common_utils browser specific specs', () => {
@@ -14,7 +15,7 @@ describe('common_utils browser specific specs', () => {
it('does not add height for fileTitle or compareVersionsHeader if screen is too small', () => {
jest.spyOn(breakpointInstance, 'isDesktop').mockReturnValue(false);
- setFixtures(`
+ setHTMLFixture(`
<div class="diff-file file-title-flex-parent">
blah blah blah
</div>
@@ -24,12 +25,14 @@ describe('common_utils browser specific specs', () => {
`);
expect(commonUtils.contentTop()).toBe(0);
+
+ resetHTMLFixture();
});
it('adds height for fileTitle and compareVersionsHeader screen is large enough', () => {
jest.spyOn(breakpointInstance, 'isDesktop').mockReturnValue(true);
- setFixtures(`
+ setHTMLFixture(`
<div class="diff-file file-title-flex-parent">
blah blah blah
</div>
@@ -41,6 +44,8 @@ describe('common_utils browser specific specs', () => {
mockOffsetHeight(document.querySelector('.diff-file'), 100);
mockOffsetHeight(document.querySelector('.mr-version-controls'), 18);
expect(commonUtils.contentTop()).toBe(18);
+
+ resetHTMLFixture();
});
});
diff --git a/spec/graphql/mutations/base_mutation_spec.rb b/spec/graphql/mutations/base_mutation_spec.rb
index 7939fadb37b..6b366b0c234 100644
--- a/spec/graphql/mutations/base_mutation_spec.rb
+++ b/spec/graphql/mutations/base_mutation_spec.rb
@@ -15,6 +15,7 @@ RSpec.describe ::Mutations::BaseMutation do
context 'when argument is nullable and required' do
let(:mutation_class) do
Class.new(described_class) do
+ graphql_name 'BaseMutation'
argument :foo, GraphQL::Types::String, required: :nullable
end
end
@@ -35,6 +36,7 @@ RSpec.describe ::Mutations::BaseMutation do
context 'when argument is required and NOT nullable' do
let(:mutation_class) do
Class.new(described_class) do
+ graphql_name 'BaseMutation'
argument :foo, GraphQL::Types::String, required: true
end
end
diff --git a/spec/graphql/mutations/boards/update_spec.rb b/spec/graphql/mutations/boards/update_spec.rb
index da3dfeecd4d..4785bc94624 100644
--- a/spec/graphql/mutations/boards/update_spec.rb
+++ b/spec/graphql/mutations/boards/update_spec.rb
@@ -29,14 +29,6 @@ RSpec.describe Mutations::Boards::Update do
end
end
- context 'with invalid params' do
- it 'raises an error' do
- mutation_params[:id] = project.to_global_id
-
- expect { subject }.to raise_error(::GraphQL::CoercionError)
- end
- end
-
context 'when user can update board' do
before do
board.resource_parent.add_reporter(user)
diff --git a/spec/graphql/mutations/ci/runner/delete_spec.rb b/spec/graphql/mutations/ci/runner/delete_spec.rb
index ee640b21918..06d360430f8 100644
--- a/spec/graphql/mutations/ci/runner/delete_spec.rb
+++ b/spec/graphql/mutations/ci/runner/delete_spec.rb
@@ -44,14 +44,6 @@ RSpec.describe Mutations::Ci::Runner::Delete do
end
end
- context 'with invalid params' do
- let(:mutation_params) { { id: "invalid-id" } }
-
- it 'raises an error' do
- expect { subject }.to raise_error(::GraphQL::CoercionError)
- end
- end
-
context 'when required arguments are missing' do
let(:mutation_params) { {} }
diff --git a/spec/graphql/mutations/ci/runner/update_spec.rb b/spec/graphql/mutations/ci/runner/update_spec.rb
index 0b3489d37dc..75e9b57e60a 100644
--- a/spec/graphql/mutations/ci/runner/update_spec.rb
+++ b/spec/graphql/mutations/ci/runner/update_spec.rb
@@ -33,14 +33,6 @@ RSpec.describe Mutations::Ci::Runner::Update do
end
end
- context 'with invalid params' do
- it 'raises an error' do
- mutation_params[:id] = "invalid-id"
-
- expect { subject }.to raise_error(::GraphQL::CoercionError)
- end
- end
-
context 'when required arguments are missing' do
let(:mutation_params) { {} }
diff --git a/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb
index fc025c8e3d3..45d421509d0 100644
--- a/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb
+++ b/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb
@@ -48,14 +48,6 @@ RSpec.describe Mutations::Clusters::AgentTokens::Create do
expect(token.description).to eq(description)
expect(token.name).to eq(name)
end
-
- context 'invalid params' do
- subject { mutation.resolve(cluster_agent_id: cluster_agent.id) }
-
- it 'generates an error message when id invalid', :aggregate_failures do
- expect { subject }.to raise_error(::GraphQL::CoercionError)
- end
- end
end
end
end
diff --git a/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb
deleted file mode 100644
index 5cdbc0f6d72..00000000000
--- a/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Mutations::Clusters::AgentTokens::Delete do
- let(:token) { create(:cluster_agent_token) }
- let(:user) { create(:user) }
-
- let(:mutation) do
- described_class.new(
- object: double,
- context: { current_user: user },
- field: double
- )
- end
-
- it { expect(described_class.graphql_name).to eq('ClusterAgentTokenDelete') }
- it { expect(described_class).to require_graphql_authorizations(:admin_cluster) }
-
- describe '#resolve' do
- let(:global_id) { token.to_global_id }
-
- subject { mutation.resolve(id: global_id) }
-
- context 'without user permissions' do
- it 'fails to delete the cluster agent', :aggregate_failures do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- expect { token.reload }.not_to raise_error
- end
- end
-
- context 'with user permissions' do
- before do
- token.agent.project.add_maintainer(user)
- end
-
- it 'deletes a cluster agent', :aggregate_failures do
- expect { subject }.to change { ::Clusters::AgentToken.count }.by(-1)
- expect { token.reload }.to raise_error(ActiveRecord::RecordNotFound)
- end
- end
-
- context 'with invalid params' do
- let(:global_id) { token.id }
-
- it 'raises an error if the cluster agent id is invalid', :aggregate_failures do
- expect { subject }.to raise_error(::GraphQL::CoercionError)
- expect { token.reload }.not_to raise_error
- end
- end
- end
-end
diff --git a/spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb
index f5f4c0cefad..1dd4eece246 100644
--- a/spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb
+++ b/spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb
@@ -40,16 +40,6 @@ RSpec.describe Mutations::Clusters::AgentTokens::Revoke do
expect(token.reload).to be_revoked
end
-
- context 'supplied ID is invalid' do
- let(:global_id) { token.id }
-
- it 'raises a coercion error' do
- expect { subject }.to raise_error(::GraphQL::CoercionError)
-
- expect(token.reload).not_to be_revoked
- end
- end
end
end
end
diff --git a/spec/graphql/mutations/clusters/agents/delete_spec.rb b/spec/graphql/mutations/clusters/agents/delete_spec.rb
index 0aabf53391a..e0ecff5fe44 100644
--- a/spec/graphql/mutations/clusters/agents/delete_spec.rb
+++ b/spec/graphql/mutations/clusters/agents/delete_spec.rb
@@ -38,14 +38,5 @@ RSpec.describe Mutations::Clusters::Agents::Delete do
expect { cluster_agent.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
-
- context 'with invalid params' do
- subject { mutation.resolve(id: cluster_agent.id) }
-
- it 'raises an error if the cluster agent id is invalid', :aggregate_failures do
- expect { subject }.to raise_error(::GraphQL::CoercionError)
- expect { cluster_agent.reload }.not_to raise_error
- end
- end
end
end
diff --git a/spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb b/spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb
index 37e0fd611e4..451f6d1fe06 100644
--- a/spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb
+++ b/spec/graphql/mutations/concerns/mutations/finds_by_gid_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Mutations::FindsByGid do
end
end
- let(:query) { double('Query', schema: GitlabSchema) }
+ let(:query) { query_double(schema: GitlabSchema) }
let(:context) { GraphQL::Query::Context.new(query: query, object: nil, values: { current_user: user }) }
let(:user) { create(:user) }
let(:gid) { user.to_global_id }
diff --git a/spec/graphql/mutations/container_expiration_policies/update_spec.rb b/spec/graphql/mutations/container_expiration_policies/update_spec.rb
index e22fb951172..e070336ef76 100644
--- a/spec/graphql/mutations/container_expiration_policies/update_spec.rb
+++ b/spec/graphql/mutations/container_expiration_policies/update_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Mutations::ContainerExpirationPolicies::Update do
let(:container_expiration_policy) { project.container_expiration_policy }
let(:params) { { project_path: project.full_path, cadence: '3month', keep_n: 100, older_than: '14d' } }
- specify { expect(described_class).to require_graphql_authorizations(:destroy_container_image) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_container_image) }
describe '#resolve' do
subject { described_class.new(object: project, context: { current_user: user }, field: nil).resolve(**params) }
@@ -76,7 +76,7 @@ RSpec.describe Mutations::ContainerExpirationPolicies::Update do
context 'with existing container expiration policy' do
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the container expiration policy'
- :developer | 'updating the container expiration policy'
+ :developer | 'denying access to container expiration policy'
:reporter | 'denying access to container expiration policy'
:guest | 'denying access to container expiration policy'
:anonymous | 'denying access to container expiration policy'
@@ -96,7 +96,7 @@ RSpec.describe Mutations::ContainerExpirationPolicies::Update do
where(:user_role, :shared_examples_name) do
:maintainer | 'creating the container expiration policy'
- :developer | 'creating the container expiration policy'
+ :developer | 'denying access to container expiration policy'
:reporter | 'denying access to container expiration policy'
:guest | 'denying access to container expiration policy'
:anonymous | 'denying access to container expiration policy'
diff --git a/spec/graphql/mutations/container_repositories/destroy_spec.rb b/spec/graphql/mutations/container_repositories/destroy_spec.rb
index 3903196a511..97da7846339 100644
--- a/spec/graphql/mutations/container_repositories/destroy_spec.rb
+++ b/spec/graphql/mutations/container_repositories/destroy_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Mutations::ContainerRepositories::Destroy do
let_it_be(:user) { create(:user) }
let(:project) { container_repository.project }
- let(:id) { container_repository.to_global_id.to_s }
+ let(:id) { container_repository.to_global_id }
specify { expect(described_class).to require_graphql_authorizations(:destroy_container_image) }
@@ -57,11 +57,5 @@ RSpec.describe Mutations::ContainerRepositories::Destroy do
it_behaves_like params[:shared_examples_name]
end
end
-
- context 'with invalid id' do
- let(:id) { 'gid://gitlab/ContainerRepository/5555' }
-
- it_behaves_like 'denying access to container respository'
- end
end
end
diff --git a/spec/graphql/mutations/container_repositories/destroy_tags_spec.rb b/spec/graphql/mutations/container_repositories/destroy_tags_spec.rb
index f22d9ffe753..3e5f28ee244 100644
--- a/spec/graphql/mutations/container_repositories/destroy_tags_spec.rb
+++ b/spec/graphql/mutations/container_repositories/destroy_tags_spec.rb
@@ -3,10 +3,12 @@
require 'spec_helper'
RSpec.describe Mutations::ContainerRepositories::DestroyTags do
+ include GraphqlHelpers
+
include_context 'container repository delete tags service shared context'
using RSpec::Parameterized::TableSyntax
- let(:id) { repository.to_global_id.to_s }
+ let(:id) { repository.to_global_id }
specify { expect(described_class).to require_graphql_authorizations(:destroy_container_image) }
@@ -67,8 +69,8 @@ RSpec.describe Mutations::ContainerRepositories::DestroyTags do
end
end
- context 'with invalid id' do
- let(:id) { 'gid://gitlab/ContainerRepository/5555' }
+ context 'with non-existing id' do
+ let(:id) { global_id_of(id: non_existing_record_id, model_name: 'ContainerRepository') }
it_behaves_like 'denying access to container respository'
end
diff --git a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb
index d17d11305b1..dafc7b4c367 100644
--- a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb
+++ b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Mutations::CustomerRelations::Contacts::Create do
+ include GraphqlHelpers
+
let_it_be(:user) { create(:user) }
let(:group) { create(:group, :crm_enabled) }
@@ -78,9 +80,9 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do
end
end
- context 'when organization_id is invalid' do
+ context 'when organization does not exist' do
before do
- valid_params[:organization_id] = "gid://gitlab/CustomerRelations::Organization/#{non_existing_record_id}"
+ valid_params[:organization_id] = global_id_of(model_name: 'CustomerRelations::Organization', id: non_existing_record_id)
end
it 'returns the relevant error' do
diff --git a/spec/graphql/mutations/dependency_proxy/group_settings/update_spec.rb b/spec/graphql/mutations/dependency_proxy/group_settings/update_spec.rb
index 35d3224d5ba..ae368e4d37e 100644
--- a/spec/graphql/mutations/dependency_proxy/group_settings/update_spec.rb
+++ b/spec/graphql/mutations/dependency_proxy/group_settings/update_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Mutations::DependencyProxy::GroupSettings::Update do
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the dependency proxy group settings'
- :developer | 'updating the dependency proxy group settings'
+ :developer | 'denying access to dependency proxy group settings'
:reporter | 'denying access to dependency proxy group settings'
:guest | 'denying access to dependency proxy group settings'
:anonymous | 'denying access to dependency proxy group settings'
diff --git a/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb b/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
index 792e87f0d25..1e5059d7ef7 100644
--- a/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
+++ b/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe Mutations::DependencyProxy::ImageTtlGroupPolicy::Update do
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the dependency proxy image ttl policy'
- :developer | 'updating the dependency proxy image ttl policy'
+ :developer | 'denying access to dependency proxy image ttl policy'
:reporter | 'denying access to dependency proxy image ttl policy'
:guest | 'denying access to dependency proxy image ttl policy'
:anonymous | 'denying access to dependency proxy image ttl policy'
@@ -92,7 +92,7 @@ RSpec.describe Mutations::DependencyProxy::ImageTtlGroupPolicy::Update do
where(:user_role, :shared_examples_name) do
:maintainer | 'creating the dependency proxy image ttl policy'
- :developer | 'creating the dependency proxy image ttl policy'
+ :developer | 'denying access to dependency proxy image ttl policy'
:reporter | 'denying access to dependency proxy image ttl policy'
:guest | 'denying access to dependency proxy image ttl policy'
:anonymous | 'denying access to dependency proxy image ttl policy'
diff --git a/spec/graphql/mutations/discussions/toggle_resolve_spec.rb b/spec/graphql/mutations/discussions/toggle_resolve_spec.rb
index 2041b86d6e7..3f7347798e5 100644
--- a/spec/graphql/mutations/discussions/toggle_resolve_spec.rb
+++ b/spec/graphql/mutations/discussions/toggle_resolve_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Mutations::Discussions::ToggleResolve do
+ include GraphqlHelpers
+
subject(:mutation) do
described_class.new(object: nil, context: { current_user: user }, field: nil)
end
@@ -15,7 +17,7 @@ RSpec.describe Mutations::Discussions::ToggleResolve do
mutation.resolve(id: id_arg, resolve: resolve_arg)
end
- let(:id_arg) { discussion.to_global_id.to_s }
+ let(:id_arg) { global_id_of(discussion) }
let(:resolve_arg) { true }
let(:mutated_discussion) { subject[:discussion] }
let(:errors) { subject[:errors] }
@@ -36,7 +38,7 @@ RSpec.describe Mutations::Discussions::ToggleResolve do
let_it_be(:user) { create(:user, developer_projects: [project]) }
context 'when discussion cannot be found' do
- let(:id_arg) { "#{discussion.to_global_id}foo" }
+ let(:id_arg) { global_id_of(id: non_existing_record_id, model_name: discussion.class.name) }
it 'raises an error' do
expect { subject }.to raise_error(
@@ -46,17 +48,6 @@ RSpec.describe Mutations::Discussions::ToggleResolve do
end
end
- context 'when discussion is not a Discussion' do
- let(:discussion) { create(:note, noteable: noteable, project: project) }
-
- it 'raises an error' do
- expect { subject }.to raise_error(
- GraphQL::CoercionError,
- "\"#{discussion.to_global_id}\" does not represent an instance of Discussion"
- )
- end
- end
-
shared_examples 'returns a resolved discussion without errors' do
it 'returns a resolved discussion' do
expect(mutated_discussion).to be_resolved
diff --git a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb
index fdf9cbaf25b..b93fb36a8ff 100644
--- a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb
+++ b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe Mutations::Environments::CanaryIngress::Update do
describe '#resolve' do
subject { mutation.resolve(id: environment_id, weight: weight) }
- let(:environment_id) { environment.to_global_id.to_s }
+ let(:environment_id) { environment.to_global_id }
let(:weight) { 50 }
let(:update_service) { double('update_service') }
@@ -62,14 +62,6 @@ RSpec.describe Mutations::Environments::CanaryIngress::Update do
end
end
- context 'when environment is not found' do
- let(:environment_id) { non_existing_record_id.to_s }
-
- it 'raises an error' do
- expect { subject }.to raise_error(GraphQL::CoercionError)
- end
- end
-
context 'when user is reporter who does not have permission to access the environment' do
let(:user) { reporter }
diff --git a/spec/graphql/mutations/incident_management/timeline_event/create_spec.rb b/spec/graphql/mutations/incident_management/timeline_event/create_spec.rb
new file mode 100644
index 00000000000..63faecad5d5
--- /dev/null
+++ b/spec/graphql/mutations/incident_management/timeline_event/create_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::IncidentManagement::TimelineEvent::Create do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+
+ let(:args) { { note: 'note', occurred_at: Time.current } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_incident_management_timeline_event) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(incident_id: incident.to_global_id, **args) }
+
+ context 'when a user has permissions to create a timeline event' do
+ let(:expected_timeline_event) do
+ instance_double(
+ 'IncidentManagement::TimelineEvent',
+ note: args[:note],
+ occurred_at: args[:occurred_at].to_s,
+ incident: incident,
+ author: current_user,
+ promoted_from_note: nil
+ )
+ end
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ it_behaves_like 'creating an incident timeline event'
+
+ context 'when TimelineEvents::CreateService responds with an error' do
+ let(:args) { {} }
+
+ it_behaves_like 'responding with an incident timeline errors',
+ errors: ["Occurred at can't be blank, Note can't be blank, and Note html can't be blank"]
+ end
+ end
+
+ it_behaves_like 'failing to create an incident timeline event'
+ end
+
+ private
+
+ def mutation_for(project, user)
+ described_class.new(object: project, context: { current_user: user }, field: nil)
+ end
+end
diff --git a/spec/graphql/mutations/incident_management/timeline_event/destroy_spec.rb b/spec/graphql/mutations/incident_management/timeline_event/destroy_spec.rb
new file mode 100644
index 00000000000..4dd7b2ccb14
--- /dev/null
+++ b/spec/graphql/mutations/incident_management/timeline_event/destroy_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::IncidentManagement::TimelineEvent::Destroy do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+
+ let(:timeline_event) { create(:incident_management_timeline_event, incident: incident, project: project) }
+ let(:args) { { id: timeline_event.to_global_id } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_incident_management_timeline_event) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(**args) }
+
+ context 'when a user has permissions to delete timeline event' do
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'when TimelineEvents::DestroyService responds with success' do
+ it 'returns the timeline event with no errors' do
+ expect(resolve).to eq(
+ timeline_event: timeline_event,
+ errors: []
+ )
+ end
+ end
+
+ context 'when TimelineEvents::DestroyService responds with an error' do
+ before do
+ allow_next_instance_of(::IncidentManagement::TimelineEvents::DestroyService) do |service|
+ allow(service)
+ .to receive(:execute)
+ .and_return(ServiceResponse.error(payload: { timeline_event: nil }, message: 'An error has occurred'))
+ end
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ timeline_event: nil,
+ errors: ['An error has occurred']
+ )
+ end
+ end
+ end
+
+ context 'when a user has no permissions to delete timeline event' do
+ before do
+ project.add_guest(current_user)
+ end
+
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+
+ private
+
+ def mutation_for(project, user)
+ described_class.new(object: project, context: { current_user: user }, field: nil)
+ end
+end
diff --git a/spec/graphql/mutations/incident_management/timeline_event/promote_from_note_spec.rb b/spec/graphql/mutations/incident_management/timeline_event/promote_from_note_spec.rb
new file mode 100644
index 00000000000..598ee496cf1
--- /dev/null
+++ b/spec/graphql/mutations/incident_management/timeline_event/promote_from_note_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::IncidentManagement::TimelineEvent::PromoteFromNote do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:comment) { create(:note, project: project, noteable: incident) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:issue_comment) { create(:note, project: project, noteable: issue) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert_comment) { create(:note, project: project, noteable: alert) }
+
+ let(:args) { { note_id: comment.to_global_id.to_s } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_incident_management_timeline_event) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(**args) }
+
+ context 'when a user has permissions to create timeline event' do
+ let(:expected_timeline_event) do
+ instance_double(
+ 'IncidentManagement::TimelineEvent',
+ note: comment.note,
+ occurred_at: comment.created_at.to_s,
+ incident: incident,
+ author: current_user,
+ promoted_from_note: comment
+ )
+ end
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ it_behaves_like 'creating an incident timeline event'
+
+ context 'when TimelineEvents::CreateService responds with an error' do
+ before do
+ allow_next_instance_of(::IncidentManagement::TimelineEvents::CreateService) do |service|
+ allow(service).to receive(:execute).and_return(
+ ServiceResponse.error(payload: { timeline_event: nil }, message: 'Some error')
+ )
+ end
+ end
+
+ it_behaves_like 'responding with an incident timeline errors', errors: ['Some error']
+ end
+ end
+
+ context 'when note does not exist' do
+ let(:args) { { note_id: 'gid://gitlab/Note/0' } }
+
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when note does not belong to an incident' do
+ let(:args) { { note_id: issue_comment.to_global_id.to_s } }
+
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when note belongs to anything else but issuable' do
+ let(:args) { { note_id: alert_comment.to_global_id.to_s } }
+
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ it_behaves_like 'failing to create an incident timeline event'
+ end
+
+ private
+
+ def mutation_for(project, user)
+ described_class.new(object: project, context: { current_user: user }, field: nil)
+ end
+end
diff --git a/spec/graphql/mutations/incident_management/timeline_event/update_spec.rb b/spec/graphql/mutations/incident_management/timeline_event/update_spec.rb
new file mode 100644
index 00000000000..8296e5c6c15
--- /dev/null
+++ b/spec/graphql/mutations/incident_management/timeline_event/update_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::IncidentManagement::TimelineEvent::Update do
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be_with_reload(:timeline_event) do
+ create(:incident_management_timeline_event, project: project, incident: incident)
+ end
+
+ let(:args) do
+ {
+ id: timeline_event_id,
+ note: note,
+ occurred_at: occurred_at
+ }
+ end
+
+ let(:note) { 'Updated Note' }
+ let(:timeline_event_id) { GitlabSchema.id_from_object(timeline_event).to_s }
+ let(:occurred_at) { 1.minute.ago }
+
+ before do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ describe '#resolve' do
+ let(:current_user) { developer }
+
+ subject(:resolve) { mutation_for(current_user).resolve(**args) }
+
+ shared_examples 'failed update with a top-level access error' do |error|
+ specify do
+ expect { resolve }.to raise_error(
+ Gitlab::Graphql::Errors::ResourceNotAvailable,
+ error || Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
+ )
+ end
+ end
+
+ context 'when user has permissions to update the timeline event' do
+ context 'when timeline event exists' do
+ it 'updates the timeline event' do
+ expect { resolve }.to change { timeline_event.reload.note }.to(note)
+ .and change { timeline_event.reload.occurred_at.to_s }.to(occurred_at.to_s)
+ end
+
+ it 'returns updated timeline event' do
+ expect(resolve).to eq(
+ timeline_event: timeline_event.reload,
+ errors: []
+ )
+ end
+
+ context 'when there is a validation error' do
+ let(:occurred_at) { 'invalid date' }
+
+ it 'does not update the timeline event' do
+ expect { resolve }.not_to change { timeline_event.reload.updated_at }
+ end
+
+ it 'responds with error' do
+ expect(resolve).to eq(
+ timeline_event: nil,
+ errors: ["Occurred at can't be blank"]
+ )
+ end
+ end
+ end
+
+ context 'when timeline event cannot be found' do
+ let(:timeline_event_id) do
+ Gitlab::GlobalId.build(
+ nil,
+ model_name: ::IncidentManagement::TimelineEvent.name,
+ id: non_existing_record_id
+ ).to_s
+ end
+
+ it_behaves_like 'failed update with a top-level access error'
+ end
+ end
+
+ context 'when user does not have permissions to update the timeline event' do
+ let(:current_user) { reporter }
+
+ it_behaves_like 'failed update with a top-level access error'
+ end
+ end
+
+ private
+
+ def mutation_for(user)
+ described_class.new(object: nil, context: { current_user: user }, field: nil)
+ end
+end
diff --git a/spec/graphql/mutations/issues/set_due_date_spec.rb b/spec/graphql/mutations/issues/set_due_date_spec.rb
index 263122e5d5f..83edd670695 100644
--- a/spec/graphql/mutations/issues/set_due_date_spec.rb
+++ b/spec/graphql/mutations/issues/set_due_date_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe Mutations::Issues::SetDueDate do
it 'returns the issue with updated due date', :aggregate_failures do
expect(mutated_issue).to eq(issue)
- expect(mutated_issue.due_date).to eq(Date.today + 2.days)
+ expect(mutated_issue.due_date).to eq(due_date.to_date)
expect(subject[:errors]).to be_empty
end
diff --git a/spec/graphql/mutations/merge_requests/accept_spec.rb b/spec/graphql/mutations/merge_requests/accept_spec.rb
index c97c78ec206..c99b1d988c5 100644
--- a/spec/graphql/mutations/merge_requests/accept_spec.rb
+++ b/spec/graphql/mutations/merge_requests/accept_spec.rb
@@ -3,6 +3,7 @@
require 'spec_helper'
RSpec.describe Mutations::MergeRequests::Accept do
+ include GraphqlHelpers
include AfterNextHelpers
subject(:mutation) { described_class.new(context: context, object: nil, field: nil) }
@@ -12,7 +13,7 @@ RSpec.describe Mutations::MergeRequests::Accept do
let(:project) { create(:project, :public, :repository) }
let(:context) do
GraphQL::Query::Context.new(
- query: double('query', schema: GitlabSchema),
+ query: query_double(schema: GitlabSchema),
values: { current_user: user },
object: nil
)
diff --git a/spec/graphql/mutations/merge_requests/create_spec.rb b/spec/graphql/mutations/merge_requests/create_spec.rb
index 83af1e3f1b3..e1edb60e4ff 100644
--- a/spec/graphql/mutations/merge_requests/create_spec.rb
+++ b/spec/graphql/mutations/merge_requests/create_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Mutations::MergeRequests::Create do
+ include GraphqlHelpers
+
subject(:mutation) { described_class.new(object: nil, context: context, field: nil) }
let_it_be(:project) { create(:project, :public, :repository) }
@@ -10,7 +12,7 @@ RSpec.describe Mutations::MergeRequests::Create do
let(:context) do
GraphQL::Query::Context.new(
- query: double('query', schema: nil),
+ query: query_double(schema: nil),
values: { current_user: user },
object: nil
)
diff --git a/spec/graphql/mutations/namespace/package_settings/update_spec.rb b/spec/graphql/mutations/namespace/package_settings/update_spec.rb
index 978c81fadfa..631e02ff3dc 100644
--- a/spec/graphql/mutations/namespace/package_settings/update_spec.rb
+++ b/spec/graphql/mutations/namespace/package_settings/update_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Mutations::Namespace::PackageSettings::Update do
let(:params) { { namespace_path: namespace.full_path } }
- specify { expect(described_class).to require_graphql_authorizations(:create_package_settings) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_package) }
describe '#resolve' do
subject { described_class.new(object: namespace, context: { current_user: user }, field: nil).resolve(**params) }
@@ -68,7 +68,7 @@ RSpec.describe Mutations::Namespace::PackageSettings::Update do
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the namespace package setting'
- :developer | 'updating the namespace package setting'
+ :developer | 'denying access to namespace package setting'
:reporter | 'denying access to namespace package setting'
:guest | 'denying access to namespace package setting'
:anonymous | 'denying access to namespace package setting'
@@ -88,7 +88,7 @@ RSpec.describe Mutations::Namespace::PackageSettings::Update do
where(:user_role, :shared_examples_name) do
:maintainer | 'creating the namespace package setting'
- :developer | 'creating the namespace package setting'
+ :developer | 'denying access to namespace package setting'
:reporter | 'denying access to namespace package setting'
:guest | 'denying access to namespace package setting'
:anonymous | 'denying access to namespace package setting'
diff --git a/spec/graphql/mutations/release_asset_links/delete_spec.rb b/spec/graphql/mutations/release_asset_links/delete_spec.rb
index cda292f2ffa..cca7bd2ba38 100644
--- a/spec/graphql/mutations/release_asset_links/delete_spec.rb
+++ b/spec/graphql/mutations/release_asset_links/delete_spec.rb
@@ -52,18 +52,12 @@ RSpec.describe Mutations::ReleaseAssetLinks::Delete do
end
context "when the link doesn't exist" do
- let(:mutation_arguments) { super().merge(id: "gid://gitlab/Releases::Link/#{non_existing_record_id}") }
-
- it 'raises an error' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ let(:mutation_arguments) do
+ super().merge(id: global_id_of(id: non_existing_record_id, model_name: release_link.class.name))
end
- end
-
- context "when the provided ID is invalid" do
- let(:mutation_arguments) { super().merge(id: 'not-a-valid-gid') }
it 'raises an error' do
- expect { subject }.to raise_error(::GraphQL::CoercionError)
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
diff --git a/spec/graphql/mutations/release_asset_links/update_spec.rb b/spec/graphql/mutations/release_asset_links/update_spec.rb
index 64648687336..e119cf9cc77 100644
--- a/spec/graphql/mutations/release_asset_links/update_spec.rb
+++ b/spec/graphql/mutations/release_asset_links/update_spec.rb
@@ -186,18 +186,12 @@ RSpec.describe Mutations::ReleaseAssetLinks::Update do
end
context "when the link doesn't exist" do
- let(:mutation_arguments) { super().merge(id: "gid://gitlab/Releases::Link/#{non_existing_record_id}") }
-
- it 'raises an error' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ let(:mutation_arguments) do
+ super().merge(id: global_id_of(id: non_existing_record_id, model_name: "Releases::Link"))
end
- end
-
- context "when the provided ID is invalid" do
- let(:mutation_arguments) { super().merge(id: 'not-a-valid-gid') }
it 'raises an error' do
- expect { subject }.to raise_error(::GraphQL::CoercionError)
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
diff --git a/spec/graphql/mutations/timelogs/delete_spec.rb b/spec/graphql/mutations/timelogs/delete_spec.rb
new file mode 100644
index 00000000000..f4a258e0f78
--- /dev/null
+++ b/spec/graphql/mutations/timelogs/delete_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Timelogs::Delete do
+ include GraphqlHelpers
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:administrator) { create(:user, :admin) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be_with_reload(:timelog) { create(:timelog, user: author, issue: issue, time_spent: 1800) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+ let(:timelog_id) { global_id_of(timelog) }
+ let(:mutation_arguments) { { id: timelog_id } }
+
+ describe '#resolve' do
+ subject(:resolve) do
+ mutation.resolve(**mutation_arguments)
+ end
+
+ context 'when the timelog id is not valid' do
+ let(:current_user) { author }
+ let(:timelog_id) { global_id_of(model_name: 'Timelog', id: non_existing_record_id) }
+
+ it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when the current user is not the timelog\'s author, not a maintainer and not an admin' do
+ let(:current_user) { create(:user) }
+
+ it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when the current user is the timelog\'s author' do
+ let(:current_user) { author }
+
+ it 'deletes the timelog' do
+ expect { subject }.to change { Timelog.count }.by(-1)
+ end
+
+ it 'returns the deleted timelog' do
+ expect(subject[:timelog]).to eq(timelog)
+ end
+
+ it 'returns no errors' do
+ expect(subject[:errors]).to be_empty
+ end
+ end
+
+ context 'when the current user is not the timelog\'s author but a maintainer of the project' do
+ let(:current_user) { maintainer }
+
+ before do
+ project.add_maintainer(maintainer)
+ end
+
+ it 'deletes the timelog' do
+ expect { subject }.to change { Timelog.count }.by(-1)
+ end
+
+ it 'returns the deleted timelog' do
+ expect(subject[:timelog]).to eq(timelog)
+ end
+
+ it 'returns no errors' do
+ expect(subject[:errors]).to be_empty
+ end
+ end
+
+ context 'when the current user is not the timelog\'s author, not a maintainer but an admin', :enable_admin_mode do
+ let(:current_user) { administrator }
+
+ it 'deletes the timelog' do
+ expect { subject }.to change { Timelog.count }.by(-1)
+ end
+
+ it 'returns the deleted timelog' do
+ expect(subject[:timelog]).to eq(timelog)
+ end
+
+ it 'returns no errors' do
+ expect(subject[:errors]).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/todos/create_spec.rb b/spec/graphql/mutations/todos/create_spec.rb
index bbb033e2f33..8c6dca98bad 100644
--- a/spec/graphql/mutations/todos/create_spec.rb
+++ b/spec/graphql/mutations/todos/create_spec.rb
@@ -10,12 +10,19 @@ RSpec.describe Mutations::Todos::Create do
context 'when target does not support todos' do
it 'raises error' do
current_user = create(:user)
- mutation = described_class.new(object: nil, context: { current_user: current_user }, field: nil)
-
target = create(:milestone)
- expect { mutation.resolve(target_id: global_id_of(target)) }
- .to raise_error(GraphQL::CoercionError)
+ ctx = { current_user: current_user }
+ input = { target_id: global_id_of(target).to_s }
+ mutation = graphql_mutation(described_class, input)
+
+ response = GitlabSchema.execute(mutation.query, context: ctx, variables: mutation.variables).to_h
+
+ expect(response).to include(
+ 'errors' => contain_exactly(
+ include('message' => /invalid value for targetId/)
+ )
+ )
end
end
diff --git a/spec/graphql/mutations/todos/mark_done_spec.rb b/spec/graphql/mutations/todos/mark_done_spec.rb
index 9723ac8af42..51df2032cf1 100644
--- a/spec/graphql/mutations/todos/mark_done_spec.rb
+++ b/spec/graphql/mutations/todos/mark_done_spec.rb
@@ -56,15 +56,6 @@ RSpec.describe Mutations::Todos::MarkDone do
expect(todo2.reload.state).to eq('done')
expect(other_user_todo.reload.state).to eq('pending')
end
-
- it 'ignores invalid GIDs' do
- expect { mutation.resolve(id: author.to_global_id.to_s) }
- .to raise_error(::GraphQL::CoercionError)
-
- expect(todo1.reload.state).to eq('pending')
- expect(todo2.reload.state).to eq('done')
- expect(other_user_todo.reload.state).to eq('pending')
- end
end
def mark_done_mutation(todo)
diff --git a/spec/graphql/mutations/todos/restore_many_spec.rb b/spec/graphql/mutations/todos/restore_many_spec.rb
index dc10355ef22..d43f1c8a2e9 100644
--- a/spec/graphql/mutations/todos/restore_many_spec.rb
+++ b/spec/graphql/mutations/todos/restore_many_spec.rb
@@ -49,13 +49,6 @@ RSpec.describe Mutations::Todos::RestoreMany do
expect_states_were_not_changed
end
- it 'raises an error with invalid or non-Todo GIDs' do
- expect { mutation.resolve(ids: [author.to_global_id.to_s]) }
- .to raise_error(GraphQL::CoercionError)
-
- expect_states_were_not_changed
- end
-
it 'restores multiple todos' do
todo4 = create(:todo, user: current_user, author: author, state: :done)
diff --git a/spec/graphql/mutations/todos/restore_spec.rb b/spec/graphql/mutations/todos/restore_spec.rb
index 954bb3db668..fad9d6c08a6 100644
--- a/spec/graphql/mutations/todos/restore_spec.rb
+++ b/spec/graphql/mutations/todos/restore_spec.rb
@@ -56,15 +56,6 @@ RSpec.describe Mutations::Todos::Restore do
expect(todo2.reload.state).to eq('pending')
expect(other_user_todo.reload.state).to eq('done')
end
-
- it 'raises error for invalid GID' do
- expect { mutation.resolve(id: author.to_global_id.to_s) }
- .to raise_error(::GraphQL::CoercionError)
-
- expect(todo1.reload.state).to eq('done')
- expect(todo2.reload.state).to eq('pending')
- expect(other_user_todo.reload.state).to eq('done')
- end
end
def restore_mutation(todo)
diff --git a/spec/graphql/resolvers/alert_management/alert_resolver_spec.rb b/spec/graphql/resolvers/alert_management/alert_resolver_spec.rb
index c042f6dac19..14ebe85d80e 100644
--- a/spec/graphql/resolvers/alert_management/alert_resolver_spec.rb
+++ b/spec/graphql/resolvers/alert_management/alert_resolver_spec.rb
@@ -39,8 +39,8 @@ RSpec.describe Resolvers::AlertManagement::AlertResolver do
end
context 'filtering by domain' do
- let_it_be(:alert1) { create(:alert_management_alert, project: project, monitoring_tool: 'Cilium', domain: :threat_monitoring) }
- let_it_be(:alert2) { create(:alert_management_alert, project: project, monitoring_tool: 'Cilium', domain: :threat_monitoring) }
+ let_it_be(:alert1) { create(:alert_management_alert, project: project, monitoring_tool: 'other', domain: :threat_monitoring) }
+ let_it_be(:alert2) { create(:alert_management_alert, project: project, monitoring_tool: 'other', domain: :threat_monitoring) }
let_it_be(:alert3) { create(:alert_management_alert, project: project, monitoring_tool: 'generic') }
let(:args) { { domain: 'operations' } }
diff --git a/spec/graphql/resolvers/ci/config_resolver_spec.rb b/spec/graphql/resolvers/ci/config_resolver_spec.rb
index 3ff6d8f4347..7a6104fc503 100644
--- a/spec/graphql/resolvers/ci/config_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/config_resolver_spec.rb
@@ -37,13 +37,15 @@ RSpec.describe Resolvers::Ci::ConfigResolver do
merged_yaml: content,
jobs: [],
errors: [],
- warnings: []
+ warnings: [],
+ includes: []
)
end
it 'lints the ci config file and returns the merged yaml file' do
expect(response[:status]).to eq(:valid)
expect(response[:merged_yaml]).to eq(content)
+ expect(response[:includes]).to eq([])
expect(response[:errors]).to be_empty
expect(::Gitlab::Ci::Lint).to have_received(:new).with(current_user: user, project: project, sha: sha)
end
@@ -69,7 +71,8 @@ RSpec.describe Resolvers::Ci::ConfigResolver do
jobs: [],
merged_yaml: content,
errors: ['Invalid configuration format'],
- warnings: []
+ warnings: [],
+ includes: []
)
end
diff --git a/spec/graphql/resolvers/concerns/resolves_ids_spec.rb b/spec/graphql/resolvers/concerns/resolves_ids_spec.rb
index 1dd27c0eff0..84741b7a603 100644
--- a/spec/graphql/resolvers/concerns/resolves_ids_spec.rb
+++ b/spec/graphql/resolvers/concerns/resolves_ids_spec.rb
@@ -3,33 +3,32 @@
require 'spec_helper'
RSpec.describe ResolvesIds do
+ include GraphqlHelpers
+
# gid://gitlab/Project/6
# gid://gitlab/Issue/6
# gid://gitlab/Project/6 gid://gitlab/Issue/6
context 'with a single project' do
- let(:ids) { 'gid://gitlab/Project/6' }
- let(:type) { ::Types::GlobalIDType[::Project] }
+ let(:ids) { global_id_of(model_name: 'Project', id: 6) }
it 'returns the correct array' do
- expect(resolve_ids).to match_array(['6'])
+ expect(resolve_ids).to contain_exactly('6')
end
end
context 'with a single issue' do
- let(:ids) { 'gid://gitlab/Issue/9' }
- let(:type) { ::Types::GlobalIDType[::Issue] }
+ let(:ids) { global_id_of(model_name: 'Issue', id: 9) }
it 'returns the correct array' do
- expect(resolve_ids).to match_array(['9'])
+ expect(resolve_ids).to contain_exactly('9')
end
end
context 'with multiple users' do
- let(:ids) { ['gid://gitlab/User/7', 'gid://gitlab/User/13', 'gid://gitlab/User/21'] }
- let(:type) { ::Types::GlobalIDType[::User] }
+ let(:ids) { [7, 13, 21].map { global_id_of(model_name: 'User', id: _1) } }
it 'returns the correct array' do
- expect(resolve_ids).to match_array(%w[7 13 21])
+ expect(resolve_ids).to eq %w[7 13 21]
end
end
@@ -38,6 +37,6 @@ RSpec.describe ResolvesIds do
end
def resolve_ids
- mock_resolver.resolve_ids(ids, type)
+ mock_resolver.resolve_ids(ids)
end
end
diff --git a/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb b/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb
index a16e8821cb5..3fe1ec4b5a4 100644
--- a/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Resolvers::DesignManagement::DesignAtVersionResolver do
let(:current_user) { user }
let(:object) { issue.design_collection }
- let(:global_id) { GitlabSchema.id_from_object(design_at_version).to_s }
+ let(:global_id) { GitlabSchema.id_from_object(design_at_version) }
let(:design_at_version) { ::DesignManagement::DesignAtVersion.new(design: design_a, version: version_a) }
diff --git a/spec/graphql/resolvers/design_management/design_resolver_spec.rb b/spec/graphql/resolvers/design_management/design_resolver_spec.rb
index 4c8b3116875..0915dddf438 100644
--- a/spec/graphql/resolvers/design_management/design_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/design_resolver_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Resolvers::DesignManagement::DesignResolver do
create(:design, issue: create(:issue, project: project), versions: [create(:design_version)])
end
- let(:args) { { id: GitlabSchema.id_from_object(first_design).to_s } }
+ let(:args) { { id: GitlabSchema.id_from_object(first_design) } }
let(:gql_context) { { current_user: current_user } }
before do
@@ -50,7 +50,7 @@ RSpec.describe Resolvers::DesignManagement::DesignResolver do
end
context 'when both arguments have been passed' do
- let(:args) { { filename: first_design.filename, id: GitlabSchema.id_from_object(first_design).to_s } }
+ let(:args) { { filename: first_design.filename, id: GitlabSchema.id_from_object(first_design) } }
it 'generates an error' do
expect_graphql_error_to_be_created(::Gitlab::Graphql::Errors::ArgumentError, /may/) do
@@ -71,15 +71,6 @@ RSpec.describe Resolvers::DesignManagement::DesignResolver do
expect(resolve_design).to be_nil
end
end
-
- context 'the ID does not belong to a design at all' do
- let(:args) { { id: global_id_of(issue) } }
- let(:msg) { /does not represent an instance of DesignManagement::Design/ }
-
- it 'complains meaningfully' do
- expect { resolve_design }.to raise_error(msg)
- end
- end
end
context 'by filename' do
diff --git a/spec/graphql/resolvers/design_management/designs_resolver_spec.rb b/spec/graphql/resolvers/design_management/designs_resolver_spec.rb
index b091e58b06f..64eae14d888 100644
--- a/spec/graphql/resolvers/design_management/designs_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/designs_resolver_spec.rb
@@ -109,6 +109,8 @@ RSpec.describe Resolvers::DesignManagement::DesignsResolver do
end
def resolve_designs
- resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context)
+ Gitlab::Graphql::Lazy.force(
+ resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context)
+ )
end
end
diff --git a/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb
index 8b9874c3580..00f37a8e5f6 100644
--- a/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb
@@ -50,15 +50,6 @@ RSpec.describe Resolvers::DesignManagement::VersionInCollectionResolver do
it { is_expected.to be_nil }
end
-
- context 'we pass the id of something that is not a design_version' do
- let(:params) { { id: global_id_of(project) } }
- let(:appropriate_error) { ::GraphQL::CoercionError }
-
- it 'raises an appropriate error' do
- expect { result }.to raise_error(appropriate_error)
- end
- end
end
def resolve_version(obj, context = { current_user: current_user })
diff --git a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
index d98138f6385..8eab0222cf6 100644
--- a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
@@ -18,8 +18,7 @@ RSpec.describe Resolvers::DesignManagement::VersionsResolver do
let(:project) { issue.project }
let(:params) { {} }
let(:current_user) { authorized_user }
- let(:parent_args) { { irrelevant: 1.2 } }
- let(:parent) { double('Parent', parent: nil, irep_node: double(arguments: parent_args)) }
+ let(:query_context) { { current_user: current_user } }
before do
enable_design_management
@@ -107,7 +106,9 @@ RSpec.describe Resolvers::DesignManagement::VersionsResolver do
end
context 'by at_version in parent' do
- let(:parent_args) { { atVersion: global_id_of(first_version) } }
+ before do
+ query_context[:at_version_argument] = first_version.to_global_id
+ end
it_behaves_like 'a query for all_versions up to the first_version'
end
@@ -126,8 +127,8 @@ RSpec.describe Resolvers::DesignManagement::VersionsResolver do
it_behaves_like 'a source of versions'
end
- def resolve_versions(obj, context = { current_user: current_user })
- eager_resolve(resolver, obj: obj, parent: parent, args: params, ctx: context)
+ def resolve_versions(obj)
+ eager_resolve(resolver, obj: obj, args: params, ctx: query_context)
end
end
end
diff --git a/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
index 2aef483ac95..f80b33e644e 100644
--- a/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
+++ b/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
@@ -8,28 +8,23 @@ RSpec.describe Resolvers::ErrorTracking::SentryDetailedErrorResolver do
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
- let(:issue_details_service) { spy('ErrorTracking::IssueDetailsService') }
+ let(:issue_details_service) { instance_double('ErrorTracking::IssueDetailsService') }
+ let(:service_response) { {} }
- specify do
- expect(described_class).to have_nullable_graphql_type(Types::ErrorTracking::SentryDetailedErrorType)
+ before_all do
+ project.add_developer(current_user)
end
before do
- project.add_developer(current_user)
-
allow(ErrorTracking::IssueDetailsService)
.to receive(:new)
- .and_return issue_details_service
- end
+ .and_return(issue_details_service)
- shared_examples 'it resolves to nil' do
- it 'resolves to nil' do
- allow(issue_details_service).to receive(:execute)
- .and_return(issue: nil)
+ allow(issue_details_service).to receive(:execute).and_return(service_response)
+ end
- result = resolve_error(args)
- expect(result).to be_nil
- end
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::ErrorTracking::SentryDetailedErrorType)
end
describe '#resolve' do
@@ -41,13 +36,9 @@ RSpec.describe Resolvers::ErrorTracking::SentryDetailedErrorResolver do
expect(issue_details_service).to have_received(:execute)
end
- context 'error matched' do
- let(:detailed_error) { build(:error_tracking_sentry_detailed_error) }
-
- before do
- allow(issue_details_service).to receive(:execute)
- .and_return(issue: detailed_error)
- end
+ context 'when error matches' do
+ let(:detailed_error) { build_stubbed(:error_tracking_sentry_detailed_error) }
+ let(:service_response) { { issue: detailed_error } }
it 'resolves to a detailed error' do
expect(resolve_error(args)).to eq detailed_error
@@ -58,24 +49,23 @@ RSpec.describe Resolvers::ErrorTracking::SentryDetailedErrorResolver do
end
end
- context 'if id does not match issue' do
- it_behaves_like 'it resolves to nil'
- end
-
- context 'blank id' do
- let(:args) { { id: '' } }
+ context 'when id does not match issue' do
+ let(:service_response) { { issue: nil } }
- it 'responds with an error' do
- expect { resolve_error(args) }.to raise_error(::GraphQL::CoercionError)
+ it 'resolves to nil' do
+ result = resolve_error(args)
+ expect(result).to be_nil
end
end
end
+ private
+
def resolve_error(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context)
end
def issue_global_id(issue_id)
- Gitlab::ErrorTracking::DetailedError.new(id: issue_id).to_global_id.to_s
+ Gitlab::ErrorTracking::DetailedError.new(id: issue_id).to_global_id
end
end
diff --git a/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb
index 20c2bdcd4e1..5834faea97e 100644
--- a/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb
+++ b/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb
@@ -8,18 +8,22 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorCollectionResolver do
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
- let(:list_issues_service) { spy('ErrorTracking::ListIssuesService') }
+ let(:list_issues_service) { instance_double('ErrorTracking::ListIssuesService') }
- specify do
- expect(described_class).to have_nullable_graphql_type(Types::ErrorTracking::SentryErrorCollectionType)
+ before_all do
+ project.add_developer(current_user)
end
before do
- project.add_developer(current_user)
-
allow(ErrorTracking::ListIssuesService)
.to receive(:new)
.and_return list_issues_service
+
+ allow(list_issues_service).to receive(:external_url)
+ end
+
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::ErrorTracking::SentryErrorCollectionType)
end
describe '#resolve' do
@@ -34,8 +38,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorCollectionResolver do
.to receive(:external_url)
.and_return(fake_url)
- result = resolve_error_collection
- expect(result.external_url).to eq fake_url
+ expect(resolve_error_collection.external_url).to eq fake_url
end
it 'provides the project' do
diff --git a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
index 68badb8e333..65b6c551dde 100644
--- a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
+++ b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
let_it_be(:current_user) { create(:user) }
let_it_be(:error_collection) { Gitlab::ErrorTracking::ErrorCollection.new(project: project) }
- let(:list_issues_service) { spy('ErrorTracking::ListIssuesService') }
+ let(:list_issues_service) { instance_double('ErrorTracking::ListIssuesService') }
let(:issues) { nil }
let(:pagination) { nil }
@@ -19,23 +19,25 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
end
describe '#resolve' do
+ before do
+ allow(ErrorTracking::ListIssuesService)
+ .to receive(:new)
+ .and_return list_issues_service
+
+ allow(list_issues_service).to receive(:execute).and_return({})
+ end
+
context 'with insufficient user permission' do
- let(:user) { create(:user) }
+ let(:current_user) { create(:user) }
it 'returns nil' do
- context = { current_user: user }
-
- expect(resolve_errors({}, context)).to eq nil
+ expect(resolve_errors).to eq nil
end
end
context 'with sufficient permission' do
- before do
+ before_all do
project.add_developer(current_user)
-
- allow(ErrorTracking::ListIssuesService)
- .to receive(:new)
- .and_return list_issues_service
end
context 'when after arg given' do
@@ -52,14 +54,9 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
end
context 'when no issues fetched' do
- before do
- allow(list_issues_service)
- .to receive(:execute)
- .and_return(
- issues: nil
- )
- end
it 'returns nil' do
+ expect(list_issues_service).to receive(:execute).and_return(issues: nil)
+
expect(resolve_errors).to eq nil
end
end
diff --git a/spec/graphql/resolvers/incident_management/timeline_events_resolver_spec.rb b/spec/graphql/resolvers/incident_management/timeline_events_resolver_spec.rb
new file mode 100644
index 00000000000..046cf242d56
--- /dev/null
+++ b/spec/graphql/resolvers/incident_management/timeline_events_resolver_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::IncidentManagement::TimelineEventsResolver do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:first_timeline_event) do
+ create(:incident_management_timeline_event, project: project, incident: incident)
+ end
+
+ let_it_be(:second_timeline_event) do
+ create(:incident_management_timeline_event, project: project, incident: incident)
+ end
+
+ let(:args) { { incident_id: incident.to_global_id } }
+ let(:resolver) { described_class }
+
+ subject(:resolved_timeline_events) { sync(resolve_timeline_events(args, current_user: current_user).to_a) }
+
+ before do
+ project.add_guest(current_user)
+ end
+
+ specify do
+ expect(resolver).to have_nullable_graphql_type(Types::IncidentManagement::TimelineEventType.connection_type)
+ end
+
+ it 'returns timeline events', :aggregate_failures do
+ expect(resolved_timeline_events.length).to eq(2)
+ expect(resolved_timeline_events.first).to be_a(::IncidentManagement::TimelineEvent)
+ end
+
+ context 'when user does not have permissions' do
+ let(:non_member) { create(:user) }
+
+ subject(:resolved_timeline_events) { sync(resolve_timeline_events(args, current_user: non_member).to_a) }
+
+ before do
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
+ end
+
+ it 'returns no timeline events' do
+ expect(resolved_timeline_events.length).to eq(0)
+ end
+ end
+
+ context 'when resolving a single item' do
+ let(:resolver) { described_class.single }
+
+ subject(:resolved_timeline_event) { sync(resolve_timeline_events(args, current_user: current_user)) }
+
+ context 'when id given' do
+ let(:args) { { incident_id: incident.to_global_id, id: first_timeline_event.to_global_id } }
+
+ it 'returns the timeline event' do
+ expect(resolved_timeline_event).to eq(first_timeline_event)
+ end
+ end
+ end
+
+ private
+
+ def resolve_timeline_events(args = {}, context = { current_user: current_user })
+ resolve(resolver, obj: incident, args: args, ctx: context)
+ end
+end
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index 81aeee0a3d2..e6ec9d8c895 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -389,6 +389,34 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
+ describe 'filtering by crm' do
+ let_it_be(:organization) { create(:organization, group: group) }
+ let_it_be(:contact1) { create(:contact, group: group, organization: organization) }
+ let_it_be(:contact2) { create(:contact, group: group, organization: organization) }
+ let_it_be(:contact3) { create(:contact, group: group) }
+ let_it_be(:crm_issue1) { create(:issue, project: project) }
+ let_it_be(:crm_issue2) { create(:issue, project: project) }
+ let_it_be(:crm_issue3) { create(:issue, project: project) }
+
+ before_all do
+ create(:issue_customer_relations_contact, issue: crm_issue1, contact: contact1)
+ create(:issue_customer_relations_contact, issue: crm_issue2, contact: contact2)
+ create(:issue_customer_relations_contact, issue: crm_issue3, contact: contact3)
+ end
+
+ context 'contact' do
+ it 'returns only the issues for the contact' do
+ expect(resolve_issues({ crm_contact_id: contact1.id })).to contain_exactly(crm_issue1)
+ end
+ end
+
+ context 'organization' do
+ it 'returns only the issues for the contact' do
+ expect(resolve_issues({ crm_organization_id: organization.id })).to contain_exactly(crm_issue1, crm_issue2)
+ end
+ end
+ end
+
describe 'sorting' do
context 'when sorting by created' do
it 'sorts issues ascending' do
@@ -603,13 +631,13 @@ RSpec.describe Resolvers::IssuesResolver do
end
it 'finds a specific issue with iid', :request_store do
- result = batch_sync(max_queries: 6) { resolve_issues(iid: issue1.iid).to_a }
+ result = batch_sync(max_queries: 7) { resolve_issues(iid: issue1.iid).to_a }
expect(result).to contain_exactly(issue1)
end
it 'batches queries that only include IIDs', :request_store do
- result = batch_sync(max_queries: 6) do
+ result = batch_sync(max_queries: 7) do
[issue1, issue2]
.map { |issue| resolve_issues(iid: issue.iid.to_s) }
.flat_map(&:to_a)
@@ -619,7 +647,7 @@ RSpec.describe Resolvers::IssuesResolver do
end
it 'finds a specific issue with iids', :request_store do
- result = batch_sync(max_queries: 6) do
+ result = batch_sync(max_queries: 7) do
resolve_issues(iids: [issue1.iid]).to_a
end
diff --git a/spec/graphql/resolvers/package_pipelines_resolver_spec.rb b/spec/graphql/resolvers/package_pipelines_resolver_spec.rb
index c757c876616..a52dee59bc6 100644
--- a/spec/graphql/resolvers/package_pipelines_resolver_spec.rb
+++ b/spec/graphql/resolvers/package_pipelines_resolver_spec.rb
@@ -9,10 +9,23 @@ RSpec.describe Resolvers::PackagePipelinesResolver do
let_it_be(:pipelines) { create_list(:ci_pipeline, 3, project: package.project) }
let(:user) { package.project.first_owner }
- let(:args) { {} }
describe '#resolve' do
- subject { resolve(described_class, obj: package, args: args, ctx: { current_user: user }) }
+ let(:returned_pipelines) { graphql_dig_at(subject, 'data', 'package', 'pipelines', 'nodes') }
+ let(:returned_errors) { graphql_dig_at(subject, 'errors', 'message') }
+ let(:pagination_args) { {} }
+ let(:query) do
+ pipelines_nodes = 'nodes { id }'
+ graphql_query_for(
+ :package,
+ { id: global_id_of(package) },
+ query_graphql_field('pipelines', pagination_args, pipelines_nodes)
+ )
+ end
+
+ subject do
+ GitlabSchema.execute(query, context: { current_user: user })
+ end
before do
pipelines.each do |pipeline|
@@ -20,67 +33,115 @@ RSpec.describe Resolvers::PackagePipelinesResolver do
end
end
- it { is_expected.to contain_exactly(*pipelines) }
+ it 'contains the expected pipelines' do
+ expect_to_contain_exactly(*pipelines)
+ end
+
+ context 'with valid after' do
+ let(:pagination_args) { { first: 1, after: encode_cursor(id: pipelines[1].id) } }
+
+ it 'contains the expected pipelines' do
+ expect_to_contain_exactly(pipelines[0])
+ end
+ end
+
+ context 'with valid before' do
+ let(:pagination_args) { { last: 1, before: encode_cursor(id: pipelines[1].id) } }
+
+ it 'contains the expected pipelines' do
+ expect_to_contain_exactly(pipelines[2])
+ end
+ end
context 'with invalid after' do
- let(:args) { { first: 1, after: 'not_json_string' } }
+ let(:pagination_args) { { first: 1, after: 'not_json_string' } }
it 'generates an argument error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do
- subject
- end
+ expect(returned_errors).to include('Please provide a valid cursor')
end
end
context 'with invalid after key' do
- let(:args) { { first: 1, after: encode_cursor(foo: 3) } }
+ let(:pagination_args) { { first: 1, after: encode_cursor(foo: 3) } }
it 'generates an argument error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do
- subject
- end
+ expect(returned_errors).to include('Please provide a valid cursor')
end
end
context 'with invalid before' do
- let(:args) { { last: 1, before: 'not_json_string' } }
+ let(:pagination_args) { { last: 1, before: 'not_json_string' } }
it 'generates an argument error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do
- subject
- end
+ expect(returned_errors).to include('Please provide a valid cursor')
end
end
context 'with invalid before key' do
- let(:args) { { last: 1, before: encode_cursor(foo: 3) } }
+ let(:pagination_args) { { last: 1, before: encode_cursor(foo: 3) } }
it 'generates an argument error' do
- expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do
- subject
- end
+ expect(returned_errors).to include('Please provide a valid cursor')
end
end
- context 'field options' do
- let(:field) do
- field_options = described_class.field_options.merge(
- owner: resolver_parent,
- name: 'dummy_field'
- )
- ::Types::BaseField.new(**field_options)
- end
+ context 'with unauthorized user' do
+ let_it_be(:user) { create(:user) }
- it 'sets them properly' do
- expect(field).not_to be_connection
- expect(field.extras).to match_array([:lookahead])
+ it 'returns nothing' do
+ expect(returned_pipelines).to be_nil
end
end
- context 'with unauthorized user' do
- let_it_be(:user) { create(:user) }
+ context 'with many packages' do
+ let_it_be_with_reload(:other_package) { create(:package, project: package.project) }
+ let_it_be(:other_pipelines) { create_list(:ci_pipeline, 3, project: package.project) }
+
+ let(:returned_pipelines) do
+ graphql_dig_at(subject, 'data', 'project', 'packages', 'nodes', 'pipelines', 'nodes')
+ end
- it { is_expected.to be_nil }
+ let(:query) do
+ pipelines_query = query_graphql_field('pipelines', pagination_args, 'nodes { id }')
+ <<~QUERY
+ {
+ project(fullPath: "#{package.project.full_path}") {
+ packages {
+ nodes { #{pipelines_query} }
+ }
+ }
+ }
+ QUERY
+ end
+
+ before do
+ other_pipelines.each do |pipeline|
+ create(:package_build_info, package: other_package, pipeline: pipeline)
+ end
+ end
+
+ it 'contains the expected pipelines' do
+ expect_to_contain_exactly(*(pipelines + other_pipelines))
+ end
+
+ it 'handles n+1 situations' do
+ control = ActiveRecord::QueryRecorder.new do
+ GitlabSchema.execute(query, context: { current_user: user })
+ end
+
+ create_package_with_pipelines(package.project)
+
+ expectation = expect { GitlabSchema.execute(query, context: { current_user: user }) }
+
+ expectation.not_to exceed_query_limit(control)
+ end
+
+ def create_package_with_pipelines(project)
+ extra_package = create(:package, project: project)
+ create_list(:ci_pipeline, 3, project: project).each do |pipeline|
+ create(:package_build_info, package: extra_package, pipeline: pipeline)
+ end
+ end
end
def encode_cursor(json)
@@ -89,5 +150,25 @@ RSpec.describe Resolvers::PackagePipelinesResolver do
nonce: true
)
end
+
+ def expect_to_contain_exactly(*pipelines)
+ entities = pipelines.map { |pipeline| a_graphql_entity_for(pipeline) }
+ expect(returned_pipelines).to match_array(entities)
+ end
+ end
+
+ describe '.field options' do
+ let(:field) do
+ field_options = described_class.field_options.merge(
+ owner: resolver_parent,
+ name: 'dummy_field'
+ )
+ ::Types::BaseField.new(**field_options)
+ end
+
+ it 'sets them properly' do
+ expect(field).not_to be_connection
+ expect(field.extras).to match_array([:lookahead])
+ end
end
end
diff --git a/spec/graphql/resolvers/projects/snippets_resolver_spec.rb b/spec/graphql/resolvers/projects/snippets_resolver_spec.rb
index b963f2509db..1d04db3ea6e 100644
--- a/spec/graphql/resolvers/projects/snippets_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects/snippets_resolver_spec.rb
@@ -58,12 +58,6 @@ RSpec.describe Resolvers::Projects::SnippetsResolver do
expect(snippets).to contain_exactly(project_snippet, other_project_snippet)
end
-
- it 'returns an error if the gid is invalid' do
- expect do
- resolve_snippets(args: { ids: ['foo'] })
- end.to raise_error(GraphQL::CoercionError)
- end
end
context 'when no project is provided' do
diff --git a/spec/graphql/resolvers/snippets_resolver_spec.rb b/spec/graphql/resolvers/snippets_resolver_spec.rb
index f9feb8901cd..ee9a6e67243 100644
--- a/spec/graphql/resolvers/snippets_resolver_spec.rb
+++ b/spec/graphql/resolvers/snippets_resolver_spec.rb
@@ -40,12 +40,6 @@ RSpec.describe Resolvers::SnippetsResolver do
expect(snippets).to contain_exactly(personal_snippet, project_snippet)
end
-
- it 'returns an error if the param id is invalid' do
- expect do
- resolve_snippets(args: { author_id: 'foo' })
- end.to raise_error(GraphQL::CoercionError)
- end
end
it 'returns the snippets by type' do
@@ -61,12 +55,6 @@ RSpec.describe Resolvers::SnippetsResolver do
expect(snippets).to contain_exactly(project_snippet, other_project_snippet)
end
-
- it 'returns an error if the param id is invalid' do
- expect do
- resolve_snippets(args: { project_id: 'foo' })
- end.to raise_error(GraphQL::CoercionError)
- end
end
it 'returns the snippets by visibility' do
@@ -98,16 +86,6 @@ RSpec.describe Resolvers::SnippetsResolver do
expect(found).to match_array(snippets)
end
- it 'returns an error if the id cannot be coerced' do
- args = {
- ids: [personal_snippet.to_global_id, 'foo']
- }
-
- expect do
- resolve_snippets(args: args)
- end.to raise_error(GraphQL::CoercionError, '"foo" is not a valid Global ID')
- end
-
it 'generates an error if both project and author are provided' do
expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do
args = {
diff --git a/spec/graphql/resolvers/timelog_resolver_spec.rb b/spec/graphql/resolvers/timelog_resolver_spec.rb
index 84fa2932829..da2747fdf72 100644
--- a/spec/graphql/resolvers/timelog_resolver_spec.rb
+++ b/spec/graphql/resolvers/timelog_resolver_spec.rb
@@ -265,7 +265,7 @@ RSpec.describe Resolvers::TimelogResolver do
context 'when > `default_max_page_size` records' do
let(:object) { nil }
let!(:timelog_list) { create_list(:timelog, 101, issue: issue) }
- let(:args) { { project_id: "gid://gitlab/Project/#{project.id}" } }
+ let(:args) { { project_id: global_id_of(project) } }
let(:extra_args) { {} }
it 'pagination returns `default_max_page_size` and sets `has_next_page` true' do
diff --git a/spec/graphql/resolvers/users/snippets_resolver_spec.rb b/spec/graphql/resolvers/users/snippets_resolver_spec.rb
index 04fe3213a99..12baed2560e 100644
--- a/spec/graphql/resolvers/users/snippets_resolver_spec.rb
+++ b/spec/graphql/resolvers/users/snippets_resolver_spec.rb
@@ -64,16 +64,6 @@ RSpec.describe Resolvers::Users::SnippetsResolver do
expect(found).to match_array(snippets)
end
-
- it 'returns an error if the gid is invalid' do
- args = {
- ids: [global_id_of(private_personal_snippet), 'foo']
- }
-
- expect do
- resolve_snippets(args: args)
- end.to raise_error(GraphQL::CoercionError)
- end
end
context 'when user profile is private' do
diff --git a/spec/graphql/resolvers/work_item_resolver_spec.rb b/spec/graphql/resolvers/work_item_resolver_spec.rb
index bfa0cf1d8a2..c44ed395102 100644
--- a/spec/graphql/resolvers/work_item_resolver_spec.rb
+++ b/spec/graphql/resolvers/work_item_resolver_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Resolvers::WorkItemResolver do
let(:current_user) { developer }
- subject(:resolved_work_item) { resolve_work_item('id' => work_item.to_gid.to_s) }
+ subject(:resolved_work_item) { resolve_work_item('id' => work_item.to_gid) }
context 'when the user can read the work item' do
it { is_expected.to eq(work_item) }
diff --git a/spec/graphql/subscriptions/issuable_updated_spec.rb b/spec/graphql/subscriptions/issuable_updated_spec.rb
index c15b4f532ef..0b8fcf67513 100644
--- a/spec/graphql/subscriptions/issuable_updated_spec.rb
+++ b/spec/graphql/subscriptions/issuable_updated_spec.rb
@@ -39,14 +39,6 @@ RSpec.describe Subscriptions::IssuableUpdated do
expect { subject }.to raise_error(GraphQL::ExecutionError)
end
end
-
- context 'when a GraphQL::Types::ID is provided' do
- let(:issuable_id) { issue.to_gid.to_s }
-
- it 'raises an exception' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
- end
- end
end
context 'subscription updates' do
diff --git a/spec/graphql/types/alert_management/domain_filter_enum_spec.rb b/spec/graphql/types/alert_management/domain_filter_enum_spec.rb
index 2111a33b8b4..9e8d7589352 100644
--- a/spec/graphql/types/alert_management/domain_filter_enum_spec.rb
+++ b/spec/graphql/types/alert_management/domain_filter_enum_spec.rb
@@ -6,6 +6,6 @@ RSpec.describe GitlabSchema.types['AlertManagementDomainFilter'] do
specify { expect(described_class.graphql_name).to eq('AlertManagementDomainFilter') }
it 'exposes all the severity values' do
- expect(described_class.values.keys).to include(*%w[threat_monitoring operations])
+ expect(described_class.values.keys).to include(*%w[operations threat_monitoring])
end
end
diff --git a/spec/graphql/types/ci/config/config_type_spec.rb b/spec/graphql/types/ci/config/config_type_spec.rb
index 0012ae9f51f..78dd5afc1d8 100644
--- a/spec/graphql/types/ci/config/config_type_spec.rb
+++ b/spec/graphql/types/ci/config/config_type_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe Types::Ci::Config::ConfigType do
it 'exposes the expected fields' do
expected_fields = %i[
errors
+ includes
mergedYaml
stages
status
diff --git a/spec/graphql/types/ci/config/include_type_enum_spec.rb b/spec/graphql/types/ci/config/include_type_enum_spec.rb
new file mode 100644
index 00000000000..a88316ae6f2
--- /dev/null
+++ b/spec/graphql/types/ci/config/include_type_enum_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CiConfigIncludeType'] do
+ it { expect(described_class.graphql_name).to eq('CiConfigIncludeType') }
+
+ it 'exposes all the existing include types' do
+ expect(described_class.values.keys).to match_array(%w[remote local file template])
+ end
+end
diff --git a/spec/graphql/types/ci/config/include_type_spec.rb b/spec/graphql/types/ci/config/include_type_spec.rb
new file mode 100644
index 00000000000..971e185ccd9
--- /dev/null
+++ b/spec/graphql/types/ci/config/include_type_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::Ci::Config::IncludeType do
+ specify { expect(described_class.graphql_name).to eq('CiConfigInclude') }
+
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ context_project
+ context_sha
+ extra
+ location
+ blob
+ raw
+ type
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/ci/runner_type_spec.rb b/spec/graphql/types/ci/runner_type_spec.rb
index 7697cd0ef79..26ac7a4da8d 100644
--- a/spec/graphql/types/ci/runner_type_spec.rb
+++ b/spec/graphql/types/ci/runner_type_spec.rb
@@ -11,8 +11,8 @@ RSpec.describe GitlabSchema.types['CiRunner'] do
expected_fields = %w[
id description created_at contacted_at maximum_timeout access_level active paused status
version short_sha revision locked run_untagged ip_address runner_type tag_list
- project_count job_count admin_url edit_admin_url user_permissions executor_name
- groups projects jobs token_expires_at
+ project_count job_count admin_url edit_admin_url user_permissions executor_name architecture_name platform_name
+ maintenance_note groups projects jobs token_expires_at
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/ci/runner_upgrade_status_type_enum_spec.rb b/spec/graphql/types/ci/runner_upgrade_status_type_enum_spec.rb
new file mode 100644
index 00000000000..81a852471b9
--- /dev/null
+++ b/spec/graphql/types/ci/runner_upgrade_status_type_enum_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::Ci::RunnerUpgradeStatusTypeEnum do
+ specify { expect(described_class.graphql_name).to eq('CiRunnerUpgradeStatusType') }
+
+ it 'exposes all upgrade status values' do
+ expect(described_class.values.keys).to eq(
+ ['UNKNOWN'] + ::Gitlab::Ci::RunnerUpgradeCheck::STATUSES.map { |sym, _| sym.to_s.upcase }
+ )
+ end
+end
diff --git a/spec/graphql/types/color_type_spec.rb b/spec/graphql/types/color_type_spec.rb
new file mode 100644
index 00000000000..57c26e12b51
--- /dev/null
+++ b/spec/graphql/types/color_type_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::ColorType do
+ let(:hex) { '#663399' }
+ let(:color_name) { 'rebeccapurple' }
+ let(:color) { ::Gitlab::Color.of(hex) }
+ let(:named_color) { ::Gitlab::Color.of(color_name) }
+
+ specify { expect(described_class.graphql_name).to eq('Color') }
+
+ it 'coerces Color object into hex string' do
+ expect(described_class.coerce_isolated_result(color)).to eq(hex)
+ end
+
+ it 'coerces an hex string into Color object' do
+ expect(described_class.coerce_isolated_input(hex)).to eq(color)
+ end
+
+ it 'coerces an named Color into hex string' do
+ expect(described_class.coerce_isolated_result(named_color)).to eq(hex)
+ end
+
+ it 'coerces an named color into Color object' do
+ expect(described_class.coerce_isolated_input(color_name)).to eq(named_color)
+ end
+
+ it 'rejects invalid input' do
+ expect { described_class.coerce_isolated_input('not valid') }
+ .to raise_error(GraphQL::CoercionError)
+ end
+
+ it 'rejects nil' do
+ expect { described_class.coerce_isolated_input(nil) }
+ .to raise_error(GraphQL::CoercionError)
+ end
+end
diff --git a/spec/graphql/types/container_expiration_policy_type_spec.rb b/spec/graphql/types/container_expiration_policy_type_spec.rb
index 9e9ddaf1cb0..95c2be9dfcc 100644
--- a/spec/graphql/types/container_expiration_policy_type_spec.rb
+++ b/spec/graphql/types/container_expiration_policy_type_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['ContainerExpirationPolicy'] do
specify { expect(described_class.description).to eq('A tag expiration policy designed to keep only the images that matter most') }
- specify { expect(described_class).to require_graphql_authorizations(:destroy_container_image) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_container_image) }
describe 'older_than field' do
subject { described_class.fields['olderThan'] }
diff --git a/spec/graphql/types/container_repository_details_type_spec.rb b/spec/graphql/types/container_repository_details_type_spec.rb
index d94516c6fce..62e72089e09 100644
--- a/spec/graphql/types/container_repository_details_type_spec.rb
+++ b/spec/graphql/types/container_repository_details_type_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepositoryDetails'] do
fields = %i[id name path location created_at updated_at expiration_policy_started_at
status tags_count can_delete expiration_policy_cleanup_status tags size
- project migration_state]
+ project migration_state last_cleanup_deleted_tags_count]
it { expect(described_class.graphql_name).to eq('ContainerRepositoryDetails') }
diff --git a/spec/graphql/types/container_repository_type_spec.rb b/spec/graphql/types/container_repository_type_spec.rb
index 9815449dd68..bc92fa24050 100644
--- a/spec/graphql/types/container_repository_type_spec.rb
+++ b/spec/graphql/types/container_repository_type_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepository'] do
fields = %i[id name path location created_at updated_at expiration_policy_started_at
status tags_count can_delete expiration_policy_cleanup_status project
- migration_state]
+ migration_state last_cleanup_deleted_tags_count]
it { expect(described_class.graphql_name).to eq('ContainerRepository') }
diff --git a/spec/graphql/types/current_user_todos_type_spec.rb b/spec/graphql/types/current_user_todos_type_spec.rb
index a0015e96788..4ce97e1c006 100644
--- a/spec/graphql/types/current_user_todos_type_spec.rb
+++ b/spec/graphql/types/current_user_todos_type_spec.rb
@@ -3,7 +3,214 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['CurrentUserTodos'] do
+ include GraphqlHelpers
+
specify { expect(described_class.graphql_name).to eq('CurrentUserTodos') }
specify { expect(described_class).to have_graphql_fields(:current_user_todos).only }
+
+ # Request store is necessary to prevent duplicate max-member-access lookups
+ describe '.current_user_todos', :request_store, :aggregate_failures do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:issue_a) { create(:issue, project: project) }
+ let_it_be(:issue_b) { create(:issue, project: project) }
+
+ let_it_be(:todo_a) { create(:todo, :pending, user: user, project: project, target: issue_a) }
+ let_it_be(:todo_b) { create(:todo, :done, user: user, project: project, target: issue_a) }
+ let_it_be(:todo_c) { create(:todo, :pending, user: user, project: project, target: issue_b) }
+ let_it_be(:todo_d) { create(:todo, :done, user: user, project: project, target: issue_b) }
+
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+ let_it_be(:todo_e) { create(:todo, :pending, user: user, project: project, target: merge_request) }
+
+ let(:object_type) do
+ fresh_object_type('HasTodos').tap { _1.implements(Types::CurrentUserTodos) }
+ end
+
+ let(:id_enum) do
+ Class.new(Types::BaseEnum) do
+ graphql_name 'AorB'
+
+ value 'A'
+ value 'B'
+ end
+ end
+
+ let(:query_type) do
+ i_a = issue_a
+ i_b = issue_b
+ issue_id = id_enum
+ mr = merge_request
+
+ q = fresh_object_type('Query')
+
+ q.field :issue, null: false, type: object_type do
+ argument :id, type: issue_id, required: true
+ end
+
+ q.field :mr, null: false, type: object_type
+
+ q.define_method(:issue) do |id:|
+ case id
+ when 'A'
+ i_a
+ when 'B'
+ i_b
+ end
+ end
+
+ q.define_method(:mr) { mr }
+
+ q
+ end
+
+ let(:todo_fragment) do
+ <<-GQL
+ fragment todos on HasTodos {
+ todos: currentUserTodos {
+ nodes { id }
+ }
+ }
+ GQL
+ end
+
+ let(:base_query) do
+ <<-GQL
+ query {
+ issue(id: A) { ... todos }
+ }
+ #{todo_fragment}
+ GQL
+ end
+
+ let(:query_without_state_arguments) do
+ <<-GQL
+ query {
+ a: issue(id: A) {
+ ... todos
+ }
+ b: issue(id: B) {
+ ... todos
+ }
+ c: mr {
+ ... todos
+ }
+ d: mr {
+ ... todos
+ }
+ e: issue(id: A) {
+ ... todos
+ }
+ }
+
+ #{todo_fragment}
+ GQL
+ end
+
+ let(:with_state_arguments) do
+ <<-GQL
+ query {
+ a: issue(id: A) {
+ todos: currentUserTodos(state: pending) { nodes { id } }
+ }
+ b: issue(id: B) {
+ todos: currentUserTodos(state: done) { nodes { id } }
+ }
+ c: mr {
+ ... todos
+ }
+ }
+
+ #{todo_fragment}
+ GQL
+ end
+
+ before_all do
+ project.add_developer(user)
+ end
+
+ it 'batches todo lookups, linear in the number of target types/state arguments' do
+ # The baseline is 4 queries:
+ #
+ # When we batch queries, we see the following three groups of queries:
+ # # user authorization
+ # 1. SELECT "users".* FROM "users"
+ # INNER JOIN "project_authorizations"
+ # ON "users"."id" = "project_authorizations"."user_id"
+ # WHERE "project_authorizations"."project_id" = project_id
+ # AND "project_authorizations"."access_level" = 50
+ # 2. SELECT MAX("project_authorizations"."access_level") AS maximum_access_level,
+ # "project_authorizations"."user_id" AS project_authorizations_user_id
+ # FROM "project_authorizations"
+ # WHERE "project_authorizations"."project_id" = project_id
+ # AND "project_authorizations"."user_id" = user_id
+ # GROUP BY "project_authorizations"."user_id"
+ #
+ # # find todos for issues
+ # 1. SELECT "todos".* FROM "todos"
+ # WHERE "todos"."user_id" = user_id
+ # AND ("todos"."state" IN ('done','pending'))
+ # AND "todos"."target_id" IN (issue_a, issue_b)
+ # AND "todos"."target_type" = 'Issue' ORDER BY "todos"."id" DESC
+ #
+ # # find todos for merge_requests
+ # 1. SELECT "todos".* FROM "todos" WHERE "todos"."user_id" = user_id
+ # AND ("todos"."state" IN ('done','pending'))
+ # AND "todos"."target_id" = merge_request
+ # AND "todos"."target_type" = 'MergeRequest' ORDER BY "todos"."id" DESC
+ baseline = ActiveRecord::QueryRecorder.new do
+ execute_query(query_type, graphql: base_query)
+ end
+
+ expect do
+ execute_query(query_type, graphql: query_without_state_arguments)
+ end.not_to exceed_query_limit(baseline) # at present this is 3
+
+ expect do
+ execute_query(query_type, graphql: with_state_arguments)
+ end.not_to exceed_query_limit(baseline.count + 1)
+ end
+
+ it 'returns correct data' do
+ result = execute_query(query_type,
+ graphql: query_without_state_arguments,
+ raise_on_error: true).to_h
+
+ expect(result.dig('data', 'a', 'todos', 'nodes')).to contain_exactly(
+ a_graphql_entity_for(todo_a),
+ a_graphql_entity_for(todo_b)
+ )
+ expect(result.dig('data', 'b', 'todos', 'nodes')).to contain_exactly(
+ a_graphql_entity_for(todo_c),
+ a_graphql_entity_for(todo_d)
+ )
+ expect(result.dig('data', 'c', 'todos', 'nodes')).to contain_exactly(
+ a_graphql_entity_for(todo_e)
+ )
+ expect(result.dig('data', 'd', 'todos', 'nodes')).to contain_exactly(
+ a_graphql_entity_for(todo_e)
+ )
+ expect(result.dig('data', 'e', 'todos', 'nodes')).to contain_exactly(
+ a_graphql_entity_for(todo_a),
+ a_graphql_entity_for(todo_b)
+ )
+ end
+
+ it 'returns correct data, when state arguments are supplied' do
+ result = execute_query(query_type,
+ raise_on_error: true,
+ graphql: with_state_arguments).to_h
+
+ expect(result.dig('data', 'a', 'todos', 'nodes')).to contain_exactly(
+ a_graphql_entity_for(todo_a)
+ )
+ expect(result.dig('data', 'b', 'todos', 'nodes')).to contain_exactly(
+ a_graphql_entity_for(todo_d)
+ )
+ expect(result.dig('data', 'c', 'todos', 'nodes')).to contain_exactly(
+ a_graphql_entity_for(todo_e)
+ )
+ end
+ end
end
diff --git a/spec/graphql/types/customer_relations/contact_type_spec.rb b/spec/graphql/types/customer_relations/contact_type_spec.rb
index bb447f405b6..965cc4b60e6 100644
--- a/spec/graphql/types/customer_relations/contact_type_spec.rb
+++ b/spec/graphql/types/customer_relations/contact_type_spec.rb
@@ -3,7 +3,20 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['CustomerRelationsContact'] do
- let(:fields) { %i[id organization first_name last_name phone email description created_at updated_at] }
+ let(:fields) do
+ %w[
+ id
+ organization
+ first_name
+ last_name
+ phone
+ email
+ description
+ active
+ created_at
+ updated_at
+ ]
+ end
it { expect(described_class.graphql_name).to eq('CustomerRelationsContact') }
it { expect(described_class).to have_graphql_fields(fields) }
diff --git a/spec/graphql/types/customer_relations/organization_type_spec.rb b/spec/graphql/types/customer_relations/organization_type_spec.rb
index 93844df1239..ae1f41ca098 100644
--- a/spec/graphql/types/customer_relations/organization_type_spec.rb
+++ b/spec/graphql/types/customer_relations/organization_type_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['CustomerRelationsOrganization'] do
- let(:fields) { %i[id name default_rate description created_at updated_at] }
+ let(:fields) { %i[id name default_rate description active created_at updated_at] }
it { expect(described_class.graphql_name).to eq('CustomerRelationsOrganization') }
it { expect(described_class).to have_graphql_fields(fields) }
diff --git a/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb b/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb
index 7c6d7b8aece..cd648cf4b4d 100644
--- a/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb
+++ b/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb
@@ -10,4 +10,10 @@ RSpec.describe GitlabSchema.types['DependencyProxySetting'] do
expect(described_class).to include_graphql_fields(*expected_fields)
end
+
+ it { expect(described_class).to require_graphql_authorizations(:admin_dependency_proxy) }
+
+ it { expect(described_class.graphql_name).to eq('DependencyProxySetting') }
+
+ it { expect(described_class.description).to eq('Group-level Dependency Proxy settings') }
end
diff --git a/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb b/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb
index 46347e0434f..af0f91a844e 100644
--- a/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb
+++ b/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['DependencyProxyImageTtlGroupPolicy'] do
it { expect(described_class.description).to eq('Group-level Dependency Proxy TTL policy settings') }
- it { expect(described_class).to require_graphql_authorizations(:read_dependency_proxy) }
+ it { expect(described_class).to require_graphql_authorizations(:admin_dependency_proxy) }
it 'includes dependency proxy image ttl policy fields' do
expected_fields = %w[enabled ttl created_at updated_at]
diff --git a/spec/graphql/types/duration_type_spec.rb b/spec/graphql/types/duration_type_spec.rb
index 5b88819f157..4199e6cc41b 100644
--- a/spec/graphql/types/duration_type_spec.rb
+++ b/spec/graphql/types/duration_type_spec.rb
@@ -17,11 +17,6 @@ RSpec.describe GitlabSchema.types['Duration'] do
expect(described_class.coerce_isolated_input(0.5)).to eq(0.5)
end
- it 'rejects invalid input' do
- expect { described_class.coerce_isolated_input('not valid') }
- .to raise_error(GraphQL::CoercionError)
- end
-
it 'rejects nil' do
expect { described_class.coerce_isolated_input(nil) }
.to raise_error(GraphQL::CoercionError)
diff --git a/spec/graphql/types/global_id_type_spec.rb b/spec/graphql/types/global_id_type_spec.rb
index 8df92c818fc..a57db9234f1 100644
--- a/spec/graphql/types/global_id_type_spec.rb
+++ b/spec/graphql/types/global_id_type_spec.rb
@@ -246,131 +246,6 @@ RSpec.describe Types::GlobalIDType do
end
end
- describe 'compatibility' do
- def query(doc, vars)
- GraphQL::Query.new(schema, document: doc, context: {}, variables: vars)
- end
-
- def run_query(gql_query, vars)
- query(GraphQL.parse(gql_query), vars).result
- end
-
- all_types = [::GraphQL::Types::ID, ::Types::GlobalIDType, ::Types::GlobalIDType[::Project]]
-
- shared_examples 'a working query' do
- # Simplified schema to test compatibility
- let!(:schema) do
- # capture values so they can be closed over
- arg_type = argument_type
- res_type = result_type
-
- project = Class.new(GraphQL::Schema::Object) do
- graphql_name 'Project'
- field :name, String, null: false
- field :id, res_type, null: false, resolver_method: :global_id
-
- def global_id
- object.to_global_id
- end
- end
-
- Class.new(GraphQL::Schema) do
- query(Class.new(GraphQL::Schema::Object) do
- graphql_name 'Query'
-
- field :project_by_id, project, null: true do
- argument :id, arg_type, required: true
- end
-
- # This is needed so that all types are always registered as input types
- field :echo, String, null: true do
- argument :id, ::GraphQL::Types::ID, required: false
- argument :gid, ::Types::GlobalIDType, required: false
- argument :pid, ::Types::GlobalIDType[::Project], required: false
- end
-
- def project_by_id(id:)
- gid = ::Types::GlobalIDType[::Project].coerce_isolated_input(id)
- gid.model_class.find(gid.model_id)
- end
-
- def echo(id: nil, gid: nil, pid: nil)
- "id: #{id}, gid: #{gid}, pid: #{pid}"
- end
- end)
- end
- end
-
- it 'works' do
- res = run_query(document, 'projectId' => project.to_global_id.to_s)
-
- expect(res['errors']).to be_blank
- expect(res.dig('data', 'project', 'name')).to eq(project.name)
- expect(res.dig('data', 'project', 'id')).to eq(project.to_global_id.to_s)
- end
- end
-
- context 'when the client declares the argument as ID the actual argument can be any type' do
- let(:document) do
- <<-GRAPHQL
- query($projectId: ID!){
- project: projectById(id: $projectId) {
- name, id
- }
- }
- GRAPHQL
- end
-
- where(:result_type, :argument_type) do
- all_types.flat_map { |arg_type| all_types.zip([arg_type].cycle) }
- end
-
- with_them do
- it_behaves_like 'a working query'
- end
- end
-
- context 'when the client passes the argument as GlobalID' do
- let(:document) do
- <<-GRAPHQL
- query($projectId: GlobalID!) {
- project: projectById(id: $projectId) {
- name, id
- }
- }
- GRAPHQL
- end
-
- let(:argument_type) { ::Types::GlobalIDType }
-
- where(:result_type) { all_types }
-
- with_them do
- it_behaves_like 'a working query'
- end
- end
-
- context 'when the client passes the argument as ProjectID' do
- let(:document) do
- <<-GRAPHQL
- query($projectId: ProjectID!) {
- project: projectById(id: $projectId) {
- name, id
- }
- }
- GRAPHQL
- end
-
- let(:argument_type) { ::Types::GlobalIDType[::Project] }
-
- where(:result_type) { all_types }
-
- with_them do
- it_behaves_like 'a working query'
- end
- end
- end
-
describe '.model_name_to_graphql_name' do
it 'returns a graphql name for the given model name' do
expect(described_class.model_name_to_graphql_name('DesignManagement::Design')).to eq('DesignManagementDesignID')
diff --git a/spec/graphql/types/incident_management/timeline_event_type_spec.rb b/spec/graphql/types/incident_management/timeline_event_type_spec.rb
new file mode 100644
index 00000000000..5a6bc461f20
--- /dev/null
+++ b/spec/graphql/types/incident_management/timeline_event_type_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['TimelineEventType'] do
+ specify { expect(described_class.graphql_name).to eq('TimelineEventType') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_incident_management_timeline_event) }
+
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ id
+ author
+ updated_by_user
+ incident
+ note
+ note_html
+ promoted_from_note
+ editable
+ action
+ occurred_at
+ created_at
+ updated_at
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb
index e9e92bbdc85..7ab254238fb 100644
--- a/spec/graphql/types/merge_request_type_spec.rb
+++ b/spec/graphql/types/merge_request_type_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
milestone assignees reviewers participants subscribed labels discussion_locked time_estimate
total_time_spent human_time_estimate human_total_time_spent reference author merged_at
commit_count current_user_todos conflicts auto_merge_enabled approved_by source_branch_protected
- default_merge_commit_message_with_description squash_on_merge available_auto_merge_strategies
+ squash_on_merge available_auto_merge_strategies
has_ci mergeable commits committers commits_without_merge_commits squash security_auto_fix default_squash_commit_message
auto_merge_strategy merge_user
]
diff --git a/spec/graphql/types/mutation_type_spec.rb b/spec/graphql/types/mutation_type_spec.rb
index 1fc46f2d511..95d835c88cf 100644
--- a/spec/graphql/types/mutation_type_spec.rb
+++ b/spec/graphql/types/mutation_type_spec.rb
@@ -7,14 +7,6 @@ RSpec.describe Types::MutationType do
expect(described_class).to have_graphql_mutation(Mutations::MergeRequests::SetDraft)
end
- describe 'deprecated mutations' do
- describe 'clusterAgentTokenDelete' do
- let(:field) { get_field('clusterAgentTokenDelete') }
-
- it { expect(field.deprecation_reason).to eq('Tokens must be revoked with ClusterAgentTokenRevoke. Deprecated in 14.7.') }
- end
- end
-
def get_field(name)
described_class.fields[GraphqlHelpers.fieldnamerize(name)]
end
diff --git a/spec/graphql/types/namespace/package_settings_type_spec.rb b/spec/graphql/types/namespace/package_settings_type_spec.rb
index b9592d230ca..f63a0a7010f 100644
--- a/spec/graphql/types/namespace/package_settings_type_spec.rb
+++ b/spec/graphql/types/namespace/package_settings_type_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['PackageSettings'] do
specify { expect(described_class.description).to eq('Namespace-level Package Registry settings') }
- specify { expect(described_class).to require_graphql_authorizations(:read_package_settings) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_package) }
describe 'maven_duplicate_exception_regex field' do
subject { described_class.fields['mavenDuplicateExceptionRegex'] }
diff --git a/spec/graphql/types/packages/package_base_type_spec.rb b/spec/graphql/types/packages/package_base_type_spec.rb
new file mode 100644
index 00000000000..7156f22c513
--- /dev/null
+++ b/spec/graphql/types/packages/package_base_type_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['PackageBase'] do
+ specify { expect(described_class.description).to eq('Represents a package in the Package Registry') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_package) }
+
+ it 'includes all expected fields' do
+ expected_fields = %w[
+ id name version package_type
+ created_at updated_at
+ project
+ tags metadata
+ status can_destroy
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/packages/package_details_type_spec.rb b/spec/graphql/types/packages/package_details_type_spec.rb
index ceeb000ff85..d5688fc64c5 100644
--- a/spec/graphql/types/packages/package_details_type_spec.rb
+++ b/spec/graphql/types/packages/package_details_type_spec.rb
@@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['PackageDetailsType'] do
+ specify { expect(described_class.description).to eq('Represents a package details in the Package Registry') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_package) }
+
it 'includes all the package fields' do
expected_fields = %w[
id name version created_at updated_at package_type tags project
@@ -13,13 +17,4 @@ RSpec.describe GitlabSchema.types['PackageDetailsType'] do
expect(described_class).to include_graphql_fields(*expected_fields)
end
-
- it 'overrides the pipelines field' do
- field = described_class.fields['pipelines']
-
- expect(field).to have_graphql_type(Types::Ci::PipelineType.connection_type)
- expect(field).to have_graphql_extension(Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension)
- expect(field).to have_graphql_resolver(Resolvers::PackagePipelinesResolver)
- expect(field).not_to be_connection
- end
end
diff --git a/spec/graphql/types/packages/package_type_spec.rb b/spec/graphql/types/packages/package_type_spec.rb
index 3267c765dc7..df8135ed87e 100644
--- a/spec/graphql/types/packages/package_type_spec.rb
+++ b/spec/graphql/types/packages/package_type_spec.rb
@@ -3,12 +3,16 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['Package'] do
- it 'includes all the package fields' do
+ specify { expect(described_class.description).to eq('Represents a package with pipelines in the Package Registry') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_package) }
+
+ it 'includes all the package fields and pipelines' do
expected_fields = %w[
id name version package_type
created_at updated_at
project
- tags pipelines metadata versions
+ tags pipelines metadata
status can_destroy
]
diff --git a/spec/graphql/types/permission_types/work_item_spec.rb b/spec/graphql/types/permission_types/work_item_spec.rb
new file mode 100644
index 00000000000..e604ce5d6e0
--- /dev/null
+++ b/spec/graphql/types/permission_types/work_item_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::PermissionTypes::WorkItem do
+ it do
+ expected_permissions = [
+ :read_work_item, :update_work_item, :delete_work_item
+ ]
+
+ expected_permissions.each do |permission|
+ expect(described_class).to have_graphql_field(permission)
+ end
+ end
+end
diff --git a/spec/graphql/types/project_statistics_type_spec.rb b/spec/graphql/types/project_statistics_type_spec.rb
index f515907b6a8..a958a5150aa 100644
--- a/spec/graphql/types/project_statistics_type_spec.rb
+++ b/spec/graphql/types/project_statistics_type_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe GitlabSchema.types['ProjectStatistics'] do
it 'has all the required fields' do
expect(described_class).to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :commit_count,
- :wiki_size, :snippets_size, :pipeline_artifacts_size, :uploads_size)
+ :wiki_size, :snippets_size, :pipeline_artifacts_size,
+ :uploads_size, :container_registry_size)
end
end
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 7433d465b38..a08bd717c72 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -29,6 +29,7 @@ RSpec.describe GitlabSchema.types['Project'] do
grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments
environment boards jira_import_status jira_imports services releases release
alert_management_alerts alert_management_alert alert_management_alert_status_counts
+ incident_management_timeline_event incident_management_timeline_events
container_expiration_policy service_desk_enabled service_desk_address
issue_status_counts terraform_states alert_management_integrations
container_repositories container_repositories_count
diff --git a/spec/graphql/types/projects/topic_type_spec.rb b/spec/graphql/types/projects/topic_type_spec.rb
index 01c19e111be..318307fa6f4 100644
--- a/spec/graphql/types/projects/topic_type_spec.rb
+++ b/spec/graphql/types/projects/topic_type_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe Types::Projects::TopicType do
expect(described_class).to have_graphql_fields(
:id,
:name,
+ :title,
:description,
:description_html,
:avatar_url
diff --git a/spec/graphql/types/range_input_type_spec.rb b/spec/graphql/types/range_input_type_spec.rb
index dbfcf4a41c7..239f4e4ba15 100644
--- a/spec/graphql/types/range_input_type_spec.rb
+++ b/spec/graphql/types/range_input_type_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe ::Types::RangeInputType do
input = { start: 1, end: 10 }
output = { start: 1, end: 10 }
- expect(type.coerce_isolated_input(input)).to eq(output)
+ expect(type.coerce_isolated_input(input).to_h).to eq(output)
end
it 'rejects inverted ranges' do
@@ -28,7 +28,7 @@ RSpec.describe ::Types::RangeInputType do
values: {},
object: nil
)
- instance = described_class[of_integer].new(context: context, defaults_used: [], ruby_kwargs: {})
+ instance = described_class[of_integer].new({}, context: context, defaults_used: [], ruby_kwargs: {})
expect(instance).to be_a_kind_of(described_class)
expect(instance).to be_a_kind_of(described_class[of_integer])
diff --git a/spec/graphql/types/root_storage_statistics_type_spec.rb b/spec/graphql/types/root_storage_statistics_type_spec.rb
index 7818be6ee02..07c8378e7a6 100644
--- a/spec/graphql/types/root_storage_statistics_type_spec.rb
+++ b/spec/graphql/types/root_storage_statistics_type_spec.rb
@@ -8,7 +8,8 @@ RSpec.describe GitlabSchema.types['RootStorageStatistics'] do
it 'has all the required fields' do
expect(described_class).to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :wiki_size, :snippets_size,
- :pipeline_artifacts_size, :uploads_size, :dependency_proxy_size)
+ :pipeline_artifacts_size, :uploads_size, :dependency_proxy_size,
+ :container_registry_size)
end
specify { expect(described_class).to require_graphql_authorizations(:read_statistics) }
diff --git a/spec/graphql/types/terraform/state_version_type_spec.rb b/spec/graphql/types/terraform/state_version_type_spec.rb
index b015a2045da..6a17d932d03 100644
--- a/spec/graphql/types/terraform/state_version_type_spec.rb
+++ b/spec/graphql/types/terraform/state_version_type_spec.rb
@@ -52,8 +52,8 @@ RSpec.describe GitlabSchema.types['TerraformStateVersion'] do
shared_examples 'returning latest version' do
it 'returns latest version of terraform state' do
- expect(execute.dig('data', 'project', 'terraformState', 'latestVersion', 'id')).to eq(
- global_id_of(terraform_state.latest_version)
+ expect(execute.dig('data', 'project', 'terraformState', 'latestVersion')).to match a_graphql_entity_for(
+ terraform_state.latest_version
)
end
end
diff --git a/spec/graphql/types/timeframe_type_spec.rb b/spec/graphql/types/timeframe_type_spec.rb
index dfde3242897..288124ad24b 100644
--- a/spec/graphql/types/timeframe_type_spec.rb
+++ b/spec/graphql/types/timeframe_type_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['Timeframe'] do
let(:output) { { start: Date.parse(input[:start]), end: Date.parse(input[:end]) } }
it 'coerces ISO-dates into Time objects' do
- expect(described_class.coerce_isolated_input(input)).to eq(output)
+ expect(described_class.coerce_isolated_input(input).to_h).to eq(output)
end
it 'rejects invalid input' do
@@ -20,7 +20,7 @@ RSpec.describe GitlabSchema.types['Timeframe'] do
it 'accepts times as input' do
with_time = input.merge(start: '2018-06-04T13:48:14Z')
- expect(described_class.coerce_isolated_input(with_time)).to eq(output)
+ expect(described_class.coerce_isolated_input(with_time).to_h).to eq(output)
end
it 'requires both ends of the range' do
diff --git a/spec/graphql/types/timelog_type_spec.rb b/spec/graphql/types/timelog_type_spec.rb
index dc1b1e2253e..c897a25d10d 100644
--- a/spec/graphql/types/timelog_type_spec.rb
+++ b/spec/graphql/types/timelog_type_spec.rb
@@ -3,11 +3,12 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['Timelog'] do
- let(:fields) { %i[spent_at time_spent user issue merge_request note summary] }
+ let_it_be(:fields) { %i[id spent_at time_spent user issue merge_request note summary userPermissions] }
it { expect(described_class.graphql_name).to eq('Timelog') }
it { expect(described_class).to have_graphql_fields(fields) }
it { expect(described_class).to require_graphql_authorizations(:read_issue) }
+ it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Timelog) }
describe 'user field' do
subject { described_class.fields['user'] }
diff --git a/spec/graphql/types/user_merge_request_interaction_type_spec.rb b/spec/graphql/types/user_merge_request_interaction_type_spec.rb
index 1eaaa0c23d0..4782a1faf8d 100644
--- a/spec/graphql/types/user_merge_request_interaction_type_spec.rb
+++ b/spec/graphql/types/user_merge_request_interaction_type_spec.rb
@@ -76,6 +76,7 @@ RSpec.describe GitlabSchema.types['UserMergeRequestInteraction'] do
context 'when the user has been asked to review the MR' do
before do
merge_request.reviewers << user
+ merge_request.find_reviewer(user).update!(state: :attention_requested)
end
it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['ATTENTION_REQUESTED'].value) }
diff --git a/spec/graphql/types/work_item_type_spec.rb b/spec/graphql/types/work_item_type_spec.rb
index 6a5b4a0882e..a0480506156 100644
--- a/spec/graphql/types/work_item_type_spec.rb
+++ b/spec/graphql/types/work_item_type_spec.rb
@@ -7,8 +7,10 @@ RSpec.describe GitlabSchema.types['WorkItem'] do
specify { expect(described_class).to require_graphql_authorizations(:read_work_item) }
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::WorkItem) }
+
it 'has specific fields' do
- fields = %i[description description_html id iid lock_version state title title_html work_item_type]
+ fields = %i[description description_html id iid lock_version state title title_html userPermissions work_item_type]
fields.each do |field_name|
expect(described_class).to have_graphql_fields(*fields)
diff --git a/spec/haml_lint/linter/documentation_links_spec.rb b/spec/haml_lint/linter/documentation_links_spec.rb
index f2aab4304c1..49a720700da 100644
--- a/spec/haml_lint/linter/documentation_links_spec.rb
+++ b/spec/haml_lint/linter/documentation_links_spec.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'fast_spec_helper'
require 'haml_lint'
require 'haml_lint/spec'
-require Rails.root.join('haml_lint/linter/documentation_links')
+
+require_relative '../../../haml_lint/linter/documentation_links'
RSpec.describe HamlLint::Linter::DocumentationLinks do
include_context 'linter'
diff --git a/spec/haml_lint/linter/inline_javascript_spec.rb b/spec/haml_lint/linter/inline_javascript_spec.rb
new file mode 100644
index 00000000000..fb35bb68247
--- /dev/null
+++ b/spec/haml_lint/linter/inline_javascript_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'haml_lint'
+require 'haml_lint/spec'
+require 'rspec-parameterized'
+
+require_relative '../../../haml_lint/linter/inline_javascript'
+
+RSpec.describe HamlLint::Linter::InlineJavaScript do # rubocop:disable RSpec/FilePath
+ using RSpec::Parameterized::TableSyntax
+
+ include_context 'linter'
+
+ let(:message) { described_class::MSG }
+
+ where(:haml, :should_report) do
+ '%script' | true
+ '%javascript' | false
+ ':javascript' | true
+ ':markdown' | false
+ end
+
+ with_them do
+ if params[:should_report]
+ it { is_expected.to report_lint message: message }
+ else
+ it { is_expected.not_to report_lint }
+ end
+ end
+end
diff --git a/spec/haml_lint/linter/no_plain_nodes_spec.rb b/spec/haml_lint/linter/no_plain_nodes_spec.rb
index 08f7e6131cc..eeb0e4ea96f 100644
--- a/spec/haml_lint/linter/no_plain_nodes_spec.rb
+++ b/spec/haml_lint/linter/no_plain_nodes_spec.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'fast_spec_helper'
require 'haml_lint'
require 'haml_lint/spec'
-require Rails.root.join('haml_lint/linter/no_plain_nodes')
+
+require_relative '../../../haml_lint/linter/no_plain_nodes'
RSpec.describe HamlLint::Linter::NoPlainNodes do
include_context 'linter'
diff --git a/spec/helpers/appearances_helper_spec.rb b/spec/helpers/appearances_helper_spec.rb
index d972ac27119..edd704ce739 100644
--- a/spec/helpers/appearances_helper_spec.rb
+++ b/spec/helpers/appearances_helper_spec.rb
@@ -80,6 +80,50 @@ RSpec.describe AppearancesHelper do
end
end
+ describe '#brand_header_logo' do
+ let(:options) { {} }
+
+ subject do
+ helper.brand_header_logo(options)
+ end
+
+ context 'with header logo' do
+ let!(:appearance) { create(:appearance, :with_header_logo) }
+
+ it 'renders image tag' do
+ expect(helper).to receive(:image_tag).with(appearance.header_logo_path, class: 'brand-header-logo')
+
+ subject
+ end
+ end
+
+ context 'with add_gitlab_white_text option' do
+ let(:options) { { add_gitlab_white_text: true } }
+
+ it 'renders shared/logo_with_white_text partial' do
+ expect(helper).to receive(:render).with(partial: 'shared/logo_with_white_text', formats: :svg)
+
+ subject
+ end
+ end
+
+ context 'with add_gitlab_black_text option' do
+ let(:options) { { add_gitlab_black_text: true } }
+
+ it 'renders shared/logo_with_black_text partial' do
+ expect(helper).to receive(:render).with(partial: 'shared/logo_with_black_text', formats: :svg)
+
+ subject
+ end
+ end
+
+ it 'renders shared/logo by default' do
+ expect(helper).to receive(:render).with(partial: 'shared/logo', formats: :svg)
+
+ subject
+ end
+ end
+
describe '#brand_title' do
it 'returns the default title when no appearance is present' do
allow(helper).to receive(:current_appearance).and_return(nil)
diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb
index c93762416f5..0304aac18ae 100644
--- a/spec/helpers/application_settings_helper_spec.rb
+++ b/spec/helpers/application_settings_helper_spec.rb
@@ -295,7 +295,7 @@ RSpec.describe ApplicationSettingsHelper do
it { is_expected.to eq([%w(Track track), %w(Compress compress)]) }
end
- describe '#instance_clusters_enabled?' do
+ describe '#instance_clusters_enabled?', :request_store do
let_it_be(:user) { create(:user) }
subject { helper.instance_clusters_enabled? }
diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb
index 4bb09699db4..4b0b44d1325 100644
--- a/spec/helpers/auth_helper_spec.rb
+++ b/spec/helpers/auth_helper_spec.rb
@@ -500,6 +500,16 @@ RSpec.describe AuthHelper do
)
end
+ context 'when SAML is enabled without specifying a strategy class' do
+ before do
+ allow(Gitlab::Auth::OAuth::Provider).to receive(:providers).and_return([:saml])
+ end
+
+ it 'returns the saml provider' do
+ expect(saml_providers).to match_array([:saml])
+ end
+ end
+
context 'when configuration specifies no provider' do
before do
allow(Devise).to receive(:omniauth_providers).and_return([])
diff --git a/spec/helpers/badges_helper_spec.rb b/spec/helpers/badges_helper_spec.rb
index 5be3b4a737b..8e1f92305da 100644
--- a/spec/helpers/badges_helper_spec.rb
+++ b/spec/helpers/badges_helper_spec.rb
@@ -89,16 +89,16 @@ RSpec.describe BadgesHelper do
end
describe 'icons' do
- let(:spacing_class_regex) { %r{<svg .*class=".*gl-mr-2.*".*>.*</svg>} }
+ let(:spacing_class_regex) { %r{<svg .*class=".*my-icon-class gl-mr-2".*>.*</svg>} }
describe 'with text' do
- subject { helper.gl_badge_tag(label, icon: "question-o") }
+ subject { helper.gl_badge_tag(label, icon: "question-o", icon_classes: 'my-icon-class') }
it 'renders an icon' do
expect(subject).to match(%r{<svg .*#question-o".*>.*</svg>})
end
- it 'adds a spacing class to the icon' do
+ it 'adds a spacing class and any custom classes to the icon' do
expect(subject).to match(spacing_class_regex)
end
end
diff --git a/spec/helpers/ci/builds_helper_spec.rb b/spec/helpers/ci/builds_helper_spec.rb
index 143d96cf632..ea3b5aac4ea 100644
--- a/spec/helpers/ci/builds_helper_spec.rb
+++ b/spec/helpers/ci/builds_helper_spec.rb
@@ -97,6 +97,20 @@ RSpec.describe Ci::BuildsHelper do
end
end
+ describe '#prepare_failed_jobs_summary_data' do
+ let(:failed_build) { create(:ci_build, :failed, :trace_live) }
+
+ subject { helper.prepare_failed_jobs_summary_data([failed_build]) }
+
+ it 'returns array of failed jobs with id, failure and failure summary' do
+ expect(subject).to eq([{
+ id: failed_build.id,
+ failure: failed_build.present.callout_failure_message,
+ failure_summary: helper.build_summary(failed_build)
+ }].to_json)
+ end
+ end
+
def assign_project
build(:project).tap do |project|
assign(:project, project)
diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb
index 12456deb538..429d4c7941a 100644
--- a/spec/helpers/ci/pipeline_editor_helper_spec.rb
+++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb
@@ -45,6 +45,7 @@ RSpec.describe Ci::PipelineEditorHelper do
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'foo',
"initial-branch-name" => nil,
+ "includes-help-page-path" => help_page_path('ci/yaml/includes'),
"lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
"lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'),
"needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'),
@@ -72,6 +73,7 @@ RSpec.describe Ci::PipelineEditorHelper do
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'foo',
"initial-branch-name" => nil,
+ "includes-help-page-path" => help_page_path('ci/yaml/includes'),
"lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
"lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'),
"needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'),
diff --git a/spec/helpers/ci/pipelines_helper_spec.rb b/spec/helpers/ci/pipelines_helper_spec.rb
index c473e1e4ab6..19946afb1a4 100644
--- a/spec/helpers/ci/pipelines_helper_spec.rb
+++ b/spec/helpers/ci/pipelines_helper_spec.rb
@@ -152,18 +152,18 @@ RSpec.describe Ci::PipelinesHelper do
end
end
- describe 'the `registration_token` attribute' do
- subject { data[:registration_token] }
+ describe 'when the project is eligible for the `ios_specific_templates` experiment' do
+ let_it_be(:project) { create(:project, :auto_devops_disabled, shared_runners_enabled: false) }
+ let_it_be(:user) { create(:user) }
- describe 'when the project is eligible for the `ios_specific_templates` experiment' do
- let_it_be(:project) { create(:project, :auto_devops_disabled) }
- let_it_be(:user) { create(:user) }
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ project.add_developer(user)
+ create(:project_setting, project: project, target_platforms: %w(ios))
+ end
- before do
- allow(helper).to receive(:current_user).and_return(user)
- project.add_developer(user)
- create(:project_setting, project: project, target_platforms: %w(ios))
- end
+ describe 'the `registration_token` attribute' do
+ subject { data[:registration_token] }
context 'when the `ios_specific_templates` experiment variant is control' do
before do
@@ -191,6 +191,38 @@ RSpec.describe Ci::PipelinesHelper do
end
end
end
+
+ describe 'the `ios_runners_available` attribute' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(true)
+ end
+
+ subject { data[:ios_runners_available] }
+
+ context 'when the `ios_specific_templates` experiment variant is control' do
+ before do
+ stub_experiments(ios_specific_templates: :control)
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when the `ios_specific_templates` experiment variant is candidate' do
+ before do
+ stub_experiments(ios_specific_templates: :candidate)
+ end
+
+ context 'when shared runners are not enabled' do
+ it { is_expected.to eq('false') }
+ end
+
+ context 'when shared runners are enabled' do
+ let_it_be(:project) { create(:project, :auto_devops_disabled, shared_runners_enabled: true) }
+
+ it { is_expected.to eq('true') }
+ end
+ end
+ end
end
end
end
diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb
index 0046d481282..cf62579338f 100644
--- a/spec/helpers/ci/runners_helper_spec.rb
+++ b/spec/helpers/ci/runners_helper_spec.rb
@@ -99,17 +99,17 @@ RSpec.describe Ci::RunnersHelper do
let(:runner_constants) do
{
- runner_enabled: Namespace::SR_ENABLED,
- runner_disabled: Namespace::SR_DISABLED_AND_UNOVERRIDABLE,
- runner_allow_override: Namespace::SR_DISABLED_WITH_OVERRIDE
+ runner_enabled_value: Namespace::SR_ENABLED,
+ runner_disabled_value: Namespace::SR_DISABLED_AND_UNOVERRIDABLE,
+ runner_allow_override_value: Namespace::SR_DISABLED_WITH_OVERRIDE
}
end
it 'returns group data for top level group' do
result = {
update_path: "/api/v4/groups/#{parent.id}",
- shared_runners_availability: Namespace::SR_ENABLED,
- parent_shared_runners_availability: nil
+ shared_runners_setting: Namespace::SR_ENABLED,
+ parent_shared_runners_setting: nil
}.merge(runner_constants)
expect(helper.group_shared_runners_settings_data(parent)).to eq result
@@ -118,8 +118,8 @@ RSpec.describe Ci::RunnersHelper do
it 'returns group data for child group' do
result = {
update_path: "/api/v4/groups/#{group.id}",
- shared_runners_availability: Namespace::SR_DISABLED_AND_UNOVERRIDABLE,
- parent_shared_runners_availability: Namespace::SR_ENABLED
+ shared_runners_setting: Namespace::SR_DISABLED_AND_UNOVERRIDABLE,
+ parent_shared_runners_setting: Namespace::SR_ENABLED
}.merge(runner_constants)
expect(helper.group_shared_runners_settings_data(group)).to eq result
diff --git a/spec/helpers/ci/secure_files_helper_spec.rb b/spec/helpers/ci/secure_files_helper_spec.rb
new file mode 100644
index 00000000000..02da44f56b2
--- /dev/null
+++ b/spec/helpers/ci/secure_files_helper_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::SecureFilesHelper do
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:anonymous) { create(:user) }
+ let_it_be(:unconfirmed) { create(:user, :unconfirmed) }
+ let_it_be(:project) { create(:project, creator_id: maintainer.id) }
+
+ before_all do
+ project.add_maintainer(maintainer)
+ project.add_developer(developer)
+ project.add_guest(guest)
+ end
+
+ subject { helper.show_secure_files_setting(project, user) }
+
+ describe '#show_secure_files_setting' do
+ context 'when the :ci_secure_files feature flag is enabled' do
+ before do
+ stub_feature_flags(ci_secure_files: true)
+ end
+
+ context 'authenticated user with admin permissions' do
+ let(:user) { maintainer }
+
+ it { is_expected.to be true }
+ end
+
+ context 'authenticated user with read permissions' do
+ let(:user) { developer }
+
+ it { is_expected.to be true }
+ end
+
+ context 'authenticated user with guest permissions' do
+ let(:user) { guest }
+
+ it { is_expected.to be false }
+ end
+
+ context 'authenticated user with no permissions' do
+ let(:user) { anonymous }
+
+ it { is_expected.to be false }
+ end
+
+ context 'unconfirmed user' do
+ let(:user) { unconfirmed }
+
+ it { is_expected.to be false }
+ end
+
+ context 'unauthenticated user' do
+ let(:user) { nil }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ context 'when the :ci_secure_files feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_secure_files: false)
+ end
+
+ context 'authenticated user with admin permissions' do
+ let(:user) { maintainer }
+
+ it { is_expected.to be false }
+ end
+ end
+ end
+end
diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb
index 4feb9d1a2cd..9a3cd5fd18d 100644
--- a/spec/helpers/clusters_helper_spec.rb
+++ b/spec/helpers/clusters_helper_spec.rb
@@ -66,10 +66,6 @@ RSpec.describe ClustersHelper do
expect(subject[:empty_state_image]).to match(%r(/illustrations/empty-state/empty-state-agents|svg))
end
- it 'displays create cluster using certificate path' do
- expect(subject[:new_cluster_path]).to eq("#{project_path(project)}/-/clusters/new")
- end
-
it 'displays add cluster using certificate path' do
expect(subject[:add_cluster_path]).to eq("#{project_path(project)}/-/clusters/connect")
end
@@ -94,6 +90,10 @@ RSpec.describe ClustersHelper do
expect(subject[:gitlab_version]).to eq(Gitlab.version_info)
end
+ it 'displays KAS version' do
+ expect(subject[:kas_version]).to eq(Gitlab::Kas.version_info)
+ end
+
context 'user has no permissions to create a cluster' do
it 'displays that user can\'t add cluster' do
expect(subject[:can_add_cluster]).to eq("false")
@@ -166,14 +166,6 @@ RSpec.describe ClustersHelper do
end
end
- describe '#js_cluster_new' do
- subject { helper.js_cluster_new }
-
- it 'displays a cluster_connect_help_path' do
- expect(subject[:cluster_connect_help_path]).to eq(help_page_path('user/project/clusters/add_remove_clusters', anchor: 'add-existing-cluster'))
- end
- end
-
describe '#cluster_type_label' do
subject { helper.cluster_type_label(cluster_type) }
diff --git a/spec/helpers/container_registry_helper_spec.rb b/spec/helpers/container_registry_helper_spec.rb
index 57641d4b5df..250f26172a8 100644
--- a/spec/helpers/container_registry_helper_spec.rb
+++ b/spec/helpers/container_registry_helper_spec.rb
@@ -3,17 +3,9 @@
require 'spec_helper'
RSpec.describe ContainerRegistryHelper do
- describe '#container_registry_expiration_policies_throttling?' do
- subject { helper.container_registry_expiration_policies_throttling? }
+ describe '#container_repository_gid_prefix' do
+ subject { helper.container_repository_gid_prefix }
- it { is_expected.to eq(true) }
-
- context 'with container_registry_expiration_policies_throttling disabled' do
- before do
- stub_feature_flags(container_registry_expiration_policies_throttling: false)
- end
-
- it { is_expected.to eq(false) }
- end
+ it { is_expected.to eq('gid://gitlab/ContainerRepository/') }
end
end
diff --git a/spec/helpers/cookies_helper_spec.rb b/spec/helpers/cookies_helper_spec.rb
index c73e7d64987..95970c24086 100644
--- a/spec/helpers/cookies_helper_spec.rb
+++ b/spec/helpers/cookies_helper_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe CookiesHelper do
value = 'secure value'
expect_next_instance_of(ActionDispatch::Cookies::EncryptedKeyRotatingCookieJar) do |instance|
- expect(instance).to receive(:[]=).with(key, httponly: true, secure: true, expires: expiration, value: value)
+ expect(instance).to receive(:[]=).with(key, { httponly: true, secure: true, expires: expiration, value: value })
end
helper.set_secure_cookie(key, value, httponly: true, expires: expiration, type: CookiesHelper::COOKIE_TYPE_ENCRYPTED)
@@ -22,7 +22,7 @@ RSpec.describe CookiesHelper do
value = 'permanent value'
expect_next_instance_of(ActionDispatch::Cookies::PermanentCookieJar) do |instance|
- expect(instance).to receive(:[]=).with(key, httponly: false, secure: false, expires: nil, value: value)
+ expect(instance).to receive(:[]=).with(key, { httponly: false, secure: false, expires: nil, value: value })
end
helper.set_secure_cookie(key, value, type: CookiesHelper::COOKIE_TYPE_PERMANENT)
@@ -33,7 +33,7 @@ RSpec.describe CookiesHelper do
value = 'regular value'
expect_next_instance_of(ActionDispatch::Cookies::CookieJar) do |instance|
- expect(instance).to receive(:[]=).with(key, httponly: false, secure: false, expires: nil, value: value)
+ expect(instance).to receive(:[]=).with(key, { httponly: false, secure: false, expires: nil, value: value })
end
helper.set_secure_cookie(key, value)
diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb
index 956c19f54d1..39b919fa925 100644
--- a/spec/helpers/emails_helper_spec.rb
+++ b/spec/helpers/emails_helper_spec.rb
@@ -239,7 +239,7 @@ RSpec.describe EmailsHelper do
create :appearance, header_logo: nil
expect(header_logo).to match(
- %r{<img alt="GitLab" src="/images/mailers/gitlab_header_logo\.(?:gif|png)" width="\d+" height="\d+" />}
+ %r{<img alt="GitLab" src="/images/mailers/gitlab_logo\.(?:gif|png)" width="\d+" height="\d+" />}
)
end
end
@@ -247,7 +247,7 @@ RSpec.describe EmailsHelper do
context 'there is no brand item' do
it 'returns the default header logo' do
expect(header_logo).to match(
- %r{<img alt="GitLab" src="/images/mailers/gitlab_header_logo\.(?:gif|png)" width="\d+" height="\d+" />}
+ %r{<img alt="GitLab" src="/images/mailers/gitlab_logo\.(?:gif|png)" width="\d+" height="\d+" />}
)
end
end
diff --git a/spec/helpers/instance_configuration_helper_spec.rb b/spec/helpers/instance_configuration_helper_spec.rb
index 1ba06b97088..921ec7ee588 100644
--- a/spec/helpers/instance_configuration_helper_spec.rb
+++ b/spec/helpers/instance_configuration_helper_spec.rb
@@ -50,4 +50,14 @@ RSpec.describe InstanceConfigurationHelper do
expect(helper.instance_configuration_human_size_cell(1048576)).to eq('1 MB')
end
end
+
+ describe '#instance_configuration_disabled_cell_html' do
+ it 'returns "-" if parameter is 0' do
+ expect(helper.instance_configuration_disabled_cell_html(0)).to eq('-')
+ end
+
+ it 'return parameter if not 0' do
+ expect(helper.instance_configuration_disabled_cell_html(1)).to eq(1)
+ end
+ end
end
diff --git a/spec/helpers/integrations_helper_spec.rb b/spec/helpers/integrations_helper_spec.rb
index 3bedc1d8aec..dccbc110be6 100644
--- a/spec/helpers/integrations_helper_spec.rb
+++ b/spec/helpers/integrations_helper_spec.rb
@@ -62,6 +62,7 @@ RSpec.describe IntegrationsHelper do
:enable_comments,
:comment_detail,
:learn_more_path,
+ :about_pricing_url,
:trigger_events,
:fields,
:inherit_from_id,
diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb
index 859d145eb53..4d47732e008 100644
--- a/spec/helpers/invite_members_helper_spec.rb
+++ b/spec/helpers/invite_members_helper_spec.rb
@@ -30,6 +30,28 @@ RSpec.describe InviteMembersHelper do
expect(helper.common_invite_group_modal_data(project, ProjectMember, 'true')).to include(attributes)
end
+
+ context 'when sharing with groups outside the hierarchy is disabled' do
+ let_it_be(:group) { create(:group) }
+
+ before do
+ group.update!(prevent_sharing_groups_outside_hierarchy: true)
+ end
+
+ it 'provides the correct attributes' do
+ expect(helper.common_invite_group_modal_data(group, GroupMember, 'false')).to include({ groups_filter: 'descendant_groups', parent_id: group.id })
+ end
+ end
+
+ context 'when sharing with groups outside the hierarchy is enabled' do
+ before do
+ group.update!(prevent_sharing_groups_outside_hierarchy: false)
+ end
+
+ it 'does not return filter attributes' do
+ expect(helper.common_invite_group_modal_data(project.group, ProjectMember, 'true').keys).not_to include(:groups_filter, :parent_id)
+ end
+ end
end
describe '#common_invite_modal_dataset' do
@@ -162,28 +184,4 @@ RSpec.describe InviteMembersHelper do
end
end
end
-
- describe '#group_select_data' do
- let_it_be(:group) { create(:group) }
-
- context 'when sharing with groups outside the hierarchy is disabled' do
- before do
- group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true)
- end
-
- it 'provides the correct attributes' do
- expect(helper.group_select_data(group)).to eq({ groups_filter: 'descendant_groups', parent_id: group.id })
- end
- end
-
- context 'when sharing with groups outside the hierarchy is enabled' do
- before do
- group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: false)
- end
-
- it 'returns an empty hash' do
- expect(helper.group_select_data(project.group)).to eq({})
- end
- end
- end
end
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index ee5b0145d13..73527bea14e 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -294,6 +294,7 @@ RSpec.describe IssuablesHelper do
projectPath: @project.path,
projectId: @project.id,
projectNamespace: @project.namespace.path,
+ state: issue.state,
initialTitleHtml: issue.title,
initialTitleText: issue.title,
initialDescriptionHtml: '<p dir="auto">issue text</p>',
@@ -464,6 +465,41 @@ RSpec.describe IssuablesHelper do
end
end
+ describe '#state_name_with_icon' do
+ let_it_be(:project) { create(:project, :repository) }
+
+ context 'for an issue' do
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:issue_closed) { create(:issue, :closed, project: project) }
+
+ it 'returns the correct state name and icon when issue is open' do
+ expect(helper.state_name_with_icon(issue)).to match_array([_('Open'), 'issues'])
+ end
+
+ it 'returns the correct state name and icon when issue is closed' do
+ expect(helper.state_name_with_icon(issue_closed)).to match_array([_('Closed'), 'issue-closed'])
+ end
+ end
+
+ context 'for a merge request' do
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+ let_it_be(:merge_request_merged) { create(:merge_request, :merged, source_project: project) }
+ let_it_be(:merge_request_closed) { create(:merge_request, :closed, source_project: project) }
+
+ it 'returns the correct state name and icon when merge request is open' do
+ expect(helper.state_name_with_icon(merge_request)).to match_array([_('Open'), 'merge-request-open'])
+ end
+
+ it 'returns the correct state name and icon when merge request is merged' do
+ expect(helper.state_name_with_icon(merge_request_merged)).to match_array([_('Merged'), 'merge'])
+ end
+
+ it 'returns the correct state name and icon when merge request is closed' do
+ expect(helper.state_name_with_icon(merge_request_closed)).to match_array([_('Closed'), 'merge-request-close'])
+ end
+ end
+ end
+
describe '#issuable_display_type' do
using RSpec::Parameterized::TableSyntax
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 0f653fdd282..0421c7b7458 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -302,6 +302,7 @@ RSpec.describe IssuesHelper do
is_anonymous_search_disabled: 'true',
is_issue_repositioning_disabled: 'true',
is_project: 'true',
+ is_public_visibility_restricted: 'false',
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'),
diff --git a/spec/helpers/jira_connect_helper_spec.rb b/spec/helpers/jira_connect_helper_spec.rb
index 1c1b2a22b7c..169a5c0076a 100644
--- a/spec/helpers/jira_connect_helper_spec.rb
+++ b/spec/helpers/jira_connect_helper_spec.rb
@@ -23,6 +23,7 @@ RSpec.describe JiraConnectHelper do
it 'includes Jira Connect app attributes' do
is_expected.to include(
:groups_path,
+ :add_subscriptions_path,
:subscriptions_path,
:users_path,
:subscriptions,
diff --git a/spec/helpers/lazy_image_tag_helper_spec.rb b/spec/helpers/lazy_image_tag_helper_spec.rb
new file mode 100644
index 00000000000..2d9445bb6cb
--- /dev/null
+++ b/spec/helpers/lazy_image_tag_helper_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe LazyImageTagHelper do
+ describe '#image_tag' do
+ let(:image_src) { '/path/to/image.jpg' }
+ let(:dark_image_src) { '/path/to/image_dark.jpg' }
+
+ context 'when only source passed' do
+ let(:current_user) { create(:user) }
+ let(:result) { image_tag(image_src) }
+
+ it 'returns a lazy image tag by default' do
+ expect(result).to eq(
+ "<img data-src=\"#{image_src}\" class=\"lazy\" src=\"#{placeholder_image}\" />"
+ )
+ end
+ end
+
+ context 'when lazy mode is disabled' do
+ let(:current_user) { create(:user) }
+ let(:result) { image_tag(image_src, lazy: false) }
+
+ it 'returns a normal image tag' do
+ expect(result).to eq(
+ "<img src=\"#{image_src}\" />"
+ )
+ end
+ end
+
+ context 'when Dark Mode is enabled' do
+ let(:current_user) { create(:user, theme_id: 11) }
+
+ context 'when auto dark enabled' do
+ let(:result) { image_tag(image_src, auto_dark: true) }
+
+ it 'adds an auto dark mode class from gitlab-ui' do
+ expect(result).to eq(
+ "<img class=\"gl-dark-invert-keep-hue lazy\" data-src=\"#{image_src}\" src=\"#{placeholder_image}\" />"
+ )
+ end
+ end
+
+ context 'when auto dark disabled' do
+ let(:result) { image_tag(image_src, auto_dark: false) }
+
+ it 'does nothing' do
+ expect(result).to eq(
+ "<img data-src=\"#{image_src}\" class=\"lazy\" src=\"#{placeholder_image}\" />"
+ )
+ end
+ end
+
+ context 'when dark variant is present' do
+ let(:result) { image_tag(image_src, dark_variant: dark_image_src) }
+
+ it 'uses dark variant as a source' do
+ expect(result).to eq(
+ "<img data-src=\"#{dark_image_src}\" class=\"lazy\" src=\"#{placeholder_image}\" />"
+ )
+ end
+ end
+ end
+
+ context 'when Dark Mode is disabled' do
+ let(:current_user) { create(:user, theme_id: 1) }
+
+ context 'when auto dark enabled' do
+ let(:result) { image_tag(image_src, auto_dark: true) }
+
+ it 'does not add a dark mode class from gitlab-ui' do
+ expect(result).to eq(
+ "<img data-src=\"#{image_src}\" class=\"lazy\" src=\"#{placeholder_image}\" />"
+ )
+ end
+ end
+
+ context 'when auto dark disabled' do
+ let(:result) { image_tag(image_src, auto_dark: true) }
+
+ it 'does nothing' do
+ expect(result).to eq(
+ "<img data-src=\"#{image_src}\" class=\"lazy\" src=\"#{placeholder_image}\" />"
+ )
+ end
+ end
+
+ context 'when dark variant is present' do
+ let(:result) { image_tag(image_src, dark_variant: dark_image_src) }
+
+ it 'uses original image as a source' do
+ expect(result).to eq(
+ "<img data-src=\"#{image_src}\" class=\"lazy\" src=\"#{placeholder_image}\" />"
+ )
+ end
+ end
+ end
+
+ context 'when auto_dark and dark_variant are both passed' do
+ let(:current_user) { create(:user) }
+
+ it 'does not add a dark mode class from gitlab-ui' do
+ expect { image_tag('image.jpg', dark_variant: 'image_dark.jpg', auto_dark: true) }
+ .to raise_error(ArgumentError, 'dark_variant and auto_dark are mutually exclusive')
+ end
+ end
+ end
+end
diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb
index 38f2efd75a8..97ad55d9df9 100644
--- a/spec/helpers/merge_requests_helper_spec.rb
+++ b/spec/helpers/merge_requests_helper_spec.rb
@@ -5,31 +5,6 @@ require 'spec_helper'
RSpec.describe MergeRequestsHelper do
include ProjectForksHelper
- describe '#state_name_with_icon' do
- using RSpec::Parameterized::TableSyntax
-
- let(:merge_request) { MergeRequest.new }
-
- where(:state, :expected_name, :expected_icon) do
- :merged? | 'Merged' | 'git-merge'
- :closed? | 'Closed' | 'close'
- :opened? | 'Open' | 'issue-open-m'
- end
-
- with_them do
- before do
- allow(merge_request).to receive(state).and_return(true)
- end
-
- it 'returns name and icon' do
- name, icon = helper.state_name_with_icon(merge_request)
-
- expect(name).to eq(expected_name)
- expect(icon).to eq(expected_icon)
- end
- end
- end
-
describe '#format_mr_branch_names' do
describe 'within the same project' do
let(:merge_request) { create(:merge_request) }
@@ -84,7 +59,7 @@ RSpec.describe MergeRequestsHelper do
describe 'mr_attention_requests disabled' do
before do
- stub_feature_flags(mr_attention_requests: false)
+ allow(user).to receive(:mr_attention_requests_enabled?).and_return(false)
end
it "returns assigned, review requested and total merge request counts" do
@@ -97,6 +72,10 @@ RSpec.describe MergeRequestsHelper do
end
describe 'mr_attention_requests enabled' do
+ before do
+ allow(user).to receive(:mr_attention_requests_enabled?).and_return(true)
+ end
+
it "returns assigned, review requested, attention requests and total merge request counts" do
expect(subject).to eq(
assigned: user.assigned_open_merge_requests_count,
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index 52c1130e818..39f0e1c15f5 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -269,12 +269,13 @@ RSpec.describe NamespacesHelper do
end
end
- describe '#pipeline_usage_quota_app_data' do
+ describe '#pipeline_usage_app_data' do
it 'returns a hash with necessary data for the frontend' do
- expect(helper.pipeline_usage_quota_app_data(user_group)).to eql({
+ expect(helper.pipeline_usage_app_data(user_group)).to eql({
namespace_actual_plan_name: user_group.actual_plan_name,
namespace_path: user_group.full_path,
namespace_id: user_group.id,
+ user_namespace: user_group.user_namespace?.to_s,
page_size: Kaminari.config.default_per_page
})
end
diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb
index d261fb43bb6..d0d399ad10f 100644
--- a/spec/helpers/page_layout_helper_spec.rb
+++ b/spec/helpers/page_layout_helper_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe PageLayoutHelper do
describe 'page_image' do
it 'defaults to the GitLab logo' do
- expect(helper.page_image).to match_asset_path 'assets/gitlab_logo.png'
+ expect(helper.page_image).to match_asset_path 'assets/twitter_card.jpg'
end
%w(project user group).each do |type|
@@ -72,14 +72,14 @@ RSpec.describe PageLayoutHelper do
let(:trait) { nil }
it 'falls back to the default when avatar_url is nil' do
- expect(helper.page_image).to match_asset_path 'assets/gitlab_logo.png'
+ expect(helper.page_image).to match_asset_path 'assets/twitter_card.jpg'
end
end
end
context "with no assignments" do
it 'falls back to the default' do
- expect(helper.page_image).to match_asset_path 'assets/gitlab_logo.png'
+ expect(helper.page_image).to match_asset_path 'assets/twitter_card.jpg'
end
end
end
diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb
index c3a3c2a0178..399726263db 100644
--- a/spec/helpers/profiles_helper_spec.rb
+++ b/spec/helpers/profiles_helper_spec.rb
@@ -111,7 +111,6 @@ RSpec.describe ProfilesHelper do
where(:error, :expired, :result) do
false | false | nil
true | false | error_message
- false | true | 'Key usable beyond expiration date.'
true | true | error_message
end
@@ -130,13 +129,9 @@ RSpec.describe ProfilesHelper do
end
describe "#ssh_key_expires_field_description" do
- before do
- allow(Key).to receive(:enforce_ssh_key_expiration_feature_available?).and_return(false)
- end
+ subject { helper.ssh_key_expires_field_description }
- it 'returns the description' do
- expect(helper.ssh_key_expires_field_description).to eq('Key can still be used after expiration.')
- end
+ it { is_expected.to eq('Key becomes invalid on this date.') }
end
describe '#middle_dot_divider_classes' do
diff --git a/spec/helpers/projects/pipeline_helper_spec.rb b/spec/helpers/projects/pipeline_helper_spec.rb
index 67405ee3b21..90cf3cb03f8 100644
--- a/spec/helpers/projects/pipeline_helper_spec.rb
+++ b/spec/helpers/projects/pipeline_helper_spec.rb
@@ -16,6 +16,7 @@ RSpec.describe Projects::PipelineHelper do
can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json,
graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json),
+ pipeline_iid: pipeline.iid,
pipeline_project_path: project.full_path
})
end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 1cf36fd69cf..d13c5dfcc9e 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -279,7 +279,7 @@ RSpec.describe ProjectsHelper do
it 'returns message prompting user to set password or set up a PAT' do
stub_application_setting(password_authentication_enabled_for_git?: true)
- expect(helper.no_password_message).to eq('Your account is authenticated with SSO or SAML. To <a href="/help/gitlab-basics/start-using-git#pull-and-push" target="_blank" rel="noopener noreferrer">push and pull</a> over HTTP with Git using this account, you must <a href="/-/profile/password/edit">set a password</a> or <a href="/-/profile/personal_access_tokens">set up a Personal Access Token</a> to use instead of a password. For more information, see <a href="/help/gitlab-basics/start-using-git#clone-with-https" target="_blank" rel="noopener noreferrer">Clone with HTTPS</a>.')
+ expect(helper.no_password_message).to eq('Your account is authenticated with SSO or SAML. To <a href="/help/topics/git/terminology#pull-and-push" target="_blank" rel="noopener noreferrer">push and pull</a> over HTTP with Git using this account, you must <a href="/-/profile/password/edit">set a password</a> or <a href="/-/profile/personal_access_tokens">set up a Personal Access Token</a> to use instead of a password. For more information, see <a href="/help/gitlab-basics/start-using-git#clone-with-https" target="_blank" rel="noopener noreferrer">Clone with HTTPS</a>.')
end
end
@@ -287,7 +287,7 @@ RSpec.describe ProjectsHelper do
it 'returns message prompting user to set up a PAT' do
stub_application_setting(password_authentication_enabled_for_git?: false)
- expect(helper.no_password_message).to eq('Your account is authenticated with SSO or SAML. To <a href="/help/gitlab-basics/start-using-git#pull-and-push" target="_blank" rel="noopener noreferrer">push and pull</a> over HTTP with Git using this account, you must <a href="/-/profile/personal_access_tokens">set up a Personal Access Token</a> to use instead of a password. For more information, see <a href="/help/gitlab-basics/start-using-git#clone-with-https" target="_blank" rel="noopener noreferrer">Clone with HTTPS</a>.')
+ expect(helper.no_password_message).to eq('Your account is authenticated with SSO or SAML. To <a href="/help/topics/git/terminology#pull-and-push" target="_blank" rel="noopener noreferrer">push and pull</a> over HTTP with Git using this account, you must <a href="/-/profile/personal_access_tokens">set up a Personal Access Token</a> to use instead of a password. For more information, see <a href="/help/gitlab-basics/start-using-git#clone-with-https" target="_blank" rel="noopener noreferrer">Clone with HTTPS</a>.')
end
end
end
diff --git a/spec/helpers/releases_helper_spec.rb b/spec/helpers/releases_helper_spec.rb
index 69f66dc6488..b7493e84c6a 100644
--- a/spec/helpers/releases_helper_spec.rb
+++ b/spec/helpers/releases_helper_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe ReleasesHelper do
end
end
- describe '#help_page' do
+ describe '#releases_help_page_path' do
it 'returns the correct link to the help page' do
- expect(helper.help_page).to include('user/project/releases/index')
+ expect(helper.releases_help_page_path).to include('user/project/releases/index')
end
end
@@ -63,7 +63,8 @@ RSpec.describe ReleasesHelper do
releases_page_path
release_assets_docs_path
manage_milestones_path
- new_milestone_path)
+ new_milestone_path
+ edit_release_docs_path)
expect(helper.data_for_edit_release_page.keys).to match_array(keys)
end
@@ -81,7 +82,8 @@ RSpec.describe ReleasesHelper do
release_assets_docs_path
manage_milestones_path
new_milestone_path
- default_branch)
+ default_branch
+ edit_release_docs_path)
expect(helper.data_for_new_release_page.keys).to match_array(keys)
end
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index d1be451a759..8e2ec014383 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -328,7 +328,6 @@ RSpec.describe SearchHelper do
end
it 'includes project endpoints' do
- expect(search_filter_input_options('')[:data]['runner-tags-endpoint']).to eq(tag_list_admin_runners_path)
expect(search_filter_input_options('')[:data]['labels-endpoint']).to eq(project_labels_path(@project))
expect(search_filter_input_options('')[:data]['milestones-endpoint']).to eq(project_milestones_path(@project))
expect(search_filter_input_options('')[:data]['releases-endpoint']).to eq(project_releases_path(@project))
@@ -349,7 +348,6 @@ RSpec.describe SearchHelper do
end
it 'includes group endpoints' do
- expect(search_filter_input_options('')[:data]['runner-tags-endpoint']).to eq(tag_list_admin_runners_path)
expect(search_filter_input_options('')[:data]['labels-endpoint']).to eq(group_labels_path(@group))
expect(search_filter_input_options('')[:data]['milestones-endpoint']).to eq(group_milestones_path(@group))
end
@@ -362,7 +360,6 @@ RSpec.describe SearchHelper do
end
it 'includes dashboard endpoints' do
- expect(search_filter_input_options('')[:data]['runner-tags-endpoint']).to eq(tag_list_admin_runners_path)
expect(search_filter_input_options('')[:data]['labels-endpoint']).to eq(dashboard_labels_path)
expect(search_filter_input_options('')[:data]['milestones-endpoint']).to eq(dashboard_milestones_path)
end
diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb
index e329968e6c0..6db955f3637 100644
--- a/spec/helpers/sidebars_helper_spec.rb
+++ b/spec/helpers/sidebars_helper_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe SidebarsHelper do
subject { helper.sidebar_tracking_attributes_by_object(object) }
before do
- allow(helper).to receive(:tracking_enabled?).and_return(true)
+ stub_application_setting(snowplow_enabled: true)
end
context 'when object is a project' do
diff --git a/spec/helpers/storage_helper_spec.rb b/spec/helpers/storage_helper_spec.rb
index 6b743422b04..5bc4024ae24 100644
--- a/spec/helpers/storage_helper_spec.rb
+++ b/spec/helpers/storage_helper_spec.rb
@@ -88,14 +88,26 @@ RSpec.describe StorageHelper do
expect(helper.storage_enforcement_banner_info(free_group)).to be(nil)
end
- it 'returns a hash when current_user can access usage quotas page' do
- expect(helper.storage_enforcement_banner_info(free_group)).to eql({
- text: "From #{storage_enforcement_date} storage limits will apply to this namespace. View and manage your usage in <strong>Group settings &gt; Usage quotas</strong>.",
- variant: 'warning',
- callouts_feature_name: 'storage_enforcement_banner_second_enforcement_threshold',
- callouts_path: '/-/users/group_callouts',
- learn_more_link: '<a rel="noopener noreferrer" target="_blank" href="/help//">Learn more.</a>'
- })
+ context 'when current_user can access the usage quotas page' do
+ it 'returns a hash' do
+ expect(helper.storage_enforcement_banner_info(free_group)).to eql({
+ text: "From #{storage_enforcement_date} storage limits will apply to this namespace. You are currently using 0 Bytes of namespace storage. View and manage your usage from <strong>Group settings &gt; Usage quotas</strong>.",
+ variant: 'warning',
+ callouts_feature_name: 'storage_enforcement_banner_second_enforcement_threshold',
+ callouts_path: '/-/users/group_callouts',
+ learn_more_link: '<a rel="noopener noreferrer" target="_blank" href="/help//">Learn more.</a>'
+ })
+ end
+
+ context 'when namespace has used storage' do
+ before do
+ create(:namespace_root_storage_statistics, namespace: free_group, storage_size: 102400)
+ end
+
+ it 'returns a hash with the correct storage size text' do
+ expect(helper.storage_enforcement_banner_info(free_group)[:text]).to eql("From #{storage_enforcement_date} storage limits will apply to this namespace. You are currently using 100 KB of namespace storage. View and manage your usage from <strong>Group settings &gt; Usage quotas</strong>.")
+ end
+ end
end
end
diff --git a/spec/helpers/tracking_helper_spec.rb b/spec/helpers/tracking_helper_spec.rb
index cd2f8f9b7d1..81121275c92 100644
--- a/spec/helpers/tracking_helper_spec.rb
+++ b/spec/helpers/tracking_helper_spec.rb
@@ -7,29 +7,24 @@ RSpec.describe TrackingHelper do
using RSpec::Parameterized::TableSyntax
let(:input) { %w(a b c) }
- let(:results) do
- {
- no_data: {},
- with_data: { data: { track_label: 'a', track_action: 'b', track_property: 'c' } }
- }
+ let(:result) { { data: { track_label: 'a', track_action: 'b', track_property: 'c' } } }
+
+ before do
+ stub_application_setting(snowplow_enabled: true)
end
- where(:snowplow_enabled, :environment, :result) do
- true | 'production' | :with_data
- false | 'production' | :no_data
- true | 'development' | :no_data
- false | 'development' | :no_data
- true | 'test' | :no_data
- false | 'test' | :no_data
+ it 'returns no data if snowplow is disabled' do
+ stub_application_setting(snowplow_enabled: false)
+
+ expect(helper.tracking_attrs(*input)).to eq({})
end
- with_them do
- it 'returns a hash' do
- stub_application_setting(snowplow_enabled: snowplow_enabled)
- allow(Rails).to receive(:env).and_return(environment.inquiry)
+ it 'returns data hash' do
+ expect(helper.tracking_attrs(*input)).to eq(result)
+ end
- expect(helper.tracking_attrs(*input)).to eq(results[result])
- end
+ it 'can return data directly' do
+ expect(helper.tracking_attrs_data(*input)).to eq(result[:data])
end
end
end
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index 82f4ae596e1..88030299574 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -378,7 +378,7 @@ RSpec.describe UsersHelper do
it 'users matches the serialized json' do
entity = double
expect_next_instance_of(Admin::UserSerializer) do |instance|
- expect(instance).to receive(:represent).with([user], current_user: user).and_return(entity)
+ expect(instance).to receive(:represent).with([user], { current_user: user }).and_return(entity)
end
expect(entity).to receive(:to_json).and_return("{\"username\":\"admin\"}")
expect(data[:users]).to eq "{\"username\":\"admin\"}"
diff --git a/spec/initializers/00_connection_logger_spec.rb b/spec/initializers/00_connection_logger_spec.rb
new file mode 100644
index 00000000000..8b288b463c4
--- /dev/null
+++ b/spec/initializers/00_connection_logger_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do # rubocop:disable RSpec/FilePath
+ before do
+ allow(PG).to receive(:connect)
+ end
+
+ let(:conn_params) { PG::Connection.conndefaults_hash }
+
+ context 'when warn_on_new_connection is enabled' do
+ before do
+ described_class.warn_on_new_connection = true
+ end
+
+ it 'warns on new connection' do
+ expect(ActiveSupport::Deprecation)
+ .to receive(:warn).with(/Database connection should not be called during initializers/, anything)
+
+ expect(PG).to receive(:connect).with(conn_params)
+
+ described_class.new_client(conn_params)
+ end
+ end
+
+ context 'when warn_on_new_connection is disabled' do
+ before do
+ described_class.warn_on_new_connection = false
+ end
+
+ it 'does not warn on new connection' do
+ expect(ActiveSupport::Deprecation).not_to receive(:warn)
+ expect(PG).to receive(:connect).with(conn_params)
+
+ described_class.new_client(conn_params)
+ end
+ end
+end
diff --git a/spec/initializers/validate_database_config_spec.rb b/spec/initializers/validate_database_config_spec.rb
index 209d9691350..5f3f950a852 100644
--- a/spec/initializers/validate_database_config_spec.rb
+++ b/spec/initializers/validate_database_config_spec.rb
@@ -39,47 +39,23 @@ RSpec.describe 'validate database config' do
end
context 'when config/database.yml is valid' do
- context 'uses legacy syntax' do
- let(:database_yml) do
- <<-EOS
- production:
+ let(:database_yml) do
+ <<-EOS
+ production:
+ main:
adapter: postgresql
encoding: unicode
database: gitlabhq_production
username: git
password: "secure password"
host: localhost
- EOS
- end
-
- it 'validates configuration with a warning' do
- expect(main_object).to receive(:warn).with /uses a deprecated syntax for/
-
- expect { subject }.not_to raise_error
- end
-
- it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
+ EOS
end
- context 'uses new syntax' do
- let(:database_yml) do
- <<-EOS
- production:
- main:
- adapter: postgresql
- encoding: unicode
- database: gitlabhq_production
- username: git
- password: "secure password"
- host: localhost
- EOS
- end
+ it 'validates configuration without errors and warnings' do
+ expect(main_object).not_to receive(:warn)
- it 'validates configuration without errors and warnings' do
- expect(main_object).not_to receive(:warn)
-
- expect { subject }.not_to raise_error
- end
+ expect { subject }.not_to raise_error
end
end
diff --git a/spec/lib/api/ci/helpers/runner_helpers_spec.rb b/spec/lib/api/ci/helpers/runner_helpers_spec.rb
index c4d740f0adc..c6cdc1732f5 100644
--- a/spec/lib/api/ci/helpers/runner_helpers_spec.rb
+++ b/spec/lib/api/ci/helpers/runner_helpers_spec.rb
@@ -70,5 +70,17 @@ RSpec.describe API::Ci::Helpers::Runner do
expect(details['ip_address']).to eq(ip_address)
end
end
+
+ describe '#log_artifact_size' do
+ subject { runner_helper.log_artifact_size(artifact) }
+
+ let(:runner_params) { {} }
+ let(:artifact) { create(:ci_job_artifact, size: 42) }
+ let(:expected_params) { { artifact_size: artifact.size } }
+ let(:subject_proc) { proc { subject } }
+
+ it_behaves_like 'storing arguments in the application context'
+ it_behaves_like 'not executing any extra queries for the application context'
+ end
end
end
diff --git a/spec/lib/api/entities/ci/job_request/dependency_spec.rb b/spec/lib/api/entities/ci/job_request/dependency_spec.rb
index fa5f3da554c..bbeb864c2ee 100644
--- a/spec/lib/api/entities/ci/job_request/dependency_spec.rb
+++ b/spec/lib/api/entities/ci/job_request/dependency_spec.rb
@@ -3,8 +3,9 @@
require 'spec_helper'
RSpec.describe API::Entities::Ci::JobRequest::Dependency do
+ let(:running_job) { create(:ci_build, :artifacts) }
let(:job) { create(:ci_build, :artifacts) }
- let(:entity) { described_class.new(job) }
+ let(:entity) { described_class.new(job, { running_job: running_job }) }
subject { entity.as_json }
@@ -16,8 +17,8 @@ RSpec.describe API::Entities::Ci::JobRequest::Dependency do
expect(subject[:name]).to eq(job.name)
end
- it 'returns the dependency token' do
- expect(subject[:token]).to eq(job.token)
+ it 'returns the token belonging to the running job' do
+ expect(subject[:token]).to eq(running_job.token)
end
it 'returns the dependency artifacts_file', :aggregate_failures do
diff --git a/spec/lib/api/entities/plan_limit_spec.rb b/spec/lib/api/entities/plan_limit_spec.rb
index 1b8b21d47f3..a88ea3f4cad 100644
--- a/spec/lib/api/entities/plan_limit_spec.rb
+++ b/spec/lib/api/entities/plan_limit_spec.rb
@@ -9,6 +9,14 @@ RSpec.describe API::Entities::PlanLimit do
it 'exposes correct attributes' do
expect(subject).to include(
+ :ci_pipeline_size,
+ :ci_active_jobs,
+ :ci_active_pipelines,
+ :ci_project_subscriptions,
+ :ci_pipeline_schedules,
+ :ci_needs_size_limit,
+ :ci_registered_group_runners,
+ :ci_registered_project_runners,
:conan_max_file_size,
:generic_packages_max_file_size,
:helm_max_file_size,
@@ -16,7 +24,8 @@ RSpec.describe API::Entities::PlanLimit do
:npm_max_file_size,
:nuget_max_file_size,
:pypi_max_file_size,
- :terraform_module_max_file_size
+ :terraform_module_max_file_size,
+ :storage_size_limit
)
end
diff --git a/spec/lib/api/entities/projects/topic_spec.rb b/spec/lib/api/entities/projects/topic_spec.rb
index cdf142dbb7d..1ea0e724fed 100644
--- a/spec/lib/api/entities/projects/topic_spec.rb
+++ b/spec/lib/api/entities/projects/topic_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe API::Entities::Projects::Topic do
expect(subject).to include(
:id,
:name,
+ :title,
:description,
:total_projects_count,
:avatar_url
diff --git a/spec/lib/api/entities/user_spec.rb b/spec/lib/api/entities/user_spec.rb
index be5e8e8e8c2..407f2894f01 100644
--- a/spec/lib/api/entities/user_spec.rb
+++ b/spec/lib/api/entities/user_spec.rb
@@ -12,7 +12,40 @@ RSpec.describe API::Entities::User do
subject { entity.as_json }
it 'exposes correct attributes' do
- expect(subject).to include(:name, :bio, :location, :public_email, :skype, :linkedin, :twitter, :website_url, :organization, :job_title, :work_information, :pronouns)
+ expect(subject.keys).to contain_exactly(
+ # UserSafe
+ :id, :username, :name,
+ # UserBasic
+ :state, :avatar_url, :web_url,
+ # User
+ :created_at, :bio, :location, :public_email, :skype, :linkedin, :twitter,
+ :website_url, :organization, :job_title, :pronouns, :bot, :work_information,
+ :followers, :following, :is_followed, :local_time
+ )
+ end
+
+ context 'exposing follow relationships' do
+ before do
+ allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, user).and_return(can_read_user_profile)
+ end
+
+ %i(followers following is_followed).each do |relationship|
+ context 'when current user cannot read user profile' do
+ let(:can_read_user_profile) { false }
+
+ it "does not expose #{relationship}" do
+ expect(subject).not_to include(relationship)
+ end
+ end
+
+ context 'when current user can read user profile' do
+ let(:can_read_user_profile) { true }
+
+ it "exposes #{relationship}" do
+ expect(subject).to include(relationship)
+ end
+ end
+ end
end
it 'exposes created_at if the current user can read the user profile' do
@@ -135,6 +168,16 @@ RSpec.describe API::Entities::User do
end
end
+ context 'with logged-out user' do
+ let(:current_user) { nil }
+
+ it 'exposes is_followed as nil' do
+ allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, user).and_return(true)
+
+ expect(subject.keys).not_to include(:is_followed)
+ end
+ end
+
it 'exposes local_time' do
local_time = '2:30 PM'
expect(entity).to receive(:local_time).with(timezone).and_return(local_time)
diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb
index 2afe5a1a9d7..78ce9642392 100644
--- a/spec/lib/api/helpers_spec.rb
+++ b/spec/lib/api/helpers_spec.rb
@@ -150,8 +150,8 @@ RSpec.describe API::Helpers do
context 'when user is authenticated' do
before do
- subject.instance_variable_set(:@current_user, user)
- subject.instance_variable_set(:@initial_current_user, user)
+ allow(subject).to receive(:current_user).and_return(user)
+ allow(subject).to receive(:initial_current_user).and_return(user)
end
context 'public project' do
@@ -167,8 +167,8 @@ RSpec.describe API::Helpers do
context 'when user is not authenticated' do
before do
- subject.instance_variable_set(:@current_user, nil)
- subject.instance_variable_set(:@initial_current_user, nil)
+ allow(subject).to receive(:current_user).and_return(nil)
+ allow(subject).to receive(:initial_current_user).and_return(nil)
end
context 'public project' do
@@ -181,59 +181,214 @@ RSpec.describe API::Helpers do
it_behaves_like 'private project without access'
end
end
+
+ context 'support for IDs and paths as argument' do
+ let_it_be(:project) { create(:project) }
+
+ let(:user) { project.first_owner}
+
+ before do
+ allow(subject).to receive(:current_user).and_return(user)
+ allow(subject).to receive(:authorized_project_scope?).and_return(true)
+ allow(subject).to receive(:job_token_authentication?).and_return(false)
+ allow(subject).to receive(:authenticate_non_public?).and_return(false)
+ end
+
+ shared_examples 'project finder' do
+ context 'when project exists' do
+ it 'returns requested project' do
+ expect(subject.find_project!(existing_id)).to eq(project)
+ end
+
+ it 'returns nil' do
+ expect(subject).to receive(:render_api_error!).with('404 Project Not Found', 404)
+ expect(subject.find_project!(non_existing_id)).to be_nil
+ end
+ end
+ end
+
+ context 'when ID is used as an argument' do
+ let(:existing_id) { project.id }
+ let(:non_existing_id) { non_existing_record_id }
+
+ it_behaves_like 'project finder'
+ end
+
+ context 'when PATH is used as an argument' do
+ let(:existing_id) { project.full_path }
+ let(:non_existing_id) { 'something/else' }
+
+ it_behaves_like 'project finder'
+
+ context 'with an invalid PATH' do
+ let(:non_existing_id) { 'undefined' } # path without slash
+
+ it_behaves_like 'project finder'
+
+ it 'does not hit the database' do
+ expect(Project).not_to receive(:find_by_full_path)
+ expect(subject).to receive(:render_api_error!).with('404 Project Not Found', 404)
+
+ subject.find_project!(non_existing_id)
+ end
+ end
+ end
+ end
end
- describe '#find_project!' do
- let_it_be(:project) { create(:project) }
+ describe '#find_group!' do
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:user) { create(:user) }
- let(:user) { project.first_owner}
+ shared_examples 'private group without access' do
+ before do
+ group.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value('private'))
+ allow(subject).to receive(:authenticate_non_public?).and_return(false)
+ end
- before do
- allow(subject).to receive(:current_user).and_return(user)
- allow(subject).to receive(:authorized_project_scope?).and_return(true)
- allow(subject).to receive(:job_token_authentication?).and_return(false)
- allow(subject).to receive(:authenticate_non_public?).and_return(false)
+ it 'returns not found' do
+ expect(subject).to receive(:not_found!)
+
+ subject.find_group!(group.id)
+ end
end
- shared_examples 'project finder' do
- context 'when project exists' do
- it 'returns requested project' do
- expect(subject.find_project!(existing_id)).to eq(project)
+ context 'when user is authenticated' do
+ before do
+ allow(subject).to receive(:current_user).and_return(user)
+ allow(subject).to receive(:initial_current_user).and_return(user)
+ end
+
+ context 'public group' do
+ it 'returns requested group' do
+ expect(subject.find_group!(group.id)).to eq(group)
end
+ end
- it 'returns nil' do
- expect(subject).to receive(:render_api_error!).with('404 Project Not Found', 404)
- expect(subject.find_project!(non_existing_id)).to be_nil
+ context 'private group' do
+ it_behaves_like 'private group without access'
+ end
+ end
+
+ context 'when user is not authenticated' do
+ before do
+ allow(subject).to receive(:current_user).and_return(nil)
+ allow(subject).to receive(:initial_current_user).and_return(nil)
+ end
+
+ context 'public group' do
+ it 'returns requested group' do
+ expect(subject.find_group!(group.id)).to eq(group)
end
end
+
+ context 'private group' do
+ it_behaves_like 'private group without access'
+ end
end
- context 'when ID is used as an argument' do
- let(:existing_id) { project.id }
- let(:non_existing_id) { non_existing_record_id }
+ context 'support for IDs and paths as arguments' do
+ let_it_be(:group) { create(:group) }
- it_behaves_like 'project finder'
+ let(:user) { group.first_owner }
+
+ before do
+ allow(subject).to receive(:current_user).and_return(user)
+ allow(subject).to receive(:authorized_project_scope?).and_return(true)
+ allow(subject).to receive(:job_token_authentication?).and_return(false)
+ allow(subject).to receive(:authenticate_non_public?).and_return(false)
+ end
+
+ shared_examples 'group finder' do
+ context 'when group exists' do
+ it 'returns requested group' do
+ expect(subject.find_group!(existing_id)).to eq(group)
+ end
+
+ it 'returns nil' do
+ expect(subject).to receive(:render_api_error!).with('404 Group Not Found', 404)
+ expect(subject.find_group!(non_existing_id)).to be_nil
+ end
+ end
+ end
+
+ context 'when ID is used as an argument' do
+ let(:existing_id) { group.id }
+ let(:non_existing_id) { non_existing_record_id }
+
+ it_behaves_like 'group finder'
+ end
+
+ context 'when PATH is used as an argument' do
+ let(:existing_id) { group.full_path }
+ let(:non_existing_id) { 'something/else' }
+
+ it_behaves_like 'group finder'
+ end
end
+ end
- context 'when PATH is used as an argument' do
- let(:existing_id) { project.full_path }
- let(:non_existing_id) { 'something/else' }
+ describe '#find_group_by_full_path!' do
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:user) { create(:user) }
- it_behaves_like 'project finder'
+ shared_examples 'private group without access' do
+ before do
+ group.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value('private'))
+ allow(subject).to receive(:authenticate_non_public?).and_return(false)
+ end
- context 'with an invalid PATH' do
- let(:non_existing_id) { 'undefined' } # path without slash
+ it 'returns not found' do
+ expect(subject).to receive(:not_found!)
- it_behaves_like 'project finder'
+ subject.find_group_by_full_path!(group.full_path)
+ end
+ end
- it 'does not hit the database' do
- expect(Project).not_to receive(:find_by_full_path)
- expect(subject).to receive(:render_api_error!).with('404 Project Not Found', 404)
+ context 'when user is authenticated' do
+ before do
+ allow(subject).to receive(:current_user).and_return(user)
+ allow(subject).to receive(:initial_current_user).and_return(user)
+ end
- subject.find_project!(non_existing_id)
+ context 'public group' do
+ it 'returns requested group' do
+ expect(subject.find_group_by_full_path!(group.full_path)).to eq(group)
+ end
+ end
+
+ context 'private group' do
+ it_behaves_like 'private group without access'
+
+ context 'with access' do
+ before do
+ group.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value('private'))
+ group.add_developer(user)
+ end
+
+ it 'returns requested group with access' do
+ expect(subject.find_group_by_full_path!(group.full_path)).to eq(group)
+ end
end
end
end
+
+ context 'when user is not authenticated' do
+ before do
+ allow(subject).to receive(:current_user).and_return(nil)
+ allow(subject).to receive(:initial_current_user).and_return(nil)
+ end
+
+ context 'public group' do
+ it 'returns requested group' do
+ expect(subject.find_group_by_full_path!(group.full_path)).to eq(group)
+ end
+ end
+
+ context 'private group' do
+ it_behaves_like 'private group without access'
+ end
+ end
end
describe '#find_namespace' do
@@ -433,7 +588,7 @@ RSpec.describe API::Helpers do
end
end
- describe '#order_options_with_tie_breaker' do
+ shared_examples '#order_options_with_tie_breaker' do
subject { Class.new.include(described_class).new.order_options_with_tie_breaker }
before do
@@ -475,6 +630,30 @@ RSpec.describe API::Helpers do
end
end
+ describe '#order_options_with_tie_breaker' do
+ include_examples '#order_options_with_tie_breaker'
+
+ context 'with created_at order given' do
+ let(:params) { { order_by: 'created_at', sort: 'asc' } }
+
+ it 'converts to id' do
+ is_expected.to eq({ 'id' => 'asc' })
+ end
+
+ context 'when replace_order_by_created_at_with_id feature flag is disabled' do
+ before do
+ stub_feature_flags(replace_order_by_created_at_with_id: false)
+ end
+
+ include_examples '#order_options_with_tie_breaker'
+
+ it 'maintains created_at order' do
+ is_expected.to eq({ 'created_at' => 'asc', 'id' => 'asc' })
+ end
+ end
+ end
+ end
+
describe "#destroy_conditionally!" do
let!(:project) { create(:project) }
diff --git a/spec/lib/atlassian/jira_connect/asymmetric_jwt_spec.rb b/spec/lib/atlassian/jira_connect/jwt/asymmetric_spec.rb
index c57d8ece86b..12ed47a1025 100644
--- a/spec/lib/atlassian/jira_connect/asymmetric_jwt_spec.rb
+++ b/spec/lib/atlassian/jira_connect/jwt/asymmetric_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Atlassian::JiraConnect::AsymmetricJwt do
+RSpec.describe Atlassian::JiraConnect::Jwt::Asymmetric do
describe '#valid?' do
subject(:asymmetric_jwt) { described_class.new(jwt, verification_claims) }
@@ -10,15 +10,19 @@ RSpec.describe Atlassian::JiraConnect::AsymmetricJwt do
let(:jwt_claims) { { aud: aud, iss: client_key, qsh: qsh } }
let(:aud) { 'https://test.host/-/jira_connect' }
let(:client_key) { '1234' }
- let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/events/installed', 'POST', 'https://gitlab.test') }
let(:public_key_id) { '123e4567-e89b-12d3-a456-426614174000' }
let(:jwt_headers) { { kid: public_key_id } }
let(:private_key) { OpenSSL::PKey::RSA.generate 2048 }
let(:jwt) { JWT.encode(jwt_claims, private_key, 'RS256', jwt_headers) }
let(:public_key) { private_key.public_key }
+ let(:install_keys_url) { "https://connect-install-keys.atlassian.com/#{public_key_id}" }
+ let(:qsh) do
+ Atlassian::Jwt.create_query_string_hash('https://gitlab.test/events/installed', 'POST', 'https://gitlab.test')
+ end
before do
- stub_request(:get, "https://connect-install-keys.atlassian.com/#{public_key_id}").to_return(body: public_key.to_s, status: 200)
+ stub_request(:get, install_keys_url)
+ .to_return(body: public_key.to_s, status: 200)
end
it 'returns true when verified with public key from CDN' do
@@ -26,7 +30,7 @@ RSpec.describe Atlassian::JiraConnect::AsymmetricJwt do
expect(asymmetric_jwt).to be_valid
- expect(WebMock).to have_requested(:get, "https://connect-install-keys.atlassian.com/#{public_key_id}")
+ expect(WebMock).to have_requested(:get, install_keys_url)
end
context 'JWT does not contain a key ID' do
@@ -43,7 +47,7 @@ RSpec.describe Atlassian::JiraConnect::AsymmetricJwt do
context 'public key can not be retrieved' do
before do
- stub_request(:get, "https://connect-install-keys.atlassian.com/#{public_key_id}").to_return(body: '', status: 404)
+ stub_request(:get, install_keys_url).to_return(body: '', status: 404)
end
it { is_expected.not_to be_valid }
@@ -61,7 +65,8 @@ RSpec.describe Atlassian::JiraConnect::AsymmetricJwt do
before do
allow(JWT).to receive(:decode).and_call_original
allow(JWT).to receive(:decode).with(
- jwt, anything, true, aud: anything, verify_aud: true, iss: client_key, verify_iss: true, algorithm: 'RS256'
+ jwt, anything, true,
+ { aud: anything, verify_aud: true, iss: client_key, verify_iss: true, algorithm: 'RS256' }
).and_raise(JWT::DecodeError)
end
diff --git a/spec/lib/atlassian/jira_connect/jwt/symmetric_spec.rb b/spec/lib/atlassian/jira_connect/jwt/symmetric_spec.rb
new file mode 100644
index 00000000000..61adff7e221
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/jwt/symmetric_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Jwt::Symmetric do
+ let(:shared_secret) { 'secret' }
+
+ describe '#iss_claim' do
+ let(:jwt) { Atlassian::Jwt.encode({ iss: '123' }, shared_secret) }
+
+ subject { described_class.new(jwt).iss_claim }
+
+ it { is_expected.to eq('123') }
+
+ context 'invalid JWT' do
+ let(:jwt) { '123' }
+
+ it { is_expected.to eq(nil) }
+ end
+ end
+
+ describe '#sub_claim' do
+ let(:jwt) { Atlassian::Jwt.encode({ sub: '123' }, shared_secret) }
+
+ subject { described_class.new(jwt).sub_claim }
+
+ it { is_expected.to eq('123') }
+
+ context 'invalid JWT' do
+ let(:jwt) { '123' }
+
+ it { is_expected.to eq(nil) }
+ end
+ end
+
+ describe '#valid?' do
+ subject { described_class.new(jwt).valid?(shared_secret) }
+
+ context 'invalid JWT' do
+ let(:jwt) { '123' }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'valid JWT' do
+ let(:jwt) { Atlassian::Jwt.encode({}, shared_secret) }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
+ describe '#verify_qsh_claim' do
+ let(:jwt) { Atlassian::Jwt.encode({ qsh: qsh_claim }, shared_secret) }
+ let(:qsh_claim) do
+ Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test')
+ end
+
+ subject(:verify_qsh_claim) do
+ described_class.new(jwt).verify_qsh_claim('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test')
+ end
+
+ it { is_expected.to eq(true) }
+
+ context 'qsh does not match' do
+ let(:qsh_claim) do
+ Atlassian::Jwt.create_query_string_hash('https://example.com/foo', 'POST', 'https://example.com')
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'creating query string hash raises an error' do
+ let(:qsh_claim) { '123' }
+
+ specify do
+ expect(Atlassian::Jwt).to receive(:create_query_string_hash).and_raise(StandardError)
+
+ expect(verify_qsh_claim).to eq(false)
+ end
+ end
+ end
+
+ describe '#verify_context_qsh_claim' do
+ let(:jwt) { Atlassian::Jwt.encode({ qsh: qsh_claim }, shared_secret) }
+ let(:qsh_claim) { 'context-qsh' }
+
+ subject(:verify_context_qsh_claim) { described_class.new(jwt).verify_context_qsh_claim }
+
+ it { is_expected.to eq(true) }
+
+ context 'jwt does not contain a context qsh' do
+ let(:qsh_claim) { '123' }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+end
diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb
index 192739d05a7..a2477834dde 100644
--- a/spec/lib/backup/manager_spec.rb
+++ b/spec/lib/backup/manager_spec.rb
@@ -145,16 +145,12 @@ RSpec.describe Backup::Manager do
describe '#create' do
let(:incremental_env) { 'false' }
let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz task2.tar.gz} }
- let(:backup_id) { '1546300800_2019_01_01_12.3' }
- let(:tar_file) { "#{backup_id}_gitlab_backup.tar" }
- let(:tar_system_options) { { out: [tar_file, 'w', Gitlab.config.backup.archive_permissions] } }
- let(:tar_cmdline) { ['tar', '-cf', '-', *expected_backup_contents, tar_system_options] }
- let(:backup_information) do
- {
- backup_created_at: Time.zone.parse('2019-01-01'),
- gitlab_version: '12.3'
- }
- end
+ let(:backup_time) { Time.utc(2019, 1, 1) }
+ let(:backup_id) { "1546300800_2019_01_01_#{Gitlab::VERSION}" }
+ let(:full_backup_id) { backup_id }
+ let(:pack_tar_file) { "#{backup_id}_gitlab_backup.tar" }
+ let(:pack_tar_system_options) { { out: [pack_tar_file, 'w', Gitlab.config.backup.archive_permissions] } }
+ let(:pack_tar_cmdline) { ['tar', '-cf', '-', *expected_backup_contents, pack_tar_system_options] }
let(:task1) { instance_double(Backup::Task) }
let(:task2) { instance_double(Backup::Task) }
@@ -170,427 +166,437 @@ RSpec.describe Backup::Manager do
allow(ActiveRecord::Base.connection).to receive(:reconnect!)
allow(Gitlab::BackupLogger).to receive(:info)
allow(Kernel).to receive(:system).and_return(true)
- allow(YAML).to receive(:load_file).and_call_original
- allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml'))
- .and_return(backup_information)
- allow(subject).to receive(:backup_information).and_return(backup_information)
- allow(task1).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz'), backup_id)
- allow(task2).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz'), backup_id)
+ allow(task1).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz'), full_backup_id)
+ allow(task2).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz'), full_backup_id)
end
it 'executes tar' do
- subject.create # rubocop:disable Rails/SaveBang
-
- expect(Kernel).to have_received(:system).with(*tar_cmdline)
- end
-
- context 'tar fails' do
- before do
- expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false)
- end
-
- it 'logs a failure' do
- expect do
- subject.create # rubocop:disable Rails/SaveBang
- end.to raise_error(Backup::Error, 'Backup failed')
+ travel_to(backup_time) do
+ subject.create # rubocop:disable Rails/SaveBang
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: "Creating archive #{tar_file} failed")
+ expect(Kernel).to have_received(:system).with(*pack_tar_cmdline)
end
end
context 'when BACKUP is set' do
let(:backup_id) { 'custom' }
- it 'uses the given value as tar file name' do
+ before do
stub_env('BACKUP', '/ignored/path/custom')
- subject.create # rubocop:disable Rails/SaveBang
-
- expect(Kernel).to have_received(:system).with(*tar_cmdline)
- end
- end
-
- context 'when skipped is set in backup_information.yml' do
- let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} }
- let(:backup_information) do
- {
- backup_created_at: Time.zone.parse('2019-01-01'),
- gitlab_version: '12.3',
- skipped: ['task2']
- }
end
- it 'executes tar' do
+ it 'uses the given value as tar file name' do
subject.create # rubocop:disable Rails/SaveBang
- expect(Kernel).to have_received(:system).with(*tar_cmdline)
- end
- end
-
- context 'when SKIP env is set' do
- let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} }
-
- before do
- stub_env('SKIP', 'task2')
+ expect(Kernel).to have_received(:system).with(*pack_tar_cmdline)
end
- it 'executes tar' do
- subject.create # rubocop:disable Rails/SaveBang
+ context 'tar fails' do
+ before do
+ expect(Kernel).to receive(:system).with(*pack_tar_cmdline).and_return(false)
+ end
- expect(Kernel).to have_received(:system).with(*tar_cmdline)
- end
- end
+ it 'logs a failure' do
+ expect do
+ subject.create # rubocop:disable Rails/SaveBang
+ end.to raise_error(Backup::Error, 'Backup failed')
- context 'when the destination is optional' do
- let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} }
- let(:definitions) do
- {
- 'task1' => Backup::Manager::TaskDefinition.new(task: task1, destination_path: 'task1.tar.gz'),
- 'task2' => Backup::Manager::TaskDefinition.new(task: task2, destination_path: 'task2.tar.gz', destination_optional: true)
- }
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: "Creating archive #{pack_tar_file} failed")
+ end
end
- it 'executes tar' do
- expect(File).to receive(:exist?).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz')).and_return(false)
+ context 'when SKIP env is set' do
+ let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} }
- subject.create # rubocop:disable Rails/SaveBang
+ before do
+ stub_env('SKIP', 'task2')
+ end
- expect(Kernel).to have_received(:system).with(*tar_cmdline)
- end
- end
+ it 'executes tar' do
+ subject.create # rubocop:disable Rails/SaveBang
- context 'many backup files' do
- let(:files) do
- [
- '1451606400_2016_01_01_1.2.3_gitlab_backup.tar',
- '1451520000_2015_12_31_4.5.6_gitlab_backup.tar',
- '1451520000_2015_12_31_4.5.6-pre_gitlab_backup.tar',
- '1451520000_2015_12_31_4.5.6-rc1_gitlab_backup.tar',
- '1451520000_2015_12_31_4.5.6-pre-ee_gitlab_backup.tar',
- '1451510000_2015_12_30_gitlab_backup.tar',
- '1450742400_2015_12_22_gitlab_backup.tar',
- '1449878400_gitlab_backup.tar',
- '1449014400_gitlab_backup.tar',
- 'manual_gitlab_backup.tar'
- ]
+ expect(Kernel).to have_received(:system).with(*pack_tar_cmdline)
+ end
end
- before do
- allow(Gitlab::BackupLogger).to receive(:info)
- allow(Dir).to receive(:chdir).and_yield
- allow(Dir).to receive(:glob).and_return(files)
- allow(FileUtils).to receive(:rm)
- allow(Time).to receive(:now).and_return(Time.utc(2016))
- end
+ context 'when the destination is optional' do
+ let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} }
+ let(:definitions) do
+ {
+ 'task1' => Backup::Manager::TaskDefinition.new(task: task1, destination_path: 'task1.tar.gz'),
+ 'task2' => Backup::Manager::TaskDefinition.new(task: task2, destination_path: 'task2.tar.gz', destination_optional: true)
+ }
+ end
- context 'when keep_time is zero' do
- before do
- allow(Gitlab.config.backup).to receive(:keep_time).and_return(0)
+ it 'executes tar' do
+ expect(File).to receive(:exist?).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz')).and_return(false)
subject.create # rubocop:disable Rails/SaveBang
- end
- it 'removes no files' do
- expect(FileUtils).not_to have_received(:rm)
- end
-
- it 'prints a skipped message' do
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... [SKIPPED]')
+ expect(Kernel).to have_received(:system).with(*pack_tar_cmdline)
end
end
- context 'when no valid file is found' do
+ context 'many backup files' do
let(:files) do
[
- '14516064000_2016_01_01_1.2.3_gitlab_backup.tar',
- 'foo_1451520000_2015_12_31_4.5.6_gitlab_backup.tar',
- '1451520000_2015_12_31_4.5.6-foo_gitlab_backup.tar'
+ '1451606400_2016_01_01_1.2.3_gitlab_backup.tar',
+ '1451520000_2015_12_31_4.5.6_gitlab_backup.tar',
+ '1451520000_2015_12_31_4.5.6-pre_gitlab_backup.tar',
+ '1451520000_2015_12_31_4.5.6-rc1_gitlab_backup.tar',
+ '1451520000_2015_12_31_4.5.6-pre-ee_gitlab_backup.tar',
+ '1451510000_2015_12_30_gitlab_backup.tar',
+ '1450742400_2015_12_22_gitlab_backup.tar',
+ '1449878400_gitlab_backup.tar',
+ '1449014400_gitlab_backup.tar',
+ 'manual_gitlab_backup.tar'
]
end
before do
- allow(Gitlab.config.backup).to receive(:keep_time).and_return(1)
-
- subject.create # rubocop:disable Rails/SaveBang
- end
-
- it 'removes no files' do
- expect(FileUtils).not_to have_received(:rm)
- end
-
- it 'prints a done message' do
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)')
+ allow(Gitlab::BackupLogger).to receive(:info)
+ allow(Dir).to receive(:chdir).and_yield
+ allow(Dir).to receive(:glob).and_return(files)
+ allow(FileUtils).to receive(:rm)
+ allow(Time).to receive(:now).and_return(Time.utc(2016))
end
- end
- context 'when there are no files older than keep_time' do
- before do
- # Set to 30 days
- allow(Gitlab.config.backup).to receive(:keep_time).and_return(2592000)
+ context 'when keep_time is zero' do
+ before do
+ allow(Gitlab.config.backup).to receive(:keep_time).and_return(0)
- subject.create # rubocop:disable Rails/SaveBang
- end
+ subject.create # rubocop:disable Rails/SaveBang
+ end
- it 'removes no files' do
- expect(FileUtils).not_to have_received(:rm)
- end
+ it 'removes no files' do
+ expect(FileUtils).not_to have_received(:rm)
+ end
- it 'prints a done message' do
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)')
+ it 'prints a skipped message' do
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... [SKIPPED]')
+ end
end
- end
- context 'when keep_time is set to remove files' do
- before do
- # Set to 1 second
- allow(Gitlab.config.backup).to receive(:keep_time).and_return(1)
+ context 'when no valid file is found' do
+ let(:files) do
+ [
+ '14516064000_2016_01_01_1.2.3_gitlab_backup.tar',
+ 'foo_1451520000_2015_12_31_4.5.6_gitlab_backup.tar',
+ '1451520000_2015_12_31_4.5.6-foo_gitlab_backup.tar'
+ ]
+ end
- subject.create # rubocop:disable Rails/SaveBang
- end
+ before do
+ allow(Gitlab.config.backup).to receive(:keep_time).and_return(1)
- it 'removes matching files with a human-readable versioned timestamp' do
- expect(FileUtils).to have_received(:rm).with(files[1])
- expect(FileUtils).to have_received(:rm).with(files[2])
- expect(FileUtils).to have_received(:rm).with(files[3])
- end
+ subject.create # rubocop:disable Rails/SaveBang
+ end
- it 'removes matching files with a human-readable versioned timestamp with tagged EE' do
- expect(FileUtils).to have_received(:rm).with(files[4])
- end
+ it 'removes no files' do
+ expect(FileUtils).not_to have_received(:rm)
+ end
- it 'removes matching files with a human-readable non-versioned timestamp' do
- expect(FileUtils).to have_received(:rm).with(files[5])
- expect(FileUtils).to have_received(:rm).with(files[6])
+ it 'prints a done message' do
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)')
+ end
end
- it 'removes matching files without a human-readable timestamp' do
- expect(FileUtils).to have_received(:rm).with(files[7])
- expect(FileUtils).to have_received(:rm).with(files[8])
- end
+ context 'when there are no files older than keep_time' do
+ before do
+ # Set to 30 days
+ allow(Gitlab.config.backup).to receive(:keep_time).and_return(2592000)
- it 'does not remove files that are not old enough' do
- expect(FileUtils).not_to have_received(:rm).with(files[0])
- end
+ subject.create # rubocop:disable Rails/SaveBang
+ end
- it 'does not remove non-matching files' do
- expect(FileUtils).not_to have_received(:rm).with(files[9])
- end
+ it 'removes no files' do
+ expect(FileUtils).not_to have_received(:rm)
+ end
- it 'prints a done message' do
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (8 removed)')
+ it 'prints a done message' do
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)')
+ end
end
- end
-
- context 'when removing a file fails' do
- let(:file) { files[1] }
- let(:message) { "Permission denied @ unlink_internal - #{file}" }
- before do
- allow(Gitlab.config.backup).to receive(:keep_time).and_return(1)
- allow(FileUtils).to receive(:rm).with(file).and_raise(Errno::EACCES, message)
-
- subject.create # rubocop:disable Rails/SaveBang
- end
+ context 'when keep_time is set to remove files' do
+ before do
+ # Set to 1 second
+ allow(Gitlab.config.backup).to receive(:keep_time).and_return(1)
- it 'removes the remaining expected files' do
- expect(FileUtils).to have_received(:rm).with(files[4])
- expect(FileUtils).to have_received(:rm).with(files[5])
- expect(FileUtils).to have_received(:rm).with(files[6])
- expect(FileUtils).to have_received(:rm).with(files[7])
- expect(FileUtils).to have_received(:rm).with(files[8])
- end
+ subject.create # rubocop:disable Rails/SaveBang
+ end
- it 'sets the correct removed count' do
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (7 removed)')
- end
+ it 'removes matching files with a human-readable versioned timestamp' do
+ expect(FileUtils).to have_received(:rm).with(files[1])
+ expect(FileUtils).to have_received(:rm).with(files[2])
+ expect(FileUtils).to have_received(:rm).with(files[3])
+ end
- it 'prints the error from file that could not be removed' do
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: a_string_matching(message))
- end
- end
- end
+ it 'removes matching files with a human-readable versioned timestamp with tagged EE' do
+ expect(FileUtils).to have_received(:rm).with(files[4])
+ end
- describe 'cloud storage' do
- let(:backup_file) { Tempfile.new('backup', Gitlab.config.backup.path) }
- let(:backup_filename) { File.basename(backup_file.path) }
+ it 'removes matching files with a human-readable non-versioned timestamp' do
+ expect(FileUtils).to have_received(:rm).with(files[5])
+ expect(FileUtils).to have_received(:rm).with(files[6])
+ end
- before do
- allow(Gitlab::BackupLogger).to receive(:info)
- allow(subject).to receive(:tar_file).and_return(backup_filename)
-
- stub_backup_setting(
- upload: {
- connection: {
- provider: 'AWS',
- aws_access_key_id: 'id',
- aws_secret_access_key: 'secret'
- },
- remote_directory: 'directory',
- multipart_chunk_size: 104857600,
- encryption: nil,
- encryption_key: nil,
- storage_class: nil
- }
- )
+ it 'removes matching files without a human-readable timestamp' do
+ expect(FileUtils).to have_received(:rm).with(files[7])
+ expect(FileUtils).to have_received(:rm).with(files[8])
+ end
- Fog.mock!
+ it 'does not remove files that are not old enough' do
+ expect(FileUtils).not_to have_received(:rm).with(files[0])
+ end
- # the Fog mock only knows about directories we create explicitly
- connection = ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys)
- connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) # rubocop:disable Rails/SaveBang
- end
+ it 'does not remove non-matching files' do
+ expect(FileUtils).not_to have_received(:rm).with(files[9])
+ end
- context 'skipped upload' do
- let(:backup_information) do
- {
- backup_created_at: Time.zone.parse('2019-01-01'),
- gitlab_version: '12.3',
- skipped: ['remote']
- }
+ it 'prints a done message' do
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (8 removed)')
+ end
end
- it 'informs the user' do
- stub_env('SKIP', 'remote')
- subject.create # rubocop:disable Rails/SaveBang
-
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... [SKIPPED]')
- end
- end
+ context 'when removing a file fails' do
+ let(:file) { files[1] }
+ let(:message) { "Permission denied @ unlink_internal - #{file}" }
- context 'target path' do
- it 'uses the tar filename by default' do
- expect_any_instance_of(Fog::Collection).to receive(:create)
- .with(hash_including(key: backup_filename, public: false))
- .and_call_original
+ before do
+ allow(Gitlab.config.backup).to receive(:keep_time).and_return(1)
+ allow(FileUtils).to receive(:rm).with(file).and_raise(Errno::EACCES, message)
- subject.create # rubocop:disable Rails/SaveBang
- end
+ subject.create # rubocop:disable Rails/SaveBang
+ end
- it 'adds the DIRECTORY environment variable if present' do
- stub_env('DIRECTORY', 'daily')
+ it 'removes the remaining expected files' do
+ expect(FileUtils).to have_received(:rm).with(files[4])
+ expect(FileUtils).to have_received(:rm).with(files[5])
+ expect(FileUtils).to have_received(:rm).with(files[6])
+ expect(FileUtils).to have_received(:rm).with(files[7])
+ expect(FileUtils).to have_received(:rm).with(files[8])
+ end
- expect_any_instance_of(Fog::Collection).to receive(:create)
- .with(hash_including(key: "daily/#{backup_filename}", public: false))
- .and_call_original
+ it 'sets the correct removed count' do
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (7 removed)')
+ end
- subject.create # rubocop:disable Rails/SaveBang
+ it 'prints the error from file that could not be removed' do
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: a_string_matching(message))
+ end
end
end
- context 'with AWS with server side encryption' do
- let(:connection) { ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys) }
- let(:encryption_key) { nil }
- let(:encryption) { nil }
- let(:storage_options) { nil }
+ describe 'cloud storage' do
+ let(:backup_file) { Tempfile.new('backup', Gitlab.config.backup.path) }
+ let(:backup_filename) { File.basename(backup_file.path) }
before do
+ allow(Gitlab::BackupLogger).to receive(:info)
+ allow(subject).to receive(:tar_file).and_return(backup_filename)
+
stub_backup_setting(
upload: {
connection: {
provider: 'AWS',
- aws_access_key_id: 'AWS_ACCESS_KEY_ID',
- aws_secret_access_key: 'AWS_SECRET_ACCESS_KEY'
+ aws_access_key_id: 'id',
+ aws_secret_access_key: 'secret'
},
remote_directory: 'directory',
- multipart_chunk_size: Gitlab.config.backup.upload.multipart_chunk_size,
- encryption: encryption,
- encryption_key: encryption_key,
- storage_options: storage_options,
+ multipart_chunk_size: 104857600,
+ encryption: nil,
+ encryption_key: nil,
storage_class: nil
}
)
+ Fog.mock!
+
+ # the Fog mock only knows about directories we create explicitly
+ connection = ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys)
connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) # rubocop:disable Rails/SaveBang
end
- context 'with SSE-S3 without using storage_options' do
- let(:encryption) { 'AES256' }
+ context 'skipped upload' do
+ let(:backup_information) do
+ {
+ backup_created_at: Time.zone.parse('2019-01-01'),
+ gitlab_version: '12.3',
+ skipped: ['remote']
+ }
+ end
- it 'sets encryption attributes' do
+ it 'informs the user' do
+ stub_env('SKIP', 'remote')
subject.create # rubocop:disable Rails/SaveBang
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)')
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... [SKIPPED]')
end
end
- context 'with SSE-C (customer-provided keys) options' do
- let(:encryption) { 'AES256' }
- let(:encryption_key) { SecureRandom.hex }
+ context 'target path' do
+ it 'uses the tar filename by default' do
+ expect_any_instance_of(Fog::Collection).to receive(:create)
+ .with(hash_including(key: backup_filename, public: false))
+ .and_call_original
- it 'sets encryption attributes' do
subject.create # rubocop:disable Rails/SaveBang
+ end
+
+ it 'adds the DIRECTORY environment variable if present' do
+ stub_env('DIRECTORY', 'daily')
+
+ expect_any_instance_of(Fog::Collection).to receive(:create)
+ .with(hash_including(key: "daily/#{backup_filename}", public: false))
+ .and_call_original
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)')
+ subject.create # rubocop:disable Rails/SaveBang
end
end
- context 'with SSE-KMS options' do
- let(:storage_options) do
- {
- server_side_encryption: 'aws:kms',
- server_side_encryption_kms_key_id: 'arn:aws:kms:12345'
- }
+ context 'with AWS with server side encryption' do
+ let(:connection) { ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys) }
+ let(:encryption_key) { nil }
+ let(:encryption) { nil }
+ let(:storage_options) { nil }
+
+ before do
+ stub_backup_setting(
+ upload: {
+ connection: {
+ provider: 'AWS',
+ aws_access_key_id: 'AWS_ACCESS_KEY_ID',
+ aws_secret_access_key: 'AWS_SECRET_ACCESS_KEY'
+ },
+ remote_directory: 'directory',
+ multipart_chunk_size: Gitlab.config.backup.upload.multipart_chunk_size,
+ encryption: encryption,
+ encryption_key: encryption_key,
+ storage_options: storage_options,
+ storage_class: nil
+ }
+ )
+
+ connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) # rubocop:disable Rails/SaveBang
end
- it 'sets encryption attributes' do
- subject.create # rubocop:disable Rails/SaveBang
+ context 'with SSE-S3 without using storage_options' do
+ let(:encryption) { 'AES256' }
- expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with aws:kms)')
+ it 'sets encryption attributes' do
+ subject.create # rubocop:disable Rails/SaveBang
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)')
+ end
+ end
+
+ context 'with SSE-C (customer-provided keys) options' do
+ let(:encryption) { 'AES256' }
+ let(:encryption_key) { SecureRandom.hex }
+
+ it 'sets encryption attributes' do
+ subject.create # rubocop:disable Rails/SaveBang
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)')
+ end
+ end
+
+ context 'with SSE-KMS options' do
+ let(:storage_options) do
+ {
+ server_side_encryption: 'aws:kms',
+ server_side_encryption_kms_key_id: 'arn:aws:kms:12345'
+ }
+ end
+
+ it 'sets encryption attributes' do
+ subject.create # rubocop:disable Rails/SaveBang
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with aws:kms)')
+ end
end
end
- end
- context 'with Google provider' do
- before do
- stub_backup_setting(
- upload: {
- connection: {
- provider: 'Google',
- google_storage_access_key_id: 'test-access-id',
- google_storage_secret_access_key: 'secret'
- },
- remote_directory: 'directory',
- multipart_chunk_size: Gitlab.config.backup.upload.multipart_chunk_size,
- encryption: nil,
- encryption_key: nil,
- storage_class: nil
- }
- )
+ context 'with Google provider' do
+ before do
+ stub_backup_setting(
+ upload: {
+ connection: {
+ provider: 'Google',
+ google_storage_access_key_id: 'test-access-id',
+ google_storage_secret_access_key: 'secret'
+ },
+ remote_directory: 'directory',
+ multipart_chunk_size: Gitlab.config.backup.upload.multipart_chunk_size,
+ encryption: nil,
+ encryption_key: nil,
+ storage_class: nil
+ }
+ )
+
+ connection = ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys)
+ connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) # rubocop:disable Rails/SaveBang
+ end
- connection = ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys)
- connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) # rubocop:disable Rails/SaveBang
+ it 'does not attempt to set ACL' do
+ expect_any_instance_of(Fog::Collection).to receive(:create)
+ .with(hash_excluding(public: false))
+ .and_call_original
+
+ subject.create # rubocop:disable Rails/SaveBang
+ end
end
- it 'does not attempt to set ACL' do
- expect_any_instance_of(Fog::Collection).to receive(:create)
- .with(hash_excluding(public: false))
- .and_call_original
+ context 'with AzureRM provider' do
+ before do
+ stub_backup_setting(
+ upload: {
+ connection: {
+ provider: 'AzureRM',
+ azure_storage_account_name: 'test-access-id',
+ azure_storage_access_key: 'secret'
+ },
+ remote_directory: 'directory',
+ multipart_chunk_size: nil,
+ encryption: nil,
+ encryption_key: nil,
+ storage_class: nil
+ }
+ )
+ end
- subject.create # rubocop:disable Rails/SaveBang
+ it 'loads the provider' do
+ expect { subject.create }.not_to raise_error # rubocop:disable Rails/SaveBang
+ end
end
end
+ end
- context 'with AzureRM provider' do
- before do
- stub_backup_setting(
- upload: {
- connection: {
- provider: 'AzureRM',
- azure_storage_account_name: 'test-access-id',
- azure_storage_access_key: 'secret'
- },
- remote_directory: 'directory',
- multipart_chunk_size: nil,
- encryption: nil,
- encryption_key: nil,
- storage_class: nil
- }
- )
- end
+ context 'tar skipped' do
+ before do
+ stub_env('SKIP', 'tar')
+ end
- it 'loads the provider' do
- expect { subject.create }.not_to raise_error # rubocop:disable Rails/SaveBang
+ after do
+ FileUtils.rm_rf(Dir.glob(File.join(Gitlab.config.backup.path, '*')), secure: true)
+ end
+
+ it 'creates a non-tarred backup' do
+ travel_to(backup_time) do
+ subject.create # rubocop:disable Rails/SaveBang
end
+
+ expect(Kernel).not_to have_received(:system).with(*pack_tar_cmdline)
+ expect(YAML.load_file(File.join(Gitlab.config.backup.path, 'backup_information.yml'))).to include(
+ backup_created_at: backup_time.localtime,
+ db_version: be_a(String),
+ gitlab_version: Gitlab::VERSION,
+ installation_type: Gitlab::INSTALLATION_TYPE,
+ skipped: 'tar',
+ tar_version: be_a(String)
+ )
end
end
@@ -598,14 +604,21 @@ RSpec.describe Backup::Manager do
let(:incremental_env) { 'true' }
let(:gitlab_version) { Gitlab::VERSION }
let(:backup_id) { "1546300800_2019_01_01_#{gitlab_version}" }
- let(:tar_file) { "#{backup_id}_gitlab_backup.tar" }
+ let(:unpack_tar_file) { "#{full_backup_id}_gitlab_backup.tar" }
+ let(:unpack_tar_cmdline) { ['tar', '-xf', unpack_tar_file] }
let(:backup_information) do
{
- backup_created_at: Time.zone.parse('2019-01-01'),
+ backup_created_at: Time.zone.parse('2018-01-01'),
gitlab_version: gitlab_version
}
end
+ before do
+ allow(YAML).to receive(:load_file).and_call_original
+ allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml'))
+ .and_return(backup_information)
+ end
+
context 'when there are no backup files in the directory' do
before do
allow(Dir).to receive(:glob).and_return([])
@@ -663,7 +676,6 @@ RSpec.describe Backup::Manager do
context 'when BACKUP variable is set to a correct file' do
let(:backup_id) { '1451606400_2016_01_01_1.2.3' }
- let(:tar_cmdline) { %w{tar -xf 1451606400_2016_01_01_1.2.3_gitlab_backup.tar} }
before do
allow(Gitlab::BackupLogger).to receive(:info)
@@ -678,15 +690,18 @@ RSpec.describe Backup::Manager do
stub_env('BACKUP', '/ignored/path/1451606400_2016_01_01_1.2.3')
end
- it 'unpacks the file' do
- subject.create # rubocop:disable Rails/SaveBang
+ it 'unpacks and packs the backup' do
+ travel_to(backup_time) do
+ subject.create # rubocop:disable Rails/SaveBang
+ end
- expect(Kernel).to have_received(:system).with(*tar_cmdline)
+ expect(Kernel).to have_received(:system).with(*unpack_tar_cmdline)
+ expect(Kernel).to have_received(:system).with(*pack_tar_cmdline)
end
- context 'tar fails' do
+ context 'untar fails' do
before do
- expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false)
+ expect(Kernel).to receive(:system).with(*unpack_tar_cmdline).and_return(false)
end
it 'logs a failure' do
@@ -698,6 +713,20 @@ RSpec.describe Backup::Manager do
end
end
+ context 'tar fails' do
+ before do
+ expect(Kernel).to receive(:system).with(*pack_tar_cmdline).and_return(false)
+ end
+
+ it 'logs a failure' do
+ expect do
+ subject.create # rubocop:disable Rails/SaveBang
+ end.to raise_error(Backup::Error, 'Backup failed')
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: "Creating archive #{pack_tar_file} failed")
+ end
+ end
+
context 'on version mismatch' do
let(:backup_information) do
{
@@ -714,21 +743,138 @@ RSpec.describe Backup::Manager do
end
end
+ context 'when PREVIOUS_BACKUP variable is set to a non-existing file' do
+ before do
+ allow(Dir).to receive(:glob).and_return(
+ [
+ '1451606400_2016_01_01_gitlab_backup.tar'
+ ]
+ )
+ allow(File).to receive(:exist?).and_return(false)
+
+ stub_env('PREVIOUS_BACKUP', 'wrong')
+ end
+
+ it 'fails the operation and prints an error' do
+ expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
+ expect(File).to have_received(:exist?).with('wrong_gitlab_backup.tar')
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('The backup file wrong_gitlab_backup.tar does not exist'))
+ end
+ end
+
+ context 'when PREVIOUS_BACKUP variable is set to a correct file' do
+ let(:full_backup_id) { 'some_previous_backup' }
+
+ before do
+ allow(Gitlab::BackupLogger).to receive(:info)
+ allow(Dir).to receive(:glob).and_return(
+ [
+ 'some_previous_backup_gitlab_backup.tar'
+ ]
+ )
+ allow(File).to receive(:exist?).with('some_previous_backup_gitlab_backup.tar').and_return(true)
+ allow(Kernel).to receive(:system).and_return(true)
+
+ stub_env('PREVIOUS_BACKUP', '/ignored/path/some_previous_backup')
+ end
+
+ it 'unpacks and packs the backup' do
+ travel_to(backup_time) do
+ subject.create # rubocop:disable Rails/SaveBang
+ end
+
+ expect(Kernel).to have_received(:system).with(*unpack_tar_cmdline)
+ expect(Kernel).to have_received(:system).with(*pack_tar_cmdline)
+ end
+
+ context 'untar fails' do
+ before do
+ expect(Kernel).to receive(:system).with(*unpack_tar_cmdline).and_return(false)
+ end
+
+ it 'logs a failure' do
+ expect do
+ travel_to(backup_time) do
+ subject.create # rubocop:disable Rails/SaveBang
+ end
+ end.to raise_error(SystemExit)
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Unpacking backup failed')
+ end
+ end
+
+ context 'tar fails' do
+ before do
+ expect(Kernel).to receive(:system).with(*pack_tar_cmdline).and_return(false)
+ end
+
+ it 'logs a failure' do
+ expect do
+ travel_to(backup_time) do
+ subject.create # rubocop:disable Rails/SaveBang
+ end
+ end.to raise_error(Backup::Error, 'Backup failed')
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: "Creating archive #{pack_tar_file} failed")
+ end
+ end
+
+ context 'on version mismatch' do
+ let(:backup_information) do
+ {
+ backup_created_at: Time.zone.parse('2018-01-01'),
+ gitlab_version: "not #{gitlab_version}"
+ }
+ end
+
+ it 'stops the process' do
+ expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('GitLab version mismatch'))
+ end
+ end
+ end
+
context 'when there is a non-tarred backup in the directory' do
+ let(:full_backup_id) { "1514764800_2018_01_01_#{Gitlab::VERSION}" }
+ let(:backup_information) do
+ {
+ backup_created_at: Time.zone.parse('2018-01-01'),
+ gitlab_version: gitlab_version,
+ skipped: 'tar'
+ }
+ end
+
before do
allow(Dir).to receive(:glob).and_return(
[
'backup_information.yml'
]
)
- allow(File).to receive(:exist?).and_return(true)
+ allow(File).to receive(:exist?).with(File.join(Gitlab.config.backup.path, 'backup_information.yml')).and_return(true)
+ stub_env('SKIP', 'something')
end
- it 'selects the non-tarred backup to restore from' do
- subject.create # rubocop:disable Rails/SaveBang
+ after do
+ FileUtils.rm(File.join(Gitlab.config.backup.path, 'backup_information.yml'), force: true)
+ end
+
+ it 'updates the non-tarred backup' do
+ travel_to(backup_time) do
+ subject.create # rubocop:disable Rails/SaveBang
+ end
expect(progress).to have_received(:puts)
.with(a_string_matching('Non tarred backup found '))
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching("Backup #{backup_id} is done"))
+ expect(YAML.load_file(File.join(Gitlab.config.backup.path, 'backup_information.yml'))).to include(
+ backup_created_at: backup_time,
+ full_backup_id: full_backup_id,
+ gitlab_version: Gitlab::VERSION,
+ skipped: 'something,tar'
+ )
end
context 'on version mismatch' do
diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb
index c6f611e727c..1581e4793e3 100644
--- a/spec/lib/backup/repositories_spec.rb
+++ b/spec/lib/backup/repositories_spec.rb
@@ -5,13 +5,15 @@ require 'spec_helper'
RSpec.describe Backup::Repositories do
let(:progress) { spy(:stdout) }
let(:strategy) { spy(:strategy) }
+ let(:storages) { [] }
let(:destination) { 'repositories' }
let(:backup_id) { 'backup_id' }
subject do
described_class.new(
progress,
- strategy: strategy
+ strategy: strategy,
+ storages: storages
)
end
@@ -67,17 +69,50 @@ RSpec.describe Backup::Repositories do
end.count
create_list(:project, 2, :repository)
+ create_list(:snippet, 2, :repository)
expect do
subject.dump(destination, backup_id)
end.not_to exceed_query_limit(control_count)
end
+
+ describe 'storages' do
+ let(:storages) { %w{default} }
+
+ let_it_be(:project) { create(:project, :repository) }
+
+ before do
+ stub_storage_settings('test_second_storage' => {
+ 'gitaly_address' => Gitlab.config.repositories.storages.default.gitaly_address,
+ 'path' => TestEnv::SECOND_STORAGE_PATH
+ })
+ end
+
+ it 'calls enqueue for all repositories on the specified storage', :aggregate_failures do
+ excluded_project = create(:project, :repository, repository_storage: 'test_second_storage')
+ excluded_project_snippet = create(:project_snippet, :repository, project: excluded_project)
+ excluded_project_snippet.track_snippet_repository('test_second_storage')
+ excluded_personal_snippet = create(:personal_snippet, :repository, author: excluded_project.first_owner)
+ excluded_personal_snippet.track_snippet_repository('test_second_storage')
+
+ subject.dump(destination, backup_id)
+
+ expect(strategy).to have_received(:start).with(:create, destination, backup_id: backup_id)
+ expect(strategy).not_to have_received(:enqueue).with(excluded_project, Gitlab::GlRepository::PROJECT)
+ expect(strategy).not_to have_received(:enqueue).with(excluded_project_snippet, Gitlab::GlRepository::SNIPPET)
+ expect(strategy).not_to have_received(:enqueue).with(excluded_personal_snippet, Gitlab::GlRepository::SNIPPET)
+ expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
+ expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::WIKI)
+ expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN)
+ expect(strategy).to have_received(:finish!)
+ end
+ end
end
describe '#restore' do
- let_it_be(:project) { create(:project) }
- let_it_be(:personal_snippet) { create(:personal_snippet, author: project.first_owner) }
- let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.first_owner) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:personal_snippet) { create(:personal_snippet, :repository, author: project.first_owner) }
+ let_it_be(:project_snippet) { create(:project_snippet, :repository, project: project, author: project.first_owner) }
it 'calls enqueue for each repository type', :aggregate_failures do
subject.restore(destination)
@@ -116,9 +151,6 @@ RSpec.describe Backup::Repositories do
context 'cleanup snippets' do
before do
- create(:snippet_repository, snippet: personal_snippet)
- create(:snippet_repository, snippet: project_snippet)
-
error_response = ServiceResponse.error(message: "Repository has more than one branch")
allow(Snippets::RepositoryValidationService).to receive_message_chain(:new, :execute).and_return(error_response)
end
@@ -146,5 +178,35 @@ RSpec.describe Backup::Repositories do
expect(gitlab_shell.repository_exists?(shard_name, path)).to eq false
end
end
+
+ context 'storages' do
+ let(:storages) { %w{default} }
+
+ before do
+ stub_storage_settings('test_second_storage' => {
+ 'gitaly_address' => Gitlab.config.repositories.storages.default.gitaly_address,
+ 'path' => TestEnv::SECOND_STORAGE_PATH
+ })
+ end
+
+ it 'calls enqueue for all repositories on the specified storage', :aggregate_failures do
+ excluded_project = create(:project, :repository, repository_storage: 'test_second_storage')
+ excluded_project_snippet = create(:project_snippet, :repository, project: excluded_project)
+ excluded_project_snippet.track_snippet_repository('test_second_storage')
+ excluded_personal_snippet = create(:personal_snippet, :repository, author: excluded_project.first_owner)
+ excluded_personal_snippet.track_snippet_repository('test_second_storage')
+
+ subject.restore(destination)
+
+ expect(strategy).to have_received(:start).with(:restore, destination)
+ expect(strategy).not_to have_received(:enqueue).with(excluded_project, Gitlab::GlRepository::PROJECT)
+ expect(strategy).not_to have_received(:enqueue).with(excluded_project_snippet, Gitlab::GlRepository::SNIPPET)
+ expect(strategy).not_to have_received(:enqueue).with(excluded_personal_snippet, Gitlab::GlRepository::SNIPPET)
+ expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
+ expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::WIKI)
+ expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN)
+ expect(strategy).to have_received(:finish!)
+ end
+ end
end
end
diff --git a/spec/lib/banzai/filter/image_lazy_load_filter_spec.rb b/spec/lib/banzai/filter/image_lazy_load_filter_spec.rb
index 9f5aa558f24..5b32be0ea62 100644
--- a/spec/lib/banzai/filter/image_lazy_load_filter_spec.rb
+++ b/spec/lib/banzai/filter/image_lazy_load_filter_spec.rb
@@ -23,6 +23,11 @@ RSpec.describe Banzai::Filter::ImageLazyLoadFilter do
expect(doc.at_css('img')['class']).to eq 'test lazy'
end
+ it 'adds a async decoding attribute' do
+ doc = filter(image_with_class('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg', 'test'))
+ expect(doc.at_css('img')['decoding']).to eq 'async'
+ end
+
it 'transforms the image src to a data-src' do
doc = filter(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))
expect(doc.at_css('img')['data-src']).to eq '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'
diff --git a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb
index c493cb77c98..c6f0e592cdf 100644
--- a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb
@@ -15,6 +15,14 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
let(:issue_path) { "/#{issue.project.namespace.path}/#{issue.project.path}/-/issues/#{issue.iid}" }
let(:issue_url) { "http://#{Gitlab.config.gitlab.host}#{issue_path}" }
+ shared_examples 'a reference with issue type information' do
+ it 'contains issue-type as a data attribute' do
+ doc = reference_filter("Fixed #{reference}")
+
+ expect(doc.css('a').first.attr('data-issue-type')).to eq('issue')
+ end
+ end
+
it 'requires project context' do
expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
end
@@ -44,6 +52,8 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'a reference with issue type information'
+
it 'links to a valid reference' do
doc = reference_filter("Fixed #{reference}")
@@ -158,6 +168,8 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'a reference with issue type information'
+
it 'ignores valid references when cross-reference project uses external tracker' do
expect_any_instance_of(described_class).to receive(:find_object)
.with(project2, issue.iid)
@@ -208,6 +220,8 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'a reference with issue type information'
+
it 'ignores valid references when cross-reference project uses external tracker' do
expect_any_instance_of(described_class).to receive(:find_object)
.with(project2, issue.iid)
@@ -258,6 +272,8 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'a reference with issue type information'
+
it 'ignores valid references when cross-reference project uses external tracker' do
expect_any_instance_of(described_class).to receive(:find_object)
.with(project2, issue.iid)
@@ -307,6 +323,8 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'a reference with issue type information'
+
it 'links to a valid reference' do
doc = reference_filter("See #{reference}")
@@ -342,6 +360,8 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'a reference with issue type information'
+
it 'links to a valid reference' do
doc = reference_filter("See #{reference_link}")
@@ -371,6 +391,8 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'a reference with issue type information'
+
it 'links to a valid reference' do
doc = reference_filter("See #{reference_link}")
diff --git a/spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb b/spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb
new file mode 100644
index 00000000000..09d2919c6c4
--- /dev/null
+++ b/spec/lib/banzai/pipeline/incident_management/timeline_event_pipeline_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Pipeline::IncidentManagement::TimelineEventPipeline do
+ let_it_be(:project) { create(:project) }
+
+ describe '.filters' do
+ it 'contains required filters' do
+ expect(described_class.filters).to eq(
+ [
+ *Banzai::Pipeline::PlainMarkdownPipeline.filters,
+ *Banzai::Pipeline::GfmPipeline.reference_filters,
+ Banzai::Filter::EmojiFilter,
+ Banzai::Filter::SanitizationFilter,
+ Banzai::Filter::ExternalLinkFilter,
+ Banzai::Filter::ImageLinkFilter
+ ]
+ )
+ end
+ end
+
+ describe '.to_html' do
+ subject(:output) { described_class.to_html(markdown, project: project) }
+
+ context 'when markdown contains font style transformations' do
+ let(:markdown) { '**bold** _italic_ `code`' }
+
+ it { is_expected.to eq('<p><strong>bold</strong> <em>italic</em> <code>code</code></p>') }
+ end
+
+ context 'when markdown contains banned HTML tags' do
+ let(:markdown) { '<div>div</div><h1>h1</h1>' }
+
+ it 'filters out banned tags' do
+ is_expected.to eq(' div h1 ')
+ end
+ end
+
+ context 'when markdown contains links' do
+ let(:markdown) { '[GitLab](https://gitlab.com)' }
+
+ it do
+ is_expected.to eq(
+ %q(<p><a href="https://gitlab.com" rel="nofollow noreferrer noopener" target="_blank">GitLab</a></p>)
+ )
+ end
+ end
+
+ context 'when markdown contains images' do
+ let(:markdown) { '![Name](/path/to/image.png)' }
+
+ it 'replaces image with a link to the image' do
+ # rubocop:disable Layout/LineLength
+ is_expected.to eq(
+ '<p><a class="with-attachment-icon" href="/path/to/image.png" target="_blank" rel="noopener noreferrer">Name</a></p>'
+ )
+ # rubocop:enable Layout/LineLength
+ end
+ end
+
+ context 'when markdown contains emojis' do
+ let(:markdown) { ':+1:👍' }
+
+ it { is_expected.to eq('<p>👍👍</p>') }
+ end
+
+ context 'when markdown contains a reference to an issue' do
+ let!(:issue) { create(:issue, project: project) }
+ let(:markdown) { "issue ##{issue.iid}" }
+
+ it 'contains a link to the issue' do
+ is_expected.to match(%r(<p>issue <a href="[\w/]+-/issues/#{issue.iid}".*>##{issue.iid}</a></p>))
+ end
+ end
+
+ context 'when markdown contains a reference to a merge request' do
+ let!(:mr) { create(:merge_request, source_project: project, target_project: project) }
+ let(:markdown) { "MR !#{mr.iid}" }
+
+ it 'contains a link to the merge request' do
+ is_expected.to match(%r(<p>MR <a href="[\w/]+-/merge_requests/#{mr.iid}".*>!#{mr.iid}</a></p>))
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/common/extractors/json_extractor_spec.rb b/spec/lib/bulk_imports/common/extractors/json_extractor_spec.rb
new file mode 100644
index 00000000000..2c167cc485c
--- /dev/null
+++ b/spec/lib/bulk_imports/common/extractors/json_extractor_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'zlib'
+
+RSpec.describe BulkImports::Common::Extractors::JsonExtractor do
+ subject { described_class.new(relation: 'self') }
+
+ let_it_be(:tmpdir) { Dir.mktmpdir }
+ let_it_be(:import) { create(:bulk_import) }
+ let_it_be(:config) { create(:bulk_import_configuration, bulk_import: import) }
+ let_it_be(:entity) { create(:bulk_import_entity, bulk_import: import) }
+ let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
+
+ before do
+ allow(FileUtils).to receive(:remove_entry).with(any_args).and_call_original
+
+ subject.instance_variable_set(:@tmpdir, tmpdir)
+ end
+
+ after(:all) do
+ FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir)
+ end
+
+ describe '#extract' do
+ before do
+ Zlib::GzipWriter.open(File.join(tmpdir, 'self.json.gz')) do |gz|
+ gz.write '{"name": "Name","description": "Description","avatar":{"url":null}}'
+ end
+
+ expect(BulkImports::FileDownloadService).to receive(:new)
+ .with(
+ configuration: context.configuration,
+ relative_url: entity.relation_download_url_path('self'),
+ tmpdir: tmpdir,
+ filename: 'self.json.gz')
+ .and_return(instance_double(BulkImports::FileDownloadService, execute: nil))
+ end
+
+ it 'returns ExtractedData', :aggregate_failures do
+ extracted_data = subject.extract(context)
+
+ expect(extracted_data).to be_instance_of(BulkImports::Pipeline::ExtractedData)
+ expect(extracted_data.data).to contain_exactly(
+ { 'name' => 'Name', 'description' => 'Description', 'avatar' => { 'url' => nil } }
+ )
+ end
+ end
+
+ describe '#remove_tmpdir' do
+ it 'removes tmp dir' do
+ expect(FileUtils).to receive(:remove_entry).with(tmpdir).once
+
+ subject.remove_tmpdir
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb b/spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb
index d6e19a5fc85..8b63234ba50 100644
--- a/spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb
+++ b/spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
require 'spec_helper'
+require 'zlib'
RSpec.describe BulkImports::Common::Extractors::NdjsonExtractor do
let_it_be(:tmpdir) { Dir.mktmpdir }
- let_it_be(:filepath) { 'spec/fixtures/bulk_imports/gz/labels.ndjson.gz' }
let_it_be(:import) { create(:bulk_import) }
let_it_be(:config) { create(:bulk_import_configuration, bulk_import: import) }
let_it_be(:entity) { create(:bulk_import_entity, bulk_import: import) }
@@ -25,21 +25,30 @@ RSpec.describe BulkImports::Common::Extractors::NdjsonExtractor do
describe '#extract' do
before do
- FileUtils.copy_file(filepath, File.join(tmpdir, 'labels.ndjson.gz'))
-
- allow_next_instance_of(BulkImports::FileDownloadService) do |service|
- allow(service).to receive(:execute)
+ Zlib::GzipWriter.open(File.join(tmpdir, 'labels.ndjson.gz')) do |gz|
+ gz.write [
+ '{"title": "Title 1","description": "Description 1","type":"GroupLabel"}',
+ '{"title": "Title 2","description": "Description 2","type":"GroupLabel"}'
+ ].join("\n")
end
+
+ expect(BulkImports::FileDownloadService).to receive(:new)
+ .with(
+ configuration: context.configuration,
+ relative_url: entity.relation_download_url_path('labels'),
+ tmpdir: tmpdir,
+ filename: 'labels.ndjson.gz')
+ .and_return(instance_double(BulkImports::FileDownloadService, execute: nil))
end
- it 'returns ExtractedData' do
+ it 'returns ExtractedData', :aggregate_failures do
extracted_data = subject.extract(context)
- label = extracted_data.data.first.first
expect(extracted_data).to be_instance_of(BulkImports::Pipeline::ExtractedData)
- expect(label['title']).to include('Label')
- expect(label['description']).to include('Label')
- expect(label['type']).to eq('GroupLabel')
+ expect(extracted_data.data.to_a).to contain_exactly(
+ [{ "title" => "Title 1", "description" => "Description 1", "type" => "GroupLabel" }, 0],
+ [{ "title" => "Title 2", "description" => "Description 2", "type" => "GroupLabel" }, 1]
+ )
end
end
diff --git a/spec/lib/bulk_imports/groups/pipelines/group_attributes_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/group_attributes_pipeline_spec.rb
new file mode 100644
index 00000000000..7ac417afa0b
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/pipelines/group_attributes_pipeline_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Pipelines::GroupAttributesPipeline do
+ subject(:pipeline) { described_class.new(context) }
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:bulk_import) { create(:bulk_import, user: user) }
+ let_it_be(:entity) { create(:bulk_import_entity, :group_entity, group: group, bulk_import: bulk_import) }
+ let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
+
+ let(:group_attributes) do
+ {
+ 'id' => 1,
+ 'name' => 'Group name',
+ 'path' => 'group-path',
+ 'description' => 'description',
+ 'avatar' => {
+ 'url' => nil
+ },
+ 'membership_lock' => true,
+ 'traversal_ids' => [
+ 2
+ ]
+ }
+ end
+
+ describe '#run' do
+ before do
+ allow_next_instance_of(BulkImports::Common::Extractors::JsonExtractor) do |extractor|
+ allow(extractor).to receive(:extract).and_return(
+ BulkImports::Pipeline::ExtractedData.new(data: group_attributes)
+ )
+ end
+ end
+
+ it 'imports allowed group attributes' do
+ expect(Groups::UpdateService).to receive(:new).with(group, user, { membership_lock: true }).and_call_original
+
+ pipeline.run
+
+ expect(group).to have_attributes(membership_lock: true)
+ end
+ end
+
+ describe '#transform' do
+ it 'fetches only allowed attributes and symbolize keys' do
+ transformed_data = pipeline.transform(context, group_attributes)
+
+ expect(transformed_data).to eq({ membership_lock: true })
+ end
+
+ context 'when there is no data to transform' do
+ let(:group_attributes) { nil }
+
+ it do
+ transformed_data = pipeline.transform(context, group_attributes)
+
+ expect(transformed_data).to eq(nil)
+ end
+ end
+ end
+
+ describe '#after_run' do
+ it 'calls extractor#remove_tmpdir' do
+ expect_next_instance_of(BulkImports::Common::Extractors::JsonExtractor) do |extractor|
+ expect(extractor).to receive(:remove_tmpdir)
+ end
+
+ pipeline.after_run(nil)
+ end
+ end
+
+ describe '.relation' do
+ it { expect(described_class.relation).to eq('self') }
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb
index 39e782dc093..441a34b0c74 100644
--- a/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe BulkImports::Groups::Pipelines::GroupPipeline do
let(:group_data) do
{
- 'name' => 'source_name',
+ 'name' => 'Source Group Name',
'full_path' => 'source/full/path',
'visibility' => 'private',
'project_creation_level' => 'developer',
diff --git a/spec/lib/bulk_imports/groups/pipelines/namespace_settings_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/namespace_settings_pipeline_spec.rb
new file mode 100644
index 00000000000..90b63453b88
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/pipelines/namespace_settings_pipeline_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Pipelines::NamespaceSettingsPipeline do
+ subject(:pipeline) { described_class.new(context) }
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, namespace_settings: create(:namespace_settings) ) }
+ let_it_be(:bulk_import) { create(:bulk_import, user: user) }
+ let_it_be(:entity) { create(:bulk_import_entity, :group_entity, group: group, bulk_import: bulk_import) }
+ let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ describe '#run' do
+ before do
+ allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor|
+ namespace_settings_attributes = {
+ 'namespace_id' => 22,
+ 'prevent_forking_outside_group' => true,
+ 'prevent_sharing_groups_outside_hierarchy' => true
+ }
+ allow(extractor).to receive(:extract).and_return(
+ BulkImports::Pipeline::ExtractedData.new(data: [[namespace_settings_attributes, 0]])
+ )
+ end
+ end
+
+ it 'imports allowed namespace settings attributes' do
+ expect(Groups::UpdateService).to receive(:new).with(
+ group, user, { prevent_sharing_groups_outside_hierarchy: true }
+ ).and_call_original
+
+ pipeline.run
+
+ expect(group.namespace_settings).to have_attributes(prevent_sharing_groups_outside_hierarchy: true)
+ end
+ end
+
+ describe '#transform' do
+ it 'fetches only allowed attributes and symbolize keys' do
+ all_model_attributes = NamespaceSetting.new.attributes
+
+ transformed_data = pipeline.transform(context, [all_model_attributes, 0])
+
+ expect(transformed_data.keys).to match_array([:prevent_sharing_groups_outside_hierarchy])
+ end
+
+ context 'when there is no data to transform' do
+ it do
+ namespace_settings_attributes = nil
+
+ transformed_data = pipeline.transform(context, namespace_settings_attributes)
+
+ expect(transformed_data).to eq(nil)
+ end
+ end
+ end
+
+ describe '#after_run' do
+ it 'calls extractor#remove_tmpdir' do
+ expect_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor|
+ expect(extractor).to receive(:remove_tmpdir)
+ end
+
+ context = instance_double(BulkImports::Pipeline::Context)
+
+ pipeline.after_run(context)
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb
index e4a41428dd2..6949ac59948 100644
--- a/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, path: 'group') }
- let_it_be(:parent) { create(:group, name: 'imported-group', path: 'imported-group') }
+ let_it_be(:parent) { create(:group, name: 'Imported Group', path: 'imported-group') }
let_it_be(:parent_entity) { create(:bulk_import_entity, destination_namespace: parent.full_path, group: parent) }
let_it_be(:tracker) { create(:bulk_import_tracker, entity: parent_entity) }
let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
@@ -14,8 +14,8 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
let(:extracted_data) do
BulkImports::Pipeline::ExtractedData.new(data: {
- 'name' => 'subgroup',
- 'full_path' => 'parent/subgroup'
+ 'path' => 'sub-group',
+ 'full_path' => 'parent/sub-group'
})
end
@@ -33,9 +33,9 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
subgroup_entity = BulkImports::Entity.last
- expect(subgroup_entity.source_full_path).to eq 'parent/subgroup'
+ expect(subgroup_entity.source_full_path).to eq 'parent/sub-group'
expect(subgroup_entity.destination_namespace).to eq 'imported-group'
- expect(subgroup_entity.destination_name).to eq 'subgroup'
+ expect(subgroup_entity.destination_name).to eq 'sub-group'
expect(subgroup_entity.parent_id).to eq parent_entity.id
end
end
@@ -51,9 +51,7 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
destination_namespace: parent_entity.group.full_path,
parent_id: parent_entity.id
}
-
expect { subject.load(context, data) }.to change(BulkImports::Entity, :count).by(1)
-
subgroup_entity = BulkImports::Entity.last
expect(subgroup_entity.source_full_path).to eq 'parent/subgroup'
diff --git a/spec/lib/bulk_imports/groups/stage_spec.rb b/spec/lib/bulk_imports/groups/stage_spec.rb
index 645dee4a6f1..8ce25ff87d7 100644
--- a/spec/lib/bulk_imports/groups/stage_spec.rb
+++ b/spec/lib/bulk_imports/groups/stage_spec.rb
@@ -11,7 +11,9 @@ RSpec.describe BulkImports::Groups::Stage do
let(:pipelines) do
[
[0, BulkImports::Groups::Pipelines::GroupPipeline],
+ [1, BulkImports::Groups::Pipelines::GroupAttributesPipeline],
[1, BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline],
+ [1, BulkImports::Groups::Pipelines::NamespaceSettingsPipeline],
[1, BulkImports::Common::Pipelines::MembersPipeline],
[1, BulkImports::Common::Pipelines::LabelsPipeline],
[1, BulkImports::Common::Pipelines::MilestonesPipeline],
diff --git a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
index 75d8c15088a..c42ca9bef3b 100644
--- a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
+++ b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
@@ -6,7 +6,6 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do
describe '#transform' do
let_it_be(:user) { create(:user) }
let_it_be(:parent) { create(:group) }
- let_it_be(:group) { create(:group, name: 'My Source Group', parent: parent) }
let_it_be(:bulk_import) { create(:bulk_import, user: user) }
let_it_be(:entity) do
@@ -14,7 +13,7 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do
:bulk_import_entity,
bulk_import: bulk_import,
source_full_path: 'source/full/path',
- destination_name: group.name,
+ destination_name: 'destination-name-path',
destination_namespace: parent.full_path
)
end
@@ -24,7 +23,8 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do
let(:data) do
{
- 'name' => 'source_name',
+ 'name' => 'Source Group Name',
+ 'path' => 'source-group-path',
'full_path' => 'source/full/path',
'visibility' => 'private',
'project_creation_level' => 'developer',
@@ -34,23 +34,27 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do
subject { described_class.new }
- it 'transforms name to destination name' do
- transformed_data = subject.transform(context, data)
+ it 'returns original data with some keys transformed' do
+ transformed_data = subject.transform(context, { 'name' => 'Name', 'description' => 'Description' })
- expect(transformed_data['name']).not_to eq('source_name')
- expect(transformed_data['name']).to eq(group.name)
+ expect(transformed_data).to eq({
+ 'name' => 'Name',
+ 'description' => 'Description',
+ 'parent_id' => parent.id,
+ 'path' => 'destination-name-path'
+ })
end
- it 'removes full path' do
+ it 'transforms path from destination_name' do
transformed_data = subject.transform(context, data)
- expect(transformed_data).not_to have_key('full_path')
+ expect(transformed_data['path']).to eq(entity.destination_name)
end
- it 'transforms path to parameterized name' do
+ it 'removes full path' do
transformed_data = subject.transform(context, data)
- expect(transformed_data['path']).to eq(group.name.parameterize)
+ expect(transformed_data).not_to have_key('full_path')
end
it 'transforms visibility level' do
diff --git a/spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb
index 2f97a5721e7..6450d90ec0f 100644
--- a/spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb
+++ b/spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb
@@ -9,14 +9,14 @@ RSpec.describe BulkImports::Groups::Transformers::SubgroupToEntityTransformer do
parent_entity = instance_double(BulkImports::Entity, group: parent, id: 1)
context = instance_double(BulkImports::Pipeline::Context, entity: parent_entity)
subgroup_data = {
- "name" => "subgroup",
- "full_path" => "parent/subgroup"
+ "path" => "sub-group",
+ "full_path" => "parent/sub-group"
}
expect(subject.transform(context, subgroup_data)).to eq(
source_type: :group_entity,
- source_full_path: "parent/subgroup",
- destination_name: "subgroup",
+ source_full_path: "parent/sub-group",
+ destination_name: "sub-group",
destination_namespace: parent.full_path,
parent_id: 1
)
diff --git a/spec/lib/bulk_imports/ndjson_pipeline_spec.rb b/spec/lib/bulk_imports/ndjson_pipeline_spec.rb
index 8ea6ceb7619..25edc9feea8 100644
--- a/spec/lib/bulk_imports/ndjson_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/ndjson_pipeline_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe BulkImports::NdjsonPipeline do
subject { NdjsonPipelineClass.new(group, user) }
it 'marks pipeline as ndjson' do
- expect(NdjsonPipelineClass.ndjson_pipeline?).to eq(true)
+ expect(NdjsonPipelineClass.file_extraction_pipeline?).to eq(true)
end
describe '#deep_transform_relation!' do
diff --git a/spec/lib/bulk_imports/pipeline_spec.rb b/spec/lib/bulk_imports/pipeline_spec.rb
index 48c265d6118..e4ecf99dab0 100644
--- a/spec/lib/bulk_imports/pipeline_spec.rb
+++ b/spec/lib/bulk_imports/pipeline_spec.rb
@@ -63,7 +63,7 @@ RSpec.describe BulkImports::Pipeline do
BulkImports::MyPipeline.transformer(klass, options)
BulkImports::MyPipeline.loader(klass, options)
BulkImports::MyPipeline.abort_on_failure!
- BulkImports::MyPipeline.ndjson_pipeline!
+ BulkImports::MyPipeline.file_extraction_pipeline!
expect(BulkImports::MyPipeline.get_extractor).to eq({ klass: klass, options: options })
@@ -75,7 +75,7 @@ RSpec.describe BulkImports::Pipeline do
expect(BulkImports::MyPipeline.get_loader).to eq({ klass: klass, options: options })
expect(BulkImports::MyPipeline.abort_on_failure?).to eq(true)
- expect(BulkImports::MyPipeline.ndjson_pipeline?).to eq(true)
+ expect(BulkImports::MyPipeline.file_extraction_pipeline?).to eq(true)
end
end
end
diff --git a/spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb
index df7ff5b8062..aa9c7486c27 100644
--- a/spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb
@@ -23,7 +23,6 @@ RSpec.describe BulkImports::Projects::Pipelines::ProjectAttributesPipeline do
'merge_requests_ff_only_enabled' => true,
'issues_template' => 'test',
'shared_runners_enabled' => true,
- 'build_coverage_regex' => 'build_coverage_regex',
'build_allow_git_fetch' => true,
'build_timeout' => 3600,
'pending_delete' => false,
@@ -177,4 +176,8 @@ RSpec.describe BulkImports::Projects::Pipelines::ProjectAttributesPipeline do
end
end
end
+
+ describe '.relation' do
+ it { expect(described_class.relation).to eq('self') }
+ end
end
diff --git a/spec/lib/bulk_imports/projects/pipelines/releases_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/releases_pipeline_spec.rb
new file mode 100644
index 00000000000..2279e66720e
--- /dev/null
+++ b/spec/lib/bulk_imports/projects/pipelines/releases_pipeline_spec.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Projects::Pipelines::ReleasesPipeline do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:bulk_import) { create(:bulk_import, user: user) }
+ let_it_be(:entity) do
+ create(
+ :bulk_import_entity,
+ :project_entity,
+ project: project,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_name: 'My Destination Project',
+ destination_namespace: group.full_path
+ )
+ end
+
+ let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
+
+ let(:attributes) { {} }
+ let(:release) do
+ {
+ 'tag' => '1.1',
+ 'name' => 'release 1.1',
+ 'description' => 'Release notes',
+ 'created_at' => '2019-12-26T10:17:14.621Z',
+ 'updated_at' => '2019-12-26T10:17:14.621Z',
+ 'released_at' => '2019-12-26T10:17:14.615Z',
+ 'sha' => '901de3a8bd5573f4a049b1457d28bc1592ba6bf9'
+ }.merge(attributes)
+ end
+
+ subject(:pipeline) { described_class.new(context) }
+
+ describe '#run' do
+ before do
+ group.add_owner(user)
+ with_index = [release, 0]
+
+ allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor|
+ allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: [with_index]))
+ end
+
+ pipeline.run
+ end
+
+ it 'imports release into destination project' do
+ expect(project.releases.count).to eq(1)
+
+ imported_release = project.releases.last
+
+ aggregate_failures do
+ expect(imported_release.tag).to eq(release['tag'])
+ expect(imported_release.name).to eq(release['name'])
+ expect(imported_release.description).to eq(release['description'])
+ expect(imported_release.created_at.to_s).to eq('2019-12-26 10:17:14 UTC')
+ expect(imported_release.updated_at.to_s).to eq('2019-12-26 10:17:14 UTC')
+ expect(imported_release.released_at.to_s).to eq('2019-12-26 10:17:14 UTC')
+ expect(imported_release.sha).to eq(release['sha'])
+ end
+ end
+
+ context 'links' do
+ let(:link) do
+ {
+ 'url' => 'http://localhost/namespace6/project6/-/jobs/140463678/artifacts/download',
+ 'name' => 'release-1.1.dmg',
+ 'created_at' => '2019-12-26T10:17:14.621Z',
+ 'updated_at' => '2019-12-26T10:17:14.621Z'
+ }
+ end
+
+ let(:attributes) {{ 'links' => [link] }}
+
+ it 'restores release links' do
+ release_link = project.releases.last.links.first
+
+ aggregate_failures do
+ expect(release_link.url).to eq(link['url'])
+ expect(release_link.name).to eq(link['name'])
+ expect(release_link.created_at.to_s).to eq('2019-12-26 10:17:14 UTC')
+ expect(release_link.updated_at.to_s).to eq('2019-12-26 10:17:14 UTC')
+ end
+ end
+ end
+
+ context 'milestones' do
+ let(:milestone) do
+ {
+ 'iid' => 1,
+ 'state' => 'closed',
+ 'title' => 'test milestone',
+ 'description' => 'test milestone',
+ 'due_date' => '2016-06-14',
+ 'created_at' => '2016-06-14T15:02:04.415Z',
+ 'updated_at' => '2016-06-14T15:02:04.415Z'
+ }
+ end
+
+ let(:attributes) {{ 'milestone_releases' => [{ 'milestone' => milestone }] }}
+
+ it 'restores release milestone' do
+ release_milestone = project.releases.last.milestone_releases.first.milestone
+
+ aggregate_failures do
+ expect(release_milestone.iid).to eq(milestone['iid'])
+ expect(release_milestone.state).to eq(milestone['state'])
+ expect(release_milestone.title).to eq(milestone['title'])
+ expect(release_milestone.description).to eq(milestone['description'])
+ expect(release_milestone.due_date.to_s).to eq('2016-06-14')
+ expect(release_milestone.created_at.to_s).to eq('2016-06-14 15:02:04 UTC')
+ expect(release_milestone.updated_at.to_s).to eq('2016-06-14 15:02:04 UTC')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/projects/stage_spec.rb b/spec/lib/bulk_imports/projects/stage_spec.rb
index 9fce30f3a81..e81d9cc5fb4 100644
--- a/spec/lib/bulk_imports/projects/stage_spec.rb
+++ b/spec/lib/bulk_imports/projects/stage_spec.rb
@@ -20,10 +20,11 @@ RSpec.describe BulkImports::Projects::Stage do
[4, BulkImports::Projects::Pipelines::MergeRequestsPipeline],
[4, BulkImports::Projects::Pipelines::ExternalPullRequestsPipeline],
[4, BulkImports::Projects::Pipelines::ProtectedBranchesPipeline],
- [4, BulkImports::Projects::Pipelines::CiPipelinesPipeline],
[4, BulkImports::Projects::Pipelines::ProjectFeaturePipeline],
[4, BulkImports::Projects::Pipelines::ContainerExpirationPolicyPipeline],
[4, BulkImports::Projects::Pipelines::ServiceDeskSettingPipeline],
+ [4, BulkImports::Projects::Pipelines::ReleasesPipeline],
+ [5, BulkImports::Projects::Pipelines::CiPipelinesPipeline],
[5, BulkImports::Common::Pipelines::WikiPipeline],
[5, BulkImports::Common::Pipelines::UploadsPipeline],
[5, BulkImports::Common::Pipelines::LfsObjectsPipeline],
diff --git a/spec/lib/constraints/feature_constrainer_spec.rb b/spec/lib/constraints/feature_constrainer_spec.rb
deleted file mode 100644
index c98dc694186..00000000000
--- a/spec/lib/constraints/feature_constrainer_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Constraints::FeatureConstrainer do
- describe '#matches' do
- it 'calls Feature.enabled? with the correct arguments' do
- gate = stub_feature_flag_gate("an object")
-
- expect(Feature).to receive(:enabled?)
- .with(:feature_name, gate, default_enabled: true)
-
- described_class.new(:feature_name, gate, default_enabled: true).matches?(double('request'))
- end
- end
-end
diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb
index 39a594eba5c..f9e08df3399 100644
--- a/spec/lib/container_registry/client_spec.rb
+++ b/spec/lib/container_registry/client_spec.rb
@@ -199,69 +199,16 @@ RSpec.describe ContainerRegistry::Client do
let(:redirect_location) { 'http://redirect?foo=bar&test=signature=' }
it_behaves_like 'handling redirects'
-
- context 'with container_registry_follow_redirects_middleware disabled' do
- before do
- stub_feature_flags(container_registry_follow_redirects_middleware: false)
- end
-
- it 'follows the redirect' do
- expect(Faraday::Utils).to receive(:escape).with('foo').and_call_original
- expect(Faraday::Utils).to receive(:escape).with('bar').and_call_original
- expect(Faraday::Utils).to receive(:escape).with('test').and_call_original
- expect(Faraday::Utils).to receive(:escape).with('signature=').and_call_original
-
- expect_new_faraday(times: 2)
- expect(subject).to eq('Successfully redirected')
- end
- end
end
context 'with a redirect location with params ending with %3D' do
let(:redirect_location) { 'http://redirect?foo=bar&test=signature%3D' }
it_behaves_like 'handling redirects'
-
- context 'with container_registry_follow_redirects_middleware disabled' do
- before do
- stub_feature_flags(container_registry_follow_redirects_middleware: false)
- end
-
- it 'follows the redirect' do
- expect(Faraday::Utils).to receive(:escape).with('foo').and_call_original
- expect(Faraday::Utils).to receive(:escape).with('bar').and_call_original
- expect(Faraday::Utils).to receive(:escape).with('test').and_call_original
- expect(Faraday::Utils).to receive(:escape).with('signature=').and_call_original
-
- expect_new_faraday(times: 2)
- expect(subject).to eq('Successfully redirected')
- end
- end
end
end
it_behaves_like 'handling timeouts'
-
- # TODO Remove this context along with the
- # container_registry_follow_redirects_middleware feature flag
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/353291
- context 'faraday blob' do
- subject { client.send(:faraday_blob) }
-
- it 'has a follow redirects middleware' do
- expect(subject.builder.handlers).to include(::FaradayMiddleware::FollowRedirects)
- end
-
- context 'with container_registry_follow_redirects_middleware is disabled' do
- before do
- stub_feature_flags(container_registry_follow_redirects_middleware: false)
- end
-
- it 'has not a follow redirects middleware' do
- expect(subject.builder.handlers).not_to include(::FaradayMiddleware::FollowRedirects)
- end
- end
- end
end
describe '#upload_blob' do
diff --git a/spec/lib/container_registry/migration_spec.rb b/spec/lib/container_registry/migration_spec.rb
index 6c0fc94e27f..81dac354b8b 100644
--- a/spec/lib/container_registry/migration_spec.rb
+++ b/spec/lib/container_registry/migration_spec.rb
@@ -58,21 +58,25 @@ RSpec.describe ContainerRegistry::Migration do
describe '.capacity' do
subject { described_class.capacity }
- where(:ff_1_enabled, :ff_10_enabled, :ff_25_enabled, :expected_result) do
- false | false | false | 0
- true | false | false | 1
- true | true | false | 10
- true | true | true | 25
- false | true | false | 10
- false | true | true | 25
- false | false | true | 25
- true | false | true | 25
+ where(:ff_1_enabled, :ff_2_enabled, :ff_5_enabled, :ff_10_enabled, :ff_25_enabled, :expected_result) do
+ false | false | false | false | false | 0
+ true | false | false | false | false | 1
+ false | true | false | false | false | 2
+ true | true | false | false | false | 2
+ false | false | true | false | false | 5
+ true | true | true | false | false | 5
+ false | false | false | true | false | 10
+ true | true | true | true | false | 10
+ false | false | false | false | true | 25
+ true | true | true | true | true | 25
end
with_them do
before do
stub_feature_flags(
container_registry_migration_phase2_capacity_1: ff_1_enabled,
+ container_registry_migration_phase2_capacity_2: ff_2_enabled,
+ container_registry_migration_phase2_capacity_5: ff_5_enabled,
container_registry_migration_phase2_capacity_10: ff_10_enabled,
container_registry_migration_phase2_capacity_25: ff_25_enabled
)
@@ -154,6 +158,30 @@ RSpec.describe ContainerRegistry::Migration do
end
end
+ describe '.pre_import_timeout' do
+ let(:value) { 10.minutes }
+
+ before do
+ stub_application_setting(container_registry_pre_import_timeout: value)
+ end
+
+ it 'returns the matching application_setting' do
+ expect(described_class.pre_import_timeout).to eq(value)
+ end
+ end
+
+ describe '.import_timeout' do
+ let(:value) { 10.minutes }
+
+ before do
+ stub_application_setting(container_registry_import_timeout: value)
+ end
+
+ it 'returns the matching application_setting' do
+ expect(described_class.import_timeout).to eq(value)
+ end
+ end
+
describe '.target_plans' do
subject { described_class.target_plans }
@@ -185,4 +213,32 @@ RSpec.describe ContainerRegistry::Migration do
it { is_expected.to eq(false) }
end
end
+
+ describe '.enqueue_twice?' do
+ subject { described_class.enqueue_twice? }
+
+ it { is_expected.to eq(true) }
+
+ context 'feature flag disabled' do
+ before do
+ stub_feature_flags(container_registry_migration_phase2_enqueue_twice: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ describe '.enqueue_loop?' do
+ subject { described_class.enqueuer_loop? }
+
+ it { is_expected.to eq(true) }
+
+ context 'feature flag disabled' do
+ before do
+ stub_feature_flags(container_registry_migration_phase2_enqueuer_loop: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
end
diff --git a/spec/lib/error_tracking/collector/dsn_spec.rb b/spec/lib/error_tracking/collector/dsn_spec.rb
index af55e6f20ec..3aa8719fe38 100644
--- a/spec/lib/error_tracking/collector/dsn_spec.rb
+++ b/spec/lib/error_tracking/collector/dsn_spec.rb
@@ -3,24 +3,32 @@
require 'spec_helper'
RSpec.describe ErrorTracking::Collector::Dsn do
- describe '.build__url' do
- let(:gitlab) do
- double(
+ describe '.build_url' do
+ let(:setting) do
+ {
protocol: 'https',
https: true,
+ port: 443,
host: 'gitlab.example.com',
- port: '4567',
relative_url_root: nil
- )
+ }
end
subject { described_class.build_url('abcdef1234567890', 778) }
- it 'returns a valid URL' do
- allow(Settings).to receive(:gitlab).and_return(gitlab)
- allow(Settings).to receive(:gitlab_on_standard_port?).and_return(false)
+ it 'returns a valid URL without explicit port' do
+ stub_config_setting(setting)
- is_expected.to eq('https://abcdef1234567890@gitlab.example.com:4567/api/v4/error_tracking/collector/778')
+ is_expected.to eq('https://abcdef1234567890@gitlab.example.com/api/v4/error_tracking/collector/778')
+ end
+
+ context 'with non-standard port' do
+ it 'returns a valid URL with custom port' do
+ setting[:port] = 4567
+ stub_config_setting(setting)
+
+ is_expected.to eq('https://abcdef1234567890@gitlab.example.com:4567/api/v4/error_tracking/collector/778')
+ end
end
end
end
diff --git a/spec/lib/error_tracking/collector/sentry_auth_parser_spec.rb b/spec/lib/error_tracking/collector/sentry_auth_parser_spec.rb
index 4f00b1ec654..0e4bba04baa 100644
--- a/spec/lib/error_tracking/collector/sentry_auth_parser_spec.rb
+++ b/spec/lib/error_tracking/collector/sentry_auth_parser_spec.rb
@@ -5,11 +5,11 @@ require 'spec_helper'
RSpec.describe ErrorTracking::Collector::SentryAuthParser do
describe '.parse' do
let(:headers) { { 'X-Sentry-Auth' => "Sentry sentry_key=glet_1fedb514e17f4b958435093deb02048c" } }
- let(:request) { double('request', headers: headers) }
+ let(:request) { instance_double('ActionDispatch::Request', headers: headers) }
subject { described_class.parse(request) }
- context 'empty headers' do
+ context 'with empty headers' do
let(:headers) { {} }
it 'fails with exception' do
@@ -17,7 +17,7 @@ RSpec.describe ErrorTracking::Collector::SentryAuthParser do
end
end
- context 'missing sentry_key' do
+ context 'with missing sentry_key' do
let(:headers) { { 'X-Sentry-Auth' => "Sentry foo=bar" } }
it 'returns empty value for public_key' do
diff --git a/spec/lib/error_tracking/collector/sentry_request_parser_spec.rb b/spec/lib/error_tracking/collector/sentry_request_parser_spec.rb
index 06f4b64ce93..e86ee67c129 100644
--- a/spec/lib/error_tracking/collector/sentry_request_parser_spec.rb
+++ b/spec/lib/error_tracking/collector/sentry_request_parser_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe ErrorTracking::Collector::SentryRequestParser do
let(:body) { raw_event }
let(:headers) { { 'Content-Encoding' => '' } }
- let(:request) { double('request', headers: headers, body: StringIO.new(body)) }
+ let(:request) { instance_double('ActionDispatch::Request', headers: headers, body: StringIO.new(body)) }
subject { described_class.parse(request) }
@@ -22,7 +22,7 @@ RSpec.describe ErrorTracking::Collector::SentryRequestParser do
end
end
- context 'empty body content' do
+ context 'with empty body content' do
let(:body) { '' }
it 'fails with exception' do
@@ -30,7 +30,7 @@ RSpec.describe ErrorTracking::Collector::SentryRequestParser do
end
end
- context 'plain text sentry request' do
+ context 'with plain text sentry request' do
it_behaves_like 'valid parser'
end
end
diff --git a/spec/lib/feature/definition_spec.rb b/spec/lib/feature/definition_spec.rb
index 2f95f8eeab7..3d11ad4c0d8 100644
--- a/spec/lib/feature/definition_spec.rb
+++ b/spec/lib/feature/definition_spec.rb
@@ -54,22 +54,10 @@ RSpec.describe Feature::Definition do
describe '#valid_usage!' do
context 'validates type' do
it 'raises exception for invalid type' do
- expect { definition.valid_usage!(type_in_code: :invalid, default_enabled_in_code: false) }
+ expect { definition.valid_usage!(type_in_code: :invalid) }
.to raise_error(/The `type:` of `feature_flag` is not equal to config/)
end
end
-
- context 'validates default enabled' do
- it 'raises exception for different value' do
- expect { definition.valid_usage!(type_in_code: :development, default_enabled_in_code: false) }
- .to raise_error(/The `default_enabled:` of `feature_flag` is not equal to config/)
- end
-
- it 'allows passing `default_enabled: :yaml`' do
- expect { definition.valid_usage!(type_in_code: :development, default_enabled_in_code: :yaml) }
- .not_to raise_error
- end
- end
end
describe '.paths' do
@@ -165,18 +153,14 @@ RSpec.describe Feature::Definition do
using RSpec::Parameterized::TableSyntax
let(:definition) do
- Feature::Definition.new("development/enabled_feature_flag.yml",
- name: :enabled_feature_flag,
- type: 'development',
- milestone: milestone,
- default_enabled: false)
+ described_class.new("development/enabled_feature_flag.yml",
+ name: :enabled_feature_flag,
+ type: 'development',
+ milestone: milestone,
+ default_enabled: false)
end
before do
- allow(Feature::Definition).to receive(:definitions) do
- { definition.key => definition }
- end
-
allow(Gitlab).to receive(:version_info).and_return(Gitlab::VersionInfo.parse(current_milestone))
end
@@ -192,7 +176,7 @@ RSpec.describe Feature::Definition do
end
with_them do
- it {is_expected.to be(expected)}
+ it { is_expected.to be(expected) }
end
end
@@ -207,7 +191,7 @@ RSpec.describe Feature::Definition do
it 'validates it usage' do
expect(definition).to receive(:valid_usage!)
- described_class.valid_usage!(:feature_flag, type: :development, default_enabled: false)
+ described_class.valid_usage!(:feature_flag, type: :development)
end
end
@@ -221,7 +205,7 @@ RSpec.describe Feature::Definition do
it 'raises exception' do
expect do
- described_class.valid_usage!(:unknown_feature_flag, type: :development, default_enabled: false)
+ described_class.valid_usage!(:unknown_feature_flag, type: :development)
end.to raise_error(/Missing feature definition for `unknown_feature_flag`/)
end
end
@@ -235,7 +219,7 @@ RSpec.describe Feature::Definition do
it 'does not raise exception' do
expect do
- described_class.valid_usage!(:unknown_feature_flag, type: :development, default_enabled: false)
+ described_class.valid_usage!(:unknown_feature_flag, type: :development)
end.not_to raise_error
end
end
@@ -243,7 +227,7 @@ RSpec.describe Feature::Definition do
context 'for an unknown type' do
it 'raises exception' do
expect do
- described_class.valid_usage!(:unknown_feature_flag, type: :unknown_type, default_enabled: false)
+ described_class.valid_usage!(:unknown_feature_flag, type: :unknown_type)
end.to raise_error(/Unknown feature flag type used: `unknown_type`/)
end
end
@@ -254,23 +238,23 @@ RSpec.describe Feature::Definition do
using RSpec::Parameterized::TableSyntax
let(:definition) do
- Feature::Definition.new("development/enabled_feature_flag.yml",
- name: :enabled_feature_flag,
- type: 'development',
- milestone: milestone,
- log_state_changes: log_state_change,
- default_enabled: false)
+ described_class.new("development/enabled_feature_flag.yml",
+ name: :enabled_feature_flag,
+ type: 'development',
+ milestone: milestone,
+ log_state_changes: log_state_change,
+ default_enabled: false)
end
before do
- allow(Feature::Definition).to receive(:definitions) do
- { definition.key => definition }
- end
+ stub_feature_flag_definition(:enabled_feature_flag,
+ milestone: milestone,
+ log_state_changes: log_state_change)
allow(Gitlab).to receive(:version_info).and_return(Gitlab::VersionInfo.new(10, 0, 0))
end
- subject { Feature::Definition.log_states?(key) }
+ subject { described_class.log_states?(key) }
where(:ctx, :key, :milestone, :log_state_change, :expected) do
'When flag does not exist' | :no_flag | "0.0" | true | false
@@ -286,10 +270,11 @@ RSpec.describe Feature::Definition do
end
describe '.default_enabled?' do
- subject { described_class.default_enabled?(key) }
+ subject { described_class.default_enabled?(key, default_enabled_if_undefined: default_value) }
context 'when feature flag exist' do
let(:key) { definition.key }
+ let(:default_value) { nil }
before do
allow(described_class).to receive(:definitions) do
@@ -319,21 +304,33 @@ RSpec.describe Feature::Definition do
context 'when feature flag does not exist' do
let(:key) { :unknown_feature_flag }
- context 'when on dev or test environment' do
- it 'raises an error' do
- expect { subject }.to raise_error(
- Feature::InvalidFeatureFlagError,
- "The feature flag YAML definition for 'unknown_feature_flag' does not exist")
+ context 'when passing default value' do
+ let(:default_value) { false }
+
+ it 'returns default value' do
+ expect(subject).to eq(default_value)
end
end
- context 'when on production environment' do
- before do
- allow(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(false)
+ context 'when default value is undefined' do
+ let(:default_value) { nil }
+
+ context 'when on dev or test environment' do
+ it 'raises an error' do
+ expect { subject }.to raise_error(
+ Feature::InvalidFeatureFlagError,
+ "The feature flag YAML definition for 'unknown_feature_flag' does not exist")
+ end
end
- it 'returns false' do
- expect(subject).to eq(false)
+ context 'when on production environment' do
+ before do
+ allow(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(false)
+ end
+
+ it 'returns false' do
+ expect(subject).to eq(false)
+ end
end
end
end
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
index 90c0684f8b7..6e32db09426 100644
--- a/spec/lib/feature_spec.rb
+++ b/spec/lib/feature_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe Feature, stub_feature_flags: false do
expect_any_instance_of(Flipper::DSL).to receive(:feature).with(key)
.and_return(feature)
- expect(described_class.get(key)).to be(feature)
+ expect(described_class.get(key)).to eq(feature)
end
end
@@ -67,7 +67,7 @@ RSpec.describe Feature, stub_feature_flags: false do
expect(Gitlab::ProcessMemoryCache.cache_backend)
.to receive(:fetch)
.once
- .with('flipper/v1/features', expires_in: 1.minute)
+ .with('flipper/v1/features', { expires_in: 1.minute })
.and_call_original
2.times do
@@ -157,14 +157,65 @@ RSpec.describe Feature, stub_feature_flags: false do
describe '.enabled?' do
before do
allow(Feature).to receive(:log_feature_flag_states?).and_return(false)
+
+ stub_feature_flag_definition(:disabled_feature_flag)
+ stub_feature_flag_definition(:enabled_feature_flag, default_enabled: true)
+ end
+
+ context 'when self-recursive' do
+ before do
+ allow(Feature).to receive(:with_feature).and_wrap_original do |original, name, &block|
+ original.call(name) do |ff|
+ Feature.enabled?(name)
+ block.call(ff)
+ end
+ end
+ end
+
+ it 'returns the default value' do
+ expect(described_class.enabled?(:enabled_feature_flag)).to eq true
+ end
+
+ it 'detects self recursion' do
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(have_attributes(message: 'self recursion'), { stack: [:enabled_feature_flag] })
+
+ described_class.enabled?(:enabled_feature_flag)
+ end
end
- it 'returns false for undefined feature' do
+ context 'when deeply recursive' do
+ before do
+ allow(Feature).to receive(:with_feature).and_wrap_original do |original, name, &block|
+ original.call(name) do |ff|
+ Feature.enabled?(:"deeper_#{name}", type: :undefined, default_enabled_if_undefined: true)
+ block.call(ff)
+ end
+ end
+ end
+
+ it 'detects deep recursion' do
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(have_attributes(message: 'deep recursion'), stack: have_attributes(size: be > 10))
+
+ described_class.enabled?(:enabled_feature_flag)
+ end
+ end
+
+ it 'returns false (and tracks / raises exception for dev) for undefined feature' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+
expect(described_class.enabled?(:some_random_feature_flag)).to be_falsey
end
- it 'returns true for undefined feature with default_enabled' do
- expect(described_class.enabled?(:some_random_feature_flag, default_enabled: true)).to be_truthy
+ it 'returns false for undefined feature with default_enabled_if_undefined: false' do
+ expect(described_class.enabled?(:some_random_feature_flag, default_enabled_if_undefined: false)).to be_falsey
+ end
+
+ it 'returns true for undefined feature with default_enabled_if_undefined: true' do
+ expect(described_class.enabled?(:some_random_feature_flag, default_enabled_if_undefined: true)).to be_truthy
end
it 'returns false for existing disabled feature in the database' do
@@ -184,23 +235,23 @@ RSpec.describe Feature, stub_feature_flags: false do
it 'caches the status in L1 and L2 caches',
:request_store, :use_clean_rails_memory_store_caching do
- described_class.enable(:enabled_feature_flag)
- flipper_key = "flipper/v1/feature/enabled_feature_flag"
+ described_class.enable(:disabled_feature_flag)
+ flipper_key = "flipper/v1/feature/disabled_feature_flag"
expect(described_class.send(:l2_cache_backend))
.to receive(:fetch)
.once
- .with(flipper_key, expires_in: 1.hour)
+ .with(flipper_key, { expires_in: 1.hour })
.and_call_original
expect(described_class.send(:l1_cache_backend))
.to receive(:fetch)
.once
- .with(flipper_key, expires_in: 1.minute)
+ .with(flipper_key, { expires_in: 1.minute })
.and_call_original
2.times do
- expect(described_class.enabled?(:enabled_feature_flag)).to be_truthy
+ expect(described_class.enabled?(:disabled_feature_flag)).to be_truthy
end
end
@@ -208,22 +259,14 @@ RSpec.describe Feature, stub_feature_flags: false do
fake_default = double('fake default')
expect(ActiveRecord::Base).to receive(:connection) { raise ActiveRecord::NoDatabaseError, "No database" }
- expect(described_class.enabled?(:a_feature, default_enabled: fake_default)).to eq(fake_default)
+ expect(described_class.enabled?(:a_feature, default_enabled_if_undefined: fake_default)).to eq(fake_default)
end
context 'logging is enabled', :request_store do
before do
allow(Feature).to receive(:log_feature_flag_states?).and_call_original
- definition = Feature::Definition.new("development/enabled_feature_flag.yml",
- name: :enabled_feature_flag,
- type: 'development',
- log_state_changes: true,
- default_enabled: false)
-
- allow(Feature::Definition).to receive(:definitions) do
- { definition.key => definition }
- end
+ stub_feature_flag_definition(:enabled_feature_flag, log_state_changes: true)
described_class.enable(:feature_flag_state_logs)
described_class.enable(:enabled_feature_flag)
@@ -241,18 +284,16 @@ RSpec.describe Feature, stub_feature_flags: false do
end
context 'cached feature flag', :request_store do
- let(:flag) { :some_feature_flag }
-
before do
described_class.send(:flipper).memoize = false
- described_class.enabled?(flag)
+ described_class.enabled?(:disabled_feature_flag)
end
it 'caches the status in L1 cache for the first minute' do
expect do
expect(described_class.send(:l1_cache_backend)).to receive(:fetch).once.and_call_original
expect(described_class.send(:l2_cache_backend)).not_to receive(:fetch)
- expect(described_class.enabled?(flag)).to be_truthy
+ expect(described_class.enabled?(:disabled_feature_flag)).to be_truthy
end.not_to exceed_query_limit(0)
end
@@ -261,7 +302,7 @@ RSpec.describe Feature, stub_feature_flags: false do
expect do
expect(described_class.send(:l1_cache_backend)).to receive(:fetch).once.and_call_original
expect(described_class.send(:l2_cache_backend)).to receive(:fetch).once.and_call_original
- expect(described_class.enabled?(flag)).to be_truthy
+ expect(described_class.enabled?(:disabled_feature_flag)).to be_truthy
end.not_to exceed_query_limit(0)
end
end
@@ -271,7 +312,7 @@ RSpec.describe Feature, stub_feature_flags: false do
expect do
expect(described_class.send(:l1_cache_backend)).to receive(:fetch).once.and_call_original
expect(described_class.send(:l2_cache_backend)).to receive(:fetch).once.and_call_original
- expect(described_class.enabled?(flag)).to be_truthy
+ expect(described_class.enabled?(:disabled_feature_flag)).to be_truthy
end.not_to exceed_query_limit(1)
end
end
@@ -338,21 +379,38 @@ RSpec.describe Feature, stub_feature_flags: false do
.to raise_error(/The `type:` of/)
end
- it 'when invalid default_enabled is used' do
- expect { described_class.enabled?(:my_feature_flag, default_enabled: true) }
- .to raise_error(/The `default_enabled:` of/)
+ context 'when default_enabled: is false in the YAML definition' do
+ it 'reads the default from the YAML definition' do
+ expect(described_class.enabled?(:my_feature_flag)).to eq(default_enabled)
+ end
end
- context 'when `default_enabled: :yaml` is used in code' do
+ context 'when default_enabled: is true in the YAML definition' do
+ let(:default_enabled) { true }
+
it 'reads the default from the YAML definition' do
- expect(described_class.enabled?(:my_feature_flag, default_enabled: :yaml)).to eq(false)
+ expect(described_class.enabled?(:my_feature_flag)).to eq(true)
end
- context 'when default_enabled is true in the YAML definition' do
- let(:default_enabled) { true }
+ context 'and feature has been disabled' do
+ before do
+ described_class.disable(:my_feature_flag)
+ end
- it 'reads the default from the YAML definition' do
- expect(described_class.enabled?(:my_feature_flag, default_enabled: :yaml)).to eq(true)
+ it 'is not enabled' do
+ expect(described_class.enabled?(:my_feature_flag)).to eq(false)
+ end
+ end
+
+ context 'with a cached value and the YAML definition is changed thereafter' do
+ before do
+ described_class.enabled?(:my_feature_flag)
+ end
+
+ it 'reads new default value' do
+ allow(definition).to receive(:default_enabled).and_return(true)
+
+ expect(described_class.enabled?(:my_feature_flag)).to eq(true)
end
end
@@ -361,7 +419,7 @@ RSpec.describe Feature, stub_feature_flags: false do
context 'when in dev or test environment' do
it 'raises an error for dev' do
- expect { described_class.enabled?(:non_existent_flag, type: optional_type, default_enabled: :yaml) }
+ expect { described_class.enabled?(:non_existent_flag, type: optional_type) }
.to raise_error(
Feature::InvalidFeatureFlagError,
"The feature flag YAML definition for 'non_existent_flag' does not exist")
@@ -379,9 +437,9 @@ RSpec.describe Feature, stub_feature_flags: false do
end
it 'checks the persisted status and returns false' do
- expect(described_class).to receive(:get).with(:non_existent_flag).and_call_original
+ expect(described_class).to receive(:with_feature).with(:non_existent_flag).and_call_original
- expect(described_class.enabled?(:non_existent_flag, type: optional_type, default_enabled: :yaml)).to eq(false)
+ expect(described_class.enabled?(:non_existent_flag, type: optional_type)).to eq(false)
end
end
@@ -393,7 +451,7 @@ RSpec.describe Feature, stub_feature_flags: false do
it 'returns false without checking the status in the database' do
expect(described_class).not_to receive(:get)
- expect(described_class.enabled?(:non_existent_flag, type: optional_type, default_enabled: :yaml)).to eq(false)
+ expect(described_class.enabled?(:non_existent_flag, type: optional_type)).to eq(false)
end
end
end
@@ -403,21 +461,29 @@ RSpec.describe Feature, stub_feature_flags: false do
end
describe '.disable?' do
- it 'returns true for undefined feature' do
+ it 'returns true (and tracks / raises exception for dev) for undefined feature' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+
expect(described_class.disabled?(:some_random_feature_flag)).to be_truthy
end
- it 'returns false for undefined feature with default_enabled' do
- expect(described_class.disabled?(:some_random_feature_flag, default_enabled: true)).to be_falsey
+ it 'returns true for undefined feature with default_enabled_if_undefined: false' do
+ expect(described_class.disabled?(:some_random_feature_flag, default_enabled_if_undefined: false)).to be_truthy
+ end
+
+ it 'returns false for undefined feature with default_enabled_if_undefined: true' do
+ expect(described_class.disabled?(:some_random_feature_flag, default_enabled_if_undefined: true)).to be_falsey
end
it 'returns true for existing disabled feature in the database' do
+ stub_feature_flag_definition(:disabled_feature_flag)
described_class.disable(:disabled_feature_flag)
expect(described_class.disabled?(:disabled_feature_flag)).to be_truthy
end
it 'returns false for existing enabled feature in the database' do
+ stub_feature_flag_definition(:enabled_feature_flag)
described_class.enable(:enabled_feature_flag)
expect(described_class.disabled?(:enabled_feature_flag)).to be_falsey
@@ -556,14 +622,7 @@ RSpec.describe Feature, stub_feature_flags: false do
let(:log_state_changes) { false }
let(:milestone) { "0.0" }
let(:flag_name) { :some_flag }
- let(:definition) do
- Feature::Definition.new("development/#{flag_name}.yml",
- name: flag_name,
- type: 'development',
- milestone: milestone,
- log_state_changes: log_state_changes,
- default_enabled: false)
- end
+ let(:flag_type) { 'development' }
before do
Feature.enable(:feature_flag_state_logs)
@@ -573,9 +632,10 @@ RSpec.describe Feature, stub_feature_flags: false do
allow(Feature).to receive(:log_feature_flag_states?).with(:feature_flag_state_logs).and_call_original
allow(Feature).to receive(:log_feature_flag_states?).with(:some_flag).and_call_original
- allow(Feature::Definition).to receive(:definitions) do
- { definition.key => definition }
- end
+ stub_feature_flag_definition(flag_name,
+ type: flag_type,
+ milestone: milestone,
+ log_state_changes: log_state_changes)
end
subject { described_class.log_feature_flag_states?(flag_name) }
@@ -583,6 +643,7 @@ RSpec.describe Feature, stub_feature_flags: false do
context 'when flag is feature_flag_state_logs' do
let(:milestone) { "14.6" }
let(:flag_name) { :feature_flag_state_logs }
+ let(:flag_type) { 'ops' }
let(:log_state_changes) { true }
it { is_expected.to be_falsey }
@@ -593,13 +654,7 @@ RSpec.describe Feature, stub_feature_flags: false do
end
context 'when flag is old while log_state_changes is not present ' do
- let(:definition) do
- Feature::Definition.new("development/#{flag_name}.yml",
- name: flag_name,
- type: 'development',
- milestone: milestone,
- default_enabled: false)
- end
+ let(:log_state_changes) { nil }
it { is_expected.to be_falsey }
end
@@ -621,12 +676,7 @@ RSpec.describe Feature, stub_feature_flags: false do
end
context 'when milestone is nil' do
- let(:definition) do
- Feature::Definition.new("development/#{flag_name}.yml",
- name: flag_name,
- type: 'development',
- default_enabled: false)
- end
+ let(:milestone) { nil }
it { is_expected.to be_falsey }
end
diff --git a/spec/lib/gitlab/application_rate_limiter_spec.rb b/spec/lib/gitlab/application_rate_limiter_spec.rb
index 20c89eab5f5..efe78cd3a35 100644
--- a/spec/lib/gitlab/application_rate_limiter_spec.rb
+++ b/spec/lib/gitlab/application_rate_limiter_spec.rb
@@ -56,6 +56,20 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_rate_limiting
end
end
+ context 'when the key is valid' do
+ it 'records the checked key in request storage', :request_store do
+ subject.throttled?(:test_action, scope: [user])
+
+ expect(::Gitlab::Instrumentation::RateLimitingGates.payload)
+ .to eq(::Gitlab::Instrumentation::RateLimitingGates::GATES => [:test_action])
+
+ subject.throttled?(:another_action, scope: [user], peek: true)
+
+ expect(::Gitlab::Instrumentation::RateLimitingGates.payload)
+ .to eq(::Gitlab::Instrumentation::RateLimitingGates::GATES => [:test_action, :another_action])
+ end
+ end
+
shared_examples 'throttles based on key and scope' do
let(:start_time) { Time.current.beginning_of_hour }
diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb
index 44bbbe49cd3..d86191ca0c2 100644
--- a/spec/lib/gitlab/asciidoc_spec.rb
+++ b/spec/lib/gitlab/asciidoc_spec.rb
@@ -79,7 +79,7 @@ module Gitlab
},
'image with onerror' => {
input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]',
- output: "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt='Alt text\" onerror=\"alert(7)' class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>"
+ output: "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt='Alt text\" onerror=\"alert(7)' decoding=\"async\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>"
}
}
@@ -112,13 +112,13 @@ module Gitlab
context "images" do
it "does lazy load and link image" do
input = 'image:https://localhost.com/image.png[]'
- output = "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>"
+ output = "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" decoding=\"async\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>"
expect(render(input, context)).to include(output)
end
it "does not automatically link image if link is explicitly defined" do
input = 'image:https://localhost.com/image.png[link=https://gitlab.com]'
- output = "<div>\n<p><span><a href=\"https://gitlab.com\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>"
+ output = "<div>\n<p><span><a href=\"https://gitlab.com\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" decoding=\"async\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>"
expect(render(input, context)).to include(output)
end
end
@@ -524,7 +524,7 @@ module Gitlab
output = <<~HTML
<div>
<div>
- <a class="no-attachment-icon" href="https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqOZSUPBIzcnJ17ULzy_KSeGqBQCEzQka" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Diagram" class="lazy" data-src="https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqOZSUPBIzcnJ17ULzy_KSeGqBQCEzQka"></a>
+ <a class="no-attachment-icon" href="https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqOZSUPBIzcnJ17ULzy_KSeGqBQCEzQka" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Diagram" decoding="async" class="lazy" data-src="https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqOZSUPBIzcnJ17ULzy_KSeGqBQCEzQka"></a>
</div>
</div>
HTML
@@ -578,7 +578,7 @@ module Gitlab
output = <<~HTML
<div>
<div>
- <a class=\"no-attachment-icon\" href=\"https://kroki.io/plantuml/png/eNpLzkksLlZwyslPzg4oyk9OLS7OL-LiQuUr2NTo6ipUJ-eX5pWkFlllF-VnZ-oW5CTmlZTm5uhm5iXnlKak1gIABQEb8A==\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Diagram\" class=\"lazy\" data-src=\"https://kroki.io/plantuml/png/eNpLzkksLlZwyslPzg4oyk9OLS7OL-LiQuUr2NTo6ipUJ-eX5pWkFlllF-VnZ-oW5CTmlZTm5uhm5iXnlKak1gIABQEb8A==\"></a>
+ <a class=\"no-attachment-icon\" href=\"https://kroki.io/plantuml/png/eNpLzkksLlZwyslPzg4oyk9OLS7OL-LiQuUr2NTo6ipUJ-eX5pWkFlllF-VnZ-oW5CTmlZTm5uhm5iXnlKak1gIABQEb8A==\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Diagram\" decoding=\"async\" class=\"lazy\" data-src=\"https://kroki.io/plantuml/png/eNpLzkksLlZwyslPzg4oyk9OLS7OL-LiQuUr2NTo6ipUJ-eX5pWkFlllF-VnZ-oW5CTmlZTm5uhm5iXnlKak1gIABQEb8A==\"></a>
</div>
</div>
HTML
@@ -625,7 +625,7 @@ module Gitlab
output = <<~HTML
<div>
<div>
- <a class="no-attachment-icon" href="https://kroki.io/blockdiag/svg/eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w==" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Diagram" class="lazy" data-src="https://kroki.io/blockdiag/svg/eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w=="></a>
+ <a class="no-attachment-icon" href="https://kroki.io/blockdiag/svg/eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w==" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Diagram" decoding="async" class="lazy" data-src="https://kroki.io/blockdiag/svg/eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w=="></a>
</div>
</div>
HTML
diff --git a/spec/lib/gitlab/audit/deploy_token_author_spec.rb b/spec/lib/gitlab/audit/deploy_token_author_spec.rb
new file mode 100644
index 00000000000..449b7456a80
--- /dev/null
+++ b/spec/lib/gitlab/audit/deploy_token_author_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Audit::DeployTokenAuthor do
+ describe '#initialize' do
+ it 'sets correct attributes' do
+ expect(described_class.new(name: 'Lorem deploy token'))
+ .to have_attributes(id: -2, name: 'Lorem deploy token')
+ end
+
+ it 'sets default name when it is not provided' do
+ expect(described_class.new)
+ .to have_attributes(id: -2, name: 'Deploy Token')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/audit/null_author_spec.rb b/spec/lib/gitlab/audit/null_author_spec.rb
index 7203a0cd816..2045139a5f7 100644
--- a/spec/lib/gitlab/audit/null_author_spec.rb
+++ b/spec/lib/gitlab/audit/null_author_spec.rb
@@ -48,6 +48,15 @@ RSpec.describe Gitlab::Audit::NullAuthor do
expect(subject.for(-1, audit_event)).to be_a(Gitlab::Audit::CiRunnerTokenAuthor)
expect(subject.for(-1, audit_event)).to have_attributes(id: -1, name: 'Authentication token: cde456')
end
+
+ it 'returns DeployTokenAuthor when id equals -2', :aggregate_failures do
+ allow(audit_event).to receive(:[]).with(:author_name).and_return('Test deploy token')
+ allow(audit_event).to receive(:details).and_return({})
+ allow(audit_event).to receive(:target_type)
+
+ expect(subject.for(-2, audit_event)).to be_a(Gitlab::Audit::DeployTokenAuthor)
+ expect(subject.for(-2, audit_event)).to have_attributes(id: -2, name: 'Test deploy token')
+ end
end
describe '#current_sign_in_ip' do
diff --git a/spec/lib/gitlab/auth/ldap/adapter_spec.rb b/spec/lib/gitlab/auth/ldap/adapter_spec.rb
index b7b12e49a8e..3791b7a07dd 100644
--- a/spec/lib/gitlab/auth/ldap/adapter_spec.rb
+++ b/spec/lib/gitlab/auth/ldap/adapter_spec.rb
@@ -26,10 +26,12 @@ RSpec.describe Gitlab::Auth::Ldap::Adapter do
it 'searches with the proper options when searching by dn' do
expect(adapter).to receive(:ldap_search).with(
- base: 'uid=johndoe,ou=users,dc=example,dc=com',
- scope: Net::LDAP::SearchScope_BaseObject,
- attributes: ldap_attributes,
- filter: nil
+ {
+ base: 'uid=johndoe,ou=users,dc=example,dc=com',
+ scope: Net::LDAP::SearchScope_BaseObject,
+ attributes: ldap_attributes,
+ filter: nil
+ }
).and_return({})
adapter.users('dn', 'uid=johndoe,ou=users,dc=example,dc=com')
diff --git a/spec/lib/gitlab/auth/otp/strategies/forti_authenticator_spec.rb b/spec/lib/gitlab/auth/otp/strategies/forti_authenticator/manual_otp_spec.rb
index dc20df98185..f08c787382e 100644
--- a/spec/lib/gitlab/auth/otp/strategies/forti_authenticator_spec.rb
+++ b/spec/lib/gitlab/auth/otp/strategies/forti_authenticator/manual_otp_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Auth::Otp::Strategies::FortiAuthenticator do
+RSpec.describe Gitlab::Auth::Otp::Strategies::FortiAuthenticator::ManualOtp do
let_it_be(:user) { create(:user) }
let(:otp_code) { 42 }
diff --git a/spec/lib/gitlab/auth/otp/strategies/forti_authenticator/push_otp_spec.rb b/spec/lib/gitlab/auth/otp/strategies/forti_authenticator/push_otp_spec.rb
new file mode 100644
index 00000000000..231bd3f48f1
--- /dev/null
+++ b/spec/lib/gitlab/auth/otp/strategies/forti_authenticator/push_otp_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Auth::Otp::Strategies::FortiAuthenticator::PushOtp do
+ let_it_be(:user) { create(:user) }
+
+ let(:host) { 'forti_authenticator.example.com' }
+ let(:port) { '444' }
+ let(:api_username) { 'janedoe' }
+ let(:api_token) { 's3cr3t' }
+
+ let(:forti_authenticator_auth_url) { "https://#{host}:#{port}/api/v1/pushauth/" }
+ let(:response_status) { 200 }
+
+ subject(:validate) { described_class.new(user).validate }
+
+ before do
+ stub_feature_flags(forti_authenticator: user)
+
+ stub_forti_authenticator_config(
+ enabled: true,
+ host: host,
+ port: port,
+ username: api_username,
+ access_token: api_token
+ )
+
+ request_body = { username: user.username }
+
+ stub_request(:post, forti_authenticator_auth_url)
+ .with(body: JSON(request_body),
+ headers: { 'Content-Type': 'application/json' },
+ basic_auth: [api_username, api_token])
+ .to_return(status: response_status, body: '')
+ end
+
+ context 'successful validation' do
+ it 'returns success' do
+ expect(validate[:status]).to eq(:success)
+ end
+ end
+
+ context 'unsuccessful validation' do
+ let(:response_status) { 401 }
+
+ it 'returns error' do
+ expect(validate[:status]).to eq(:error)
+ end
+ end
+
+ context 'unexpected error' do
+ it 'returns error' do
+ error_message = 'boom!'
+ stub_request(:post, forti_authenticator_auth_url).to_raise(StandardError.new(error_message))
+
+ expect(validate[:status]).to eq(:error)
+ expect(validate[:message]).to eq(error_message)
+ end
+ end
+
+ def stub_forti_authenticator_config(forti_authenticator_settings)
+ allow(::Gitlab.config.forti_authenticator).to(receive_messages(forti_authenticator_settings))
+ end
+end
diff --git a/spec/lib/gitlab/auth/saml/config_spec.rb b/spec/lib/gitlab/auth/saml/config_spec.rb
new file mode 100644
index 00000000000..12f5da48873
--- /dev/null
+++ b/spec/lib/gitlab/auth/saml/config_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Auth::Saml::Config do
+ describe '.enabled?' do
+ subject { described_class.enabled? }
+
+ it { is_expected.to eq(false) }
+
+ context 'when SAML is enabled' do
+ before do
+ allow(Gitlab::Auth::OAuth::Provider).to receive(:providers).and_return([:saml])
+ end
+
+ it { is_expected.to eq(true) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb b/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb
deleted file mode 100644
index f5d2224747a..00000000000
--- a/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb
+++ /dev/null
@@ -1,82 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::BackfillArtifactExpiryDate, :migration, schema: 20210301200959 do
- subject(:perform) { migration.perform(1, 99) }
-
- let(:migration) { described_class.new }
- let(:artifact_outside_id_range) { create_artifact!(id: 100, created_at: 1.year.ago, expire_at: nil) }
- let(:artifact_outside_date_range) { create_artifact!(id: 40, created_at: Time.current, expire_at: nil) }
- let(:old_artifact) { create_artifact!(id: 10, created_at: 16.months.ago, expire_at: nil) }
- let(:recent_artifact) { create_artifact!(id: 20, created_at: 1.year.ago, expire_at: nil) }
- let(:artifact_with_expiry) { create_artifact!(id: 30, created_at: 1.year.ago, expire_at: Time.current + 1.day) }
-
- before do
- table(:namespaces).create!(id: 1, name: 'the-namespace', path: 'the-path')
- table(:projects).create!(id: 1, name: 'the-project', namespace_id: 1)
- table(:ci_builds).create!(id: 1, allow_failure: false)
- end
-
- context 'when current date is before the 22nd' do
- before do
- travel_to(Time.zone.local(2020, 1, 1, 0, 0, 0))
- end
-
- it 'backfills the expiry date for old artifacts' do
- expect(old_artifact.reload.expire_at).to eq(nil)
-
- perform
-
- expect(old_artifact.reload.expire_at).to be_within(1.minute).of(Time.zone.local(2020, 4, 22, 0, 0, 0))
- end
-
- it 'backfills the expiry date for recent artifacts' do
- expect(recent_artifact.reload.expire_at).to eq(nil)
-
- perform
-
- expect(recent_artifact.reload.expire_at).to be_within(1.minute).of(Time.zone.local(2021, 1, 22, 0, 0, 0))
- end
- end
-
- context 'when current date is after the 22nd' do
- before do
- travel_to(Time.zone.local(2020, 1, 23, 0, 0, 0))
- end
-
- it 'backfills the expiry date for old artifacts' do
- expect(old_artifact.reload.expire_at).to eq(nil)
-
- perform
-
- expect(old_artifact.reload.expire_at).to be_within(1.minute).of(Time.zone.local(2020, 5, 22, 0, 0, 0))
- end
-
- it 'backfills the expiry date for recent artifacts' do
- expect(recent_artifact.reload.expire_at).to eq(nil)
-
- perform
-
- expect(recent_artifact.reload.expire_at).to be_within(1.minute).of(Time.zone.local(2021, 2, 22, 0, 0, 0))
- end
- end
-
- it 'does not touch artifacts with expiry date' do
- expect { perform }.not_to change { artifact_with_expiry.reload.expire_at }
- end
-
- it 'does not touch artifacts outside id range' do
- expect { perform }.not_to change { artifact_outside_id_range.reload.expire_at }
- end
-
- it 'does not touch artifacts outside date range' do
- expect { perform }.not_to change { artifact_outside_date_range.reload.expire_at }
- end
-
- private
-
- def create_artifact!(**args)
- table(:ci_job_artifacts).create!(**args, project_id: 1, job_id: 1, file_type: 1)
- end
-end
diff --git a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb
index 1158eedfe7c..84611c88806 100644
--- a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests,
end
end
- it "updates all open draft merge request's draft field to true" do
+ it "updates all eligible draft merge request's draft field to true" do
mr_count = merge_requests.all.count
expect { subject.perform(mr_ids.first, mr_ids.last) }
diff --git a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_with_corrected_regex_spec.rb b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_with_corrected_regex_spec.rb
new file mode 100644
index 00000000000..e6e10977143
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_with_corrected_regex_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequestsWithCorrectedRegex,
+ :migration, schema: 20220326161803 do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:merge_requests) { table(:merge_requests) }
+
+ let(:group) { namespaces.create!(name: 'gitlab', path: 'gitlab') }
+ let(:project) { projects.create!(namespace_id: group.id) }
+
+ let(:draft_prefixes) { ["[Draft]", "(Draft)", "Draft:", "Draft", "[WIP]", "WIP:", "WIP"] }
+
+ def create_merge_request(params)
+ common_params = {
+ target_project_id: project.id,
+ target_branch: 'feature1',
+ source_branch: 'master'
+ }
+
+ merge_requests.create!(common_params.merge(params))
+ end
+
+ context "for MRs with #draft? == true titles but draft attribute false" do
+ let(:mr_ids) { merge_requests.all.collect(&:id) }
+
+ before do
+ draft_prefixes.each do |prefix|
+ (1..4).each do |n|
+ create_merge_request(
+ title: "#{prefix} This is a title",
+ draft: false,
+ state_id: n
+ )
+
+ create_merge_request(
+ title: "This is a title with the #{prefix} in a weird spot",
+ draft: false,
+ state_id: n
+ )
+ end
+ end
+ end
+
+ it "updates all eligible draft merge request's draft field to true" do
+ mr_count = merge_requests.all.count
+
+ expect { subject.perform(mr_ids.first, mr_ids.last) }
+ .to change { MergeRequest.where(draft: false).count }
+ .from(mr_count).to(mr_count - draft_prefixes.length)
+ end
+
+ it "marks successful slices as completed" do
+ expect(subject).to receive(:mark_job_as_succeeded).with(mr_ids.first, mr_ids.last)
+
+ subject.perform(mr_ids.first, mr_ids.last)
+ end
+
+ it_behaves_like 'marks background migration job records' do
+ let!(:non_eligible_mrs) do
+ Array.new(2) do
+ create_merge_request(
+ title: "Not a d-r-a-f-t 1",
+ draft: false,
+ state_id: 1
+ )
+ end
+ end
+
+ let(:arguments) { [non_eligible_mrs.first.id, non_eligible_mrs.last.id] }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb
index 4705f0d0ab9..d84bc479554 100644
--- a/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb
@@ -6,7 +6,15 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillGroupFeatures, :migration, s
let(:group_features) { table(:group_features) }
let(:namespaces) { table(:namespaces) }
- subject { described_class.new(connection: ActiveRecord::Base.connection) }
+ subject do
+ described_class.new(start_id: 1,
+ end_id: 4,
+ batch_table: :namespaces,
+ batch_column: :id,
+ sub_batch_size: 10,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection)
+ end
describe '#perform' do
it 'creates settings for all group namespaces in range' do
@@ -19,7 +27,7 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillGroupFeatures, :migration, s
group_features.create!(id: 1, group_id: 4)
expect(group_features.count).to eq 1
- expect { subject.perform(1, 4, :namespaces, :id, 10, 0, 4) }.to change { group_features.count }.by(2)
+ expect { subject.perform(4) }.to change { group_features.count }.by(2)
expect(group_features.count).to eq 3
expect(group_features.all.pluck(:group_id)).to contain_exactly(1, 3, 4)
diff --git a/spec/lib/gitlab/background_migration/backfill_integrations_enable_ssl_verification_spec.rb b/spec/lib/gitlab/background_migration/backfill_integrations_enable_ssl_verification_spec.rb
new file mode 100644
index 00000000000..b3825a7c4ea
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_integrations_enable_ssl_verification_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillIntegrationsEnableSslVerification, schema: 20220425121410 do
+ let(:migration) { described_class.new }
+ let(:integrations) { described_class::Integration }
+
+ before do
+ integrations.create!(id: 1, type_new: 'Integrations::Bamboo') # unaffected integration
+ integrations.create!(id: 2, type_new: 'Integrations::DroneCi') # no properties
+ integrations.create!(id: 3, type_new: 'Integrations::DroneCi',
+ properties: {}) # no URL
+ integrations.create!(id: 4, type_new: 'Integrations::DroneCi',
+ properties: { 'drone_url' => '' }) # blank URL
+ integrations.create!(id: 5, type_new: 'Integrations::DroneCi',
+ properties: { 'drone_url' => 'https://example.com:foo' }) # invalid URL
+ integrations.create!(id: 6, type_new: 'Integrations::DroneCi',
+ properties: { 'drone_url' => 'https://example.com' }) # unknown URL
+ integrations.create!(id: 7, type_new: 'Integrations::DroneCi',
+ properties: { 'drone_url' => 'http://cloud.drone.io' }) # no HTTPS
+ integrations.create!(id: 8, type_new: 'Integrations::DroneCi',
+ properties: { 'drone_url' => 'https://cloud.drone.io' }) # known URL
+ integrations.create!(id: 9, type_new: 'Integrations::Teamcity',
+ properties: { 'teamcity_url' => 'https://example.com' }) # unknown URL
+ integrations.create!(id: 10, type_new: 'Integrations::Teamcity',
+ properties: { 'teamcity_url' => 'https://foo.bar.teamcity.com' }) # unknown URL
+ integrations.create!(id: 11, type_new: 'Integrations::Teamcity',
+ properties: { 'teamcity_url' => 'https://teamcity.com' }) # unknown URL
+ integrations.create!(id: 12, type_new: 'Integrations::Teamcity',
+ properties: { 'teamcity_url' => 'https://customer.teamcity.com' }) # known URL
+ end
+
+ def properties(id)
+ integrations.find(id).properties
+ end
+
+ it 'enables SSL verification for known-good hostnames', :aggregate_failures do
+ migration.perform(1, 12)
+
+ # Bamboo
+ expect(properties(1)).to be_nil
+
+ # DroneCi
+ expect(properties(2)).to be_nil
+ expect(properties(3)).not_to include('enable_ssl_verification')
+ expect(properties(4)).not_to include('enable_ssl_verification')
+ expect(properties(5)).not_to include('enable_ssl_verification')
+ expect(properties(6)).not_to include('enable_ssl_verification')
+ expect(properties(7)).not_to include('enable_ssl_verification')
+ expect(properties(8)).to include('enable_ssl_verification' => true)
+
+ # Teamcity
+ expect(properties(9)).not_to include('enable_ssl_verification')
+ expect(properties(10)).not_to include('enable_ssl_verification')
+ expect(properties(11)).not_to include('enable_ssl_verification')
+ expect(properties(12)).to include('enable_ssl_verification' => true)
+ end
+
+ it 'only updates records within the given ID range', :aggregate_failures do
+ migration.perform(1, 8)
+
+ expect(properties(8)).to include('enable_ssl_verification' => true)
+ expect(properties(12)).not_to include('enable_ssl_verification')
+ end
+
+ it 'marks the job as succeeded' do
+ expect(Gitlab::Database::BackgroundMigrationJob).to receive(:mark_all_as_succeeded)
+ .with('BackfillIntegrationsEnableSslVerification', [1, 10])
+
+ migration.perform(1, 10)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_integrations_type_new_spec.rb b/spec/lib/gitlab/background_migration/backfill_integrations_type_new_spec.rb
index 8f765a7a536..d8a7ec775dd 100644
--- a/spec/lib/gitlab/background_migration/backfill_integrations_type_new_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_integrations_type_new_spec.rb
@@ -2,10 +2,19 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::BackfillIntegrationsTypeNew do
+RSpec.describe Gitlab::BackgroundMigration::BackfillIntegrationsTypeNew, :migration, schema: 20220212120735 do
let(:migration) { described_class.new }
let(:integrations) { table(:integrations) }
- let(:namespaced_integrations) { Gitlab::Integrations::StiType.namespaced_integrations }
+
+ let(:namespaced_integrations) do
+ Set.new(%w[
+ Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog
+ Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Harbor Irker Jenkins Jira Mattermost
+ MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker
+ Prometheus Pushover Redmine Shimo Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack Zentao
+ Github GitlabSlackApplication
+ ]).freeze
+ end
before do
integrations.connection.execute 'ALTER TABLE integrations DISABLE TRIGGER "trigger_type_new_on_insert"'
diff --git a/spec/lib/gitlab/background_migration/backfill_note_discussion_id_spec.rb b/spec/lib/gitlab/background_migration/backfill_note_discussion_id_spec.rb
new file mode 100644
index 00000000000..dcb4ede36af
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_note_discussion_id_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillNoteDiscussionId do
+ let(:migration) { described_class.new }
+ let(:notes_table) { table(:notes) }
+ let(:existing_discussion_id) { Digest::SHA1.hexdigest('test') }
+
+ before do
+ notes_table.create!(id: 1, noteable_type: 'Issue', noteable_id: 2, discussion_id: existing_discussion_id)
+ notes_table.create!(id: 2, noteable_type: 'Issue', noteable_id: 1, discussion_id: nil)
+ notes_table.create!(id: 3, noteable_type: 'MergeRequest', noteable_id: 1, discussion_id: nil)
+ notes_table.create!(id: 4, noteable_type: 'Commit', commit_id: RepoHelpers.sample_commit.id, discussion_id: nil)
+ notes_table.create!(id: 5, noteable_type: 'Issue', noteable_id: 2, discussion_id: nil)
+ notes_table.create!(id: 6, noteable_type: 'MergeRequest', noteable_id: 2, discussion_id: nil)
+ end
+
+ it 'updates records in the specified batch', :aggregate_failures do
+ migration.perform(1, 5)
+
+ expect(notes_table.where(discussion_id: nil).count).to eq(1)
+
+ expect(notes_table.find(1).discussion_id).to eq(existing_discussion_id)
+ notes_table.where(id: 2..5).each do |n|
+ expect(n.discussion_id).to match(/\A[0-9a-f]{40}\z/)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb
new file mode 100644
index 00000000000..525c236b644
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillProjectSettings, :migration, schema: 20220324165436 do
+ let(:migration) { described_class.new }
+ let(:namespaces_table) { table(:namespaces) }
+ let(:projects_table) { table(:projects) }
+ let(:project_settings_table) { table(:project_settings) }
+
+ let(:table_name) { 'projects' }
+ let(:batch_column) { :id }
+ let(:sub_batch_size) { 2 }
+ let(:pause_ms) { 0 }
+
+ subject(:perform_migration) { migration.perform(1, 30, table_name, batch_column, sub_batch_size, pause_ms) }
+
+ before do
+ namespaces_table.create!(id: 1, name: 'namespace', path: 'namespace-path', type: 'Group')
+ projects_table.create!(id: 11, name: 'group-project-1', path: 'group-project-path-1', namespace_id: 1)
+ projects_table.create!(id: 12, name: 'group-project-2', path: 'group-project-path-2', namespace_id: 1)
+ project_settings_table.create!(project_id: 11)
+
+ namespaces_table.create!(id: 2, name: 'namespace', path: 'namespace-path', type: 'User')
+ projects_table.create!(id: 21, name: 'user-project-1', path: 'user--project-path-1', namespace_id: 2)
+ projects_table.create!(id: 22, name: 'user-project-2', path: 'user-project-path-2', namespace_id: 2)
+ project_settings_table.create!(project_id: 21)
+ end
+
+ it 'backfills project settings when it does not exist', :aggregate_failures do
+ expect(project_settings_table.count).to eq 2
+
+ queries = ActiveRecord::QueryRecorder.new do
+ perform_migration
+ end
+
+ expect(queries.count).to eq(5)
+
+ expect(project_settings_table.count).to eq 4
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_topics_title_spec.rb b/spec/lib/gitlab/background_migration/backfill_topics_title_spec.rb
new file mode 100644
index 00000000000..3c46456eed0
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_topics_title_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillTopicsTitle, schema: 20220331133802 do
+ it 'correctly backfills the title of the topics' do
+ topics = table(:topics)
+
+ topic_1 = topics.create!(name: 'topic1')
+ topic_2 = topics.create!(name: 'topic2', title: 'Topic 2')
+ topic_3 = topics.create!(name: 'topic3')
+ topic_4 = topics.create!(name: 'topic4')
+
+ subject.perform(topic_1.id, topic_3.id)
+
+ expect(topic_1.reload.title).to eq('topic1')
+ expect(topic_2.reload.title).to eq('Topic 2')
+ expect(topic_3.reload.title).to eq('topic3')
+ expect(topic_4.reload.title).to be_nil
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb b/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb
new file mode 100644
index 00000000000..f8b3a8681f0
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BatchedMigrationJob do
+ describe '#perform' do
+ let(:connection) { Gitlab::Database.database_base_models[:main].connection }
+
+ let(:job_class) { Class.new(described_class) }
+
+ let(:job_instance) do
+ job_class.new(start_id: 1, end_id: 10,
+ batch_table: '_test_table',
+ batch_column: 'id',
+ sub_batch_size: 2,
+ pause_ms: 1000,
+ connection: connection)
+ end
+
+ subject(:perform_job) { job_instance.perform }
+
+ it 'raises an error if not overridden' do
+ expect { perform_job }.to raise_error(NotImplementedError, /must implement perform/)
+ end
+
+ context 'when the subclass uses sub-batching' do
+ let(:job_class) do
+ Class.new(described_class) do
+ def perform(*job_arguments)
+ each_sub_batch(
+ operation_name: :update,
+ batching_arguments: { order_hint: :updated_at },
+ batching_scope: -> (relation) { relation.where.not(bar: nil) }
+ ) do |sub_batch|
+ sub_batch.update_all('to_column = from_column')
+ end
+ end
+ end
+ end
+
+ let(:test_table) { table(:_test_table) }
+
+ before do
+ allow(job_instance).to receive(:sleep)
+
+ connection.create_table :_test_table do |t|
+ t.timestamps_with_timezone null: false
+ t.integer :from_column, null: false
+ t.text :bar
+ t.integer :to_column
+ end
+
+ test_table.create!(id: 1, from_column: 5, bar: 'value')
+ test_table.create!(id: 2, from_column: 10, bar: 'value')
+ test_table.create!(id: 3, from_column: 15)
+ test_table.create!(id: 4, from_column: 20, bar: 'value')
+ end
+
+ after do
+ connection.drop_table(:_test_table)
+ end
+
+ it 'calls the operation for each sub-batch' do
+ expect { perform_job }.to change { test_table.where(to_column: nil).count }.from(4).to(1)
+
+ expect(test_table.order(:id).pluck(:to_column)).to contain_exactly(5, 10, nil, 20)
+ end
+
+ it 'instruments the batch operation' do
+ expect(job_instance.batch_metrics.affected_rows).to be_empty
+
+ expect(job_instance.batch_metrics).to receive(:instrument_operation).with(:update).twice.and_call_original
+
+ perform_job
+
+ expect(job_instance.batch_metrics.affected_rows[:update]).to contain_exactly(2, 1)
+ end
+
+ it 'pauses after each sub-batch' do
+ expect(job_instance).to receive(:sleep).with(1.0).twice
+
+ perform_job
+ end
+
+ context 'when batching_arguments are given' do
+ it 'forwards them for batching' do
+ expect(job_instance).to receive(:parent_batch_relation).and_return(test_table)
+
+ expect(test_table).to receive(:each_batch).with(column: 'id', of: 2, order_hint: :updated_at)
+
+ perform_job
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb
index 90d9bbb42c3..78bd1afd8d2 100644
--- a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb
+++ b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb
@@ -3,123 +3,134 @@
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob do
- let(:table_name) { :_test_copy_primary_key_test }
- let(:test_table) { table(table_name) }
- let(:sub_batch_size) { 1000 }
- let(:pause_ms) { 0 }
- let(:connection) { ApplicationRecord.connection }
-
- let(:helpers) do
- ActiveRecord::Migration.new.extend(Gitlab::Database::MigrationHelpers)
- end
-
- before do
- connection.execute(<<~SQL)
- CREATE TABLE #{table_name}
- (
- id integer NOT NULL,
- name character varying,
- fk integer NOT NULL,
- #{helpers.convert_to_bigint_column(:id)} bigint DEFAULT 0 NOT NULL,
- #{helpers.convert_to_bigint_column(:fk)} bigint DEFAULT 0 NOT NULL,
- name_convert_to_text text DEFAULT 'no name'
- );
- SQL
-
- # Insert some data, it doesn't make a difference
- test_table.create!(id: 11, name: 'test1', fk: 1)
- test_table.create!(id: 12, name: 'test2', fk: 2)
- test_table.create!(id: 15, name: nil, fk: 3)
- test_table.create!(id: 19, name: 'test4', fk: 4)
- end
+ it { expect(described_class).to be < Gitlab::BackgroundMigration::BatchedMigrationJob }
- after do
- # Make sure that the temp table we created is dropped (it is not removed by the database_cleaner)
- connection.execute(<<~SQL)
- DROP TABLE IF EXISTS #{table_name};
- SQL
- end
+ describe '#perform' do
+ let(:table_name) { :_test_copy_primary_key_test }
+ let(:test_table) { table(table_name) }
+ let(:sub_batch_size) { 1000 }
+ let(:pause_ms) { 0 }
+ let(:connection) { ApplicationRecord.connection }
+
+ let(:helpers) do
+ ActiveRecord::Migration.new.extend(Gitlab::Database::MigrationHelpers)
+ end
- subject(:copy_columns) { described_class.new(connection: connection) }
+ let(:copy_job) do
+ described_class.new(start_id: 12,
+ end_id: 20,
+ batch_table: table_name,
+ batch_column: 'id',
+ sub_batch_size: sub_batch_size,
+ pause_ms: pause_ms,
+ connection: connection)
+ end
- it { expect(described_class).to be < Gitlab::BackgroundMigration::BaseJob }
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE #{table_name}
+ (
+ id integer NOT NULL,
+ name character varying,
+ fk integer NOT NULL,
+ #{helpers.convert_to_bigint_column(:id)} bigint DEFAULT 0 NOT NULL,
+ #{helpers.convert_to_bigint_column(:fk)} bigint DEFAULT 0 NOT NULL,
+ name_convert_to_text text DEFAULT 'no name'
+ );
+ SQL
+
+ # Insert some data, it doesn't make a difference
+ test_table.create!(id: 11, name: 'test1', fk: 1)
+ test_table.create!(id: 12, name: 'test2', fk: 2)
+ test_table.create!(id: 15, name: nil, fk: 3)
+ test_table.create!(id: 19, name: 'test4', fk: 4)
+ end
- describe '#perform' do
- let(:migration_class) { described_class.name }
+ after do
+ # Make sure that the temp table we created is dropped (it is not removed by the database_cleaner)
+ connection.execute(<<~SQL)
+ DROP TABLE IF EXISTS #{table_name};
+ SQL
+ end
it 'copies all primary keys in range' do
temporary_column = helpers.convert_to_bigint_column(:id)
- copy_columns.perform(12, 15, table_name, 'id', sub_batch_size, pause_ms, 'id', temporary_column)
- expect(test_table.where("id = #{temporary_column}").pluck(:id)).to contain_exactly(12, 15)
- expect(test_table.where(temporary_column => 0).pluck(:id)).to contain_exactly(11, 19)
- expect(test_table.all.count).to eq(4)
+ copy_job.perform('id', temporary_column)
+
+ expect(test_table.count).to eq(4)
+ expect(test_table.where("id = #{temporary_column}").pluck(:id)).to contain_exactly(12, 15, 19)
+ expect(test_table.where(temporary_column => 0).pluck(:id)).to contain_exactly(11)
end
it 'copies all foreign keys in range' do
temporary_column = helpers.convert_to_bigint_column(:fk)
- copy_columns.perform(10, 14, table_name, 'id', sub_batch_size, pause_ms, 'fk', temporary_column)
- expect(test_table.where("fk = #{temporary_column}").pluck(:id)).to contain_exactly(11, 12)
- expect(test_table.where(temporary_column => 0).pluck(:id)).to contain_exactly(15, 19)
- expect(test_table.all.count).to eq(4)
+ copy_job.perform('fk', temporary_column)
+
+ expect(test_table.count).to eq(4)
+ expect(test_table.where("fk = #{temporary_column}").pluck(:id)).to contain_exactly(12, 15, 19)
+ expect(test_table.where(temporary_column => 0).pluck(:id)).to contain_exactly(11)
end
it 'copies columns with NULLs' do
- expect(test_table.where("name_convert_to_text = 'no name'").count).to eq(4)
+ expect { copy_job.perform('name', 'name_convert_to_text') }
+ .to change { test_table.where("name_convert_to_text = 'no name'").count }.from(4).to(1)
- copy_columns.perform(10, 20, table_name, 'id', sub_batch_size, pause_ms, 'name', 'name_convert_to_text')
-
- expect(test_table.where('name = name_convert_to_text').pluck(:id)).to contain_exactly(11, 12, 19)
+ expect(test_table.where('name = name_convert_to_text').pluck(:id)).to contain_exactly(12, 19)
expect(test_table.where('name is NULL and name_convert_to_text is NULL').pluck(:id)).to contain_exactly(15)
- expect(test_table.where("name_convert_to_text = 'no name'").count).to eq(0)
end
- it 'copies multiple columns when given' do
- columns_to_copy_from = %w[id fk]
- id_tmp_column = helpers.convert_to_bigint_column('id')
- fk_tmp_column = helpers.convert_to_bigint_column('fk')
- columns_to_copy_to = [id_tmp_column, fk_tmp_column]
+ context 'when multiple columns are given' do
+ let(:id_tmp_column) { helpers.convert_to_bigint_column('id') }
+ let(:fk_tmp_column) { helpers.convert_to_bigint_column('fk') }
+ let(:columns_to_copy_from) { %w[id fk] }
+ let(:columns_to_copy_to) { [id_tmp_column, fk_tmp_column] }
- subject.perform(10, 15, table_name, 'id', sub_batch_size, pause_ms, columns_to_copy_from, columns_to_copy_to)
+ it 'copies all values in the range' do
+ copy_job.perform(columns_to_copy_from, columns_to_copy_to)
- expect(test_table.where("id = #{id_tmp_column} AND fk = #{fk_tmp_column}").pluck(:id)).to contain_exactly(11, 12, 15)
- expect(test_table.where(id_tmp_column => 0).where(fk_tmp_column => 0).pluck(:id)).to contain_exactly(19)
- expect(test_table.all.count).to eq(4)
- end
+ expect(test_table.count).to eq(4)
+ expect(test_table.where("id = #{id_tmp_column} AND fk = #{fk_tmp_column}").pluck(:id)).to contain_exactly(12, 15, 19)
+ expect(test_table.where(id_tmp_column => 0).where(fk_tmp_column => 0).pluck(:id)).to contain_exactly(11)
+ end
- it 'raises error when number of source and target columns does not match' do
- columns_to_copy_from = %w[id fk]
- columns_to_copy_to = [helpers.convert_to_bigint_column(:id)]
+ context 'when the number of source and target columns does not match' do
+ let(:columns_to_copy_to) { [id_tmp_column] }
- expect do
- subject.perform(10, 15, table_name, 'id', sub_batch_size, pause_ms, columns_to_copy_from, columns_to_copy_to)
- end.to raise_error(ArgumentError, 'number of source and destination columns must match')
+ it 'raises an error' do
+ expect do
+ copy_job.perform(columns_to_copy_from, columns_to_copy_to)
+ end.to raise_error(ArgumentError, 'number of source and destination columns must match')
+ end
+ end
end
it 'tracks timings of queries' do
- expect(copy_columns.batch_metrics.timings).to be_empty
+ expect(copy_job.batch_metrics.timings).to be_empty
- copy_columns.perform(10, 20, table_name, 'id', sub_batch_size, pause_ms, 'name', 'name_convert_to_text')
+ copy_job.perform('name', 'name_convert_to_text')
- expect(copy_columns.batch_metrics.timings[:update_all]).not_to be_empty
+ expect(copy_job.batch_metrics.timings[:update_all]).not_to be_empty
end
context 'pause interval between sub-batches' do
- it 'sleeps for the specified time between sub-batches' do
- sub_batch_size = 2
+ let(:pause_ms) { 5 }
- expect(copy_columns).to receive(:sleep).with(0.005)
+ it 'sleeps for the specified time between sub-batches' do
+ expect(copy_job).to receive(:sleep).with(0.005)
- copy_columns.perform(10, 12, table_name, 'id', sub_batch_size, 5, 'name', 'name_convert_to_text')
+ copy_job.perform('name', 'name_convert_to_text')
end
- it 'treats negative values as 0' do
- sub_batch_size = 2
+ context 'when pause_ms value is negative' do
+ let(:pause_ms) { -5 }
- expect(copy_columns).to receive(:sleep).with(0)
+ it 'treats it as a 0' do
+ expect(copy_job).to receive(:sleep).with(0)
- copy_columns.perform(10, 12, table_name, 'id', sub_batch_size, -5, 'name', 'name_convert_to_text')
+ copy_job.perform('name', 'name_convert_to_text')
+ end
end
end
end
diff --git a/spec/lib/gitlab/background_migration/expire_o_auth_tokens_spec.rb b/spec/lib/gitlab/background_migration/expire_o_auth_tokens_spec.rb
new file mode 100644
index 00000000000..cffcda0a2ca
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/expire_o_auth_tokens_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::ExpireOAuthTokens, :migration, schema: 20220428133724 do
+ let(:migration) { described_class.new }
+ let(:oauth_access_tokens_table) { table(:oauth_access_tokens) }
+
+ let(:table_name) { 'oauth_access_tokens' }
+
+ subject(:perform_migration) do
+ described_class.new(start_id: 1,
+ end_id: 30,
+ batch_table: :oauth_access_tokens,
+ batch_column: :id,
+ sub_batch_size: 2,
+ pause_ms: 0,
+ connection: ActiveRecord::Base.connection)
+ .perform
+ end
+
+ before do
+ oauth_access_tokens_table.create!(id: 1, token: 's3cr3t-1', expires_in: nil)
+ oauth_access_tokens_table.create!(id: 2, token: 's3cr3t-2', expires_in: 42)
+ oauth_access_tokens_table.create!(id: 3, token: 's3cr3t-3', expires_in: nil)
+ end
+
+ it 'adds expiry to oauth tokens', :aggregate_failures do
+ expect(ActiveRecord::QueryRecorder.new { perform_migration }.count).to eq(3)
+
+ expect(oauth_access_tokens_table.find(1).expires_in).to eq(7_200)
+ expect(oauth_access_tokens_table.find(2).expires_in).to eq(42)
+ expect(oauth_access_tokens_table.find(3).expires_in).to eq(7_200)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb b/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb
index a111007a984..65d55f85a98 100644
--- a/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb
+++ b/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Gitlab::BackgroundMigration::ExtractProjectTopicsIntoSeparateTabl
# Tagging records
expect { tagging_1.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect { tagging_2.reload }.to raise_error(ActiveRecord::RecordNotFound)
- expect { other_tagging.reload }.not_to raise_error(ActiveRecord::RecordNotFound)
+ expect { other_tagging.reload }.not_to raise_error
expect { tagging_3.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect { tagging_4.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect { tagging_5.reload }.to raise_error(ActiveRecord::RecordNotFound)
diff --git a/spec/lib/gitlab/background_migration/job_coordinator_spec.rb b/spec/lib/gitlab/background_migration/job_coordinator_spec.rb
index c1351481505..95847c67d94 100644
--- a/spec/lib/gitlab/background_migration/job_coordinator_spec.rb
+++ b/spec/lib/gitlab/background_migration/job_coordinator_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe Gitlab::BackgroundMigration::JobCoordinator do
it 'raises an error' do
expect do
described_class.for_tracking_database('notvalid')
- end.to raise_error(ArgumentError, /tracking_database must be one of/)
+ end.to raise_error(ArgumentError, /must be one of/)
end
end
end
diff --git a/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb b/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb
index 254b4fea698..2c2c048992f 100644
--- a/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb
+++ b/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::MergeTopicsWithSameName, schema: 20220223124428 do
+RSpec.describe Gitlab::BackgroundMigration::MergeTopicsWithSameName, schema: 20220331133802 do
def set_avatar(topic_id, avatar)
topic = ::Projects::Topic.find(topic_id)
topic.avatar = avatar
@@ -16,49 +16,62 @@ RSpec.describe Gitlab::BackgroundMigration::MergeTopicsWithSameName, schema: 202
topics = table(:topics)
project_topics = table(:project_topics)
- group = namespaces.create!(name: 'group', path: 'group')
- project_1 = projects.create!(namespace_id: group.id, visibility_level: 20)
- project_2 = projects.create!(namespace_id: group.id, visibility_level: 10)
- project_3 = projects.create!(namespace_id: group.id, visibility_level: 0)
+ group_1 = namespaces.create!(name: 'space1', type: 'Group', path: 'space1')
+ group_2 = namespaces.create!(name: 'space2', type: 'Group', path: 'space2')
+ group_3 = namespaces.create!(name: 'space3', type: 'Group', path: 'space3')
+ proj_space_1 = namespaces.create!(name: 'proj1', path: 'proj1', type: 'Project', parent_id: group_1.id)
+ proj_space_2 = namespaces.create!(name: 'proj2', path: 'proj2', type: 'Project', parent_id: group_2.id)
+ proj_space_3 = namespaces.create!(name: 'proj3', path: 'proj3', type: 'Project', parent_id: group_3.id)
+ project_1 = projects.create!(namespace_id: group_1.id, project_namespace_id: proj_space_1.id, visibility_level: 20)
+ project_2 = projects.create!(namespace_id: group_2.id, project_namespace_id: proj_space_2.id, visibility_level: 10)
+ project_3 = projects.create!(namespace_id: group_3.id, project_namespace_id: proj_space_3.id, visibility_level: 0)
topic_1_keep = topics.create!(
name: 'topic1',
+ title: 'Topic 1',
description: 'description 1 to keep',
total_projects_count: 2,
non_private_projects_count: 2
)
topic_1_remove = topics.create!(
name: 'TOPIC1',
+ title: 'Topic 1',
description: 'description 1 to remove',
total_projects_count: 2,
non_private_projects_count: 1
)
topic_2_remove = topics.create!(
name: 'topic2',
+ title: 'Topic 2',
total_projects_count: 0
)
topic_2_keep = topics.create!(
name: 'TOPIC2',
+ title: 'Topic 2',
description: 'description 2 to keep',
total_projects_count: 1
)
topic_3_remove_1 = topics.create!(
name: 'topic3',
+ title: 'Topic 3',
total_projects_count: 2,
non_private_projects_count: 1
)
topic_3_keep = topics.create!(
name: 'Topic3',
+ title: 'Topic 3',
total_projects_count: 2,
non_private_projects_count: 2
)
topic_3_remove_2 = topics.create!(
name: 'TOPIC3',
+ title: 'Topic 3',
description: 'description 3 to keep',
total_projects_count: 2,
non_private_projects_count: 1
)
topic_4_keep = topics.create!(
- name: 'topic4'
+ name: 'topic4',
+ title: 'Topic 4'
)
project_topics_1 = []
diff --git a/spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb b/spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb
index 90dd3e14606..e38edfc3643 100644
--- a/spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb
+++ b/spec/lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds_spec.rb
@@ -5,9 +5,9 @@ require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::NullifyOrphanRunnerIdOnCiBuilds, :migration, schema: 20220223112304 do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
- let(:ci_runners) { table(:ci_runners) }
- let(:ci_pipelines) { table(:ci_pipelines) }
- let(:ci_builds) { table(:ci_builds) }
+ let(:ci_runners) { table(:ci_runners, database: :ci) }
+ let(:ci_pipelines) { table(:ci_pipelines, database: :ci) }
+ let(:ci_builds) { table(:ci_builds, database: :ci) }
subject { described_class.new }
@@ -26,9 +26,9 @@ RSpec.describe Gitlab::BackgroundMigration::NullifyOrphanRunnerIdOnCiBuilds, :mi
describe '#perform' do
let(:namespace) { namespaces.create!(name: 'test', path: 'test', type: 'Group') }
let(:project) { projects.create!(namespace_id: namespace.id, name: 'test') }
- let(:pipeline) { ci_pipelines.create!(project_id: project.id, ref: 'master', sha: 'adf43c3a', status: 'success') }
it 'nullifies runner_id for orphan ci_builds in range' do
+ pipeline = ci_pipelines.create!(project_id: project.id, ref: 'master', sha: 'adf43c3a', status: 'success')
ci_runners.create!(id: 2, runner_type: 'project_type')
ci_builds.create!(id: 5, type: 'Ci::Build', commit_id: pipeline.id, runner_id: 2)
diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb
index d02f7245c15..71020746fa7 100644
--- a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb
+++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb
@@ -6,32 +6,47 @@ RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncrypte
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
- let(:perform) { described_class.new.perform(1, 4) }
+ subject(:background_migration) { described_class.new }
before do
namespaces.create!(id: 123, name: 'sample', path: 'sample')
projects.create!(id: 1, namespace_id: 123, runners_token_encrypted: 'duplicate')
projects.create!(id: 2, namespace_id: 123, runners_token_encrypted: 'a-runners-token')
- projects.create!(id: 3, namespace_id: 123, runners_token_encrypted: 'duplicate')
+ projects.create!(id: 3, namespace_id: 123, runners_token_encrypted: 'duplicate-2')
projects.create!(id: 4, namespace_id: 123, runners_token_encrypted: nil)
projects.create!(id: 5, namespace_id: 123, runners_token_encrypted: 'duplicate-2')
- projects.create!(id: 6, namespace_id: 123, runners_token_encrypted: 'duplicate-2')
+ projects.create!(id: 6, namespace_id: 123, runners_token_encrypted: 'duplicate')
+ projects.create!(id: 7, namespace_id: 123, runners_token_encrypted: 'another-runners-token')
+ projects.create!(id: 8, namespace_id: 123, runners_token_encrypted: 'another-runners-token')
end
describe '#up' do
- before do
- stub_const("#{described_class}::SUB_BATCH_SIZE", 2)
- end
-
it 'nullifies duplicate tokens', :aggregate_failures do
- perform
+ background_migration.perform(1, 2)
+ background_migration.perform(3, 4)
- expect(projects.count).to eq(6)
+ expect(projects.count).to eq(8)
expect(projects.all.pluck(:id, :runners_token_encrypted).to_h).to eq(
- { 1 => nil, 2 => 'a-runners-token', 3 => nil, 4 => nil, 5 => 'duplicate-2', 6 => 'duplicate-2' }
- )
- expect(projects.pluck(:runners_token_encrypted).uniq).to match_array [nil, 'a-runners-token', 'duplicate-2']
+ {
+ 1 => nil,
+ 2 => 'a-runners-token',
+ 3 => nil,
+ 4 => nil,
+ 5 => 'duplicate-2',
+ 6 => 'duplicate',
+ 7 => 'another-runners-token',
+ 8 => 'another-runners-token'
+ })
+ expect(projects.pluck(:runners_token_encrypted).uniq).to match_array [
+ nil, 'a-runners-token', 'duplicate', 'duplicate-2', 'another-runners-token'
+ ]
+ end
+
+ it 'does not touch projects outside id range' do
+ expect do
+ background_migration.perform(1, 2)
+ end.not_to change { projects.where(id: [3..8]).each(&:reload).map(&:updated_at) }
end
end
end
diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb
index fd61047d851..7d3df69bee2 100644
--- a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb
+++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb
@@ -6,32 +6,47 @@ RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOn
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
- let(:perform) { described_class.new.perform(1, 4) }
+ subject(:background_migration) { described_class.new }
before do
namespaces.create!(id: 123, name: 'sample', path: 'sample')
projects.create!(id: 1, namespace_id: 123, runners_token: 'duplicate')
projects.create!(id: 2, namespace_id: 123, runners_token: 'a-runners-token')
- projects.create!(id: 3, namespace_id: 123, runners_token: 'duplicate')
+ projects.create!(id: 3, namespace_id: 123, runners_token: 'duplicate-2')
projects.create!(id: 4, namespace_id: 123, runners_token: nil)
projects.create!(id: 5, namespace_id: 123, runners_token: 'duplicate-2')
- projects.create!(id: 6, namespace_id: 123, runners_token: 'duplicate-2')
+ projects.create!(id: 6, namespace_id: 123, runners_token: 'duplicate')
+ projects.create!(id: 7, namespace_id: 123, runners_token: 'another-runners-token')
+ projects.create!(id: 8, namespace_id: 123, runners_token: 'another-runners-token')
end
describe '#up' do
- before do
- stub_const("#{described_class}::SUB_BATCH_SIZE", 2)
- end
-
it 'nullifies duplicate tokens', :aggregate_failures do
- perform
+ background_migration.perform(1, 2)
+ background_migration.perform(3, 4)
- expect(projects.count).to eq(6)
+ expect(projects.count).to eq(8)
expect(projects.all.pluck(:id, :runners_token).to_h).to eq(
- { 1 => nil, 2 => 'a-runners-token', 3 => nil, 4 => nil, 5 => 'duplicate-2', 6 => 'duplicate-2' }
- )
- expect(projects.pluck(:runners_token).uniq).to match_array [nil, 'a-runners-token', 'duplicate-2']
+ {
+ 1 => nil,
+ 2 => 'a-runners-token',
+ 3 => nil,
+ 4 => nil,
+ 5 => 'duplicate-2',
+ 6 => 'duplicate',
+ 7 => 'another-runners-token',
+ 8 => 'another-runners-token'
+ })
+ expect(projects.pluck(:runners_token).uniq).to match_array [
+ nil, 'a-runners-token', 'duplicate', 'duplicate-2', 'another-runners-token'
+ ]
+ end
+
+ it 'does not touch projects outside id range' do
+ expect do
+ background_migration.perform(1, 2)
+ end.not_to change { projects.where(id: [3..8]).each(&:reload).map(&:updated_at) }
end
end
end
diff --git a/spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb b/spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb
new file mode 100644
index 00000000000..3f59b0a24a3
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/reset_too_many_tags_skipped_registry_imports_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::ResetTooManyTagsSkippedRegistryImports, :migration,
+ :aggregate_failures,
+ schema: 20220502173045 do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:container_repositories) { table(:container_repositories) }
+
+ subject(:background_migration) { described_class.new }
+
+ let!(:namespace) { namespaces.create!(id: 1, path: 'foo', name: 'foo') }
+ let!(:project) { projects.create!(id: 1, project_namespace_id: 1, namespace_id: 1, path: 'bar', name: 'bar') }
+
+ let!(:container_repository1) do
+ container_repositories.create!(id: 1,
+ project_id: 1,
+ name: 'a',
+ migration_state: 'import_skipped',
+ migration_skipped_at: Time.zone.now,
+ migration_skipped_reason: 2,
+ migration_pre_import_started_at: Time.zone.now,
+ migration_pre_import_done_at: Time.zone.now,
+ migration_import_started_at: Time.zone.now,
+ migration_import_done_at: Time.zone.now,
+ migration_aborted_at: Time.zone.now,
+ migration_retries_count: 2,
+ migration_aborted_in_state: 'importing')
+ end
+
+ let!(:container_repository2) do
+ container_repositories.create!(id: 2,
+ project_id: 1,
+ name: 'b',
+ migration_state: 'import_skipped',
+ migration_skipped_at: Time.zone.now,
+ migration_skipped_reason: 2)
+ end
+
+ let!(:container_repository3) do
+ container_repositories.create!(id: 3,
+ project_id: 1,
+ name: 'c',
+ migration_state: 'import_skipped',
+ migration_skipped_at: Time.zone.now,
+ migration_skipped_reason: 1)
+ end
+
+ # This is an unlikely state, but included here to test the edge case
+ let!(:container_repository4) do
+ container_repositories.create!(id: 4,
+ project_id: 1,
+ name: 'd',
+ migration_state: 'default',
+ migration_skipped_reason: 2)
+ end
+
+ describe '#up' do
+ it 'resets only qualified container repositories', :aggregate_failures do
+ background_migration.perform(1, 4)
+
+ expect(container_repository1.reload.migration_state).to eq('default')
+ expect(container_repository1.migration_skipped_reason).to eq(nil)
+ expect(container_repository1.migration_pre_import_started_at).to eq(nil)
+ expect(container_repository1.migration_pre_import_done_at).to eq(nil)
+ expect(container_repository1.migration_import_started_at).to eq(nil)
+ expect(container_repository1.migration_import_done_at).to eq(nil)
+ expect(container_repository1.migration_aborted_at).to eq(nil)
+ expect(container_repository1.migration_skipped_at).to eq(nil)
+ expect(container_repository1.migration_retries_count).to eq(0)
+ expect(container_repository1.migration_aborted_in_state).to eq(nil)
+
+ expect(container_repository2.reload.migration_state).to eq('default')
+ expect(container_repository2.migration_skipped_reason).to eq(nil)
+
+ expect(container_repository3.reload.migration_state).to eq('import_skipped')
+ expect(container_repository3.migration_skipped_reason).to eq(1)
+
+ expect(container_repository4.reload.migration_state).to eq('default')
+ expect(container_repository4.migration_skipped_reason).to eq(2)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/backtrace_cleaner_spec.rb b/spec/lib/gitlab/backtrace_cleaner_spec.rb
index e46a90e8606..cdde5a02d3b 100644
--- a/spec/lib/gitlab/backtrace_cleaner_spec.rb
+++ b/spec/lib/gitlab/backtrace_cleaner_spec.rb
@@ -25,7 +25,6 @@ RSpec.describe Gitlab::BacktraceCleaner do
"app/models/repository.rb:113:in `commit'",
"lib/gitlab/i18n.rb:50:in `with_locale'",
"lib/gitlab/middleware/multipart.rb:95:in `call'",
- "lib/gitlab/request_profiler/middleware.rb:14:in `call'",
"ee/lib/gitlab/database/load_balancing/rack_middleware.rb:37:in `call'",
"ee/lib/gitlab/jira/middleware.rb:15:in `call'"
]
diff --git a/spec/lib/gitlab/checks/branch_check_spec.rb b/spec/lib/gitlab/checks/branch_check_spec.rb
index c06d26d1441..d6280d3c28c 100644
--- a/spec/lib/gitlab/checks/branch_check_spec.rb
+++ b/spec/lib/gitlab/checks/branch_check_spec.rb
@@ -103,7 +103,7 @@ RSpec.describe Gitlab::Checks::BranchCheck do
it 'prevents force push' do
expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
- expect { subject.validate! }.to raise_error
+ expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError)
end
end
end
@@ -126,7 +126,7 @@ RSpec.describe Gitlab::Checks::BranchCheck do
it 'prevents force push' do
expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
- expect { subject.validate! }.to raise_error
+ expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError)
end
end
@@ -141,7 +141,7 @@ RSpec.describe Gitlab::Checks::BranchCheck do
it 'prevents force push' do
expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
- expect { subject.validate! }.to raise_error
+ expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError)
end
end
end
diff --git a/spec/lib/gitlab/checks/changes_access_spec.rb b/spec/lib/gitlab/checks/changes_access_spec.rb
index 1cb4edd7337..41ec11c1055 100644
--- a/spec/lib/gitlab/checks/changes_access_spec.rb
+++ b/spec/lib/gitlab/checks/changes_access_spec.rb
@@ -49,10 +49,17 @@ RSpec.describe Gitlab::Checks::ChangesAccess do
context 'when changes contain empty revisions' do
let(:expected_commit) { instance_double(Commit) }
+ let(:expected_allow_quarantine) { allow_quarantine }
shared_examples 'returns only commits with non empty revisions' do
+ before do
+ stub_feature_flags(filter_quarantined_commits: filter_quarantined_commits)
+ end
+
specify do
- expect(project.repository).to receive(:new_commits).with([newrev], { allow_quarantine: allow_quarantine }) { [expected_commit] }
+ expect(project.repository)
+ .to receive(:new_commits)
+ .with([newrev], allow_quarantine: expected_allow_quarantine) { [expected_commit] }
expect(subject.commits).to match_array([expected_commit])
end
end
@@ -60,13 +67,37 @@ RSpec.describe Gitlab::Checks::ChangesAccess do
it_behaves_like 'returns only commits with non empty revisions' do
let(:changes) { [{ oldrev: oldrev, newrev: newrev }, { newrev: '' }, { newrev: Gitlab::Git::BLANK_SHA }] }
let(:allow_quarantine) { true }
+ let(:filter_quarantined_commits) { true }
end
context 'without oldrev' do
- it_behaves_like 'returns only commits with non empty revisions' do
- let(:changes) { [{ newrev: newrev }, { newrev: '' }, { newrev: Gitlab::Git::BLANK_SHA }] }
- # The quarantine directory should not be used because we're lacking oldrev.
+ let(:changes) { [{ newrev: newrev }, { newrev: '' }, { newrev: Gitlab::Git::BLANK_SHA }] }
+
+ context 'with disallowed quarantine' do
+ # The quarantine directory should not be used because we're lacking
+ # oldrev, and we're not filtering commits.
let(:allow_quarantine) { false }
+ let(:filter_quarantined_commits) { false }
+
+ it_behaves_like 'returns only commits with non empty revisions'
+ end
+
+ context 'with allowed quarantine and :filter_quarantined_commits disabled' do
+ # When we allow usage of the quarantine but have no oldrev and we're
+ # not filtering commits then results returned by the quarantine aren't
+ # accurate. We thus mustn't try using it.
+ let(:allow_quarantine) { true }
+ let(:filter_quarantined_commits) { false }
+ let(:expected_allow_quarantine) { false }
+
+ it_behaves_like 'returns only commits with non empty revisions'
+ end
+
+ context 'with allowed quarantine and :filter_quarantined_commits enabled' do
+ let(:allow_quarantine) { true }
+ let(:filter_quarantined_commits) { true }
+
+ it_behaves_like 'returns only commits with non empty revisions'
end
end
end
diff --git a/spec/lib/gitlab/checks/single_change_access_spec.rb b/spec/lib/gitlab/checks/single_change_access_spec.rb
index e81e4951539..1b34e58797e 100644
--- a/spec/lib/gitlab/checks/single_change_access_spec.rb
+++ b/spec/lib/gitlab/checks/single_change_access_spec.rb
@@ -96,13 +96,26 @@ RSpec.describe Gitlab::Checks::SingleChangeAccess do
let(:provided_commits) { nil }
before do
+ stub_feature_flags(filter_quarantined_commits: filter_quarantined_commits)
+
expect(project.repository)
.to receive(:new_commits)
+ .with(newrev, allow_quarantine: filter_quarantined_commits)
.once
.and_return(expected_commits)
end
- it_behaves_like '#commits'
+ context 'with :filter_quarantined_commits disabled' do
+ let(:filter_quarantined_commits) { false }
+
+ it_behaves_like '#commits'
+ end
+
+ context 'with :filter_quarantined_commits enabled' do
+ let(:filter_quarantined_commits) { true }
+
+ it_behaves_like '#commits'
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/ansi2json_spec.rb b/spec/lib/gitlab/ci/ansi2json_spec.rb
index c9c0d1a744e..f9d23ff97bc 100644
--- a/spec/lib/gitlab/ci/ansi2json_spec.rb
+++ b/spec/lib/gitlab/ci/ansi2json_spec.rb
@@ -27,6 +27,17 @@ RSpec.describe Gitlab::Ci::Ansi2json do
])
end
+ it 'ignores empty newlines' do
+ expect(convert_json("Hello\n\nworld")).to eq([
+ { offset: 0, content: [{ text: 'Hello' }] },
+ { offset: 7, content: [{ text: 'world' }] }
+ ])
+ expect(convert_json("Hello\r\n\r\nworld")).to eq([
+ { offset: 0, content: [{ text: 'Hello' }] },
+ { offset: 9, content: [{ text: 'world' }] }
+ ])
+ end
+
it 'replace the current line when encountering \r' do
expect(convert_json("Hello\rworld")).to eq([
{ offset: 0, content: [{ text: 'world' }] }
diff --git a/spec/lib/gitlab/ci/build/rules/rule/clause/if_spec.rb b/spec/lib/gitlab/ci/build/rules/rule/clause/if_spec.rb
new file mode 100644
index 00000000000..81bce989833
--- /dev/null
+++ b/spec/lib/gitlab/ci/build/rules/rule/clause/if_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'support/helpers/stubbed_feature'
+require 'support/helpers/stub_feature_flags'
+
+RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::If do
+ include StubFeatureFlags
+
+ subject(:if_clause) { described_class.new(expression) }
+
+ describe '#satisfied_by?' do
+ let(:context_class) { Gitlab::Ci::Build::Context::Base }
+ let(:rules_context) { instance_double(context_class, variables_hash: {}) }
+
+ subject(:satisfied_by?) { if_clause.satisfied_by?(nil, rules_context) }
+
+ context 'when expression is a basic string comparison' do
+ context 'when comparison is true' do
+ let(:expression) { '"value" == "value"' }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when comparison is false' do
+ let(:expression) { '"value" == "other"' }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'when expression is a regexp' do
+ context 'when comparison is true' do
+ let(:expression) { '"abcde" =~ /^ab.*/' }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when comparison is false' do
+ let(:expression) { '"abcde" =~ /^af.*/' }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when both side of the expression are variables' do
+ let(:expression) { '$teststring =~ $pattern' }
+
+ context 'when comparison is true' do
+ let(:rules_context) do
+ instance_double(context_class, variables_hash: { 'teststring' => 'abcde', 'pattern' => '/^ab.*/' })
+ end
+
+ it { is_expected.to eq(true) }
+
+ context 'when the FF ci_fix_rules_if_comparison_with_regexp_variable is disabled' do
+ before do
+ stub_feature_flags(ci_fix_rules_if_comparison_with_regexp_variable: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'when comparison is false' do
+ let(:rules_context) do
+ instance_double(context_class, variables_hash: { 'teststring' => 'abcde', 'pattern' => '/^af.*/' })
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/build/rules_spec.rb b/spec/lib/gitlab/ci/build/rules_spec.rb
index 37bfdca4d1d..e82dcd0254d 100644
--- a/spec/lib/gitlab/ci/build/rules_spec.rb
+++ b/spec/lib/gitlab/ci/build/rules_spec.rb
@@ -188,6 +188,19 @@ RSpec.describe Gitlab::Ci::Build::Rules do
it { is_expected.to eq(described_class::Result.new('on_success', nil, nil, { MY_VAR: 'my var' })) }
end
end
+
+ context 'with a regexp variable matching rule' do
+ let(:rule_list) { [{ if: '"abcde" =~ $pattern' }] }
+
+ before do
+ allow(ci_build).to receive(:scoped_variables).and_return(
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'pattern', value: '/^ab.*/', public: true)
+ )
+ end
+
+ it { is_expected.to eq(described_class::Result.new('on_success')) }
+ end
end
describe 'Gitlab::Ci::Build::Rules::Result' do
diff --git a/spec/lib/gitlab/ci/config/entry/environment_spec.rb b/spec/lib/gitlab/ci/config/entry/environment_spec.rb
index dd8a79f0d84..36c26c8ee4f 100644
--- a/spec/lib/gitlab/ci/config/entry/environment_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/environment_spec.rb
@@ -92,24 +92,18 @@ RSpec.describe Gitlab::Ci::Config::Entry::Environment do
end
context 'when valid action is used' do
- let(:config) do
- { name: 'production',
- action: 'start' }
- end
-
- it 'is valid' do
- expect(entry).to be_valid
+ where(:action) do
+ %w(start stop prepare verify access)
end
- end
- context 'when prepare action is used' do
- let(:config) do
- { name: 'production',
- action: 'prepare' }
- end
+ with_them do
+ let(:config) do
+ { name: 'production', action: action }
+ end
- it 'is valid' do
- expect(entry).to be_valid
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
end
end
@@ -148,7 +142,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Environment do
describe '#errors' do
it 'contains error about invalid action' do
expect(entry.errors)
- .to include 'environment action should be start, stop or prepare'
+ .to include 'environment action should be start, stop, prepare, verify, or access'
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index 97691504abd..ca336c3ecaa 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
subject { described_class.nodes.keys }
let(:result) do
- %i[before_script script stage type after_script cache
+ %i[before_script script stage after_script cache
image services only except rules needs variables artifacts
environment coverage retry interruptible timeout release tags
inherit parallel]
diff --git a/spec/lib/gitlab/ci/config/entry/reports_spec.rb b/spec/lib/gitlab/ci/config/entry/reports_spec.rb
index 061d8f34c8d..051cccb4833 100644
--- a/spec/lib/gitlab/ci/config/entry/reports_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/reports_spec.rb
@@ -45,10 +45,8 @@ RSpec.describe Gitlab::Ci::Config::Entry::Reports do
:load_performance | 'load-performance.json'
:lsif | 'lsif.json'
:dotenv | 'build.dotenv'
- :cobertura | 'cobertura-coverage.xml'
:terraform | 'tfplan.json'
:accessibility | 'gl-accessibility.json'
- :cluster_applications | 'gl-cluster-applications.json'
end
with_them do
@@ -90,18 +88,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Reports do
expect(entry.value).to eq({ coverage_report: coverage_report, dast: ['gl-dast-report.json'] })
end
end
-
- context 'and a direct coverage report format is specified' do
- let(:config) { { coverage_report: coverage_report, cobertura: 'cobertura-coverage.xml' } }
-
- it 'is not valid' do
- expect(entry).not_to be_valid
- end
-
- it 'reports error' do
- expect(entry.errors).to include /please use only one the following keys: coverage_report, cobertura/
- end
- end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb
index b9c32bc51be..55ad119ea21 100644
--- a/spec/lib/gitlab/ci/config/entry/root_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
# The purpose of `Root` is have only globally defined configuration.
expect(described_class.nodes.keys)
.to match_array(%i[before_script image services after_script
- variables cache stages types include default workflow])
+ variables cache stages include default workflow])
end
end
end
@@ -55,41 +55,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
}
end
- context 'when deprecated types/type keywords are defined' do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
-
- let(:hash) do
- { types: %w(test deploy),
- rspec: { script: 'rspec', type: 'test' } }
- end
-
- before do
- root.compose!
- end
-
- it 'returns array of types as stages with a warning' do
- expect(root.jobs_value[:rspec][:stage]).to eq 'test'
- expect(root.stages_value).to eq %w[test deploy]
- expect(root.warnings).to match_array([
- "root `types` is deprecated in 9.0 and will be removed in 15.0.",
- "jobs:rspec `type` is deprecated in 9.0 and will be removed in 15.0."
- ])
- end
-
- it 'logs usage of keywords' do
- expect(Gitlab::AppJsonLogger).to(
- receive(:info)
- .with(event: 'ci_used_deprecated_keyword',
- entry: root[:stages].key.to_s,
- user_id: user.id,
- project_id: project.id)
- )
-
- root.compose!
- end
- end
-
describe '#compose!' do
before do
root.compose!
diff --git a/spec/lib/gitlab/ci/config/external/file/local_spec.rb b/spec/lib/gitlab/ci/config/external/file/local_spec.rb
index c0a0b0009ce..0e78498c98e 100644
--- a/spec/lib/gitlab/ci/config/external/file/local_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/local_spec.rb
@@ -199,6 +199,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do
context_sha: '12345',
type: :local,
location: location,
+ blob: "http://localhost/#{project.full_path}/-/blob/12345/lib/gitlab/ci/templates/existent-file.yml",
+ raw: "http://localhost/#{project.full_path}/-/raw/12345/lib/gitlab/ci/templates/existent-file.yml",
extra: {}
)
}
diff --git a/spec/lib/gitlab/ci/config/external/file/project_spec.rb b/spec/lib/gitlab/ci/config/external/file/project_spec.rb
index 5d3412a148b..77e542cf933 100644
--- a/spec/lib/gitlab/ci/config/external/file/project_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/project_spec.rb
@@ -207,6 +207,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
context_sha: '12345',
type: :file,
location: '/file.yml',
+ blob: "http://localhost/#{project.full_path}/-/blob/#{project.commit('master').id}/file.yml",
+ raw: "http://localhost/#{project.full_path}/-/raw/#{project.commit('master').id}/file.yml",
extra: { project: project.full_path, ref: 'HEAD' }
)
}
@@ -227,6 +229,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
context_sha: '12345',
type: :file,
location: '/file.yml',
+ blob: nil,
+ raw: nil,
extra: { project: 'xxxxxxxxxxxxxxxxxxxxxxxx', ref: 'xxxxxxxxxxxxxxxxxxxxxxxx' }
)
}
diff --git a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
index 5c07c87fd5a..3e1c4df4e32 100644
--- a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
@@ -213,6 +213,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do
context_sha: '12345',
type: :remote,
location: 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.xxxxxxxxxxx.yml',
+ raw: 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.xxxxxxxxxxx.yml',
+ blob: nil,
extra: {}
)
}
diff --git a/spec/lib/gitlab/ci/config/external/file/template_spec.rb b/spec/lib/gitlab/ci/config/external/file/template_spec.rb
index 4da9a933a9f..074e7a1d32d 100644
--- a/spec/lib/gitlab/ci/config/external/file/template_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/template_spec.rb
@@ -124,6 +124,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do
context_sha: '12345',
type: :template,
location: template,
+ raw: "https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/#{template}",
+ blob: nil,
extra: {}
)
}
diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb
index 56cd006717e..15a0ff40aa4 100644
--- a/spec/lib/gitlab/ci/config/external/processor_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb
@@ -267,11 +267,41 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
perform
expect(context.includes).to contain_exactly(
- { type: :local, location: '/local/file.yml', extra: {}, context_project: project.full_path, context_sha: '12345' },
- { type: :template, location: 'Ruby.gitlab-ci.yml', extra: {}, context_project: project.full_path, context_sha: '12345' },
- { type: :remote, location: 'http://my.domain.com/config.yml', extra: {}, context_project: project.full_path, context_sha: '12345' },
- { type: :file, location: '/templates/my-workflow.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' },
- { type: :local, location: '/templates/my-build.yml', extra: {}, context_project: another_project.full_path, context_sha: another_project.commit.sha }
+ { type: :local,
+ location: '/local/file.yml',
+ blob: "http://localhost/#{project.full_path}/-/blob/12345/local/file.yml",
+ raw: "http://localhost/#{project.full_path}/-/raw/12345/local/file.yml",
+ extra: {},
+ context_project: project.full_path,
+ context_sha: '12345' },
+ { type: :template,
+ location: 'Ruby.gitlab-ci.yml',
+ blob: nil,
+ raw: 'https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/Ruby.gitlab-ci.yml',
+ extra: {},
+ context_project: project.full_path,
+ context_sha: '12345' },
+ { type: :remote,
+ location: 'http://my.domain.com/config.yml',
+ blob: nil,
+ raw: "http://my.domain.com/config.yml",
+ extra: {},
+ context_project: project.full_path,
+ context_sha: '12345' },
+ { type: :file,
+ location: '/templates/my-workflow.yml',
+ blob: "http://localhost/#{another_project.full_path}/-/blob/#{another_project.commit.sha}/templates/my-workflow.yml",
+ raw: "http://localhost/#{another_project.full_path}/-/raw/#{another_project.commit.sha}/templates/my-workflow.yml",
+ extra: { project: another_project.full_path, ref: 'HEAD' },
+ context_project: project.full_path,
+ context_sha: '12345' },
+ { type: :local,
+ location: '/templates/my-build.yml',
+ blob: "http://localhost/#{another_project.full_path}/-/blob/#{another_project.commit.sha}/templates/my-build.yml",
+ raw: "http://localhost/#{another_project.full_path}/-/raw/#{another_project.commit.sha}/templates/my-build.yml",
+ extra: {},
+ context_project: another_project.full_path,
+ context_sha: another_project.commit.sha }
)
end
end
@@ -394,8 +424,20 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
perform
expect(context.includes).to contain_exactly(
- { type: :file, location: '/templates/my-build.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' },
- { type: :file, location: '/templates/my-test.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' }
+ { type: :file,
+ location: '/templates/my-build.yml',
+ blob: "http://localhost/#{another_project.full_path}/-/blob/#{another_project.commit.sha}/templates/my-build.yml",
+ raw: "http://localhost/#{another_project.full_path}/-/raw/#{another_project.commit.sha}/templates/my-build.yml",
+ extra: { project: another_project.full_path, ref: 'HEAD' },
+ context_project: project.full_path,
+ context_sha: '12345' },
+ { type: :file,
+ blob: "http://localhost/#{another_project.full_path}/-/blob/#{another_project.commit.sha}/templates/my-test.yml",
+ raw: "http://localhost/#{another_project.full_path}/-/raw/#{another_project.commit.sha}/templates/my-test.yml",
+ location: '/templates/my-test.yml',
+ extra: { project: another_project.full_path, ref: 'HEAD' },
+ context_project: project.full_path,
+ context_sha: '12345' }
)
end
end
@@ -438,8 +480,20 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
perform
expect(context.includes).to contain_exactly(
- { type: :local, location: 'myfolder/file1.yml', extra: {}, context_project: project.full_path, context_sha: '12345' },
- { type: :local, location: 'myfolder/file2.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }
+ { type: :local,
+ location: 'myfolder/file1.yml',
+ blob: "http://localhost/#{project.full_path}/-/blob/12345/myfolder/file1.yml",
+ raw: "http://localhost/#{project.full_path}/-/raw/12345/myfolder/file1.yml",
+ extra: {},
+ context_project: project.full_path,
+ context_sha: '12345' },
+ { type: :local,
+ blob: "http://localhost/#{project.full_path}/-/blob/12345/myfolder/file2.yml",
+ raw: "http://localhost/#{project.full_path}/-/raw/12345/myfolder/file2.yml",
+ location: 'myfolder/file2.yml',
+ extra: {},
+ context_project: project.full_path,
+ context_sha: '12345' }
)
end
end
diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb
index 3ba6a9059c6..5eb04d969eb 100644
--- a/spec/lib/gitlab/ci/config_spec.rb
+++ b/spec/lib/gitlab/ci/config_spec.rb
@@ -109,16 +109,22 @@ RSpec.describe Gitlab::Ci::Config do
expect(config.metadata[:includes]).to contain_exactly(
{ type: :template,
location: 'Jobs/Deploy.gitlab-ci.yml',
+ blob: nil,
+ raw: 'https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml',
extra: {},
context_project: nil,
context_sha: nil },
{ type: :template,
location: 'Jobs/Build.gitlab-ci.yml',
+ blob: nil,
+ raw: 'https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/Jobs/Build.gitlab-ci.yml',
extra: {},
context_project: nil,
context_sha: nil },
{ type: :remote,
location: 'https://example.com/gitlab-ci.yml',
+ blob: nil,
+ raw: 'https://example.com/gitlab-ci.yml',
extra: {},
context_project: nil,
context_sha: nil }
@@ -428,16 +434,22 @@ RSpec.describe Gitlab::Ci::Config do
expect(config.metadata[:includes]).to contain_exactly(
{ type: :local,
location: local_location,
+ blob: "http://localhost/#{project.full_path}/-/blob/12345/#{local_location}",
+ raw: "http://localhost/#{project.full_path}/-/raw/12345/#{local_location}",
extra: {},
context_project: project.full_path,
context_sha: '12345' },
{ type: :remote,
location: remote_location,
+ blob: nil,
+ raw: remote_location,
extra: {},
context_project: project.full_path,
context_sha: '12345' },
{ type: :file,
location: '.gitlab-ci.yml',
+ blob: "http://localhost/#{main_project.full_path}/-/blob/#{main_project.commit.sha}/.gitlab-ci.yml",
+ raw: "http://localhost/#{main_project.full_path}/-/raw/#{main_project.commit.sha}/.gitlab-ci.yml",
extra: { project: main_project.full_path, ref: 'HEAD' },
context_project: project.full_path,
context_sha: '12345' }
diff --git a/spec/lib/gitlab/ci/lint_spec.rb b/spec/lib/gitlab/ci/lint_spec.rb
index 747ff13c840..7e0b2b5aa8e 100644
--- a/spec/lib/gitlab/ci/lint_spec.rb
+++ b/spec/lib/gitlab/ci/lint_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe Gitlab::Ci::Lint do
end
end
- shared_examples 'sets merged yaml' do
+ shared_examples 'sets config metadata' do
let(:content) do
<<~YAML
:include:
@@ -106,6 +106,20 @@ RSpec.describe Gitlab::Ci::Lint do
expect(subject.merged_yaml).to eq(expected_config.to_yaml)
end
+
+ it 'sets includes' do
+ expect(subject.includes).to contain_exactly(
+ {
+ type: :local,
+ location: 'another-gitlab-ci.yml',
+ blob: "http://localhost/#{project.full_path}/-/blob/#{project.commit.sha}/another-gitlab-ci.yml",
+ raw: "http://localhost/#{project.full_path}/-/raw/#{project.commit.sha}/another-gitlab-ci.yml",
+ extra: {},
+ context_project: project.full_path,
+ context_sha: project.commit.sha
+ }
+ )
+ end
end
shared_examples 'content with errors and warnings' do
@@ -220,7 +234,7 @@ RSpec.describe Gitlab::Ci::Lint do
end
end
- it_behaves_like 'sets merged yaml'
+ it_behaves_like 'sets config metadata'
include_context 'advanced validations' do
it 'does not catch advanced logical errors' do
@@ -275,7 +289,7 @@ RSpec.describe Gitlab::Ci::Lint do
end
end
- it_behaves_like 'sets merged yaml'
+ it_behaves_like 'sets config metadata'
include_context 'advanced validations' do
it 'runs advanced logical validations' do
diff --git a/spec/lib/gitlab/ci/parsers/security/common_spec.rb b/spec/lib/gitlab/ci/parsers/security/common_spec.rb
index dfc5dec1481..6495d1f654b 100644
--- a/spec/lib/gitlab/ci/parsers/security/common_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/common_spec.rb
@@ -292,7 +292,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
expect(scans.map(&:status).all?('success')).to be(true)
expect(scans.map(&:start_time).all?('placeholder-value')).to be(true)
expect(scans.map(&:end_time).all?('placeholder-value')).to be(true)
- expect(scans.size).to eq(3)
+ expect(scans.size).to eq(7)
expect(scans.first).to be_a(::Gitlab::Ci::Reports::Security::Scan)
end
@@ -348,22 +348,29 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
it 'returns links object for each finding', :aggregate_failures do
links = report.findings.flat_map(&:links)
- expect(links.map(&:url)).to match_array(['https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1020', 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1030'])
- expect(links.map(&:name)).to match_array([nil, 'CVE-1030'])
- expect(links.size).to eq(2)
+ expect(links.map(&:url)).to match_array(['https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1020', 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1030',
+ "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-2137", "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-2138",
+ "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-2139", "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-2140"])
+ expect(links.map(&:name)).to match_array([nil, nil, nil, nil, nil, 'CVE-1030'])
+ expect(links.size).to eq(6)
expect(links.first).to be_a(::Gitlab::Ci::Reports::Security::Link)
end
end
describe 'parsing evidence' do
- it 'returns evidence object for each finding', :aggregate_failures do
- evidences = report.findings.map(&:evidence)
+ RSpec::Matchers.define_negated_matcher :have_values, :be_empty
- expect(evidences.first.data).not_to be_empty
- expect(evidences.first.data["summary"]).to match(/The Origin header was changed/)
- expect(evidences.size).to eq(3)
- expect(evidences.compact.size).to eq(2)
- expect(evidences.first).to be_a(::Gitlab::Ci::Reports::Security::Evidence)
+ it 'returns evidence object for each finding', :aggregate_failures do
+ all_evidences = report.findings.map(&:evidence)
+ evidences = all_evidences.compact
+ data = evidences.map(&:data)
+ summaries = evidences.map { |e| e.data["summary"] }
+
+ expect(all_evidences.size).to eq(7)
+ expect(evidences.size).to eq(2)
+ expect(evidences).to all( be_a(::Gitlab::Ci::Reports::Security::Evidence) )
+ expect(data).to all( have_values )
+ expect(summaries).to all( match(/The Origin header was changed/) )
end
end
diff --git a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
index f6409c8b01f..d06077d69b6 100644
--- a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
@@ -5,6 +5,8 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let_it_be(:project) { create(:project) }
+ let(:supported_dast_versions) { described_class::SUPPORTED_VERSIONS[:dast].join(', ') }
+
let(:scanner) do
{
'id' => 'gemnasium',
@@ -22,7 +24,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
expect(described_class::SUPPORTED_VERSIONS.keys).to eq(described_class::DEPRECATED_VERSIONS.keys)
end
- context 'files under schema path are explicitly listed' do
+ context 'when a schema JSON file exists for a particular report type version' do
# We only care about the part that comes before report-format.json
# https://rubular.com/r/N8Juz7r8hYDYgD
filename_regex = /(?<report_type>[-\w]*)\-report-format.json/
@@ -36,14 +38,14 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
matches = filename_regex.match(file)
report_type = matches[:report_type].tr("-", "_").to_sym
- it "#{report_type} #{version}" do
+ it "#{report_type} #{version} is in the constant" do
expect(described_class::SUPPORTED_VERSIONS[report_type]).to include(version)
end
end
end
end
- context 'every SUPPORTED_VERSION has a corresponding JSON file' do
+ context 'when every SUPPORTED_VERSION has a corresponding JSON file' do
described_class::SUPPORTED_VERSIONS.each_key do |report_type|
# api_fuzzing is covered by DAST schema
next if report_type == :api_fuzzing
@@ -66,7 +68,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:report_type) { :dast }
let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -77,7 +79,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
it { is_expected.to be_truthy }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
@@ -104,9 +106,19 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
context 'when given a deprecated schema version' do
let(:report_type) { :dast }
+ let(:deprecations_hash) do
+ {
+ dast: %w[10.0.0]
+ }
+ end
+
let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
- context 'and the report passes schema validation' do
+ before do
+ stub_const("#{described_class}::DEPRECATED_VERSIONS", deprecations_hash)
+ end
+
+ context 'when the report passes schema validation' do
let(:report_data) do
{
'version' => '10.0.0',
@@ -131,8 +143,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
end
end
- context 'and the report does not pass schema validation' do
- context 'and enforce_security_report_validation is enabled' do
+ context 'when the report does not pass schema validation' do
+ context 'when enforce_security_report_validation is enabled' do
before do
stub_feature_flags(enforce_security_report_validation: true)
end
@@ -146,7 +158,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
it { is_expected.to be_falsey }
end
- context 'and enforce_security_report_validation is disabled' do
+ context 'when enforce_security_report_validation is disabled' do
before do
stub_feature_flags(enforce_security_report_validation: false)
end
@@ -166,12 +178,12 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:report_type) { :dast }
let(:report_version) { "12.37.0" }
- context 'if enforce_security_report_validation is enabled' do
+ context 'when enforce_security_report_validation is enabled' do
before do
stub_feature_flags(enforce_security_report_validation: true)
end
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -196,14 +208,14 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
end
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
}
end
- context 'and scanner information is empty' do
+ context 'when scanner information is empty' do
let(:scanner) { {} }
it 'logs related information' do
@@ -235,12 +247,12 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
end
end
- context 'if enforce_security_report_validation is disabled' do
+ context 'when enforce_security_report_validation is disabled' do
before do
stub_feature_flags(enforce_security_report_validation: false)
end
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -251,7 +263,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
it { is_expected.to be_truthy }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
@@ -262,6 +274,30 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
end
end
end
+
+ context 'when not given a schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { nil }
+ let(:report_data) do
+ {
+ 'vulnerabilities' => []
+ }
+ end
+
+ before do
+ stub_feature_flags(enforce_security_report_validation: true)
+ end
+
+ it { is_expected.to be_falsey }
+
+ context 'when enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
end
describe '#errors' do
@@ -271,7 +307,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:report_type) { :dast }
let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -279,19 +315,17 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
}
end
- let(:expected_errors) { [] }
-
- it { is_expected.to match_array(expected_errors) }
+ it { is_expected.to be_empty }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
}
end
- context 'if enforce_security_report_validation is enabled' do
+ context 'when enforce_security_report_validation is enabled' do
before do
stub_feature_flags(enforce_security_report_validation: project)
end
@@ -305,23 +339,31 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
it { is_expected.to match_array(expected_errors) }
end
- context 'if enforce_security_report_validation is disabled' do
+ context 'when enforce_security_report_validation is disabled' do
before do
stub_feature_flags(enforce_security_report_validation: false)
end
- let(:expected_errors) { [] }
-
- it { is_expected.to match_array(expected_errors) }
+ it { is_expected.to be_empty }
end
end
end
context 'when given a deprecated schema version' do
let(:report_type) { :dast }
+ let(:deprecations_hash) do
+ {
+ dast: %w[10.0.0]
+ }
+ end
+
let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
- context 'and the report passes schema validation' do
+ before do
+ stub_const("#{described_class}::DEPRECATED_VERSIONS", deprecations_hash)
+ end
+
+ context 'when the report passes schema validation' do
let(:report_data) do
{
'version' => '10.0.0',
@@ -329,13 +371,11 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
}
end
- let(:expected_errors) { [] }
-
- it { is_expected.to match_array(expected_errors) }
+ it { is_expected.to be_empty }
end
- context 'and the report does not pass schema validation' do
- context 'and enforce_security_report_validation is enabled' do
+ context 'when the report does not pass schema validation' do
+ context 'when enforce_security_report_validation is enabled' do
before do
stub_feature_flags(enforce_security_report_validation: true)
end
@@ -356,7 +396,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
it { is_expected.to match_array(expected_errors) }
end
- context 'and enforce_security_report_validation is disabled' do
+ context 'when enforce_security_report_validation is disabled' do
before do
stub_feature_flags(enforce_security_report_validation: false)
end
@@ -367,9 +407,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
}
end
- let(:expected_errors) { [] }
-
- it { is_expected.to match_array(expected_errors) }
+ it { is_expected.to be_empty }
end
end
end
@@ -378,12 +416,12 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:report_type) { :dast }
let(:report_version) { "12.37.0" }
- context 'if enforce_security_report_validation is enabled' do
+ context 'when enforce_security_report_validation is enabled' do
before do
stub_feature_flags(enforce_security_report_validation: true)
end
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -393,14 +431,14 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:expected_errors) do
[
- "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1"
+ "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: #{supported_dast_versions}"
]
end
it { is_expected.to match_array(expected_errors) }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
@@ -409,7 +447,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:expected_errors) do
[
- "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1",
+ "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: #{supported_dast_versions}",
"root is missing required keys: vulnerabilities"
]
end
@@ -418,12 +456,12 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
end
end
- context 'if enforce_security_report_validation is disabled' do
+ context 'when enforce_security_report_validation is disabled' do
before do
stub_feature_flags(enforce_security_report_validation: false)
end
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -431,22 +469,45 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
}
end
- let(:expected_errors) { [] }
-
- it { is_expected.to match_array(expected_errors) }
+ it { is_expected.to be_empty }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
}
end
- let(:expected_errors) { [] }
+ it { is_expected.to be_empty }
+ end
+ end
+ end
- it { is_expected.to match_array(expected_errors) }
+ context 'when not given a schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { nil }
+ let(:report_data) do
+ {
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_errors) do
+ [
+ "root is missing required keys: version",
+ "Report version not provided, dast report type supports versions: #{supported_dast_versions}"
+ ]
+ end
+
+ it { is_expected.to match_array(expected_errors) }
+
+ context 'when enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
end
+
+ it { is_expected.to be_empty }
end
end
end
@@ -458,9 +519,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:report_type) { :dast }
let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
- let(:expected_deprecation_warnings) { [] }
-
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -468,30 +527,40 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
}
end
- it { is_expected.to match_array(expected_deprecation_warnings) }
+ it { is_expected.to be_empty }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
}
end
- it { is_expected.to match_array(expected_deprecation_warnings) }
+ it { is_expected.to be_empty }
end
end
context 'when given a deprecated schema version' do
let(:report_type) { :dast }
+ let(:deprecations_hash) do
+ {
+ dast: %w[V2.7.0]
+ }
+ end
+
let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
let(:expected_deprecation_warnings) do
[
- "Version V2.7.0 for report type dast has been deprecated, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1"
+ "Version V2.7.0 for report type dast has been deprecated, supported versions for this report type are: #{supported_dast_versions}"
]
end
- context 'and the report passes schema validation' do
+ before do
+ stub_const("#{described_class}::DEPRECATED_VERSIONS", deprecations_hash)
+ end
+
+ context 'when the report passes schema validation' do
let(:report_data) do
{
'version' => report_version,
@@ -502,7 +571,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
it { is_expected.to match_array(expected_deprecation_warnings) }
end
- context 'and the report does not pass schema validation' do
+ context 'when the report does not pass schema validation' do
let(:report_data) do
{
'version' => 'V2.7.0'
@@ -535,7 +604,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:report_type) { :dast }
let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -543,29 +612,25 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
}
end
- let(:expected_warnings) { [] }
-
- it { is_expected.to match_array(expected_warnings) }
+ it { is_expected.to be_empty }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
}
end
- context 'if enforce_security_report_validation is enabled' do
+ context 'when enforce_security_report_validation is enabled' do
before do
stub_feature_flags(enforce_security_report_validation: project)
end
- let(:expected_warnings) { [] }
-
- it { is_expected.to match_array(expected_warnings) }
+ it { is_expected.to be_empty }
end
- context 'if enforce_security_report_validation is disabled' do
+ context 'when enforce_security_report_validation is disabled' do
before do
stub_feature_flags(enforce_security_report_validation: false)
end
@@ -583,38 +648,44 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
context 'when given a deprecated schema version' do
let(:report_type) { :dast }
+ let(:deprecations_hash) do
+ {
+ dast: %w[V2.7.0]
+ }
+ end
+
let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
- context 'and the report passes schema validation' do
+ before do
+ stub_const("#{described_class}::DEPRECATED_VERSIONS", deprecations_hash)
+ end
+
+ context 'when the report passes schema validation' do
let(:report_data) do
{
'vulnerabilities' => []
}
end
- let(:expected_warnings) { [] }
-
- it { is_expected.to match_array(expected_warnings) }
+ it { is_expected.to be_empty }
end
- context 'and the report does not pass schema validation' do
+ context 'when the report does not pass schema validation' do
let(:report_data) do
{
'version' => 'V2.7.0'
}
end
- context 'and enforce_security_report_validation is enabled' do
+ context 'when enforce_security_report_validation is enabled' do
before do
stub_feature_flags(enforce_security_report_validation: true)
end
- let(:expected_warnings) { [] }
-
- it { is_expected.to match_array(expected_warnings) }
+ it { is_expected.to be_empty }
end
- context 'and enforce_security_report_validation is disabled' do
+ context 'when enforce_security_report_validation is disabled' do
before do
stub_feature_flags(enforce_security_report_validation: false)
end
@@ -635,12 +706,12 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:report_type) { :dast }
let(:report_version) { "12.37.0" }
- context 'if enforce_security_report_validation is enabled' do
+ context 'when enforce_security_report_validation is enabled' do
before do
stub_feature_flags(enforce_security_report_validation: true)
end
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -648,30 +719,26 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
}
end
- let(:expected_warnings) { [] }
-
- it { is_expected.to match_array(expected_warnings) }
+ it { is_expected.to be_empty }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
}
end
- let(:expected_warnings) { [] }
-
- it { is_expected.to match_array(expected_warnings) }
+ it { is_expected.to be_empty }
end
end
- context 'if enforce_security_report_validation is disabled' do
+ context 'when enforce_security_report_validation is disabled' do
before do
stub_feature_flags(enforce_security_report_validation: false)
end
- context 'and the report is valid' do
+ context 'when the report is valid' do
let(:report_data) do
{
'version' => report_version,
@@ -681,14 +748,14 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:expected_warnings) do
[
- "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1"
+ "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: #{supported_dast_versions}"
]
end
it { is_expected.to match_array(expected_warnings) }
end
- context 'and the report is invalid' do
+ context 'when the report is invalid' do
let(:report_data) do
{
'version' => report_version
@@ -697,7 +764,7 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
let(:expected_warnings) do
[
- "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1",
+ "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: #{supported_dast_versions}",
"root is missing required keys: vulnerabilities"
]
end
@@ -706,5 +773,32 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
end
end
end
+
+ context 'when not given a schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { nil }
+ let(:report_data) do
+ {
+ 'vulnerabilities' => []
+ }
+ end
+
+ it { is_expected.to be_empty }
+
+ context 'when enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ let(:expected_warnings) do
+ [
+ "root is missing required keys: version",
+ "Report version not provided, dast report type supports versions: #{supported_dast_versions}"
+ ]
+ end
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb
index 25e81f6d538..b570f2a7f75 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb
@@ -106,7 +106,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines do
create(:ci_build, :interruptible, :running, pipeline: child_pipeline)
end
- not_started_statuses = Ci::HasStatus::AVAILABLE_STATUSES - Ci::HasStatus::BUILD_STARTED_RUNNING_STATUSES
+ not_started_statuses = Ci::HasStatus::AVAILABLE_STATUSES - Ci::HasStatus::STARTED_STATUSES
context 'when the jobs are cancelable' do
cancelable_not_started_statuses = Set.new(not_started_statuses).intersection(Ci::HasStatus::CANCELABLE_STATUSES)
cancelable_not_started_statuses.each do |status|
diff --git a/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb
index 1aa104310af..431073b5a09 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb
@@ -87,7 +87,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::Deployments do
it 'logs the error' do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
instance_of(Gitlab::Ci::Limit::LimitExceededError),
- project_id: project.id, plan: namespace.actual_plan_name
+ { project_id: project.id, plan: namespace.actual_plan_name }
)
perform
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/matches_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/matches_spec.rb
index 0da04d8dcf7..83742699d3d 100644
--- a/spec/lib/gitlab/ci/pipeline/expression/lexeme/matches_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/matches_spec.rb
@@ -1,9 +1,13 @@
# frozen_string_literal: true
require 'fast_spec_helper'
+require 'support/helpers/stubbed_feature'
+require 'support/helpers/stub_feature_flags'
require_dependency 're2'
RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::Matches do
+ include StubFeatureFlags
+
let(:left) { double('left') }
let(:right) { double('right') }
@@ -148,5 +152,29 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::Matches do
it { is_expected.to eq(false) }
end
+
+ context 'when right value is a regexp string' do
+ let(:right_value) { '/^ab.*/' }
+
+ context 'when matching' do
+ let(:left_value) { 'abcde' }
+
+ it { is_expected.to eq(true) }
+
+ context 'when the FF ci_fix_rules_if_comparison_with_regexp_variable is disabled' do
+ before do
+ stub_feature_flags(ci_fix_rules_if_comparison_with_regexp_variable: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'when not matching' do
+ let(:left_value) { 'dfg' }
+
+ it { is_expected.to eq(false) }
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/not_matches_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/not_matches_spec.rb
index 9bff2355d58..aad33106647 100644
--- a/spec/lib/gitlab/ci/pipeline/expression/lexeme/not_matches_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/not_matches_spec.rb
@@ -1,9 +1,13 @@
# frozen_string_literal: true
require 'fast_spec_helper'
+require 'support/helpers/stubbed_feature'
+require 'support/helpers/stub_feature_flags'
require_dependency 're2'
RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::NotMatches do
+ include StubFeatureFlags
+
let(:left) { double('left') }
let(:right) { double('right') }
@@ -148,5 +152,29 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::NotMatches do
it { is_expected.to eq(true) }
end
+
+ context 'when right value is a regexp string' do
+ let(:right_value) { '/^ab.*/' }
+
+ context 'when matching' do
+ let(:left_value) { 'abcde' }
+
+ it { is_expected.to eq(false) }
+
+ context 'when the FF ci_fix_rules_if_comparison_with_regexp_variable is disabled' do
+ before do
+ stub_feature_flags(ci_fix_rules_if_comparison_with_regexp_variable: false)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
+ context 'when not matching' do
+ let(:left_value) { 'dfg' }
+
+ it { is_expected.to eq(true) }
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb
index fa4f8a20984..be205395b69 100644
--- a/spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb
@@ -1,8 +1,32 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'fast_spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::Pattern do
+ describe '#initialize' do
+ context 'when the value is a valid regular expression' do
+ it 'initializes the pattern' do
+ pattern = described_class.new('/foo/')
+
+ expect(pattern.value).to eq('/foo/')
+ end
+ end
+
+ context 'when the value is a valid regular expression with escaped slashes' do
+ it 'initializes the pattern' do
+ pattern = described_class.new('/foo\\/bar/')
+
+ expect(pattern.value).to eq('/foo/bar/')
+ end
+ end
+
+ context 'when the value is not a valid regular expression' do
+ it 'raises an error' do
+ expect { described_class.new('foo') }.to raise_error(Gitlab::Ci::Pipeline::Expression::Lexer::SyntaxError)
+ end
+ end
+ end
+
describe '.build' do
it 'creates a new instance of the token' do
expect(described_class.build('/.*/'))
@@ -15,6 +39,29 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::Pattern do
end
end
+ describe '.build_and_evaluate' do
+ context 'when the value is a valid regular expression' do
+ it 'returns the value as a Gitlab::UntrustedRegexp' do
+ expect(described_class.build_and_evaluate('/foo/'))
+ .to eq(Gitlab::UntrustedRegexp.new('foo'))
+ end
+ end
+
+ context 'when the value is a Gitlab::UntrustedRegexp' do
+ it 'returns the value itself' do
+ expect(described_class.build_and_evaluate(Gitlab::UntrustedRegexp.new('foo')))
+ .to eq(Gitlab::UntrustedRegexp.new('foo'))
+ end
+ end
+
+ context 'when the value is not a valid regular expression' do
+ it 'returns the value itself' do
+ expect(described_class.build_and_evaluate('foo'))
+ .to eq('foo')
+ end
+ end
+ end
+
describe '.type' do
it 'is a value lexeme' do
expect(described_class.type).to eq :value
diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb
index 84713e2a798..bbd11a00149 100644
--- a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Statement do
.to_hash
end
- subject do
+ subject(:statement) do
described_class.new(text, variables)
end
@@ -29,6 +29,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Statement do
describe '#evaluate' do
using RSpec::Parameterized::TableSyntax
+ subject(:evaluate) { statement.evaluate }
+
where(:expression, :value) do
'$PRESENT_VARIABLE == "my variable"' | true
'"my variable" == $PRESENT_VARIABLE' | true
@@ -125,7 +127,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Statement do
let(:text) { expression }
it "evaluates to `#{params[:value].inspect}`" do
- expect(subject.evaluate).to eq(value)
+ expect(evaluate).to eq(value)
end
end
end
@@ -133,6 +135,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Statement do
describe '#truthful?' do
using RSpec::Parameterized::TableSyntax
+ subject(:truthful?) { statement.truthful? }
+
where(:expression, :value) do
'$PRESENT_VARIABLE == "my variable"' | true
"$PRESENT_VARIABLE == 'no match'" | false
@@ -151,7 +155,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Statement do
let(:text) { expression }
it "returns `#{params[:value].inspect}`" do
- expect(subject.truthful?).to eq value
+ expect(truthful?).to eq value
end
end
@@ -159,10 +163,41 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Statement do
let(:text) { '$PRESENT_VARIABLE' }
it 'returns false' do
- allow(subject).to receive(:evaluate)
+ allow(statement).to receive(:evaluate)
.and_raise(described_class::StatementError)
- expect(subject.truthful?).to be_falsey
+ expect(truthful?).to be_falsey
+ end
+ end
+
+ context 'when variables have patterns' do
+ let(:variables) do
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'teststring', value: 'abcde')
+ .append(key: 'pattern1', value: '/^ab.*/')
+ .append(key: 'pattern2', value: '/^at.*/')
+ .to_hash
+ end
+
+ where(:expression, :ff, :result) do
+ '$teststring =~ "abcde"' | true | true
+ '$teststring =~ "abcde"' | false | true
+ '$teststring =~ $teststring' | true | true
+ '$teststring =~ $teststring' | false | true
+ '$teststring =~ $pattern1' | true | true
+ '$teststring =~ $pattern1' | false | false
+ '$teststring =~ $pattern2' | true | false
+ '$teststring =~ $pattern2' | false | false
+ end
+
+ with_them do
+ let(:text) { expression }
+
+ before do
+ stub_feature_flags(ci_fix_rules_if_comparison_with_regexp_variable: ff)
+ end
+
+ it { is_expected.to eq(result) }
end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb
index 9f7281fb714..51185be3e74 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb
@@ -90,29 +90,22 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Deployment do
end
end
- context 'when job has environment attribute with stop action' do
- let(:attributes) do
- {
- environment: 'production',
- options: { environment: { name: 'production', action: 'stop' } }
- }
- end
-
- it 'returns nothing' do
- is_expected.to be_nil
+ context 'when job does not start environment' do
+ where(:action) do
+ %w(stop prepare verify access)
end
- end
- context 'when job has environment attribute with prepare action' do
- let(:attributes) do
- {
- environment: 'production',
- options: { environment: { name: 'production', action: 'prepare' } }
- }
- end
+ with_them do
+ let(:attributes) do
+ {
+ environment: 'production',
+ options: { environment: { name: 'production', action: action } }
+ }
+ end
- it 'returns nothing' do
- is_expected.to be_nil
+ it 'returns nothing' do
+ is_expected.to be_nil
+ end
end
end
diff --git a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
index eb406e01b24..d7ac82e3b53 100644
--- a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
+++ b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
@@ -103,8 +103,6 @@ RSpec.describe Gitlab::Ci::Reports::Security::Scanner do
context 'when the `external_id` of the scanners are different' do
where(:scanner_1_attributes, :scanner_2_attributes, :expected_comparison_result) do
- { external_id: 'bundler_audit', name: 'foo', vendor: 'bar' } | { external_id: 'retire.js', name: 'foo', vendor: 'bar' } | -1
- { external_id: 'retire.js', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium', name: 'foo', vendor: 'bar' } | -1
{ external_id: 'gemnasium', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium-maven', name: 'foo', vendor: 'bar' } | -1
{ external_id: 'gemnasium-maven', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium-python', name: 'foo', vendor: 'bar' } | -1
{ external_id: 'gemnasium-python', name: 'foo', vendor: 'bar' } | { external_id: 'bandit', name: 'foo', vendor: 'bar' } | 1
diff --git a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb
index b430da376dd..f2b4e7573c0 100644
--- a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb
+++ b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb
@@ -22,8 +22,8 @@ RSpec.describe Gitlab::Ci::RunnerUpgradeCheck do
context 'with nil runner_version' do
let(:runner_version) { nil }
- it 'raises :unknown' do
- is_expected.to eq(:unknown)
+ it 'returns :invalid' do
+ is_expected.to eq(:invalid)
end
end
diff --git a/spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb
index 0f97bc06a4e..85516d0bbb0 100644
--- a/spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb
+++ b/spec/lib/gitlab/ci/templates/Jobs/sast_iac_gitlab_ci_yaml_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe 'Jobs/SAST-IaC.latest.gitlab-ci.yml' do
- subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Jobs/SAST-IaC.latest') }
+RSpec.describe 'Jobs/SAST-IaC.gitlab-ci.yml' do
+ subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Jobs/SAST-IaC') }
describe 'the created pipeline' do
let_it_be(:project) { create(:project, :repository) }
diff --git a/spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..0f97bc06a4e
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/Jobs/sast_iac_latest_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Jobs/SAST-IaC.latest.gitlab-ci.yml' do
+ subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Jobs/SAST-IaC.latest') }
+
+ describe 'the created pipeline' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.first_owner }
+
+ let(:default_branch) { 'main' }
+ let(:pipeline_ref) { default_branch }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) }
+ let(:pipeline) { service.execute!(:push).payload }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template.content)
+ allow_next_instance_of(Ci::BuildScheduleWorker) do |instance|
+ allow(instance).to receive(:perform).and_return(true)
+ end
+ allow(project).to receive(:default_branch).and_return(default_branch)
+ end
+
+ context 'on feature branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it 'creates the kics-iac-sast job' do
+ expect(build_names).to contain_exactly('kics-iac-sast')
+ end
+ end
+
+ context 'on merge request' do
+ let(:service) { MergeRequests::CreatePipelineService.new(project: project, current_user: user) }
+ let(:merge_request) { create(:merge_request, :simple, source_project: project) }
+ let(:pipeline) { service.execute(merge_request).payload }
+
+ it 'has no jobs' do
+ expect(pipeline).to be_merge_request_event
+ expect(build_names).to be_empty
+ end
+ end
+
+ context 'SAST_DISABLED is set' do
+ before do
+ create(:ci_variable, key: 'SAST_DISABLED', value: 'true', project: project)
+ end
+
+ context 'on default branch' do
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
+ end
+ end
+
+ context 'on feature branch' do
+ let(:pipeline_ref) { 'feature' }
+
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/templates/MATLAB_spec.rb b/spec/lib/gitlab/ci/templates/MATLAB_spec.rb
index a12d69b67a6..432040c4a14 100644
--- a/spec/lib/gitlab/ci/templates/MATLAB_spec.rb
+++ b/spec/lib/gitlab/ci/templates/MATLAB_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe 'MATLAB.gitlab-ci.yml' do
end
it 'creates all jobs' do
- expect(build_names).to include('command', 'test', 'test_artifacts_job')
+ expect(build_names).to include('command', 'test', 'test_artifacts')
end
end
end
diff --git a/spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb
index 5e9224cebd9..eca79f37779 100644
--- a/spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb
+++ b/spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb
@@ -16,7 +16,6 @@ RSpec.describe 'Terraform/Base.gitlab-ci.yml' do
before do
stub_ci_pipeline_yaml_file(template.content)
- allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
allow(project).to receive(:default_branch).and_return(default_branch)
end
diff --git a/spec/lib/gitlab/ci/templates/managed_cluster_applications_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/managed_cluster_applications_gitlab_ci_yaml_spec.rb
deleted file mode 100644
index 14aaf717453..00000000000
--- a/spec/lib/gitlab/ci/templates/managed_cluster_applications_gitlab_ci_yaml_spec.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Managed-Cluster-Applications.gitlab-ci.yml' do
- subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Managed-Cluster-Applications') }
-
- describe 'the created pipeline' do
- let_it_be(:user) { create(:user) }
-
- let(:project) { create(:project, :custom_repo, namespace: user.namespace, files: { 'README.md' => '' }) }
- let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
- let(:pipeline) { service.execute!(:push).payload }
- let(:build_names) { pipeline.builds.pluck(:name) }
- let(:default_branch) { project.default_branch_or_main }
- let(:pipeline_branch) { default_branch }
-
- before do
- stub_ci_pipeline_yaml_file(template.content)
- end
-
- context 'for a default branch' do
- it 'creates a apply job' do
- expect(build_names).to match_array('apply')
- end
- end
-
- context 'outside of default branch' do
- let(:pipeline_branch) { 'a_branch' }
-
- before do
- project.repository.create_branch(pipeline_branch, default_branch)
- end
-
- it 'has no jobs' do
- expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError, 'No stages / jobs for this pipeline.')
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/ci/templates/templates_spec.rb b/spec/lib/gitlab/ci/templates/templates_spec.rb
index ca096fcecc4..36c6e805bdf 100644
--- a/spec/lib/gitlab/ci/templates/templates_spec.rb
+++ b/spec/lib/gitlab/ci/templates/templates_spec.rb
@@ -7,10 +7,9 @@ RSpec.describe 'CI YML Templates' do
let(:all_templates) { Gitlab::Template::GitlabCiYmlTemplate.all.map(&:full_name) }
let(:excluded_templates) do
- excluded = all_templates.select do |name|
+ all_templates.select do |name|
Gitlab::Template::GitlabCiYmlTemplate.excluded_patterns.any? { |pattern| pattern.match?(name) }
end
- excluded + ["Terraform.gitlab-ci.yml"]
end
shared_examples 'require default stages to be included' do
diff --git a/spec/lib/gitlab/ci/templates/terraform_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/terraform_gitlab_ci_yaml_spec.rb
index 346ab9f7af7..2fc4b509aab 100644
--- a/spec/lib/gitlab/ci/templates/terraform_gitlab_ci_yaml_spec.rb
+++ b/spec/lib/gitlab/ci/templates/terraform_gitlab_ci_yaml_spec.rb
@@ -20,13 +20,16 @@ RSpec.describe 'Terraform.gitlab-ci.yml' do
before do
stub_ci_pipeline_yaml_file(template.content)
+ allow_next_instance_of(Ci::BuildScheduleWorker) do |instance|
+ allow(instance).to receive(:perform).and_return(true)
+ end
allow(project).to receive(:default_branch).and_return(default_branch)
end
context 'on master branch' do
it 'creates init, validate and build jobs', :aggregate_failures do
expect(pipeline.errors).to be_empty
- expect(build_names).to include('init', 'validate', 'build', 'deploy')
+ expect(build_names).to include('validate', 'build', 'deploy')
end
end
diff --git a/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb
index 6c06403adff..42e56c4ab3c 100644
--- a/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb
+++ b/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb
@@ -20,7 +20,9 @@ RSpec.describe 'Terraform.latest.gitlab-ci.yml' do
before do
stub_ci_pipeline_yaml_file(template.content)
- allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ allow_next_instance_of(Ci::BuildScheduleWorker) do |instance|
+ allow(instance).to receive(:perform).and_return(true)
+ end
allow(project).to receive(:default_branch).and_return(default_branch)
end
diff --git a/spec/lib/gitlab/ci/variables/builder_spec.rb b/spec/lib/gitlab/ci/variables/builder_spec.rb
index b9aa5f7c431..e13a0993fa8 100644
--- a/spec/lib/gitlab/ci/variables/builder_spec.rb
+++ b/spec/lib/gitlab/ci/variables/builder_spec.rb
@@ -246,7 +246,7 @@ RSpec.describe Gitlab::Ci::Variables::Builder do
subject { builder.kubernetes_variables(environment: nil, job: job) }
before do
- allow(Ci::GenerateKubeconfigService).to receive(:new).with(job).and_return(service)
+ allow(Ci::GenerateKubeconfigService).to receive(:new).with(job.pipeline, token: job.token).and_return(service)
end
it { is_expected.to include(key: 'KUBECONFIG', value: 'example-kubeconfig', public: false, file: true) }
diff --git a/spec/lib/gitlab/ci/yaml_processor/result_spec.rb b/spec/lib/gitlab/ci/yaml_processor/result_spec.rb
index 25705fd4260..8416501e949 100644
--- a/spec/lib/gitlab/ci/yaml_processor/result_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor/result_spec.rb
@@ -12,8 +12,8 @@ module Gitlab
let(:ci_config) { Gitlab::Ci::Config.new(config_content, user: user) }
let(:result) { described_class.new(ci_config: ci_config, warnings: ci_config&.warnings) }
- describe '#merged_yaml' do
- subject(:merged_yaml) { result.merged_yaml }
+ describe '#config_metadata' do
+ subject(:config_metadata) { result.config_metadata }
let(:config_content) do
YAML.dump(
@@ -33,11 +33,23 @@ module Gitlab
end
it 'returns expanded yaml config' do
- expanded_config = YAML.safe_load(merged_yaml, [Symbol])
+ expanded_config = YAML.safe_load(config_metadata[:merged_yaml], [Symbol])
included_config = YAML.safe_load(included_yml, [Symbol])
expect(expanded_config).to include(*included_config.keys)
end
+
+ it 'returns includes' do
+ expect(config_metadata[:includes]).to contain_exactly(
+ { type: :remote,
+ location: 'https://example.com/sample.yml',
+ blob: nil,
+ raw: 'https://example.com/sample.yml',
+ extra: {},
+ context_project: nil,
+ context_sha: nil }
+ )
+ end
end
describe '#yaml_variables_for' do
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 9b68ee2d6a2..1910057622b 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -630,7 +630,7 @@ module Gitlab
describe 'only / except policies validations' do
context 'when `only` has an invalid value' do
- let(:config) { { rspec: { script: "rspec", type: "test", only: only } } }
+ let(:config) { { rspec: { script: "rspec", stage: "test", only: only } } }
subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
@@ -2606,19 +2606,19 @@ module Gitlab
end
context 'returns errors if job stage is not a string' do
- let(:config) { YAML.dump({ rspec: { script: "test", type: 1 } }) }
+ let(:config) { YAML.dump({ rspec: { script: "test", stage: 1 } }) }
- it_behaves_like 'returns errors', 'jobs:rspec:type config should be a string'
+ it_behaves_like 'returns errors', 'jobs:rspec:stage config should be a string'
end
context 'returns errors if job stage is not a pre-defined stage' do
- let(:config) { YAML.dump({ rspec: { script: "test", type: "acceptance" } }) }
+ let(:config) { YAML.dump({ rspec: { script: "test", stage: "acceptance" } }) }
it_behaves_like 'returns errors', 'rspec job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post'
end
context 'returns errors if job stage is not a defined stage' do
- let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", type: "acceptance" } }) }
+ let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", stage: "acceptance" } }) }
it_behaves_like 'returns errors', 'rspec job: chosen stage does not exist; available stages are .pre, build, test, .post'
end
diff --git a/spec/lib/gitlab/color_spec.rb b/spec/lib/gitlab/color_spec.rb
index 8b16e13fa4d..28719aa6199 100644
--- a/spec/lib/gitlab/color_spec.rb
+++ b/spec/lib/gitlab/color_spec.rb
@@ -24,6 +24,48 @@ RSpec.describe Gitlab::Color do
end
end
+ describe '.color_for' do
+ subject { described_class.color_for(value) }
+
+ shared_examples 'deterministic' do
+ it 'is deterministoc' do
+ expect(subject.to_s).to eq(described_class.color_for(value).to_s)
+ end
+ end
+
+ context 'when generating color for nil value' do
+ let(:value) { nil }
+
+ specify { is_expected.to be_valid }
+
+ it_behaves_like 'deterministic'
+ end
+
+ context 'when generating color for empty string value' do
+ let(:value) { '' }
+
+ specify { is_expected.to be_valid }
+
+ it_behaves_like 'deterministic'
+ end
+
+ context 'when generating color for number value' do
+ let(:value) { 1 }
+
+ specify { is_expected.to be_valid }
+
+ it_behaves_like 'deterministic'
+ end
+
+ context 'when generating color for string value' do
+ let(:value) { "1" }
+
+ specify { is_expected.to be_valid }
+
+ it_behaves_like 'deterministic'
+ end
+ end
+
describe '#new' do
it 'handles nil values' do
expect(described_class.new(nil)).to eq(described_class.new(nil))
diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
index 44e2cb21677..2df85434f0e 100644
--- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
+++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
@@ -183,6 +183,8 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
end
describe '#load' do
+ let(:default_directives) { described_class.default_directives }
+
subject { described_class.new(csp_config[:directives]) }
def expected_config(directive)
@@ -207,5 +209,23 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
expect(policy.directives['base-uri']).to be_nil
end
+
+ it 'returns default values for directives not defined by the user' do
+ # Explicitly disabling script_src and setting report_uri
+ csp_config[:directives] = {
+ script_src: false,
+ report_uri: 'https://example.org'
+ }
+
+ subject.load(policy)
+
+ expected_policy = ActionDispatch::ContentSecurityPolicy.new
+ # Creating a policy from default settings and manually overriding the custom values
+ described_class.new(default_directives).load(expected_policy)
+ expected_policy.script_src(nil)
+ expected_policy.report_uri('https://example.org')
+
+ expect(policy.directives).to eq(expected_policy.directives)
+ end
end
end
diff --git a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb b/spec/lib/gitlab/data_builder/issuable_spec.rb
index 676396697fb..c1ae65c160f 100644
--- a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb
+++ b/spec/lib/gitlab/data_builder/issuable_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::HookData::IssuableBuilder do
+RSpec.describe Gitlab::DataBuilder::Issuable do
let_it_be(:user) { create(:user) }
# This shared example requires a `builder` and `user` variable
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
index 7a433be0e2f..a1c979bba50 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
@@ -99,6 +99,15 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
end
end
+ describe '.created_after' do
+ let!(:migration_old) { create :batched_background_migration, created_at: 2.days.ago }
+ let!(:migration_new) { create :batched_background_migration, created_at: 0.days.ago }
+
+ it 'only returns migrations created after the specified time' do
+ expect(described_class.created_after(1.day.ago)).to contain_exactly(migration_new)
+ end
+ end
+
describe '.queued' do
let!(:migration1) { create(:batched_background_migration, :finished) }
let!(:migration2) { create(:batched_background_migration, :paused) }
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
index 6a4ac317cad..83c0275a870 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
@@ -3,23 +3,20 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '#perform' do
- subject { described_class.new(connection: connection, metrics: metrics_tracker).perform(job_record) }
+ subject(:perform) { described_class.new(connection: connection, metrics: metrics_tracker).perform(job_record) }
let(:connection) { Gitlab::Database.database_base_models[:main].connection }
let(:metrics_tracker) { instance_double('::Gitlab::Database::BackgroundMigration::PrometheusMetrics', track: nil) }
- let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob }
+ let(:job_class) { Class.new(Gitlab::BackgroundMigration::BatchedMigrationJob) }
let_it_be(:pause_ms) { 250 }
let_it_be(:active_migration) { create(:batched_background_migration, :active, job_arguments: [:id, :other_id]) }
let!(:job_record) do
- create(:batched_background_migration_job,
- batched_migration: active_migration,
- pause_ms: pause_ms
- )
+ create(:batched_background_migration_job, batched_migration: active_migration, pause_ms: pause_ms)
end
- let(:job_instance) { double('job instance', batch_metrics: {}) }
+ let(:job_instance) { instance_double('Gitlab::BackgroundMigration::BatchedMigrationJob') }
around do |example|
Gitlab::Database::SharedModel.using_connection(connection) do
@@ -28,23 +25,35 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
end
before do
+ allow(active_migration).to receive(:job_class).and_return(job_class)
+
allow(job_class).to receive(:new).and_return(job_instance)
end
it 'runs the migration job' do
- expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, pause_ms, 'id', 'other_id')
-
- subject
+ expect(job_class).to receive(:new)
+ .with(start_id: 1,
+ end_id: 10,
+ batch_table: 'events',
+ batch_column: 'id',
+ sub_batch_size: 1,
+ pause_ms: pause_ms,
+ connection: connection)
+ .and_return(job_instance)
+
+ expect(job_instance).to receive(:perform).with('id', 'other_id')
+
+ perform
end
it 'updates the tracking record in the database' do
- test_metrics = { 'my_metris' => 'some value' }
+ test_metrics = { 'my_metrics' => 'some value' }
- expect(job_instance).to receive(:perform)
+ expect(job_instance).to receive(:perform).with('id', 'other_id')
expect(job_instance).to receive(:batch_metrics).and_return(test_metrics)
freeze_time do
- subject
+ perform
reloaded_job_record = job_record.reload
@@ -69,11 +78,11 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
it 'increments attempts and updates other fields' do
updated_metrics = { 'updated_metrics' => 'some_value' }
- expect(job_instance).to receive(:perform)
+ expect(job_instance).to receive(:perform).with('id', 'other_id')
expect(job_instance).to receive(:batch_metrics).and_return(updated_metrics)
freeze_time do
- subject
+ perform
job_record.reload
@@ -88,10 +97,10 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
context 'when the migration job does not raise an error' do
it 'marks the tracking record as succeeded' do
- expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, pause_ms, 'id', 'other_id')
+ expect(job_instance).to receive(:perform).with('id', 'other_id')
freeze_time do
- subject
+ perform
reloaded_job_record = job_record.reload
@@ -101,22 +110,20 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
end
it 'tracks metrics of the execution' do
- expect(job_instance).to receive(:perform)
+ expect(job_instance).to receive(:perform).with('id', 'other_id')
expect(metrics_tracker).to receive(:track).with(job_record)
- subject
+ perform
end
end
context 'when the migration job raises an error' do
shared_examples 'an error is raised' do |error_class|
it 'marks the tracking record as failed' do
- expect(job_instance).to receive(:perform)
- .with(1, 10, 'events', 'id', 1, pause_ms, 'id', 'other_id')
- .and_raise(error_class)
+ expect(job_instance).to receive(:perform).with('id', 'other_id').and_raise(error_class)
freeze_time do
- expect { subject }.to raise_error(error_class)
+ expect { perform }.to raise_error(error_class)
reloaded_job_record = job_record.reload
@@ -126,10 +133,10 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
end
it 'tracks metrics of the execution' do
- expect(job_instance).to receive(:perform).and_raise(error_class)
+ expect(job_instance).to receive(:perform).with('id', 'other_id').and_raise(error_class)
expect(metrics_tracker).to receive(:track).with(job_record)
- expect { subject }.to raise_error(error_class)
+ expect { perform }.to raise_error(error_class)
end
end
@@ -138,41 +145,14 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
it_behaves_like 'an error is raised', ActiveRecord::StatementTimeout.new('Timeout!')
end
- context 'when the batched background migration does not inherit from BaseJob' do
- let(:migration_class) { Class.new }
-
- before do
- stub_const('Gitlab::BackgroundMigration::Foo', migration_class)
- end
+ context 'when the batched background migration does not inherit from BatchedMigrationJob' do
+ let(:job_class) { Class.new }
- let(:active_migration) { create(:batched_background_migration, :active, job_class_name: 'Foo') }
- let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) }
-
- it 'does not pass any argument' do
- expect(Gitlab::BackgroundMigration::Foo).to receive(:new).with(no_args).and_return(job_instance)
-
- expect(job_instance).to receive(:perform)
-
- subject
- end
- end
-
- context 'when the batched background migration inherits from BaseJob' do
- let(:active_migration) { create(:batched_background_migration, :active, job_class_name: 'Foo') }
- let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) }
-
- let(:migration_class) { Class.new(::Gitlab::BackgroundMigration::BaseJob) }
-
- before do
- stub_const('Gitlab::BackgroundMigration::Foo', migration_class)
- end
-
- it 'passes the correct connection' do
- expect(Gitlab::BackgroundMigration::Foo).to receive(:new).with(connection: connection).and_return(job_instance)
-
- expect(job_instance).to receive(:perform)
+ it 'runs the job with the correct arguments' do
+ expect(job_class).to receive(:new).with(no_args).and_return(job_instance)
+ expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, pause_ms, 'id', 'other_id')
- subject
+ perform
end
end
end
diff --git a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
index e7b5bad8626..1009ec354c3 100644
--- a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
@@ -401,8 +401,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
ci: :dml_not_allowed
},
gitlab_schema_gitlab_shared: {
- main: :dml_access_denied,
- ci: :dml_access_denied
+ main: :runtime_error,
+ ci: :runtime_error
},
gitlab_schema_gitlab_main: {
main: :success,
@@ -465,7 +465,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
"does raise exception when accessing feature flags" => {
migration: ->(klass) do
def up
- Feature.enabled?(:redis_hll_tracking, type: :ops, default_enabled: :yaml)
+ Feature.enabled?(:redis_hll_tracking, type: :ops)
end
def down
@@ -486,6 +486,37 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
ci: :skipped
}
}
+ },
+ "does raise exception about cross schema access when suppressing restriction to ensure" => {
+ migration: ->(klass) do
+ # The purpose of this test is to ensure that we use ApplicationRecord
+ # a correct connection will be used:
+ # - this is a case for finalizing background migrations
+ def up
+ Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.with_suppressed do
+ ::ApplicationRecord.connection.execute("SELECT 1 FROM ci_builds")
+ end
+ end
+
+ def down
+ end
+ end,
+ query_matcher: /FROM ci_builds/,
+ setup: -> (_) { skip_if_multiple_databases_not_setup },
+ expected: {
+ no_gitlab_schema: {
+ main: :cross_schema_error,
+ ci: :success
+ },
+ gitlab_schema_gitlab_shared: {
+ main: :cross_schema_error,
+ ci: :success
+ },
+ gitlab_schema_gitlab_main: {
+ main: :cross_schema_error,
+ ci: :skipped
+ }
+ }
}
}
end
@@ -517,6 +548,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
%i[no_gitlab_schema gitlab_schema_gitlab_main gitlab_schema_gitlab_shared].each do |restrict_gitlab_migration|
context "while restrict_gitlab_migration=#{restrict_gitlab_migration}" do
it "does run migrate :up and :down" do
+ instance_eval(&setup) if setup
+
expected_result = expected.fetch(restrict_gitlab_migration)[db_config_name.to_sym]
skip "not configured" unless expected_result
@@ -543,10 +576,18 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DMLAccessDeniedError)
expect { ignore_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DMLAccessDeniedError) { migration_class.migrate(:down) } }.not_to raise_error
+ when :runtime_error
+ expect { migration_class.migrate(:up) }.to raise_error(RuntimeError)
+ expect { ignore_error(RuntimeError) { migration_class.migrate(:down) } }.not_to raise_error
+
when :ddl_not_allowed
expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DDLNotAllowedError)
expect { ignore_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DDLNotAllowedError) { migration_class.migrate(:down) } }.not_to raise_error
+ when :cross_schema_error
+ expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection::CrossSchemaAccessError)
+ expect { ignore_error(Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection::CrossSchemaAccessError) { migration_class.migrate(:down) } }.not_to raise_error
+
when :skipped
expect_next_instance_of(migration_class) do |migration_object|
expect(migration_object).to receive(:migration_skipped).and_call_original
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 798eee0de3e..04fe1fad10e 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -1537,10 +1537,12 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:add_concurrent_index)
.with(:issues,
%w(gl_project_id),
+ {
unique: false,
name: 'index_on_issues_gl_project_id',
length: [],
- order: [])
+ order: []
+ })
model.copy_indexes(:issues, :project_id, :gl_project_id)
end
@@ -1564,10 +1566,12 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:add_concurrent_index)
.with(:issues,
%w(gl_project_id foobar),
+ {
unique: false,
name: 'index_on_issues_gl_project_id_foobar',
length: [],
- order: [])
+ order: []
+ })
model.copy_indexes(:issues, :project_id, :gl_project_id)
end
@@ -1591,11 +1595,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:add_concurrent_index)
.with(:issues,
%w(gl_project_id),
+ {
unique: false,
name: 'index_on_issues_gl_project_id',
length: [],
order: [],
- where: 'foo')
+ where: 'foo'
+ })
model.copy_indexes(:issues, :project_id, :gl_project_id)
end
@@ -1619,11 +1625,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:add_concurrent_index)
.with(:issues,
%w(gl_project_id),
+ {
unique: false,
name: 'index_on_issues_gl_project_id',
length: [],
order: [],
- using: 'foo')
+ using: 'foo'
+ })
model.copy_indexes(:issues, :project_id, :gl_project_id)
end
@@ -1647,11 +1655,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:add_concurrent_index)
.with(:issues,
%w(gl_project_id),
+ {
unique: false,
name: 'index_on_issues_gl_project_id',
length: [],
order: [],
- opclass: { 'gl_project_id' => 'bar' })
+ opclass: { 'gl_project_id' => 'bar' }
+ })
model.copy_indexes(:issues, :project_id, :gl_project_id)
end
@@ -1660,14 +1670,16 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'using an index with multiple columns and custom operator classes' do
it 'copies the index' do
index = double(:index,
- columns: %w(project_id foobar),
- name: 'index_on_issues_project_id_foobar',
- using: :gin,
- where: nil,
- opclasses: { 'project_id' => 'bar', 'foobar' => :gin_trgm_ops },
- unique: false,
- lengths: [],
- orders: [])
+ {
+ columns: %w(project_id foobar),
+ name: 'index_on_issues_project_id_foobar',
+ using: :gin,
+ where: nil,
+ opclasses: { 'project_id' => 'bar', 'foobar' => :gin_trgm_ops },
+ unique: false,
+ lengths: [],
+ orders: []
+ })
allow(model).to receive(:indexes_for).with(:issues, 'project_id')
.and_return([index])
@@ -1675,12 +1687,14 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:add_concurrent_index)
.with(:issues,
%w(gl_project_id foobar),
+ {
unique: false,
name: 'index_on_issues_gl_project_id_foobar',
length: [],
order: [],
opclass: { 'gl_project_id' => 'bar', 'foobar' => :gin_trgm_ops },
- using: :gin)
+ using: :gin
+ })
model.copy_indexes(:issues, :project_id, :gl_project_id)
end
@@ -1689,14 +1703,16 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'using an index with multiple columns and a custom operator class on the non affected column' do
it 'copies the index' do
index = double(:index,
- columns: %w(project_id foobar),
- name: 'index_on_issues_project_id_foobar',
- using: :gin,
- where: nil,
- opclasses: { 'foobar' => :gin_trgm_ops },
- unique: false,
- lengths: [],
- orders: [])
+ {
+ columns: %w(project_id foobar),
+ name: 'index_on_issues_project_id_foobar',
+ using: :gin,
+ where: nil,
+ opclasses: { 'foobar' => :gin_trgm_ops },
+ unique: false,
+ lengths: [],
+ orders: []
+ })
allow(model).to receive(:indexes_for).with(:issues, 'project_id')
.and_return([index])
@@ -1704,12 +1720,14 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:add_concurrent_index)
.with(:issues,
%w(gl_project_id foobar),
+ {
unique: false,
name: 'index_on_issues_gl_project_id_foobar',
length: [],
order: [],
opclass: { 'foobar' => :gin_trgm_ops },
- using: :gin)
+ using: :gin
+ })
model.copy_indexes(:issues, :project_id, :gl_project_id)
end
@@ -2210,12 +2228,17 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#ensure_batched_background_migration_is_finished' do
+ let(:job_class_name) { 'CopyColumnUsingBackgroundMigrationJob' }
+ let(:table) { :events }
+ let(:column_name) { :id }
+ let(:job_arguments) { [["id"], ["id_convert_to_bigint"], nil] }
+
let(:configuration) do
{
- job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
- table_name: :events,
- column_name: :id,
- job_arguments: [["id"], ["id_convert_to_bigint"], nil]
+ job_class_name: job_class_name,
+ table_name: table,
+ column_name: column_name,
+ job_arguments: job_arguments
}
end
@@ -2224,11 +2247,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
it 'raises an error when migration exists and is not marked as finished' do
create(:batched_background_migration, :active, configuration)
+ allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
+ allow(runner).to receive(:finalize).with(job_class_name, table, column_name, job_arguments).and_return(false)
+ end
+
expect { ensure_batched_background_migration_is_finished }
.to raise_error "Expected batched background migration for the given configuration to be marked as 'finished', but it is 'active':" \
"\t#{configuration}" \
"\n\n" \
- "Finalize it manualy by running" \
+ "Finalize it manually by running" \
"\n\n" \
"\tsudo gitlab-rake gitlab:background_migrations:finalize[CopyColumnUsingBackgroundMigrationJob,events,id,'[[\"id\"]\\,[\"id_convert_to_bigint\"]\\,null]']" \
"\n\n" \
@@ -2251,6 +2278,28 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect { ensure_batched_background_migration_is_finished }
.not_to raise_error
end
+
+ it 'finalizes the migration' do
+ migration = create(:batched_background_migration, :active, configuration)
+
+ allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
+ expect(runner).to receive(:finalize).with(job_class_name, table, column_name, job_arguments).and_return(migration.finish!)
+ end
+
+ ensure_batched_background_migration_is_finished
+ end
+
+ context 'when the flag finalize is false' do
+ it 'does not finalize the migration' do
+ create(:batched_background_migration, :active, configuration)
+
+ allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
+ expect(runner).not_to receive(:finalize).with(job_class_name, table, column_name, job_arguments)
+ end
+
+ expect { model.ensure_batched_background_migration_is_finished(**configuration.merge(finalize: false)) }.to raise_error(RuntimeError)
+ end
+ end
end
describe '#index_exists_by_name?' do
@@ -3162,15 +3211,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'without proper permissions' do
before do
- allow(model).to receive(:execute).with(/CREATE EXTENSION IF NOT EXISTS #{extension}/).and_raise(ActiveRecord::StatementInvalid, 'InsufficientPrivilege: permission denied')
+ allow(model).to receive(:execute)
+ .with(/CREATE EXTENSION IF NOT EXISTS #{extension}/)
+ .and_raise(ActiveRecord::StatementInvalid, 'InsufficientPrivilege: permission denied')
end
- it 'raises the exception' do
- expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /InsufficientPrivilege/)
- end
-
- it 'prints an error message' do
- expect { subject }.to output(/user is not allowed/).to_stderr.and raise_error
+ it 'raises an exception and prints an error message' do
+ expect { subject }
+ .to output(/user is not allowed/).to_stderr
+ .and raise_error(ActiveRecord::StatementInvalid, /InsufficientPrivilege/)
end
end
end
@@ -3188,15 +3237,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'without proper permissions' do
before do
- allow(model).to receive(:execute).with(/DROP EXTENSION IF EXISTS #{extension}/).and_raise(ActiveRecord::StatementInvalid, 'InsufficientPrivilege: permission denied')
- end
-
- it 'raises the exception' do
- expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /InsufficientPrivilege/)
+ allow(model).to receive(:execute)
+ .with(/DROP EXTENSION IF EXISTS #{extension}/)
+ .and_raise(ActiveRecord::StatementInvalid, 'InsufficientPrivilege: permission denied')
end
- it 'prints an error message' do
- expect { subject }.to output(/user is not allowed/).to_stderr.and raise_error
+ it 'raises an exception and prints an error message' do
+ expect { subject }
+ .to output(/user is not allowed/).to_stderr
+ .and raise_error(ActiveRecord::StatementInvalid, /InsufficientPrivilege/)
end
end
end
diff --git a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
index e64f5807385..b0caa21e01a 100644
--- a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
@@ -3,16 +3,31 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
+ let(:base_class) { ActiveRecord::Migration }
+
let(:model) do
- ActiveRecord::Migration.new.extend(described_class)
+ base_class.new
+ .extend(described_class)
+ .extend(Gitlab::Database::Migrations::ReestablishedConnectionStack)
end
- shared_examples_for 'helpers that enqueue background migrations' do |worker_class, tracking_database|
+ shared_examples_for 'helpers that enqueue background migrations' do |worker_class, connection_class, tracking_database|
before do
allow(model).to receive(:tracking_database).and_return(tracking_database)
+
+ # Due to lib/gitlab/database/load_balancing/configuration.rb:92 requiring RequestStore
+ # we cannot use stub_feature_flags(force_no_sharing_primary_model: true)
+ allow(connection_class.connection.load_balancer.configuration)
+ .to receive(:use_dedicated_connection?).and_return(true)
+
+ allow(model).to receive(:connection).and_return(connection_class.connection)
end
describe '#queue_background_migration_jobs_by_range_at_intervals' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(false)
+ end
+
context 'when the model has an ID column' do
let!(:id1) { create(:user).id }
let!(:id2) { create(:user).id }
@@ -196,6 +211,34 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end.to raise_error(StandardError, /does not have an ID/)
end
end
+
+ context 'when using Migration[2.0]' do
+ let(:base_class) { Class.new(Gitlab::Database::Migration[2.0]) }
+
+ context 'when restriction is set to gitlab_shared' do
+ before do
+ base_class.restrict_gitlab_migration gitlab_schema: :gitlab_shared
+ end
+
+ it 'does raise an exception' do
+ expect do
+ model.queue_background_migration_jobs_by_range_at_intervals(ProjectAuthorization, 'FooJob', 10.seconds)
+ end.to raise_error /use `restrict_gitlab_migration:` " with `:gitlab_shared`/
+ end
+ end
+ end
+
+ context 'when within transaction' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(true)
+ end
+
+ it 'does raise an exception' do
+ expect do
+ model.queue_background_migration_jobs_by_range_at_intervals(ProjectAuthorization, 'FooJob', 10.seconds)
+ end.to raise_error /The `#queue_background_migration_jobs_by_range_at_intervals` can not be run inside a transaction./
+ end
+ end
end
describe '#requeue_background_migration_jobs_by_range_at_intervals' do
@@ -205,6 +248,10 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
let!(:successful_job_1) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [5, 6]) }
let!(:successful_job_2) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [7, 8]) }
+ before do
+ allow(model).to receive(:transaction_open?).and_return(false)
+ end
+
around do |example|
freeze_time do
Sidekiq::Testing.fake! do
@@ -219,6 +266,38 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
expect(subject).to eq(20.minutes)
end
+ context 'when using Migration[2.0]' do
+ let(:base_class) { Class.new(Gitlab::Database::Migration[2.0]) }
+
+ it 'does re-enqueue pending jobs' do
+ subject
+
+ expect(worker_class.jobs).not_to be_empty
+ end
+
+ context 'when restriction is set' do
+ before do
+ base_class.restrict_gitlab_migration gitlab_schema: :gitlab_main
+ end
+
+ it 'does raise an exception' do
+ expect { subject }
+ .to raise_error /The `#requeue_background_migration_jobs_by_range_at_intervals` cannot use `restrict_gitlab_migration:`./
+ end
+ end
+ end
+
+ context 'when within transaction' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(true)
+ end
+
+ it 'does raise an exception' do
+ expect { subject }
+ .to raise_error /The `#requeue_background_migration_jobs_by_range_at_intervals` can not be run inside a transaction./
+ end
+ end
+
context 'when nothing is queued' do
subject { model.requeue_background_migration_jobs_by_range_at_intervals('FakeJob', 10.minutes) }
@@ -290,7 +369,7 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
end
- describe '#finalized_background_migration' do
+ describe '#finalize_background_migration' do
let(:coordinator) { Gitlab::BackgroundMigration::JobCoordinator.new(worker_class) }
let!(:tracked_pending_job) { create(:background_migration_job, class_name: job_class_name, status: :pending, arguments: [1]) }
@@ -309,8 +388,8 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database)
.with(tracking_database).and_return(coordinator)
- expect(coordinator).to receive(:migration_class_for)
- .with(job_class_name).at_least(:once) { job_class }
+ allow(coordinator).to receive(:migration_class_for)
+ .with(job_class_name) { job_class }
Sidekiq::Testing.disable! do
worker_class.perform_async(job_class_name, [1, 2])
@@ -318,6 +397,8 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
worker_class.perform_in(10, job_class_name, [5, 6])
worker_class.perform_in(20, job_class_name, [7, 8])
end
+
+ allow(model).to receive(:transaction_open?).and_return(false)
end
it_behaves_like 'finalized tracked background migration', worker_class do
@@ -326,6 +407,52 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
end
+ context 'when within transaction' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(true)
+ end
+
+ it 'does raise an exception' do
+ expect { model.finalize_background_migration(job_class_name, delete_tracking_jobs: %w[pending succeeded]) }
+ .to raise_error /The `#finalize_background_migration` can not be run inside a transaction./
+ end
+ end
+
+ context 'when using Migration[2.0]' do
+ let(:base_class) { Class.new(Gitlab::Database::Migration[2.0]) }
+
+ it_behaves_like 'finalized tracked background migration', worker_class do
+ before do
+ model.finalize_background_migration(job_class_name)
+ end
+ end
+
+ context 'when restriction is set' do
+ before do
+ base_class.restrict_gitlab_migration gitlab_schema: :gitlab_main
+ end
+
+ it 'does raise an exception' do
+ expect { model.finalize_background_migration(job_class_name, delete_tracking_jobs: %w[pending succeeded]) }
+ .to raise_error /The `#finalize_background_migration` cannot use `restrict_gitlab_migration:`./
+ end
+ end
+ end
+
+ context 'when running migration in reconfigured ActiveRecord::Base context' do
+ it_behaves_like 'reconfigures connection stack', tracking_database do
+ it 'does restore connection hierarchy' do
+ expect_next_instances_of(job_class, 1..) do |job|
+ expect(job).to receive(:perform) do
+ validate_connections!
+ end
+ end
+
+ model.finalize_background_migration(job_class_name, delete_tracking_jobs: %w[pending succeeded])
+ end
+ end
+ end
+
context 'when removing all tracked job records' do
let!(:job_class) do
Class.new do
@@ -443,7 +570,7 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
context 'when the migration is running against the main database' do
- it_behaves_like 'helpers that enqueue background migrations', BackgroundMigrationWorker, 'main'
+ it_behaves_like 'helpers that enqueue background migrations', BackgroundMigrationWorker, ActiveRecord::Base, 'main'
end
context 'when the migration is running against the ci database', if: Gitlab::Database.has_config?(:ci) do
@@ -453,7 +580,7 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
end
- it_behaves_like 'helpers that enqueue background migrations', BackgroundMigration::CiDatabaseWorker, 'ci'
+ it_behaves_like 'helpers that enqueue background migrations', BackgroundMigration::CiDatabaseWorker, Ci::ApplicationRecord, 'ci'
end
describe '#delete_job_tracking' do
diff --git a/spec/lib/gitlab/database/migrations/base_background_runner_spec.rb b/spec/lib/gitlab/database/migrations/base_background_runner_spec.rb
new file mode 100644
index 00000000000..34c83c42056
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/base_background_runner_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::BaseBackgroundRunner, :freeze_time do
+ let(:result_dir) { Dir.mktmpdir }
+
+ after do
+ FileUtils.rm_rf(result_dir)
+ end
+
+ context 'subclassing' do
+ subject { described_class.new(result_dir: result_dir) }
+
+ it 'requires that jobs_by_migration_name be implemented' do
+ expect { subject.jobs_by_migration_name }.to raise_error(NotImplementedError)
+ end
+
+ it 'requires that run_job be implemented' do
+ expect { subject.run_job(nil) }.to raise_error(NotImplementedError)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
index f9347a174c4..d1a66036149 100644
--- a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
@@ -163,4 +163,45 @@ RSpec.describe Gitlab::Database::Migrations::BatchedBackgroundMigrationHelpers d
end
end
end
+
+ describe '#finalize_batched_background_migration' do
+ let!(:batched_migration) { create(:batched_background_migration, job_class_name: 'MyClass', table_name: :projects, column_name: :id, job_arguments: []) }
+
+ it 'finalizes the migration' do
+ allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
+ expect(runner).to receive(:finalize).with('MyClass', :projects, :id, [])
+ end
+
+ migration.finalize_batched_background_migration(job_class_name: 'MyClass', table_name: :projects, column_name: :id, job_arguments: [])
+ end
+
+ context 'when the migration does not exist' do
+ it 'raises an exception' do
+ expect do
+ migration.finalize_batched_background_migration(job_class_name: 'MyJobClass', table_name: :projects, column_name: :id, job_arguments: [])
+ end.to raise_error(RuntimeError, 'Could not find batched background migration')
+ end
+ end
+
+ context 'when uses a CI connection', :reestablished_active_record_base do
+ before do
+ skip_if_multiple_databases_not_setup
+
+ ActiveRecord::Base.establish_connection(:ci) # rubocop:disable Database/EstablishConnection
+ end
+
+ it 'raises an exception' do
+ ci_migration = create(:batched_background_migration, :active)
+
+ expect do
+ migration.finalize_batched_background_migration(
+ job_class_name: ci_migration.job_class_name,
+ table_name: ci_migration.table_name,
+ column_name: ci_migration.column_name,
+ job_arguments: ci_migration.job_arguments
+ )
+ end.to raise_error /is currently not supported when running in decomposed/
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb b/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb
index 2515f0d4a06..66de25d65bb 100644
--- a/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb
+++ b/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb
@@ -43,6 +43,7 @@ RSpec.describe Gitlab::Database::Migrations::Observers::QueryStatistics do
<<~SQL
SELECT query, calls, total_time, max_time, mean_time, rows
FROM pg_stat_statements
+ WHERE pg_get_userbyid(userid) = current_user
ORDER BY total_time DESC
SQL
end
diff --git a/spec/lib/gitlab/database/migrations/reestablished_connection_stack_spec.rb b/spec/lib/gitlab/database/migrations/reestablished_connection_stack_spec.rb
new file mode 100644
index 00000000000..cfb308c63e4
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/reestablished_connection_stack_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::ReestablishedConnectionStack do
+ let(:base_class) { ActiveRecord::Migration }
+
+ let(:model) do
+ base_class.new
+ .extend(described_class)
+ end
+
+ describe '#with_restored_connection_stack' do
+ Gitlab::Database.database_base_models.each do |db_config_name, _|
+ context db_config_name do
+ it_behaves_like "reconfigures connection stack", db_config_name do
+ it 'does restore connection hierarchy' do
+ model.with_restored_connection_stack do
+ validate_connections!
+ end
+ end
+
+ primary_db_config = ActiveRecord::Base.configurations.primary?(db_config_name)
+
+ it 'does reconfigure connection handler', unless: primary_db_config do
+ original_handler = ActiveRecord::Base.connection_handler
+ new_handler = nil
+
+ model.with_restored_connection_stack do
+ new_handler = ActiveRecord::Base.connection_handler
+
+ # establish connection
+ ApplicationRecord.connection.select_one("SELECT 1 FROM projects LIMIT 1")
+ Ci::ApplicationRecord.connection.select_one("SELECT 1 FROM ci_builds LIMIT 1")
+ end
+
+ expect(new_handler).not_to eq(original_handler), "is reconnected"
+ expect(new_handler).not_to be_active_connections
+ expect(ActiveRecord::Base.connection_handler).to eq(original_handler), "is restored"
+ end
+
+ it 'does keep original connection handler', if: primary_db_config do
+ original_handler = ActiveRecord::Base.connection_handler
+ new_handler = nil
+
+ model.with_restored_connection_stack do
+ new_handler = ActiveRecord::Base.connection_handler
+ end
+
+ expect(new_handler).to eq(original_handler)
+ expect(ActiveRecord::Base.connection_handler).to eq(original_handler)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migrations/runner_spec.rb b/spec/lib/gitlab/database/migrations/runner_spec.rb
index 8b1ccf05eb1..e7f68e3e4a8 100644
--- a/spec/lib/gitlab/database/migrations/runner_spec.rb
+++ b/spec/lib/gitlab/database/migrations/runner_spec.rb
@@ -2,6 +2,8 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::Migrations::Runner do
+ include Database::MultipleDatabases
+
let(:result_dir) { Pathname.new(Dir.mktmpdir) }
let(:migration_runs) { [] } # This list gets populated as the runner tries to run migrations
@@ -136,4 +138,35 @@ RSpec.describe Gitlab::Database::Migrations::Runner do
expect(runner.result_dir).to eq(described_class::BASE_RESULT_DIR.join( 'background_migrations'))
end
end
+
+ describe '.batched_background_migrations' do
+ it 'is a TestBatchedBackgroundRunner' do
+ expect(described_class.batched_background_migrations(for_database: 'main')).to be_a(Gitlab::Database::Migrations::TestBatchedBackgroundRunner)
+ end
+
+ context 'choosing the database to test against' do
+ it 'chooses the main database' do
+ runner = described_class.batched_background_migrations(for_database: 'main')
+
+ chosen_connection_name = Gitlab::Database.db_config_name(runner.connection)
+
+ expect(chosen_connection_name).to eq('main')
+ end
+
+ it 'chooses the ci database' do
+ skip_if_multiple_databases_not_setup
+
+ runner = described_class.batched_background_migrations(for_database: 'ci')
+
+ chosen_connection_name = Gitlab::Database.db_config_name(runner.connection)
+
+ expect(chosen_connection_name).to eq('ci')
+ end
+
+ it 'throws an error with an invalid name' do
+ expect { described_class.batched_background_migrations(for_database: 'not_a_database') }
+ .to raise_error(/not a valid database name/)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb b/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb
index 9407efad91f..a2fe91712c7 100644
--- a/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb
+++ b/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb
@@ -3,7 +3,9 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
+ include Gitlab::Database::Migrations::ReestablishedConnectionStack
include Gitlab::Database::Migrations::BackgroundMigrationHelpers
+ include Database::MigrationTestingHelpers
# In order to test the interaction between queueing sidekiq jobs and seeing those jobs in queues,
# we need to disable sidekiq's testing mode and actually send our jobs to redis
@@ -12,6 +14,7 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
end
let(:result_dir) { Dir.mktmpdir }
+ let(:connection) { ApplicationRecord.connection }
after do
FileUtils.rm_rf(result_dir)
@@ -41,40 +44,6 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
end
context 'running migrations', :freeze_time do
- def define_background_migration(name)
- klass = Class.new do
- # Can't simply def perform here as we won't have access to the block,
- # similarly can't define_method(:perform, &block) here as it would change the block receiver
- define_method(:perform) { |*args| yield(*args) }
- end
- stub_const("Gitlab::BackgroundMigration::#{name}", klass)
- klass
- end
-
- def expect_migration_call_counts(migrations_to_calls)
- migrations_to_calls.each do |migration, calls|
- expect_next_instances_of(migration, calls) do |m|
- expect(m).to receive(:perform).and_call_original
- end
- end
- end
-
- def expect_recorded_migration_runs(migrations_to_runs)
- migrations_to_runs.each do |migration, runs|
- path = File.join(result_dir, migration.name.demodulize)
- num_subdirs = Pathname(path).children.count(&:directory?)
- expect(num_subdirs).to eq(runs)
- end
- end
-
- def expect_migration_runs(migrations_to_run_counts)
- expect_migration_call_counts(migrations_to_run_counts)
-
- yield
-
- expect_recorded_migration_runs(migrations_to_run_counts)
- end
-
it 'runs the migration class correctly' do
calls = []
define_background_migration(migration_name) do |i|
diff --git a/spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb b/spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb
new file mode 100644
index 00000000000..fbfff1268cc
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/test_batched_background_runner_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::TestBatchedBackgroundRunner, :freeze_time do
+ include Gitlab::Database::MigrationHelpers
+ include Gitlab::Database::Migrations::BatchedBackgroundMigrationHelpers
+ include Database::MigrationTestingHelpers
+
+ let(:result_dir) { Dir.mktmpdir }
+
+ after do
+ FileUtils.rm_rf(result_dir)
+ end
+
+ let(:connection) { ApplicationRecord.connection }
+
+ let(:table_name) { "_test_column_copying"}
+
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE #{table_name} (
+ id bigint primary key not null,
+ data bigint
+ );
+
+ insert into #{table_name} (id) select i from generate_series(1, 1000) g(i);
+ SQL
+ end
+
+ context 'running a real background migration' do
+ it 'runs sampled jobs from the batched background migration' do
+ queue_batched_background_migration('CopyColumnUsingBackgroundMigrationJob',
+ table_name, :id,
+ :id, :data,
+ batch_size: 100,
+ job_interval: 5.minutes) # job_interval is skipped when testing
+ described_class.new(result_dir: result_dir, connection: connection).run_jobs(for_duration: 1.minute)
+ unmigrated_row_count = define_batchable_model(table_name).where('id != data').count
+
+ expect(unmigrated_row_count).to eq(0)
+ end
+ end
+
+ context 'with jobs to run' do
+ let(:migration_name) { 'TestBackgroundMigration' }
+
+ before do
+ queue_batched_background_migration(migration_name, table_name, :id, job_interval: 5.minutes, batch_size: 100)
+ end
+
+ it 'samples jobs' do
+ calls = []
+ define_background_migration(migration_name) do |*args|
+ calls << args
+ end
+
+ described_class.new(result_dir: result_dir, connection: connection).run_jobs(for_duration: 3.minutes)
+
+ expect(calls.count).to eq(10) # 1000 rows / batch size 100 = 10
+ end
+
+ context 'with multiple jobs to run' do
+ it 'runs all jobs created within the last 48 hours' do
+ old_migration = define_background_migration(migration_name)
+
+ travel 3.days
+
+ new_migration = define_background_migration('NewMigration') { travel 1.second }
+ queue_batched_background_migration('NewMigration', table_name, :id,
+ job_interval: 5.minutes,
+ batch_size: 10,
+ sub_batch_size: 5)
+
+ other_new_migration = define_background_migration('NewMigration2') { travel 2.seconds }
+ queue_batched_background_migration('NewMigration2', table_name, :id,
+ job_interval: 5.minutes,
+ batch_size: 10,
+ sub_batch_size: 5)
+
+ expect_migration_runs(new_migration => 3, other_new_migration => 2, old_migration => 0) do
+ described_class.new(result_dir: result_dir, connection: connection).run_jobs(for_duration: 5.seconds)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb
index 8ab3816529b..edb8ae36c45 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb
@@ -54,7 +54,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do
expect_add_concurrent_index_and_call_original(partition2_identifier, column_name, partition2_index)
expect(migration).to receive(:with_lock_retries).ordered.and_yield
- expect(migration).to receive(:add_index).with(table_name, column_name, name: index_name).ordered.and_call_original
+ expect(migration).to receive(:add_index).with(table_name, column_name, { name: index_name }).ordered.and_call_original
migration.add_concurrent_partitioned_index(table_name, column_name, name: index_name)
@@ -64,7 +64,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do
end
def expect_add_concurrent_index_and_call_original(table, column, index)
- expect(migration).to receive(:add_concurrent_index).ordered.with(table, column, name: index)
+ expect(migration).to receive(:add_concurrent_index).ordered.with(table, column, { name: index })
.and_wrap_original { |_, table, column, options| connection.add_index(table, column, **options) }
end
end
@@ -90,13 +90,13 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do
it 'forwards them to the index helper methods', :aggregate_failures do
expect(migration).to receive(:add_concurrent_index)
- .with(partition1_identifier, column_name, name: partition1_index, where: 'x > 0', unique: true)
+ .with(partition1_identifier, column_name, { name: partition1_index, where: 'x > 0', unique: true })
expect(migration).to receive(:add_index)
- .with(table_name, column_name, name: index_name, where: 'x > 0', unique: true)
+ .with(table_name, column_name, { name: index_name, where: 'x > 0', unique: true })
migration.add_concurrent_partitioned_index(table_name, column_name,
- name: index_name, where: 'x > 0', unique: true)
+ { name: index_name, where: 'x > 0', unique: true })
end
end
diff --git a/spec/lib/gitlab/database/query_analyzer_spec.rb b/spec/lib/gitlab/database/query_analyzer_spec.rb
index 3b4cbc79de2..0b849063562 100644
--- a/spec/lib/gitlab/database/query_analyzer_spec.rb
+++ b/spec/lib/gitlab/database/query_analyzer_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Database::QueryAnalyzer, query_analyzers: false do
let(:analyzer) { double(:query_analyzer) }
- let(:user_analyzer) { double(:query_analyzer) }
+ let(:user_analyzer) { double(:user_query_analyzer) }
let(:disabled_analyzer) { double(:disabled_query_analyzer) }
before do
@@ -49,14 +49,36 @@ RSpec.describe Gitlab::Database::QueryAnalyzer, query_analyzers: false do
end
end
- it 'does not evaluate enabled? again do yield block' do
- expect(analyzer).not_to receive(:enabled?)
+ it 'does initialize analyzer only once' do
+ expect(analyzer).to receive(:enabled?).once
+ expect(analyzer).to receive(:begin!).once
+ expect(analyzer).to receive(:end!).once
expect { |b| described_class.instance.within(&b) }.to yield_control
end
- it 'raises exception when trying to re-define analyzers' do
- expect { |b| described_class.instance.within([user_analyzer], &b) }.to raise_error /Query analyzers are already defined, cannot re-define them/
+ it 'does initialize user analyzer when enabled' do
+ expect(user_analyzer).to receive(:enabled?).and_return(true)
+ expect(user_analyzer).to receive(:begin!)
+ expect(user_analyzer).to receive(:end!)
+
+ expect { |b| described_class.instance.within([user_analyzer], &b) }.to yield_control
+ end
+
+ it 'does initialize user analyzer only once' do
+ expect(user_analyzer).to receive(:enabled?).and_return(false, true)
+ expect(user_analyzer).to receive(:begin!).once
+ expect(user_analyzer).to receive(:end!).once
+
+ expect { |b| described_class.instance.within([user_analyzer, user_analyzer, user_analyzer], &b) }.to yield_control
+ end
+
+ it 'does not initializer user analyzer when disabled' do
+ expect(user_analyzer).to receive(:enabled?).and_return(false)
+ expect(user_analyzer).not_to receive(:begin!)
+ expect(user_analyzer).not_to receive(:end!)
+
+ expect { |b| described_class.instance.within([user_analyzer], &b) }.to yield_control
end
end
@@ -162,7 +184,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzer, query_analyzers: false do
def process_sql(sql)
described_class.instance.within do
ApplicationRecord.load_balancer.read_write do |connection|
- described_class.instance.process_sql(sql, connection)
+ described_class.instance.send(:process_sql, sql, connection)
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 b8c1ecd9089..0d687db0f96 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
@@ -140,7 +140,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.process_sql(sql, model.retrieve_connection)
+ Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection)
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
new file mode 100644
index 00000000000..5e8afc0102e
--- /dev/null
+++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_validate_connection_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection, query_analyzers: false do
+ let(:analyzer) { described_class }
+
+ context 'properly observes all queries', :request_store do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "for simple query observes schema correctly" => {
+ model: ApplicationRecord,
+ sql: "SELECT 1 FROM projects",
+ expect_error: nil,
+ setup: nil
+ },
+ "for query accessing gitlab_ci and gitlab_main" => {
+ model: ApplicationRecord,
+ sql: "SELECT 1 FROM projects LEFT JOIN ci_builds ON ci_builds.project_id=projects.id",
+ expect_error: /The query tried to access \["projects", "ci_builds"\]/,
+ setup: -> (_) { skip_if_multiple_databases_not_setup }
+ },
+ "for query accessing gitlab_ci and gitlab_main the gitlab_schemas is always ordered" => {
+ model: ApplicationRecord,
+ sql: "SELECT 1 FROM ci_builds LEFT JOIN projects ON ci_builds.project_id=projects.id",
+ expect_error: /The query tried to access \["ci_builds", "projects"\]/,
+ setup: -> (_) { skip_if_multiple_databases_not_setup }
+ },
+ "for query accessing main table from CI database" => {
+ model: Ci::ApplicationRecord,
+ sql: "SELECT 1 FROM projects",
+ expect_error: /The query tried to access \["projects"\]/,
+ setup: -> (_) { skip_if_multiple_databases_not_setup }
+ },
+ "for query accessing CI database" => {
+ model: Ci::ApplicationRecord,
+ sql: "SELECT 1 FROM ci_builds",
+ expect_error: nil
+ },
+ "for query accessing CI table from main database" => {
+ model: ::ApplicationRecord,
+ sql: "SELECT 1 FROM ci_builds",
+ expect_error: /The query tried to access \["ci_builds"\]/,
+ setup: -> (_) { skip_if_multiple_databases_not_setup }
+ }
+ }
+ end
+
+ with_them do
+ it do
+ instance_eval(&setup) if setup
+
+ if expect_error
+ expect { process_sql(model, sql) }.to raise_error(expect_error)
+ else
+ expect { process_sql(model, sql) }.not_to raise_error
+ end
+ end
+ end
+ end
+
+ 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)
+ end
+ 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 a2c7916fa01..261bef58bb6 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
@@ -155,7 +155,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas, query_a
yield if block_given?
# Skip load balancer and retrieve connection assigned to model
- Gitlab::Database::QueryAnalyzer.instance.process_sql(sql, model.retrieve_connection)
+ Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection)
end
end
end
diff --git a/spec/lib/gitlab/database/shared_model_spec.rb b/spec/lib/gitlab/database/shared_model_spec.rb
index 54af4a0c4dc..574111f4c01 100644
--- a/spec/lib/gitlab/database/shared_model_spec.rb
+++ b/spec/lib/gitlab/database/shared_model_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe Gitlab::Database::SharedModel do
expect do
described_class.using_connection(second_connection) {}
- end.to raise_error(/cannot nest connection overrides/)
+ end.to raise_error(/Cannot change connection for Gitlab::Database::SharedModel/)
expect(described_class.connection).to be(new_connection)
end
diff --git a/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb b/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb
index 8c3d372cc55..d044170dc75 100644
--- a/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb
+++ b/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter do
- subject { described_class.import }
+ subject { described_class.upsert_types }
it_behaves_like 'work item base types importer'
end
diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb
index ac8616f84a7..23f4f0e7089 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -70,40 +70,6 @@ RSpec.describe Gitlab::Database do
end
end
- describe '.main_database?' do
- using RSpec::Parameterized::TableSyntax
-
- where(:database_name, :result) do
- :main | true
- 'main' | true
- :ci | false
- 'ci' | false
- :archive | false
- 'archive' | false
- end
-
- with_them do
- it { expect(described_class.main_database?(database_name)).to eq(result) }
- end
- end
-
- describe '.ci_database?' do
- using RSpec::Parameterized::TableSyntax
-
- where(:database_name, :result) do
- :main | false
- 'main' | false
- :ci | true
- 'ci' | true
- :archive | false
- 'archive' | false
- end
-
- with_them do
- it { expect(described_class.ci_database?(database_name)).to eq(result) }
- end
- end
-
describe '.check_for_non_superuser' do
subject { described_class.check_for_non_superuser }
diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb
index 0d7a183bb11..b7262629e0a 100644
--- a/spec/lib/gitlab/diff/file_spec.rb
+++ b/spec/lib/gitlab/diff/file_spec.rb
@@ -99,6 +99,22 @@ RSpec.describe Gitlab::Diff::File do
end
end
+ describe '#ipynb?' do
+ context 'is ipynb' do
+ let(:commit) { project.commit("532c837") }
+
+ it 'is true' do
+ expect(diff_file.ipynb?).to be_truthy
+ end
+ end
+
+ context 'is not ipynb' do
+ it 'is false' do
+ expect(diff_file.ipynb?).to be_falsey
+ end
+ end
+ end
+
describe '#has_renderable?' do
context 'file is ipynb' do
let(:commit) { project.commit("532c837") }
diff --git a/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb b/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb
index 89b284feee0..1b74e24bf81 100644
--- a/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb
+++ b/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe Gitlab::Diff::Rendered::Notebook::DiffFile do
end
it 'falls back to nil on timeout' do
- allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ expect(Gitlab::ErrorTracking).to receive(:log_exception)
expect(Timeout).to receive(:timeout).and_raise(Timeout::Error)
expect(nb_file.diff).to be_nil
@@ -101,6 +101,22 @@ RSpec.describe Gitlab::Diff::Rendered::Notebook::DiffFile do
expect(nb_file.has_renderable?).to be_truthy
end
end
+
+ context 'when old blob file is truncated' do
+ it 'is false' do
+ allow(source.old_blob).to receive(:truncated?).and_return(true)
+
+ expect(nb_file.has_renderable?).to be_falsey
+ end
+ end
+
+ context 'when new blob file is truncated' do
+ it 'is false' do
+ allow(source.new_blob).to receive(:truncated?).and_return(true)
+
+ expect(nb_file.has_renderable?).to be_falsey
+ end
+ end
end
describe '#highlighted_diff_lines?' do
@@ -125,5 +141,9 @@ RSpec.describe Gitlab::Diff::Rendered::Notebook::DiffFile do
expect(nb_file.highlighted_diff_lines[12].old_pos).to eq(18)
end
end
+
+ it 'computes de first line where the remove would appear' do
+ expect(nb_file.highlighted_diff_lines.map(&:text).join('')).to include('[Hidden Image Output]')
+ end
end
end
diff --git a/spec/lib/gitlab/doctor/secrets_spec.rb b/spec/lib/gitlab/doctor/secrets_spec.rb
index f95a7eb1492..efdd6cc1199 100644
--- a/spec/lib/gitlab/doctor/secrets_spec.rb
+++ b/spec/lib/gitlab/doctor/secrets_spec.rb
@@ -7,10 +7,25 @@ RSpec.describe Gitlab::Doctor::Secrets do
let!(:group) { create(:group, runners_token: "test") }
let!(:project) { create(:project) }
let!(:grafana_integration) { create(:grafana_integration, project: project, token: "test") }
+ let!(:integration) { create(:integration, project: project, properties: { test_key: "test_value" }) }
let(:logger) { double(:logger).as_null_object }
subject { described_class.new(logger).run! }
+ before do
+ allow(Gitlab::Runtime).to receive(:rake?).and_return(true)
+ end
+
+ context 'when not ran in a Rake runtime' do
+ before do
+ allow(Gitlab::Runtime).to receive(:rake?).and_return(false)
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(StandardError, 'can only be used in a Rake environment')
+ end
+ end
+
context 'when encrypted attributes are properly set' do
it 'detects decryptable secrets' do
expect(logger).to receive(:info).with(/User failures: 0/)
@@ -42,6 +57,25 @@ RSpec.describe Gitlab::Doctor::Secrets do
end
end
+ context 'when initializers attempt to use encrypted data' do
+ it 'skips the initializers and detects bad data' do
+ integration.encrypted_properties = "invalid"
+ integration.save!
+
+ expect(logger).to receive(:info).with(/Integration failures: 1/)
+
+ subject
+ end
+
+ it 'resets the initializers after the task runs' do
+ subject
+
+ expect(integration).to receive(:initialize_properties)
+
+ integration.run_callbacks(:initialize)
+ end
+ end
+
context 'when GrafanaIntegration token is set via private method' do
it 'can access GrafanaIntegration token value' do
expect(logger).to receive(:info).with(/GrafanaIntegration failures: 0/)
diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
index 59b87c5d8e7..9ff395070ea 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
end
it 'does not raise a UserNotFoundError' do
- expect { receiver.execute }.not_to raise_error(Gitlab::Email::UserNotFoundError)
+ expect { receiver.execute }.not_to raise_error
end
end
end
@@ -71,7 +71,7 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
let(:original_recipient) { User.support_bot }
it 'does not raise a UserNotFoundError' do
- expect { receiver.execute }.not_to raise_error(Gitlab::Email::UserNotFoundError)
+ expect { receiver.execute }.not_to raise_error
end
end
end
diff --git a/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb b/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb
new file mode 100644
index 00000000000..3089f955252
--- /dev/null
+++ b/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Email::Message::BuildIosAppGuide do
+ subject(:message) { described_class.new }
+
+ before do
+ allow(Gitlab).to receive(:com?) { true }
+ end
+
+ it 'contains the correct message', :aggregate_failures do
+ expect(message.subject_line).to eq 'Get set up to build for iOS'
+ expect(message.title).to eq "Building for iOS? We've got you covered."
+ expect(message.body_line1).to eq "Want to get your iOS app up and running, including " \
+ "publishing all the way to TestFlight? Follow our guide to set up GitLab and fastlane to publish iOS apps to " \
+ "the App Store."
+ expect(message.cta_text).to eq 'Learn how to build for iOS'
+ expect(message.cta2_text).to eq 'Watch iOS building in action.'
+ expect(message.logo_path).to eq 'mailers/in_product_marketing/create-0.png'
+ expect(message.unsubscribe).to include('%tag_unsubscribe_url%')
+ end
+end
diff --git a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb
index 8bd873cf008..dfa18c27d5e 100644
--- a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb
+++ b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Base do
let(:series) { 0 }
it 'does not raise error' do
- expect { subject }.not_to raise_error(ArgumentError)
+ expect { subject }.not_to raise_error
end
end
end
diff --git a/spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb
new file mode 100644
index 00000000000..3c0d83d0f9e
--- /dev/null
+++ b/spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Email::Message::InProductMarketing::Helper do
+ describe 'unsubscribe_message' do
+ include Gitlab::Routing
+
+ let(:dummy_class_with_helper) do
+ Class.new do
+ include Gitlab::Email::Message::InProductMarketing::Helper
+ include Gitlab::Routing
+
+ def initialize(format = :html)
+ @format = format
+ end
+
+ def default_url_options
+ {}
+ end
+
+ attr_accessor :format
+ end
+ end
+
+ let(:format) { :html }
+
+ subject(:class_with_helper) { dummy_class_with_helper.new(format) }
+
+ context 'gitlab.com' do
+ before do
+ allow(Gitlab).to receive(:com?) { true }
+ end
+
+ context 'format is HTML' do
+ it 'returns the correct HTML' do
+ message = "If you no longer wish to receive marketing emails from us, " \
+ "you may <a href=\"%tag_unsubscribe_url%\">unsubscribe</a> at any time."
+ expect(class_with_helper.unsubscribe_message).to match message
+ end
+ end
+
+ context 'format is text' do
+ let(:format) { :text }
+
+ it 'returns the correct string' do
+ message = "If you no longer wish to receive marketing emails from us, " \
+ "you may unsubscribe (%tag_unsubscribe_url%) at any time."
+ expect(class_with_helper.unsubscribe_message.squish).to match message
+ end
+ end
+ end
+
+ context 'self-managed' do
+ context 'format is HTML' do
+ it 'returns the correct HTML' do
+ preferences_link = "http://example.com/preferences"
+ message = "To opt out of these onboarding emails, " \
+ "<a href=\"#{profile_notifications_url}\">unsubscribe</a>. " \
+ "If you don't want to receive marketing emails directly from GitLab, #{preferences_link}."
+ expect(class_with_helper.unsubscribe_message(preferences_link))
+ .to match message
+ end
+ end
+
+ context 'format is text' do
+ let(:format) { :text }
+
+ it 'returns the correct string' do
+ preferences_link = "http://example.com/preferences"
+ message = "To opt out of these onboarding emails, " \
+ "unsubscribe (#{profile_notifications_url}). " \
+ "If you don't want to receive marketing emails directly from GitLab, #{preferences_link}."
+ expect(class_with_helper.unsubscribe_message(preferences_link).squish).to match message
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/experiment/rollout/feature_spec.rb b/spec/lib/gitlab/experiment/rollout/feature_spec.rb
index 82603e6fe0f..a66f4fea207 100644
--- a/spec/lib/gitlab/experiment/rollout/feature_spec.rb
+++ b/spec/lib/gitlab/experiment/rollout/feature_spec.rb
@@ -53,8 +53,7 @@ RSpec.describe Gitlab::Experiment::Rollout::Feature, :experiment do
expect(Feature).to receive(:enabled?).with(
'namespaced_stub',
subject,
- type: :experiment,
- default_enabled: :yaml
+ type: :experiment
).and_return(false)
expect(subject.execute_assignment).to be_nil
diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
index 435a0d56301..799884d7a74 100644
--- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb
+++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
@@ -274,7 +274,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
action: 'start',
property: 'control_group',
value: 1,
- label: Digest::MD5.hexdigest('abc'),
+ label: Digest::SHA256.hexdigest('abc'),
user: user
)
end
@@ -289,7 +289,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
action: 'start',
property: 'control_group',
value: 1,
- label: Digest::MD5.hexdigest('somestring'),
+ label: Digest::SHA256.hexdigest('somestring'),
user: user
)
end
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 46f544797bb..2c931a999f1 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -165,17 +165,21 @@ EOT
context 'when diff contains invalid characters' do
let(:bad_string) { [0xae].pack("C*") }
let(:bad_string_two) { [0x89].pack("C*") }
+ let(:bad_string_three) { "@@ -1,5 +1,6 @@\n \xFF\xFE#\x00l\x00a\x00n\x00g\x00u\x00" }
let(:diff) { described_class.new(@raw_diff_hash.merge({ diff: bad_string })) }
let(:diff_two) { described_class.new(@raw_diff_hash.merge({ diff: bad_string_two })) }
+ let(:diff_three) { described_class.new(@raw_diff_hash.merge({ diff: bad_string_three })) }
context 'when replace_invalid_utf8_chars is true' do
it 'will convert invalid characters and not cause an encoding error' do
expect(diff.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER)
expect(diff_two.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER)
+ expect(diff_three.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER)
- expect { Oj.dump(diff) }.not_to raise_error(EncodingError)
- expect { Oj.dump(diff_two) }.not_to raise_error(EncodingError)
+ expect { Oj.dump(diff) }.not_to raise_error
+ expect { Oj.dump(diff_two) }.not_to raise_error
+ expect { Oj.dump(diff_three) }.not_to raise_error
end
context 'when the diff is binary' do
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index d6ef1836ad9..e628a06a542 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -228,6 +228,15 @@ RSpec.describe Gitlab::GitAccess do
project.add_maintainer(user)
end
+ context 'key is expired' do
+ let(:actor) { create(:rsa_key_2048, :expired) }
+
+ it 'does not allow expired keys', :aggregate_failures do
+ expect { pull_access_check }.to raise_forbidden('Your SSH key has expired.')
+ expect { push_access_check }.to raise_forbidden('Your SSH key has expired.')
+ end
+ end
+
context 'key is too small' do
before do
stub_application_setting(rsa_key_restriction: 4096)
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
index 50a0f20e775..92860c9232f 100644
--- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -339,11 +339,18 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
describe '#list_new_commits' do
let(:revisions) { [revision] }
let(:gitaly_commits) { create_list(:gitaly_commit, 3) }
- let(:commits) { gitaly_commits.map { |c| Gitlab::Git::Commit.new(repository, c) }}
+ let(:expected_commits) { gitaly_commits.map { |c| Gitlab::Git::Commit.new(repository, c) }}
+ let(:filter_quarantined_commits) { false }
- subject { client.list_new_commits(revisions, allow_quarantine: allow_quarantine) }
+ subject do
+ client.list_new_commits(revisions, allow_quarantine: allow_quarantine)
+ end
shared_examples 'a #list_all_commits message' do
+ before do
+ stub_feature_flags(filter_quarantined_commits: filter_quarantined_commits)
+ end
+
it 'sends a list_all_commits message' do
expected_repository = repository.gitaly_repository.dup
expected_repository.git_alternate_object_directories = Google::Protobuf::RepeatedField.new(:string)
@@ -352,9 +359,33 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
expect(service).to receive(:list_all_commits)
.with(gitaly_request_with_params(repository: expected_repository), kind_of(Hash))
.and_return([Gitaly::ListAllCommitsResponse.new(commits: gitaly_commits)])
+
+ if filter_quarantined_commits
+ # The object directory of the repository must not be set so that we
+ # don't use the quarantine directory.
+ objects_exist_repo = repository.gitaly_repository.dup
+ objects_exist_repo.git_object_directory = ""
+
+ # The first request contains the repository, the second request the
+ # commit IDs we want to check for existence.
+ objects_exist_request = [
+ gitaly_request_with_params(repository: objects_exist_repo),
+ gitaly_request_with_params(revisions: gitaly_commits.map(&:id))
+ ]
+
+ objects_exist_response = Gitaly::CheckObjectsExistResponse.new(revisions: revision_existence.map do
+ |rev, exists| Gitaly::CheckObjectsExistResponse::RevisionExistence.new(name: rev, exists: exists)
+ end)
+
+ expect(service).to receive(:check_objects_exist)
+ .with(objects_exist_request, kind_of(Hash))
+ .and_return([objects_exist_response])
+ else
+ expect(service).not_to receive(:check_objects_exist)
+ end
end
- expect(subject).to eq(commits)
+ expect(subject).to eq(expected_commits)
end
end
@@ -366,7 +397,7 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
.and_return([Gitaly::ListCommitsResponse.new(commits: gitaly_commits)])
end
- expect(subject).to eq(commits)
+ expect(subject).to eq(expected_commits)
end
end
@@ -390,7 +421,40 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
context 'with allowed quarantine' do
let(:allow_quarantine) { true }
- it_behaves_like 'a #list_all_commits message'
+ context 'without commit filtering' do
+ it_behaves_like 'a #list_all_commits message'
+ end
+
+ context 'with commit filtering' do
+ let(:filter_quarantined_commits) { true }
+
+ context 'reject commits which exist in target repository' do
+ let(:revision_existence) { gitaly_commits.to_h { |c| [c.id, true] } }
+ let(:expected_commits) { [] }
+
+ it_behaves_like 'a #list_all_commits message'
+ end
+
+ context 'keep commits which do not exist in target repository' do
+ let(:revision_existence) { gitaly_commits.to_h { |c| [c.id, false] } }
+
+ it_behaves_like 'a #list_all_commits message'
+ end
+
+ context 'mixed existing and nonexisting commits' do
+ let(:revision_existence) do
+ {
+ gitaly_commits[0].id => true,
+ gitaly_commits[1].id => false,
+ gitaly_commits[2].id => true
+ }
+ end
+
+ let(:expected_commits) { [Gitlab::Git::Commit.new(repository, gitaly_commits[1])] }
+
+ it_behaves_like 'a #list_all_commits message'
+ end
+ end
end
context 'with disallowed quarantine' do
@@ -493,6 +557,61 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
end
end
+ describe '#object_existence_map' do
+ shared_examples 'a CheckObjectsExistRequest' do
+ before do
+ ::Gitlab::GitalyClient.clear_stubs!
+ end
+
+ it 'returns expected results' do
+ expect_next_instance_of(Gitaly::CommitService::Stub) do |service|
+ expect(service)
+ .to receive(:check_objects_exist)
+ .and_call_original
+ end
+
+ expect(client.object_existence_map(revisions.keys)).to eq(revisions)
+ end
+ end
+
+ context 'with empty request' do
+ let(:revisions) { {} }
+
+ it_behaves_like 'a CheckObjectsExistRequest'
+ end
+
+ context 'when revision exists' do
+ let(:revisions) { { 'refs/heads/master' => true } }
+
+ it_behaves_like 'a CheckObjectsExistRequest'
+ end
+
+ context 'when revision does not exist' do
+ let(:revisions) { { 'refs/does/not/exist' => false } }
+
+ it_behaves_like 'a CheckObjectsExistRequest'
+ end
+
+ context 'when request contains mixed revisions' do
+ let(:revisions) do
+ {
+ "refs/heads/master" => true,
+ "refs/does/not/exist" => false
+ }
+ end
+
+ it_behaves_like 'a CheckObjectsExistRequest'
+ end
+
+ context 'when requesting many revisions' do
+ let(:revisions) do
+ Array(1..1234).to_h { |i| ["refs/heads/#{i}", false] }
+ end
+
+ it_behaves_like 'a CheckObjectsExistRequest'
+ end
+ end
+
describe '#commits_by_message' do
shared_examples 'a CommitsByMessageRequest' do
let(:commits) { create_list(:gitaly_commit, 2) }
diff --git a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb
index 321ad7d3238..8eeb2332131 100644
--- a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb
@@ -181,8 +181,10 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter, :aggregate_fail
expect(Gitlab::GithubImport::Logger)
.to receive(:warn)
.with(
- message: "Validation failed: Line code can't be blank, Line code must be a valid line code, Position is incomplete",
- 'error.class': 'Gitlab::GithubImport::Importer::DiffNoteImporter::DiffNoteCreationError'
+ {
+ message: "Validation failed: Line code can't be blank, Line code must be a valid line code, Position is incomplete",
+ 'error.class': 'Gitlab::GithubImport::Importer::DiffNoteImporter::DiffNoteCreationError'
+ }
)
expect { subject.execute }
@@ -204,8 +206,10 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter, :aggregate_fail
expect(Gitlab::GithubImport::Logger)
.to receive(:warn)
.with(
- message: 'Failed to create diff note file',
- 'error.class': 'DiffNote::NoteDiffFileCreationError'
+ {
+ message: 'Failed to create diff note file',
+ 'error.class': 'DiffNote::NoteDiffFileCreationError'
+ }
)
expect { subject.execute }
diff --git a/spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb
index c5fa67e50aa..0eb86feb040 100644
--- a/spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/pull_requests_reviews_importer_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestsReviewsImporter do
expect(client)
.to receive(:each_page)
.exactly(:once) # ensure to be cached on the second call
- .with(:pull_request_reviews, 'github/repo', merge_request.iid, page: 1)
+ .with(:pull_request_reviews, 'github/repo', merge_request.iid, { page: 1 })
.and_yield(page)
expect { |b| subject.each_object_to_import(&b) }
@@ -67,7 +67,7 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestsReviewsImporter do
expect(client)
.to receive(:each_page)
.exactly(:once) # ensure to be cached on the second call
- .with(:pull_request_reviews, 'github/repo', merge_request.iid, page: 2)
+ .with(:pull_request_reviews, 'github/repo', merge_request.iid, { page: 2 })
subject.each_object_to_import {}
end
diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb
index 8c71d7d0ed7..471302cb31b 100644
--- a/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointDiffNotesImporter d
expect(client)
.to receive(:each_page)
.exactly(:once) # ensure to be cached on the second call
- .with(:pull_request_comments, 'github/repo', merge_request.iid, page: 1)
+ .with(:pull_request_comments, 'github/repo', merge_request.iid, { page: 1 })
.and_yield(page)
expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note)
@@ -56,7 +56,7 @@ RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointDiffNotesImporter d
expect(client)
.to receive(:each_page)
.exactly(:once) # ensure to be cached on the second call
- .with(:pull_request_comments, 'github/repo', merge_request.iid, page: 2)
+ .with(:pull_request_comments, 'github/repo', merge_request.iid, { page: 2 })
subject.each_object_to_import {}
end
diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb
index 8d8f2730880..d769f4fdcf5 100644
--- a/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointIssueNotesImporter
expect(client)
.to receive(:each_page)
.exactly(:once) # ensure to be cached on the second call
- .with(:issue_comments, 'github/repo', issue.iid, page: 1)
+ .with(:issue_comments, 'github/repo', issue.iid, { page: 1 })
.and_yield(page)
expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note)
@@ -55,7 +55,7 @@ RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointIssueNotesImporter
expect(client)
.to receive(:each_page)
.exactly(:once) # ensure to be cached on the second call
- .with(:issue_comments, 'github/repo', issue.iid, page: 2)
+ .with(:issue_comments, 'github/repo', issue.iid, { page: 2 })
subject.each_object_to_import {}
end
diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb
index b8282212a90..1dcc466d34c 100644
--- a/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointMergeRequestNotesIm
expect(client)
.to receive(:each_page)
.exactly(:once) # ensure to be cached on the second call
- .with(:issue_comments, 'github/repo', merge_request.iid, page: 1)
+ .with(:issue_comments, 'github/repo', merge_request.iid, { page: 1 })
.and_yield(page)
expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note)
@@ -56,7 +56,7 @@ RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointMergeRequestNotesIm
expect(client)
.to receive(:each_page)
.exactly(:once) # ensure to be cached on the second call
- .with(:issue_comments, 'github/repo', merge_request.iid, page: 2)
+ .with(:issue_comments, 'github/repo', merge_request.iid, { page: 2 })
subject.each_object_to_import {}
end
diff --git a/spec/lib/gitlab/github_import/milestone_finder_spec.rb b/spec/lib/gitlab/github_import/milestone_finder_spec.rb
index fe8652eb5a2..e7f47d334e8 100644
--- a/spec/lib/gitlab/github_import/milestone_finder_spec.rb
+++ b/spec/lib/gitlab/github_import/milestone_finder_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe Gitlab::GithubImport::MilestoneFinder, :clean_gitlab_redis_cache
it 'builds the cache of all project milestones' do
expect(Gitlab::Cache::Import::Caching)
.to receive(:write_multiple)
- .with("github-import/milestone-finder/#{project.id}/1" => milestone.id)
+ .with({ "github-import/milestone-finder/#{project.id}/1" => milestone.id })
.and_call_original
finder.build_cache
diff --git a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
index 200898f8f03..999f8ffb21e 100644
--- a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
+++ b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
@@ -87,19 +87,23 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'starting importer',
- parallel: false,
- project_id: project.id,
- importer: 'Class'
+ {
+ message: 'starting importer',
+ parallel: false,
+ project_id: project.id,
+ importer: 'Class'
+ }
)
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'importer finished',
- parallel: false,
- project_id: project.id,
- importer: 'Class'
+ {
+ message: 'importer finished',
+ parallel: false,
+ project_id: project.id,
+ importer: 'Class'
+ }
)
importer.execute
@@ -118,20 +122,24 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'starting importer',
- parallel: false,
- project_id: project.id,
- importer: 'Class'
+ {
+ message: 'starting importer',
+ parallel: false,
+ project_id: project.id,
+ importer: 'Class'
+ }
)
expect(Gitlab::Import::ImportFailureService)
.to receive(:track)
.with(
- project_id: project.id,
- exception: exception,
- error_source: 'MyImporter',
- fail_import: false,
- metrics: true
+ {
+ project_id: project.id,
+ exception: exception,
+ error_source: 'MyImporter',
+ fail_import: false,
+ metrics: true
+ }
).and_call_original
expect { importer.execute }
@@ -184,10 +192,12 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'starting importer',
- parallel: false,
- project_id: project.id,
- importer: 'Class'
+ {
+ message: 'starting importer',
+ parallel: false,
+ project_id: project.id,
+ importer: 'Class'
+ }
)
expect(Gitlab::Import::ImportFailureService)
@@ -290,25 +300,6 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do
importer.parallel_import
end
end
-
- context 'when distribute_github_parallel_import feature flag is disabled' do
- before do
- stub_feature_flags(distribute_github_parallel_import: false)
- end
-
- it 'imports data in parallel' do
- expect(importer)
- .to receive(:each_object_to_import)
- .and_yield(object)
-
- expect(worker_class)
- .to receive(:perform_async)
- .with(project.id, { title: 'Foo' }, an_instance_of(String))
-
- expect(importer.parallel_import)
- .to be_an_instance_of(Gitlab::JobWaiter)
- end
- end
end
describe '#each_object_to_import' do
diff --git a/spec/lib/gitlab/gon_helper_spec.rb b/spec/lib/gitlab/gon_helper_spec.rb
index 28cb9125af1..dd4dcca809b 100644
--- a/spec/lib/gitlab/gon_helper_spec.rb
+++ b/spec/lib/gitlab/gon_helper_spec.rb
@@ -44,6 +44,7 @@ RSpec.describe Gitlab::GonHelper do
describe '#push_frontend_feature_flag' do
before do
skip_feature_flags_yaml_validation
+ skip_default_enabled_yaml_check
end
it 'pushes a feature flag to the frontend' do
diff --git a/spec/lib/gitlab/graphql/find_argument_in_parent_spec.rb b/spec/lib/gitlab/graphql/find_argument_in_parent_spec.rb
deleted file mode 100644
index 1b9301cd1aa..00000000000
--- a/spec/lib/gitlab/graphql/find_argument_in_parent_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Graphql::FindArgumentInParent do
- describe '#find' do
- def build_node(parent = nil, args: {})
- props = { irep_node: double(arguments: args) }
- props[:parent] = parent if parent # The root node shouldn't respond to parent
-
- double(props)
- end
-
- let(:parent) do
- build_node(
- build_node(
- build_node(
- build_node,
- args: { myArg: 1 }
- )
- )
- )
- end
-
- let(:arg_name) { :my_arg }
-
- it 'searches parents and returns the argument' do
- expect(described_class.find(parent, :my_arg)).to eq(1)
- end
-
- it 'can find argument when passed in as both Ruby and GraphQL-formatted symbols and strings' do
- [:my_arg, :myArg, 'my_arg', 'myArg'].each do |arg|
- expect(described_class.find(parent, arg)).to eq(1)
- end
- end
-
- it 'returns nil if no arguments found in parents' do
- expect(described_class.find(parent, :bar)).to eq(nil)
- end
-
- it 'can limit the depth it searches to' do
- expect(described_class.find(parent, :my_arg, limit_depth: 1)).to eq(nil)
- end
- end
-end
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb
index 86e7d4e344c..b6c3cb4e04a 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb
@@ -3,13 +3,15 @@
require 'spec_helper'
RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
+ include GraphqlHelpers
+
# https://gitlab.com/gitlab-org/gitlab/-/issues/334973
# The spec will be merged with connection_spec.rb in the future.
let(:nodes) { Project.all.order(id: :asc) }
let(:arguments) { {} }
let(:query_type) { GraphQL::ObjectType.new }
let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
- let(:context) { GraphQL::Query::Context.new(query: double('query', schema: schema), values: nil, object: nil) }
+ let(:context) { GraphQL::Query::Context.new(query: query_double(schema: schema), values: nil, object: nil) }
let_it_be(:column_order_id) { Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(attribute_name: 'id', order_expression: Project.arel_table[:id].asc) }
let_it_be(:column_order_id_desc) { Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(attribute_name: 'id', order_expression: Project.arel_table[:id].desc) }
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
index f31ec6c09fd..a4ba288b7f1 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
@@ -3,11 +3,13 @@
require 'spec_helper'
RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
+ include GraphqlHelpers
+
let(:nodes) { Project.all.order(id: :asc) }
let(:arguments) { {} }
let(:query_type) { GraphQL::ObjectType.new }
let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
- let(:context) { GraphQL::Query::Context.new(query: double('query', schema: schema), values: nil, object: nil) }
+ let(:context) { GraphQL::Query::Context.new(query: query_double(schema: schema), values: nil, object: nil) }
subject(:connection) do
described_class.new(nodes, **{ context: context, max_page_size: 3 }.merge(arguments))
diff --git a/spec/lib/gitlab/graphql/queries_spec.rb b/spec/lib/gitlab/graphql/queries_spec.rb
index ad1aaac712e..2c2ec821385 100644
--- a/spec/lib/gitlab/graphql/queries_spec.rb
+++ b/spec/lib/gitlab/graphql/queries_spec.rb
@@ -85,11 +85,15 @@ RSpec.describe Gitlab::Graphql::Queries do
describe '.all' do
it 'is the combination of finding queries in CE and EE' do
expect(described_class)
- .to receive(:find).with(Rails.root / 'app/assets/javascripts').and_return([:ce])
+ .to receive(:find).with(Rails.root / 'app/assets/javascripts').and_return([:ce_assets])
expect(described_class)
- .to receive(:find).with(Rails.root / 'ee/app/assets/javascripts').and_return([:ee])
+ .to receive(:find).with(Rails.root / 'ee/app/assets/javascripts').and_return([:ee_assets])
+ expect(described_class)
+ .to receive(:find).with(Rails.root / 'app/graphql/queries').and_return([:ce_gql])
+ expect(described_class)
+ .to receive(:find).with(Rails.root / 'ee/app/graphql/queries').and_return([:ee_gql])
- expect(described_class.all).to eq([:ce, :ee])
+ expect(described_class.all).to contain_exactly(:ce_assets, :ee_assets, :ce_gql, :ee_gql)
end
end
diff --git a/spec/lib/gitlab/metrics/exporter/health_checks_middleware_spec.rb b/spec/lib/gitlab/health_checks/middleware_spec.rb
index 9ee46a45e7a..3b644539acc 100644
--- a/spec/lib/gitlab/metrics/exporter/health_checks_middleware_spec.rb
+++ b/spec/lib/gitlab/health_checks/middleware_spec.rb
@@ -2,12 +2,12 @@
require 'fast_spec_helper'
-RSpec.describe Gitlab::Metrics::Exporter::HealthChecksMiddleware do
- let(:app) { double(:app) }
+RSpec.describe Gitlab::HealthChecks::Middleware do
+ let(:app) { instance_double(Proc) }
let(:env) { { 'PATH_INFO' => path } }
- let(:readiness_probe) { double(:readiness_probe) }
- let(:liveness_probe) { double(:liveness_probe) }
+ let(:readiness_probe) { instance_double(Gitlab::HealthChecks::Probes::Collection) }
+ let(:liveness_probe) { instance_double(Gitlab::HealthChecks::Probes::Collection) }
let(:probe_result) { Gitlab::HealthChecks::Probes::Status.new(200, { status: 'ok' }) }
subject(:middleware) { described_class.new(app, readiness_probe, liveness_probe) }
diff --git a/spec/lib/gitlab/health_checks/server_spec.rb b/spec/lib/gitlab/health_checks/server_spec.rb
new file mode 100644
index 00000000000..65d24acbf22
--- /dev/null
+++ b/spec/lib/gitlab/health_checks/server_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HealthChecks::Server do
+ context 'with running server thread' do
+ subject(:server) { described_class.new(address: 'localhost', port: 8082) }
+
+ before do
+ # We need to send a request to localhost
+ WebMock.allow_net_connect!
+
+ server.start
+ end
+
+ after do
+ webmock_enable!
+
+ server.stop
+ end
+
+ shared_examples 'serves health check at' do |path|
+ it 'responds with 200 OK' do
+ response = Gitlab::HTTP.try_get("http://localhost:8082/#{path}", allow_local_requests: true)
+
+ expect(response.code).to eq(200)
+ end
+ end
+
+ describe '/readiness' do
+ it_behaves_like 'serves health check at', 'readiness'
+ end
+
+ describe '/liveness' do
+ it_behaves_like 'serves health check at', 'liveness'
+ end
+
+ describe 'other routes' do
+ it 'serves 404' do
+ response = Gitlab::HTTP.try_get("http://localhost:8082/other", allow_local_requests: true)
+
+ expect(response.code).to eq(404)
+ end
+ end
+ end
+
+ context 'when server thread goes away' do
+ before do
+ expect_next_instance_of(::WEBrick::HTTPServer) do |webrick|
+ allow(webrick).to receive(:start)
+ expect(webrick).to receive(:listeners).and_call_original
+ end
+ end
+
+ specify 'stop closes TCP socket' do
+ server = described_class.new(address: 'localhost', port: 8082)
+ server.start
+
+ expect(server.thread).to receive(:alive?).and_return(false).at_least(:once)
+
+ server.stop
+ end
+ end
+end
diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb
index 7dbd21e6914..c2fb987d195 100644
--- a/spec/lib/gitlab/http_spec.rb
+++ b/spec/lib/gitlab/http_spec.rb
@@ -246,10 +246,10 @@ RSpec.describe Gitlab::HTTP do
context 'when :timeout is set' do
it 'does not set any default timeouts' do
expect(described_class).to receive(:httparty_perform_request).with(
- Net::HTTP::Get, 'http://example.org', timeout: 1
+ Net::HTTP::Get, 'http://example.org', { timeout: 1 }
).and_call_original
- described_class.get('http://example.org', timeout: 1)
+ described_class.get('http://example.org', { timeout: 1 })
end
end
diff --git a/spec/lib/gitlab/import/import_failure_service_spec.rb b/spec/lib/gitlab/import/import_failure_service_spec.rb
index e3fec63adde..eb71b307b8d 100644
--- a/spec/lib/gitlab/import/import_failure_service_spec.rb
+++ b/spec/lib/gitlab/import/import_failure_service_spec.rb
@@ -64,19 +64,23 @@ RSpec.describe Gitlab::Import::ImportFailureService, :aggregate_failures do
.to receive(:track_exception)
.with(
exception,
- project_id: project.id,
- import_type: import_type,
- source: 'SomeImporter'
+ {
+ project_id: project.id,
+ import_type: import_type,
+ source: 'SomeImporter'
+ }
)
expect(Gitlab::Import::Logger)
.to receive(:error)
.with(
- message: 'importer failed',
- 'error.message': 'some error',
- project_id: project.id,
- import_type: import_type,
- source: 'SomeImporter'
+ {
+ message: 'importer failed',
+ 'error.message': 'some error',
+ project_id: project.id,
+ import_type: import_type,
+ source: 'SomeImporter'
+ }
)
service.execute
@@ -96,19 +100,23 @@ RSpec.describe Gitlab::Import::ImportFailureService, :aggregate_failures do
.to receive(:track_exception)
.with(
exception,
- project_id: project.id,
- import_type: import_type,
- source: 'SomeImporter'
+ {
+ project_id: project.id,
+ import_type: import_type,
+ source: 'SomeImporter'
+ }
)
expect(Gitlab::Import::Logger)
.to receive(:error)
.with(
- message: 'importer failed',
- 'error.message': 'some error',
- project_id: project.id,
- import_type: import_type,
- source: 'SomeImporter'
+ {
+ message: 'importer failed',
+ 'error.message': 'some error',
+ project_id: project.id,
+ import_type: import_type,
+ source: 'SomeImporter'
+ }
)
service.execute
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 730f9035293..1546b6a26c8 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -550,6 +550,7 @@ project:
- project_registry
- packages
- package_files
+- packages_cleanup_policy
- tracing_setting
- alerting_setting
- project_setting
diff --git a/spec/lib/gitlab/import_export/group/relation_factory_spec.rb b/spec/lib/gitlab/import_export/group/relation_factory_spec.rb
index 8e7fe8849d4..9dbe8426f52 100644
--- a/spec/lib/gitlab/import_export/group/relation_factory_spec.rb
+++ b/spec/lib/gitlab/import_export/group/relation_factory_spec.rb
@@ -88,6 +88,21 @@ RSpec.describe Gitlab::ImportExport::Group::RelationFactory do
end
end
+ context 'when relation is namespace_settings' do
+ let(:relation_sym) { :namespace_settings }
+ let(:relation_hash) do
+ {
+ 'namespace_id' => 1,
+ 'prevent_forking_outside_group' => true,
+ 'prevent_sharing_groups_outside_hierarchy' => true
+ }
+ end
+
+ it do
+ expect(created_object).to eq(nil)
+ end
+ end
+
def random_id
rand(1000..10000)
end
diff --git a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
index a3e891db658..d3397e89f1f 100644
--- a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
@@ -383,7 +383,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer do
end
end
- it 'restores releases with links' do
+ it 'restores releases with links & milestones' do
release = @project.releases.last
link = release.links.last
@@ -393,6 +393,8 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer do
expect(release.name).to eq('release-1.1')
expect(release.sha).to eq('901de3a8bd5573f4a049b1457d28bc1592ba6bf9')
expect(release.released_at).to eq('2019-12-26T10:17:14.615Z')
+ expect(release.milestone_releases.count).to eq(1)
+ expect(release.milestone_releases.first.milestone.title).to eq('test milestone')
expect(link.url).to eq('http://localhost/namespace6/project6/-/jobs/140463678/artifacts/download')
expect(link.name).to eq('release-1.1.dmg')
diff --git a/spec/lib/gitlab/inactive_projects_deletion_warning_tracker_spec.rb b/spec/lib/gitlab/inactive_projects_deletion_warning_tracker_spec.rb
new file mode 100644
index 00000000000..4eb2388f3f7
--- /dev/null
+++ b/spec/lib/gitlab/inactive_projects_deletion_warning_tracker_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Gitlab::InactiveProjectsDeletionWarningTracker do
+ let_it_be(:project_id) { 1 }
+
+ describe '.notified_projects', :clean_gitlab_redis_shared_state do
+ before do
+ freeze_time do
+ Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).mark_notified
+ end
+ end
+
+ it 'returns the list of projects for which deletion warning email has been sent' do
+ expected_hash = { "project:1" => "#{Date.current}" }
+
+ expect(Gitlab::InactiveProjectsDeletionWarningTracker.notified_projects).to eq(expected_hash)
+ end
+ end
+
+ describe '.reset_all' do
+ before do
+ Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).mark_notified
+ end
+
+ it 'deletes all the projects for which deletion warning email was sent' do
+ Gitlab::InactiveProjectsDeletionWarningTracker.reset_all
+
+ expect(Gitlab::InactiveProjectsDeletionWarningTracker.notified_projects).to eq({})
+ end
+ end
+
+ describe '#notified?' do
+ before do
+ Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).mark_notified
+ end
+
+ it 'returns true if the project has already been notified' do
+ expect(Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).notified?).to eq(true)
+ end
+
+ it 'returns false if the project has not been notified' do
+ expect(Gitlab::InactiveProjectsDeletionWarningTracker.new(2).notified?).to eq(false)
+ end
+ end
+
+ describe '#mark_notified' do
+ it 'marks the project as being notified' do
+ Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).mark_notified
+
+ expect(Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).notified?).to eq(true)
+ end
+ end
+
+ describe '#reset' do
+ before do
+ Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).mark_notified
+ end
+
+ it 'resets the project as not being notified' do
+ Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).reset
+
+ expect(Gitlab::InactiveProjectsDeletionWarningTracker.new(project_id).notified?).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/instrumentation/rate_limiting_gates_spec.rb b/spec/lib/gitlab/instrumentation/rate_limiting_gates_spec.rb
new file mode 100644
index 00000000000..ac308eb7c80
--- /dev/null
+++ b/spec/lib/gitlab/instrumentation/rate_limiting_gates_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Instrumentation::RateLimitingGates, :request_store do
+ describe '.gates' do
+ it 'returns an empty array when no gates are tracked' do
+ expect(described_class.gates).to eq([])
+ end
+
+ it 'returns all gates used in the request' do
+ described_class.track(:foo)
+
+ RequestStore.clear!
+
+ described_class.track(:bar)
+ described_class.track(:baz)
+
+ expect(described_class.gates).to contain_exactly(:bar, :baz)
+ end
+
+ it 'deduplicates its results' do
+ described_class.track(:foo)
+ described_class.track(:bar)
+ described_class.track(:foo)
+
+ expect(described_class.gates).to contain_exactly(:foo, :bar)
+ end
+ end
+
+ describe '.payload' do
+ it 'returns the gates in a hash' do
+ described_class.track(:foo)
+ described_class.track(:bar)
+
+ expect(described_class.payload).to eq(described_class::GATES => [:foo, :bar])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/instrumentation_helper_spec.rb b/spec/lib/gitlab/instrumentation_helper_spec.rb
index a9663012e9a..5fea355ab4f 100644
--- a/spec/lib/gitlab/instrumentation_helper_spec.rb
+++ b/spec/lib/gitlab/instrumentation_helper_spec.rb
@@ -77,6 +77,27 @@ RSpec.describe Gitlab::InstrumentationHelper do
end
end
+ context 'rate-limiting gates' do
+ context 'when the request did not pass through any rate-limiting gates' do
+ it 'logs an empty array of gates' do
+ subject
+
+ expect(payload[:rate_limiting_gates]).to eq([])
+ end
+ end
+
+ context 'when the request passed through rate-limiting gates' do
+ it 'logs an array of gates used' do
+ Gitlab::Instrumentation::RateLimitingGates.track(:foo)
+ Gitlab::Instrumentation::RateLimitingGates.track(:bar)
+
+ subject
+
+ expect(payload[:rate_limiting_gates]).to contain_exactly(:foo, :bar)
+ end
+ end
+ end
+
it 'logs cpu_s duration' do
subject
diff --git a/spec/lib/gitlab/jira/middleware_spec.rb b/spec/lib/gitlab/jira/middleware_spec.rb
index 1fe22b145a6..e7a79e40ac5 100644
--- a/spec/lib/gitlab/jira/middleware_spec.rb
+++ b/spec/lib/gitlab/jira/middleware_spec.rb
@@ -23,8 +23,8 @@ RSpec.describe Gitlab::Jira::Middleware do
describe '#call' do
it 'adjusts HTTP_AUTHORIZATION env when request from Jira DVCS user agent' do
- expect(app).to receive(:call).with('HTTP_USER_AGENT' => jira_user_agent,
- 'HTTP_AUTHORIZATION' => 'Bearer hash-123')
+ expect(app).to receive(:call).with({ 'HTTP_USER_AGENT' => jira_user_agent,
+ 'HTTP_AUTHORIZATION' => 'Bearer hash-123' })
middleware.call('HTTP_USER_AGENT' => jira_user_agent, 'HTTP_AUTHORIZATION' => 'token hash-123')
end
diff --git a/spec/lib/gitlab/json_cache_spec.rb b/spec/lib/gitlab/json_cache_spec.rb
index d7d28a94cfe..f4f6624bae9 100644
--- a/spec/lib/gitlab/json_cache_spec.rb
+++ b/spec/lib/gitlab/json_cache_spec.rb
@@ -313,9 +313,9 @@ RSpec.describe Gitlab::JsonCache do
it 'passes options the underlying cache implementation' do
expect(backend).to receive(:write)
- .with(expanded_key, "true", expires_in: 15.seconds)
+ .with(expanded_key, "true", { expires_in: 15.seconds })
- cache.fetch(key, expires_in: 15.seconds) { true }
+ cache.fetch(key, { expires_in: 15.seconds }) { true }
end
context 'when the given key does not exist in the cache' do
diff --git a/spec/lib/gitlab/json_spec.rb b/spec/lib/gitlab/json_spec.rb
index 5ffe736da54..7c093049e18 100644
--- a/spec/lib/gitlab/json_spec.rb
+++ b/spec/lib/gitlab/json_spec.rb
@@ -290,7 +290,7 @@ RSpec.describe Gitlab::Json do
end
it "skips legacy mode handling" do
- expect(Feature).not_to receive(:enabled?).with(:json_wrapper_legacy_mode, default_enabled: true)
+ expect(Feature).not_to receive(:enabled?).with(:json_wrapper_legacy_mode)
subject.send(:handle_legacy_mode!, {})
end
diff --git a/spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb b/spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb
deleted file mode 100644
index ec1f46100a4..00000000000
--- a/spec/lib/gitlab/kubernetes/cilium_network_policy_spec.rb
+++ /dev/null
@@ -1,274 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::CiliumNetworkPolicy do
- let(:policy) do
- described_class.new(
- name: name,
- namespace: namespace,
- description: description,
- selector: selector,
- ingress: ingress,
- egress: egress,
- labels: labels,
- resource_version: resource_version,
- annotations: annotations
- )
- end
-
- let(:resource) do
- ::Kubeclient::Resource.new(
- apiVersion: Gitlab::Kubernetes::CiliumNetworkPolicy::API_VERSION,
- kind: Gitlab::Kubernetes::CiliumNetworkPolicy::KIND,
- metadata: { name: name, namespace: namespace, resourceVersion: resource_version, annotations: annotations },
- spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: egress },
- description: description
- )
- end
-
- let(:selector) { endpoint_selector }
- let(:labels) { nil }
- let(:name) { 'example-name' }
- let(:namespace) { 'example-namespace' }
- let(:endpoint_selector) { { matchLabels: { role: 'db' } } }
- let(:description) { 'example-description' }
- let(:partial_class_name) { described_class.name.split('::').last }
- let(:resource_version) { 101 }
- let(:annotations) { { 'app.gitlab.com/alert': 'true' } }
- let(:ingress) do
- [
- {
- fromEndpoints: [
- { matchLabels: { project: 'myproject' } }
- ]
- }
- ]
- end
-
- let(:egress) do
- [
- {
- ports: [{ port: 5978 }]
- }
- ]
- end
-
- include_examples 'network policy common specs'
-
- describe '.from_yaml' do
- let(:manifest) do
- <<~POLICY
- apiVersion: cilium.io/v2
- kind: CiliumNetworkPolicy
- description: example-description
- metadata:
- name: example-name
- namespace: example-namespace
- resourceVersion: 101
- annotations:
- app.gitlab.com/alert: "true"
- spec:
- endpointSelector:
- matchLabels:
- role: db
- ingress:
- - fromEndpoints:
- - matchLabels:
- project: myproject
- egress:
- - ports:
- - port: 5978
- POLICY
- end
-
- subject { Gitlab::Kubernetes::CiliumNetworkPolicy.from_yaml(manifest)&.generate }
-
- it { is_expected.to eq(resource) }
-
- context 'with nil manifest' do
- let(:manifest) { nil }
-
- it { is_expected.to be_nil }
- end
-
- context 'with invalid manifest' do
- let(:manifest) { "\tfoo: bar" }
-
- it { is_expected.to be_nil }
- end
-
- context 'with manifest without metadata' do
- let(:manifest) do
- <<~POLICY
- apiVersion: cilium.io/v2
- kind: CiliumNetworkPolicy
- spec:
- endpointSelector:
- matchLabels:
- role: db
- ingress:
- - fromEndpoints:
- matchLabels:
- project: myproject
- POLICY
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with manifest without spec' do
- let(:manifest) do
- <<~POLICY
- apiVersion: cilium.io/v2
- kind: CiliumNetworkPolicy
- metadata:
- name: example-name
- namespace: example-namespace
- POLICY
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with disallowed class' do
- let(:manifest) do
- <<~POLICY
- apiVersion: cilium.io/v2
- kind: CiliumNetworkPolicy
- metadata:
- name: example-name
- namespace: example-namespace
- creationTimestamp: 2020-04-14T00:08:30Z
- spec:
- endpointSelector:
- matchLabels:
- role: db
- ingress:
- - fromEndpoints:
- matchLabels:
- project: myproject
- POLICY
- end
-
- it { is_expected.to be_nil }
- end
- end
-
- describe '.from_resource' do
- let(:resource) do
- ::Kubeclient::Resource.new(
- description: description,
- metadata: {
- name: name, namespace: namespace, creationTimestamp: '2020-04-14T00:08:30Z',
- labels: { app: 'foo' }, resourceVersion: resource_version, annotations: annotations
- },
- spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: nil, labels: nil }
- )
- end
-
- let(:generated_resource) do
- ::Kubeclient::Resource.new(
- apiVersion: Gitlab::Kubernetes::CiliumNetworkPolicy::API_VERSION,
- kind: Gitlab::Kubernetes::CiliumNetworkPolicy::KIND,
- description: description,
- metadata: { name: name, namespace: namespace, resourceVersion: resource_version, labels: { app: 'foo' }, annotations: annotations },
- spec: { endpointSelector: endpoint_selector, ingress: ingress }
- )
- end
-
- subject { Gitlab::Kubernetes::CiliumNetworkPolicy.from_resource(resource)&.generate }
-
- it { is_expected.to eq(generated_resource) }
-
- context 'with nil resource' do
- let(:resource) { nil }
-
- it { is_expected.to be_nil }
- end
-
- context 'with resource without metadata' do
- let(:resource) do
- ::Kubeclient::Resource.new(
- spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: nil, labels: nil }
- )
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with resource without spec' do
- let(:resource) do
- ::Kubeclient::Resource.new(
- metadata: { name: name, namespace: namespace, uid: '128cf288-7de4-11ea-aceb-42010a800089', resourceVersion: resource_version }
- )
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with environment_ids' do
- subject { Gitlab::Kubernetes::CiliumNetworkPolicy.from_resource(resource, [1, 2, 3]) }
-
- it 'includes environment_ids in as_json result' do
- expect(subject.as_json).to include(environment_ids: [1, 2, 3])
- end
- end
- end
-
- describe '#resource' do
- subject { policy.resource }
-
- let(:resource) do
- {
- apiVersion: Gitlab::Kubernetes::CiliumNetworkPolicy::API_VERSION,
- kind: Gitlab::Kubernetes::CiliumNetworkPolicy::KIND,
- metadata: { name: name, namespace: namespace, resourceVersion: resource_version, annotations: annotations },
- spec: { endpointSelector: endpoint_selector, ingress: ingress, egress: egress },
- description: description
- }
- end
-
- it { is_expected.to eq(resource) }
-
- context 'with labels' do
- let(:labels) { { app: 'foo' } }
-
- before do
- resource[:metadata][:labels] = { app: 'foo' }
- end
-
- it { is_expected.to eq(resource) }
- end
-
- context 'without resource_version' do
- let(:resource_version) { nil }
-
- before do
- resource[:metadata].delete(:resourceVersion)
- end
-
- it { is_expected.to eq(resource) }
- end
-
- context 'with nil egress' do
- let(:egress) { nil }
-
- before do
- resource[:spec].delete(:egress)
- end
-
- it { is_expected.to eq(resource) }
- end
-
- context 'without annotations' do
- let(:annotations) { nil }
-
- before do
- resource[:metadata].delete(:annotations)
- end
-
- it { is_expected.to eq(resource) }
- end
- end
-end
diff --git a/spec/lib/gitlab/kubernetes/kube_client_spec.rb b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
index 521f13dc9cc..dfd5092b54d 100644
--- a/spec/lib/gitlab/kubernetes/kube_client_spec.rb
+++ b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
@@ -227,20 +227,6 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
end
end
- describe '#cilium_networking_client' do
- subject { client.cilium_networking_client }
-
- it_behaves_like 'a Kubeclient'
-
- it 'has the cilium API group endpoint' do
- expect(subject.api_endpoint.to_s).to match(%r{\/apis\/cilium.io\Z})
- end
-
- it 'has the api_version' do
- expect(subject.instance_variable_get(:@api_version)).to eq('v2')
- end
- end
-
describe '#metrics_client' do
subject { client.metrics_client }
@@ -428,56 +414,6 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
end
end
- describe 'networking API group' do
- let(:networking_client) { client.networking_client }
-
- [
- :create_network_policy,
- :get_network_policies,
- :get_network_policy,
- :update_network_policy,
- :delete_network_policy
- ].each do |method|
- describe "##{method}" do
- include_examples 'redirection not allowed', method
- include_examples 'dns rebinding not allowed', method
-
- it 'delegates to the networking client' do
- expect(client).to delegate_method(method).to(:networking_client)
- end
-
- it 'responds to the method' do
- expect(client).to respond_to method
- end
- end
- end
- end
-
- describe 'cilium API group' do
- let(:cilium_networking_client) { client.cilium_networking_client }
-
- [
- :create_cilium_network_policy,
- :get_cilium_network_policies,
- :get_cilium_network_policy,
- :update_cilium_network_policy,
- :delete_cilium_network_policy
- ].each do |method|
- describe "##{method}" do
- include_examples 'redirection not allowed', method
- include_examples 'dns rebinding not allowed', method
-
- it 'delegates to the cilium client' do
- expect(client).to delegate_method(method).to(:cilium_networking_client)
- end
-
- it 'responds to the method' do
- expect(client).to respond_to method
- end
- end
- end
- end
-
describe 'non-entity methods' do
it 'does not proxy for non-entity methods' do
expect(client).not_to respond_to :proxy_url
diff --git a/spec/lib/gitlab/kubernetes/network_policy_spec.rb b/spec/lib/gitlab/kubernetes/network_policy_spec.rb
deleted file mode 100644
index 2cba37a1302..00000000000
--- a/spec/lib/gitlab/kubernetes/network_policy_spec.rb
+++ /dev/null
@@ -1,235 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Kubernetes::NetworkPolicy do
- let(:policy) do
- described_class.new(
- name: name,
- namespace: namespace,
- selector: selector,
- ingress: ingress,
- labels: labels
- )
- end
-
- let(:resource) do
- ::Kubeclient::Resource.new(
- kind: Gitlab::Kubernetes::NetworkPolicy::KIND,
- metadata: { name: name, namespace: namespace },
- spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
- )
- end
-
- let(:selector) { pod_selector }
- let(:labels) { nil }
- let(:name) { 'example-name' }
- let(:namespace) { 'example-namespace' }
- let(:pod_selector) { { matchLabels: { role: 'db' } } }
-
- let(:ingress) do
- [
- {
- from: [
- { namespaceSelector: { matchLabels: { project: 'myproject' } } }
- ]
- }
- ]
- end
-
- let(:egress) do
- [
- {
- ports: [{ port: 5978 }]
- }
- ]
- end
-
- include_examples 'network policy common specs'
-
- describe '.from_yaml' do
- let(:manifest) do
- <<~POLICY
- apiVersion: networking.k8s.io/v1
- kind: NetworkPolicy
- metadata:
- name: example-name
- namespace: example-namespace
- spec:
- podSelector:
- matchLabels:
- role: db
- policyTypes:
- - Ingress
- ingress:
- - from:
- - namespaceSelector:
- matchLabels:
- project: myproject
- POLICY
- end
-
- subject { Gitlab::Kubernetes::NetworkPolicy.from_yaml(manifest)&.generate }
-
- it { is_expected.to eq(resource) }
-
- context 'with nil manifest' do
- let(:manifest) { nil }
-
- it { is_expected.to be_nil }
- end
-
- context 'with invalid manifest' do
- let(:manifest) { "\tfoo: bar" }
-
- it { is_expected.to be_nil }
- end
-
- context 'with manifest without metadata' do
- let(:manifest) do
- <<~POLICY
- apiVersion: networking.k8s.io/v1
- kind: NetworkPolicy
- spec:
- podSelector:
- matchLabels:
- role: db
- policyTypes:
- - Ingress
- ingress:
- - from:
- - namespaceSelector:
- matchLabels:
- project: myproject
- POLICY
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with manifest without spec' do
- let(:manifest) do
- <<~POLICY
- apiVersion: networking.k8s.io/v1
- kind: NetworkPolicy
- metadata:
- name: example-name
- namespace: example-namespace
- POLICY
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with disallowed class' do
- let(:manifest) do
- <<~POLICY
- apiVersion: networking.k8s.io/v1
- kind: NetworkPolicy
- metadata:
- name: example-name
- namespace: example-namespace
- creationTimestamp: 2020-04-14T00:08:30Z
- spec:
- podSelector:
- matchLabels:
- role: db
- policyTypes:
- - Ingress
- ingress:
- - from:
- - namespaceSelector:
- matchLabels:
- project: myproject
- POLICY
- end
-
- it { is_expected.to be_nil }
- end
- end
-
- describe '.from_resource' do
- let(:resource) do
- ::Kubeclient::Resource.new(
- metadata: {
- name: name, namespace: namespace, creationTimestamp: '2020-04-14T00:08:30Z',
- labels: { app: 'foo' }, resourceVersion: '4990'
- },
- spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
- )
- end
-
- let(:generated_resource) do
- ::Kubeclient::Resource.new(
- kind: Gitlab::Kubernetes::NetworkPolicy::KIND,
- metadata: { name: name, namespace: namespace, labels: { app: 'foo' } },
- spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
- )
- end
-
- subject { Gitlab::Kubernetes::NetworkPolicy.from_resource(resource)&.generate }
-
- it { is_expected.to eq(generated_resource) }
-
- context 'with nil resource' do
- let(:resource) { nil }
-
- it { is_expected.to be_nil }
- end
-
- context 'with resource without metadata' do
- let(:resource) do
- ::Kubeclient::Resource.new(
- spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
- )
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with resource without spec' do
- let(:resource) do
- ::Kubeclient::Resource.new(
- metadata: { name: name, namespace: namespace, uid: '128cf288-7de4-11ea-aceb-42010a800089', resourceVersion: '4990' }
- )
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with environment_ids' do
- subject { Gitlab::Kubernetes::NetworkPolicy.from_resource(resource, [1, 2, 3]) }
-
- it 'includes environment_ids in as_json result' do
- expect(subject.as_json).to include(environment_ids: [1, 2, 3])
- end
- end
- end
-
- describe '#resource' do
- subject { policy.resource }
-
- let(:resource) do
- {
- kind: Gitlab::Kubernetes::NetworkPolicy::KIND,
- metadata: { name: name, namespace: namespace },
- spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
- }
- end
-
- it { is_expected.to eq(resource) }
-
- context 'with labels' do
- let(:labels) { { app: 'foo' } }
- let(:resource) do
- {
- kind: Gitlab::Kubernetes::NetworkPolicy::KIND,
- metadata: { name: name, namespace: namespace, labels: { app: 'foo' } },
- spec: { podSelector: pod_selector, policyTypes: %w(Ingress), ingress: ingress, egress: nil }
- }
- end
-
- it { is_expected.to eq(resource) }
- end
- end
-end
diff --git a/spec/lib/gitlab/legacy_github_import/importer_spec.rb b/spec/lib/gitlab/legacy_github_import/importer_spec.rb
index 9a4d7bd996e..e69edbe6dc0 100644
--- a/spec/lib/gitlab/legacy_github_import/importer_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/importer_spec.rb
@@ -274,8 +274,7 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
it 'instantiates a Client' do
allow(project).to receive(:import_data).and_return(double(credentials: credentials))
expect(Gitlab::LegacyGithubImport::Client).to receive(:new).with(
- credentials[:user],
- **{}
+ credentials[:user]
)
subject.client
diff --git a/spec/lib/gitlab/lograge/custom_options_spec.rb b/spec/lib/gitlab/lograge/custom_options_spec.rb
index d8f351bb8a3..58b05be6ff9 100644
--- a/spec/lib/gitlab/lograge/custom_options_spec.rb
+++ b/spec/lib/gitlab/lograge/custom_options_spec.rb
@@ -96,23 +96,15 @@ RSpec.describe Gitlab::Lograge::CustomOptions do
end
end
- context 'when feature flags are present', :request_store do
+ context 'when feature flags are present', :request_store do
before do
allow(Feature).to receive(:log_feature_flag_states?).and_return(false)
- definitions = {}
[:enabled_feature, :disabled_feature].each do |flag_name|
- definitions[flag_name] = Feature::Definition.new("development/enabled_feature.yml",
- name: flag_name,
- type: 'development',
- log_state_changes: true,
- default_enabled: false)
-
+ stub_feature_flag_definition(flag_name, log_state_changes: true)
allow(Feature).to receive(:log_feature_flag_states?).with(flag_name).and_call_original
end
- allow(Feature::Definition).to receive(:definitions).and_return(definitions)
-
Feature.enable(:enabled_feature)
Feature.disable(:disabled_feature)
end
diff --git a/spec/lib/gitlab/markdown_cache/active_record/extension_spec.rb b/spec/lib/gitlab/markdown_cache/active_record/extension_spec.rb
index 98385cd80cc..d22bef5bda9 100644
--- a/spec/lib/gitlab/markdown_cache/active_record/extension_spec.rb
+++ b/spec/lib/gitlab/markdown_cache/active_record/extension_spec.rb
@@ -171,9 +171,9 @@ RSpec.describe Gitlab::MarkdownCache::ActiveRecord::Extension do
expect(thing).to receive(:persisted?).and_return(true)
expect(thing).to receive(:update_columns)
- .with("title_html" => updated_html,
+ .with({ "title_html" => updated_html,
"description_html" => "",
- "cached_markdown_version" => cache_version)
+ "cached_markdown_version" => cache_version })
thing.refresh_markdown_cache!
end
diff --git a/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb b/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb
index ff8f5797f9d..c15e717b126 100644
--- a/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb
@@ -12,12 +12,6 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
subject { described_class.new(dashboard_hash, project: project, dashboard_path: dashboard_path) }
- before do
- allow_next_instance_of(::Clusters::Applications::ScheduleUpdateService) do |update_service|
- allow(update_service).to receive(:execute)
- end
- end
-
context 'valid dashboard' do
let(:dashboard_hash) { load_sample_dashboard }
@@ -45,13 +39,6 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
create(:prometheus_metric, existing_metric_attributes)
end
- let!(:existing_alert) do
- alert = create(:prometheus_alert, project: project, prometheus_metric: existing_metric)
- existing_metric.prometheus_alerts << alert
-
- alert
- end
-
it 'updates existing PrometheusMetrics' do
subject.execute
@@ -68,15 +55,6 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
expect { subject.execute }.to change { PrometheusMetric.count }.by(2)
end
- it 'updates affected environments' do
- expect(::Clusters::Applications::ScheduleUpdateService).to receive(:new).with(
- existing_alert.environment.cluster_prometheus_adapter,
- project
- ).and_return(double('ScheduleUpdateService', execute: true))
-
- subject.execute
- end
-
context 'with stale metrics' do
let!(:stale_metric) do
create(:prometheus_metric,
@@ -87,13 +65,6 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
)
end
- let!(:stale_alert) do
- alert = create(:prometheus_alert, project: project, prometheus_metric: stale_metric)
- stale_metric.prometheus_alerts << alert
-
- alert
- end
-
it 'updates existing PrometheusMetrics' do
subject.execute
@@ -111,21 +82,6 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
expect { stale_metric.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
-
- it 'deletes stale alert' do
- subject.execute
-
- expect { stale_alert.reload }.to raise_error(ActiveRecord::RecordNotFound)
- end
-
- it 'updates affected environments' do
- expect(::Clusters::Applications::ScheduleUpdateService).to receive(:new).with(
- existing_alert.environment.cluster_prometheus_adapter,
- project
- ).and_return(double('ScheduleUpdateService', execute: true))
-
- subject.execute
- end
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 c7afc02f0af..66fba7ab683 100644
--- a/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb
+++ b/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb
@@ -152,8 +152,6 @@ RSpec.describe Gitlab::Metrics::Exporter::BaseExporter do
where(:method_class, :path, :http_status) do
Net::HTTP::Get | '/metrics' | 200
- Net::HTTP::Get | '/liveness' | 200
- Net::HTTP::Get | '/readiness' | 200
Net::HTTP::Get | '/' | 404
end
diff --git a/spec/lib/gitlab/metrics/methods_spec.rb b/spec/lib/gitlab/metrics/methods_spec.rb
index 71135a6e9c5..eb7c8891e98 100644
--- a/spec/lib/gitlab/metrics/methods_spec.rb
+++ b/spec/lib/gitlab/metrics/methods_spec.rb
@@ -35,7 +35,7 @@ RSpec.describe Gitlab::Metrics::Methods do
context 'metric is not cached' do
it 'calls fetch_metric' do
- expect(subject).to receive(:init_metric).with(metric_type, metric_name, docstring: docstring)
+ expect(subject).to receive(:init_metric).with(metric_type, metric_name, { docstring: docstring })
subject.public_send(metric_name)
end
diff --git a/spec/lib/gitlab/metrics/rails_slis_spec.rb b/spec/lib/gitlab/metrics/rails_slis_spec.rb
index 2ba06316507..b30eb57101f 100644
--- a/spec/lib/gitlab/metrics/rails_slis_spec.rb
+++ b/spec/lib/gitlab/metrics/rails_slis_spec.rb
@@ -36,18 +36,8 @@ RSpec.describe Gitlab::Metrics::RailsSlis do
}
end
- expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:rails_request_apdex) { false }
- expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:graphql_query_apdex) { false }
- expect(Gitlab::Metrics::Sli).to receive(:initialize_sli).with(:rails_request_apdex, array_including(*possible_labels)).and_call_original
- expect(Gitlab::Metrics::Sli).to receive(:initialize_sli).with(:graphql_query_apdex, array_including(*possible_graphql_labels)).and_call_original
-
- described_class.initialize_request_slis!
- end
-
- it 'does not initialize the SLI if they were initialized already', :aggregate_failures do
- expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:rails_request_apdex) { true }
- expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:graphql_query_apdex) { true }
- expect(Gitlab::Metrics::Sli).not_to receive(:initialize_sli)
+ expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli).with(:rails_request, array_including(*possible_labels)).and_call_original
+ expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli).with(:graphql_query, array_including(*possible_graphql_labels)).and_call_original
described_class.initialize_request_slis!
end
diff --git a/spec/lib/gitlab/metrics/sli_spec.rb b/spec/lib/gitlab/metrics/sli_spec.rb
index 8ba4bf29568..9b776d6738d 100644
--- a/spec/lib/gitlab/metrics/sli_spec.rb
+++ b/spec/lib/gitlab/metrics/sli_spec.rb
@@ -10,72 +10,151 @@ RSpec.describe Gitlab::Metrics::Sli do
end
describe 'Class methods' do
- before do
- described_class.instance_variable_set(:@known_slis, nil)
+ it 'does not allow them to be called on the parent module' do
+ expect(described_class).not_to respond_to(:[])
+ expect(described_class).not_to respond_to(:initialize_sli)
end
- describe '.[]' do
- it 'warns about an uninitialized SLI but returns and stores a new one' do
- sli = described_class[:bar]
+ it 'allows different SLIs to be defined on each subclass' do
+ apdex_counters = [
+ fake_total_counter('foo', 'apdex'),
+ fake_numerator_counter('foo', 'apdex', 'success')
+ ]
- expect(described_class[:bar]).to be(sli)
- end
+ error_rate_counters = [
+ fake_total_counter('foo', 'error_rate'),
+ fake_numerator_counter('foo', 'error_rate', 'error')
+ ]
- it 'returns the same object for multiple accesses' do
- sli = described_class.initialize_sli(:huzzah, [])
+ apdex = described_class::Apdex.initialize_sli(:foo, [{ hello: :world }])
- 2.times do
- expect(described_class[:huzzah]).to be(sli)
- end
- end
- end
+ expect(apdex_counters).to all(have_received(:get).with(hello: :world))
- describe '.initialized?' do
- before do
- fake_total_counter(:boom)
- fake_success_counter(:boom)
- end
+ error_rate = described_class::ErrorRate.initialize_sli(:foo, [{ other: :labels }])
- it 'is true when an SLI was initialized with labels' do
- expect { described_class.initialize_sli(:boom, [{ hello: :world }]) }
- .to change { described_class.initialized?(:boom) }.from(false).to(true)
- end
+ expect(error_rate_counters).to all(have_received(:get).with(other: :labels))
- it 'is false when an SLI was not initialized with labels' do
- expect { described_class.initialize_sli(:boom, []) }
- .not_to change { described_class.initialized?(:boom) }.from(false)
- end
+ expect(described_class::Apdex[:foo]).to be(apdex)
+ expect(described_class::ErrorRate[:foo]).to be(error_rate)
end
end
- describe '#initialize_counters' do
- it 'initializes counters for the passed label combinations' do
- counters = [fake_total_counter(:hey), fake_success_counter(:hey)]
+ subclasses = {
+ Gitlab::Metrics::Sli::Apdex => :success,
+ Gitlab::Metrics::Sli::ErrorRate => :error
+ }
- described_class.new(:hey).initialize_counters([{ foo: 'bar' }, { foo: 'baz' }])
+ subclasses.each do |subclass, numerator_type|
+ subclass_type = subclass.to_s.demodulize.underscore
- expect(counters).to all(have_received(:get).with({ foo: 'bar' }))
- expect(counters).to all(have_received(:get).with({ foo: 'baz' }))
- end
- end
+ describe subclass do
+ describe 'Class methods' do
+ before do
+ described_class.instance_variable_set(:@known_slis, nil)
+ end
- describe "#increment" do
- let!(:sli) { described_class.new(:heyo) }
- let!(:total_counter) { fake_total_counter(:heyo) }
- let!(:success_counter) { fake_success_counter(:heyo) }
+ describe '.[]' do
+ it 'returns and stores a new, uninitialized SLI' do
+ sli = described_class[:bar]
- it 'increments both counters for labels successes' do
- sli.increment(labels: { hello: "world" }, success: true)
+ expect(described_class[:bar]).to be(sli)
+ expect(described_class[:bar]).not_to be_initialized
+ end
- expect(total_counter).to have_received(:increment).with({ hello: 'world' })
- expect(success_counter).to have_received(:increment).with({ hello: 'world' })
- end
+ it 'returns the same object for multiple accesses' do
+ sli = described_class.initialize_sli(:huzzah, [])
+
+ 2.times do
+ expect(described_class[:huzzah]).to be(sli)
+ end
+ end
+ end
+
+ describe '.initialize_sli' do
+ it 'returns and stores a new initialized SLI' do
+ counters = [
+ fake_total_counter(:bar, subclass_type),
+ fake_numerator_counter(:bar, subclass_type, numerator_type)
+ ]
+
+ sli = described_class.initialize_sli(:bar, [{ hello: :world }])
+
+ expect(sli).to be_initialized
+ expect(counters).to all(have_received(:get).with(hello: :world))
+ expect(counters).to all(have_received(:get).with(hello: :world))
+ end
+
+ it 'does not change labels for an already-initialized SLI' do
+ counters = [
+ fake_total_counter(:bar, subclass_type),
+ fake_numerator_counter(:bar, subclass_type, numerator_type)
+ ]
+
+ sli = described_class.initialize_sli(:bar, [{ hello: :world }])
- it 'only increments the total counters for labels when not successful' do
- sli.increment(labels: { hello: "world" }, success: false)
+ expect(sli).to be_initialized
+ expect(counters).to all(have_received(:get).with(hello: :world))
+ expect(counters).to all(have_received(:get).with(hello: :world))
- expect(total_counter).to have_received(:increment).with({ hello: 'world' })
- expect(success_counter).not_to have_received(:increment).with({ hello: 'world' })
+ counters.each do |counter|
+ expect(counter).not_to receive(:get)
+ end
+
+ expect(described_class.initialize_sli(:bar, [{ other: :labels }])).to eq(sli)
+ end
+ end
+
+ describe '.initialized?' do
+ before do
+ fake_total_counter(:boom, subclass_type)
+ fake_numerator_counter(:boom, subclass_type, numerator_type)
+ end
+
+ it 'is true when an SLI was initialized with labels' do
+ expect { described_class.initialize_sli(:boom, [{ hello: :world }]) }
+ .to change { described_class.initialized?(:boom) }.from(false).to(true)
+ end
+
+ it 'is false when an SLI was not initialized with labels' do
+ expect { described_class.initialize_sli(:boom, []) }
+ .not_to change { described_class.initialized?(:boom) }.from(false)
+ end
+ end
+ end
+
+ describe '#initialize_counters' do
+ it 'initializes counters for the passed label combinations' do
+ counters = [
+ fake_total_counter(:hey, subclass_type),
+ fake_numerator_counter(:hey, subclass_type, numerator_type)
+ ]
+
+ described_class.new(:hey).initialize_counters([{ foo: 'bar' }, { foo: 'baz' }])
+
+ expect(counters).to all(have_received(:get).with({ foo: 'bar' }))
+ expect(counters).to all(have_received(:get).with({ foo: 'baz' }))
+ end
+ end
+
+ describe "#increment" do
+ let!(:sli) { described_class.new(:heyo) }
+ let!(:total_counter) { fake_total_counter(:heyo, subclass_type) }
+ let!(:numerator_counter) { fake_numerator_counter(:heyo, subclass_type, numerator_type) }
+
+ it "increments both counters for labels when #{numerator_type} is true" do
+ sli.increment(labels: { hello: "world" }, numerator_type => true)
+
+ expect(total_counter).to have_received(:increment).with({ hello: 'world' })
+ expect(numerator_counter).to have_received(:increment).with({ hello: 'world' })
+ end
+
+ it "only increments the total counters for labels when #{numerator_type} is false" do
+ sli.increment(labels: { hello: "world" }, numerator_type => false)
+
+ expect(total_counter).to have_received(:increment).with({ hello: 'world' })
+ expect(numerator_counter).not_to have_received(:increment).with({ hello: 'world' })
+ end
+ end
end
end
@@ -89,11 +168,11 @@ RSpec.describe Gitlab::Metrics::Sli do
fake_counter
end
- def fake_total_counter(name)
- fake_prometheus_counter("gitlab_sli:#{name}:total")
+ def fake_total_counter(name, type)
+ fake_prometheus_counter("gitlab_sli:#{name}_#{type}:total")
end
- def fake_success_counter(name)
- fake_prometheus_counter("gitlab_sli:#{name}:success_total")
+ def fake_numerator_counter(name, type, numerator_name)
+ fake_prometheus_counter("gitlab_sli:#{name}_#{type}:#{numerator_name}_total")
end
end
diff --git a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
index 389b0ef1044..28c3ef229ab 100644
--- a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
@@ -10,6 +10,124 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
let(:connection) { ActiveRecord::Base.retrieve_connection }
let(:db_config_name) { ::Gitlab::Database.db_config_name(connection) }
+ describe '.load_balancing_metric_counter_keys' do
+ context 'multiple databases' do
+ before do
+ skip_if_multiple_databases_not_setup
+ end
+
+ it 'has expected keys' do
+ expect(described_class.load_balancing_metric_counter_keys).to include(
+ :db_replica_count,
+ :db_primary_count,
+ :db_main_count,
+ :db_main_replica_count,
+ :db_ci_count,
+ :db_ci_replica_count,
+ :db_replica_cached_count,
+ :db_primary_cached_count,
+ :db_main_cached_count,
+ :db_main_replica_cached_count,
+ :db_ci_cached_count,
+ :db_ci_replica_cached_count,
+ :db_replica_wal_count,
+ :db_primary_wal_count,
+ :db_main_wal_count,
+ :db_main_replica_wal_count,
+ :db_ci_wal_count,
+ :db_ci_replica_wal_count,
+ :db_replica_wal_cached_count,
+ :db_primary_wal_cached_count,
+ :db_main_wal_cached_count,
+ :db_main_replica_wal_cached_count,
+ :db_ci_wal_cached_count,
+ :db_ci_replica_wal_cached_count
+ )
+ end
+ end
+
+ context 'single database' do
+ before do
+ skip_if_multiple_databases_are_setup
+ end
+
+ it 'has expected keys' do
+ expect(described_class.load_balancing_metric_counter_keys).to include(
+ :db_replica_count,
+ :db_primary_count,
+ :db_main_count,
+ :db_main_replica_count,
+ :db_replica_cached_count,
+ :db_primary_cached_count,
+ :db_main_cached_count,
+ :db_main_replica_cached_count,
+ :db_replica_wal_count,
+ :db_primary_wal_count,
+ :db_main_wal_count,
+ :db_main_replica_wal_count,
+ :db_replica_wal_cached_count,
+ :db_primary_wal_cached_count,
+ :db_main_wal_cached_count,
+ :db_main_replica_wal_cached_count
+ )
+ end
+
+ it 'does not have ci keys' do
+ expect(described_class.load_balancing_metric_counter_keys).not_to include(
+ :db_ci_count,
+ :db_ci_replica_count,
+ :db_ci_cached_count,
+ :db_ci_replica_cached_count,
+ :db_ci_wal_count,
+ :db_ci_replica_wal_count,
+ :db_ci_wal_cached_count,
+ :db_ci_replica_wal_cached_count
+ )
+ end
+ end
+ end
+
+ describe '.load_balancing_metric_duration_keys' do
+ context 'multiple databases' do
+ before do
+ skip_if_multiple_databases_not_setup
+ end
+
+ it 'has expected keys' do
+ expect(described_class.load_balancing_metric_duration_keys).to include(
+ :db_replica_duration_s,
+ :db_primary_duration_s,
+ :db_main_duration_s,
+ :db_main_replica_duration_s,
+ :db_ci_duration_s,
+ :db_ci_replica_duration_s
+ )
+ end
+ end
+
+ context 'single database' do
+ before do
+ skip_if_multiple_databases_are_setup
+ end
+
+ it 'has expected keys' do
+ expect(described_class.load_balancing_metric_duration_keys).to include(
+ :db_replica_duration_s,
+ :db_primary_duration_s,
+ :db_main_duration_s,
+ :db_main_replica_duration_s
+ )
+ end
+
+ it 'does not have ci keys' do
+ expect(described_class.load_balancing_metric_duration_keys).not_to include(
+ :db_ci_duration_s,
+ :db_ci_replica_duration_s
+ )
+ end
+ end
+ end
+
describe '#transaction' do
let(:web_transaction) { double('Gitlab::Metrics::WebTransaction') }
let(:background_transaction) { double('Gitlab::Metrics::WebTransaction') }
@@ -37,7 +155,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
end
it 'captures the metrics for web only' do
- expect(web_transaction).to receive(:observe).with(:gitlab_database_transaction_seconds, 0.23, db_config_name: db_config_name)
+ expect(web_transaction).to receive(:observe).with(:gitlab_database_transaction_seconds, 0.23, { db_config_name: db_config_name })
expect(background_transaction).not_to receive(:observe)
expect(background_transaction).not_to receive(:increment)
@@ -77,7 +195,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
end
it 'captures the metrics for web only' do
- expect(background_transaction).to receive(:observe).with(:gitlab_database_transaction_seconds, 0.23, db_config_name: db_config_name)
+ expect(background_transaction).to receive(:observe).with(:gitlab_database_transaction_seconds, 0.23, { db_config_name: db_config_name })
expect(web_transaction).not_to receive(:observe)
expect(web_transaction).not_to receive(:increment)
diff --git a/spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb b/spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb
index fda4b94bd78..9f939d0d7d6 100644
--- a/spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/rack_attack_spec.rb
@@ -77,8 +77,8 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
end
it 'logs request information' do
- expect(Gitlab::AuthLogger).to receive(:error).with(
- include(
+ expect(Gitlab::AuthLogger).to receive(:error) do |arguments|
+ expect(arguments).to include(
message: 'Rack_Attack',
env: match_type,
remote_ip: '1.2.3.4',
@@ -86,7 +86,14 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
path: '/api/v4/internal/authorized_keys',
matched: 'throttle_unauthenticated'
)
- )
+
+ if expected_status
+ expect(arguments).to include(status: expected_status)
+ else
+ expect(arguments).not_to have_key(:status)
+ end
+ end
+
subscriber.send(match_type, event)
end
end
@@ -111,8 +118,8 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
end
it 'logs request information and user id' do
- expect(Gitlab::AuthLogger).to receive(:error).with(
- include(
+ expect(Gitlab::AuthLogger).to receive(:error) do |arguments|
+ expect(arguments).to include(
message: 'Rack_Attack',
env: match_type,
remote_ip: '1.2.3.4',
@@ -121,7 +128,14 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
matched: 'throttle_authenticated_api',
user_id: non_existing_record_id
)
- )
+
+ if expected_status
+ expect(arguments).to include(status: expected_status)
+ else
+ expect(arguments).not_to have_key(:status)
+ end
+ end
+
subscriber.send(match_type, event)
end
end
@@ -145,8 +159,8 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
end
it 'logs request information and user meta' do
- expect(Gitlab::AuthLogger).to receive(:error).with(
- include(
+ expect(Gitlab::AuthLogger).to receive(:error) do |arguments|
+ expect(arguments).to include(
message: 'Rack_Attack',
env: match_type,
remote_ip: '1.2.3.4',
@@ -156,7 +170,14 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
user_id: user.id,
'meta.user' => user.username
)
- )
+
+ if expected_status
+ expect(arguments).to include(status: expected_status)
+ else
+ expect(arguments).not_to have_key(:status)
+ end
+ end
+
subscriber.send(match_type, event)
end
end
@@ -182,8 +203,8 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
end
it 'logs request information and user meta' do
- expect(Gitlab::AuthLogger).to receive(:error).with(
- include(
+ expect(Gitlab::AuthLogger).to receive(:error) do |arguments|
+ expect(arguments).to include(
message: 'Rack_Attack',
env: match_type,
remote_ip: '1.2.3.4',
@@ -192,7 +213,14 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
matched: 'throttle_authenticated_api',
deploy_token_id: deploy_token.id
)
- )
+
+ if expected_status
+ expect(arguments).to include(status: expected_status)
+ else
+ expect(arguments).not_to have_key(:status)
+ end
+ end
+
subscriber.send(match_type, event)
end
end
@@ -202,6 +230,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
describe '#throttle' do
let(:match_type) { :throttle }
+ let(:expected_status) { 429 }
let(:event_name) { 'throttle.rack_attack' }
it_behaves_like 'log into auth logger'
@@ -209,6 +238,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
describe '#blocklist' do
let(:match_type) { :blocklist }
+ let(:expected_status) { 403 }
let(:event_name) { 'blocklist.rack_attack' }
it_behaves_like 'log into auth logger'
@@ -216,6 +246,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::RackAttack, :request_store do
describe '#track' do
let(:match_type) { :track }
+ let(:expected_status) { nil }
let(:event_name) { 'track.rack_attack' }
it_behaves_like 'log into auth logger'
diff --git a/spec/lib/gitlab/patch/database_config_spec.rb b/spec/lib/gitlab/patch/database_config_spec.rb
index d6f36ab86d5..73dc84bb2ef 100644
--- a/spec/lib/gitlab/patch/database_config_spec.rb
+++ b/spec/lib/gitlab/patch/database_config_spec.rb
@@ -34,9 +34,8 @@ RSpec.describe Gitlab::Patch::DatabaseConfig do
end
end
- context 'when a new syntax is used' do
- let(:database_yml) do
- <<-EOS
+ let(:database_yml) do
+ <<-EOS
production:
main:
adapter: postgresql
@@ -68,59 +67,9 @@ RSpec.describe Gitlab::Patch::DatabaseConfig do
prepared_statements: false
variables:
statement_timeout: 15s
- EOS
- end
-
- include_examples 'hash containing main: connection name'
-
- it 'configuration is not legacy one' do
- configuration.database_configuration
-
- expect(configuration.uses_legacy_database_config).to eq(false)
- end
+ EOS
end
- context 'when a legacy syntax is used' do
- let(:database_yml) do
- <<-EOS
- production:
- adapter: postgresql
- encoding: unicode
- database: gitlabhq_production
- username: git
- password: "secure password"
- host: localhost
-
- development:
- adapter: postgresql
- encoding: unicode
- database: gitlabhq_development
- username: postgres
- password: "secure password"
- host: localhost
- variables:
- statement_timeout: 15s
-
- test: &test
- adapter: postgresql
- encoding: unicode
- database: gitlabhq_test
- username: postgres
- password:
- host: localhost
- prepared_statements: false
- variables:
- statement_timeout: 15s
- EOS
- end
-
- include_examples 'hash containing main: connection name'
-
- it 'configuration is legacy' do
- configuration.database_configuration
-
- expect(configuration.uses_legacy_database_config).to eq(true)
- end
- end
+ include_examples 'hash containing main: connection name'
end
end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index e5fa7538515..0a647befb50 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -183,7 +183,7 @@ RSpec.describe Gitlab::PathRegex do
# We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362
it 'does not allow expansion' do
- expect(described_class::TOP_LEVEL_ROUTES.size).to eq(40)
+ expect(described_class::TOP_LEVEL_ROUTES.size).to eq(39)
end
end
diff --git a/spec/lib/gitlab/popen_spec.rb b/spec/lib/gitlab/popen_spec.rb
index 8211806a809..0a186b07d19 100644
--- a/spec/lib/gitlab/popen_spec.rb
+++ b/spec/lib/gitlab/popen_spec.rb
@@ -103,7 +103,7 @@ RSpec.describe Gitlab::Popen do
it 'raises error' do
expect do
@klass.new.popen(%w[foobar])
- end.to raise_error
+ end.to raise_error(Errno::ENOENT)
end
end
end
diff --git a/spec/lib/gitlab/process_supervisor_spec.rb b/spec/lib/gitlab/process_supervisor_spec.rb
index 60b127dadda..8356197805c 100644
--- a/spec/lib/gitlab/process_supervisor_spec.rb
+++ b/spec/lib/gitlab/process_supervisor_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Gitlab::ProcessSupervisor do
let(:health_check_interval_seconds) { 0.1 }
let(:check_terminate_interval_seconds) { 1 }
let(:forwarded_signals) { [] }
+ let(:term_signals) { [] }
let(:process_ids) { [spawn_process, spawn_process] }
def spawn_process
@@ -19,7 +20,8 @@ RSpec.describe Gitlab::ProcessSupervisor do
health_check_interval_seconds: health_check_interval_seconds,
check_terminate_interval_seconds: check_terminate_interval_seconds,
terminate_timeout_seconds: 1 + check_terminate_interval_seconds,
- forwarded_signals: forwarded_signals
+ forwarded_signals: forwarded_signals,
+ term_signals: term_signals
)
end
@@ -29,6 +31,8 @@ RSpec.describe Gitlab::ProcessSupervisor do
rescue Errno::ESRCH
# Ignore if a process wasn't actually alive.
end
+
+ supervisor.stop
end
describe '#supervise' do
@@ -60,7 +64,7 @@ RSpec.describe Gitlab::ProcessSupervisor do
[42] # Fake starting a new process in place of the terminated one.
end
- # Terminate the supervised process.
+ # Terminate a supervised process.
Process.kill('TERM', process_ids.first)
await_condition(sleep_sec: health_check_interval_seconds) do
@@ -71,6 +75,72 @@ RSpec.describe Gitlab::ProcessSupervisor do
expect(Gitlab::ProcessManagement.process_alive?(process_ids.last)).to be(true)
expect(supervisor.supervised_pids).to match_array([process_ids.last, 42])
end
+
+ it 'deduplicates PIDs returned from callback' do
+ expect(Gitlab::ProcessManagement.all_alive?(process_ids)).to be(true)
+ pids_killed = []
+
+ supervisor.supervise(process_ids) do |dead_pids|
+ pids_killed = dead_pids
+ # Fake a new process having the same pid as one that was just terminated.
+ [process_ids.last]
+ end
+
+ # Terminate a supervised process.
+ Process.kill('TERM', process_ids.first)
+
+ await_condition(sleep_sec: health_check_interval_seconds) do
+ pids_killed == [process_ids.first]
+ end
+
+ expect(supervisor.supervised_pids).to contain_exactly(process_ids.last)
+ end
+
+ it 'accepts single PID returned from callback' do
+ expect(Gitlab::ProcessManagement.all_alive?(process_ids)).to be(true)
+ pids_killed = []
+
+ supervisor.supervise(process_ids) do |dead_pids|
+ pids_killed = dead_pids
+ 42
+ end
+
+ # Terminate a supervised process.
+ Process.kill('TERM', process_ids.first)
+
+ await_condition(sleep_sec: health_check_interval_seconds) do
+ pids_killed == [process_ids.first]
+ end
+
+ expect(supervisor.supervised_pids).to contain_exactly(42, process_ids.last)
+ end
+
+ context 'but supervisor has entered shutdown' do
+ it 'does not trigger callback again' do
+ expect(Gitlab::ProcessManagement.all_alive?(process_ids)).to be(true)
+ callback_count = 0
+
+ supervisor.supervise(process_ids) do |dead_pids|
+ callback_count += 1
+
+ Thread.new { supervisor.shutdown }
+
+ [42]
+ end
+
+ # Terminate the supervised processes to trigger more than 1 callback.
+ Process.kill('TERM', process_ids.first)
+ Process.kill('TERM', process_ids.last)
+
+ await_condition(sleep_sec: health_check_interval_seconds * 3) do
+ supervisor.alive == false
+ end
+
+ # Since we shut down the supervisor during the first callback, it should not
+ # be called anymore.
+ expect(callback_count).to eq(1)
+ end
+ end
end
context 'signal handling' do
@@ -82,6 +152,8 @@ RSpec.describe Gitlab::ProcessSupervisor do
end
context 'termination signals' do
+ let(:term_signals) { %i(INT TERM) }
+
context 'when TERM results in timely shutdown of processes' do
it 'forwards them to observed processes without waiting for grace period to expire' do
allow(Gitlab::ProcessManagement).to receive(:any_alive?).and_return(false)
diff --git a/spec/lib/gitlab/query_limiting/transaction_spec.rb b/spec/lib/gitlab/query_limiting/transaction_spec.rb
index 76bb2b4c4cc..27da1f23556 100644
--- a/spec/lib/gitlab/query_limiting/transaction_spec.rb
+++ b/spec/lib/gitlab/query_limiting/transaction_spec.rb
@@ -78,6 +78,21 @@ RSpec.describe Gitlab::QueryLimiting::Transaction do
expect { transaction.increment }.not_to change { transaction.count }
end
+
+ it 'does not increment the number of executed queries when the query is known to be ignorable' do
+ transaction = described_class.new
+
+ expect do
+ transaction.increment(described_class::GEO_NODES_LOAD)
+ transaction.increment(described_class::LICENSES_LOAD)
+ transaction.increment('SELECT a.attname, a.other_column FROM pg_attribute a')
+ transaction.increment('SELECT x.foo, a.attname FROM some_table x JOIN pg_attribute a')
+ transaction.increment(<<-SQL)
+ SELECT a.attname, a.other_column
+ FROM pg_attribute a
+ SQL
+ end.not_to change(transaction, :count)
+ end
end
describe '#raise_error?' do
diff --git a/spec/lib/gitlab/request_profiler/profile_spec.rb b/spec/lib/gitlab/request_profiler/profile_spec.rb
deleted file mode 100644
index 30e23a99b22..00000000000
--- a/spec/lib/gitlab/request_profiler/profile_spec.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::RequestProfiler::Profile do
- let(:profile) { described_class.new(filename) }
-
- describe '.new' do
- context 'using old filename' do
- let(:filename) { '|api|v4|version.txt_1562854738.html' }
-
- it 'returns valid data' do
- expect(profile).to be_valid
- expect(profile.request_path).to eq('/api/v4/version.txt')
- expect(profile.time).to eq(Time.at(1562854738).utc)
- expect(profile.type).to eq('html')
- end
- end
-
- context 'using new filename' do
- let(:filename) { '|api|v4|version.txt_1563547949_execution.html' }
-
- it 'returns valid data' do
- expect(profile).to be_valid
- expect(profile.request_path).to eq('/api/v4/version.txt')
- expect(profile.profile_mode).to eq('execution')
- expect(profile.time).to eq(Time.at(1563547949).utc)
- expect(profile.type).to eq('html')
- end
- end
- end
-
- describe '#content_type' do
- context 'when using html file' do
- let(:filename) { '|api|v4|version.txt_1562854738_memory.html' }
-
- it 'returns valid data' do
- expect(profile).to be_valid
- expect(profile.content_type).to eq('text/html')
- end
- end
-
- context 'when using text file' do
- let(:filename) { '|api|v4|version.txt_1562854738_memory.txt' }
-
- it 'returns valid data' do
- expect(profile).to be_valid
- expect(profile.content_type).to eq('text/plain')
- end
- end
-
- context 'when file is unknown' do
- let(:filename) { '|api|v4|version.txt_1562854738_memory.xxx' }
-
- it 'returns valid data' do
- expect(profile).not_to be_valid
- expect(profile.content_type).to be_nil
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/request_profiler_spec.rb b/spec/lib/gitlab/request_profiler_spec.rb
deleted file mode 100644
index 4d3b361efcb..00000000000
--- a/spec/lib/gitlab/request_profiler_spec.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::RequestProfiler do
- describe '.profile_token' do
- it 'returns a token' do
- expect(described_class.profile_token).to be_present
- end
-
- it 'caches the token' do
- expect(Rails.cache).to receive(:fetch).with('profile-token')
-
- described_class.profile_token
- end
- end
-
- context 'with temporary PROFILES_DIR' do
- let(:tmpdir) { Dir.mktmpdir('profiler-test') }
- let(:profile_name) { '|api|v4|version.txt_1562854738_memory.html' }
- let(:profile_path) { File.join(tmpdir, profile_name) }
-
- before do
- stub_const('Gitlab::RequestProfiler::PROFILES_DIR', tmpdir)
- FileUtils.touch(profile_path)
- end
-
- after do
- FileUtils.rm_rf(tmpdir)
- end
-
- describe '.remove_all_profiles' do
- it 'removes Gitlab::RequestProfiler::PROFILES_DIR directory' do
- described_class.remove_all_profiles
-
- expect(Dir.exist?(tmpdir)).to be false
- end
- end
-
- describe '.all' do
- subject { described_class.all }
-
- it 'returns all profiles' do
- expect(subject.map(&:name)).to contain_exactly(profile_name)
- end
- end
-
- describe '.find' do
- subject { described_class.find(profile_name) }
-
- it 'returns all profiles' do
- expect(subject.name).to eq(profile_name)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/saas_spec.rb b/spec/lib/gitlab/saas_spec.rb
index 1be36a60a97..a8656c44831 100644
--- a/spec/lib/gitlab/saas_spec.rb
+++ b/spec/lib/gitlab/saas_spec.rb
@@ -3,11 +3,11 @@
require 'spec_helper'
RSpec.describe Gitlab::Saas do
+ include SaasTestHelper
+
describe '.canary_toggle_com_url' do
subject { described_class.canary_toggle_com_url }
- let(:next_url) { 'https://next.gitlab.com' }
-
- it { is_expected.to eq(next_url) }
+ it { is_expected.to eq(get_next_url) }
end
end
diff --git a/spec/lib/gitlab/safe_request_purger_spec.rb b/spec/lib/gitlab/safe_request_purger_spec.rb
new file mode 100644
index 00000000000..02f3f11d469
--- /dev/null
+++ b/spec/lib/gitlab/safe_request_purger_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SafeRequestPurger do
+ let(:resource_key) { '_key_' }
+ let(:resource_ids) { ['foo'] }
+ let(:args) { { resource_key: resource_key, resource_ids: resource_ids } }
+ let(:resource_data) { { 'foo' => 'bar' } }
+
+ before do
+ Gitlab::SafeRequestStore[resource_key] = resource_data
+ end
+
+ describe '.execute', :request_store do
+ subject(:execute_instance) { described_class.execute(**args) }
+
+ it 'purges an entry from the store' do
+ execute_instance
+
+ expect(Gitlab::SafeRequestStore.fetch(resource_key)).to be_empty
+ end
+ end
+
+ describe '#execute' do
+ subject(:execute_instance) { described_class.new(**args).execute }
+
+ context 'when request store is active', :request_store do
+ it 'purges an entry from the store' do
+ execute_instance
+
+ expect(Gitlab::SafeRequestStore.fetch(resource_key)).to be_empty
+ end
+
+ context 'when there are multiple resource_ids to purge' do
+ let(:resource_data) do
+ {
+ 'foo' => 'bar',
+ 'two' => '_two_',
+ 'three' => '_three_',
+ 'four' => '_four_'
+ }
+ end
+
+ let(:resource_ids) { %w[two three] }
+
+ it 'purges an entry from the store' do
+ execute_instance
+
+ expect(Gitlab::SafeRequestStore.fetch(resource_key)).to eq resource_data.slice('foo', 'four')
+ end
+ end
+
+ context 'when there is no matching resource_ids' do
+ let(:resource_ids) { ['_bogus_resource_id_'] }
+
+ it 'purges an entry from the store' do
+ execute_instance
+
+ expect(Gitlab::SafeRequestStore.fetch(resource_key)).to eq resource_data
+ end
+ end
+ end
+
+ context 'when request store is not active' do
+ let(:resource_ids) { ['_bogus_resource_id_'] }
+
+ it 'does offer the ability to interact with data store' do
+ expect(execute_instance).to eq({})
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/setup_helper/praefect_spec.rb b/spec/lib/gitlab/setup_helper/praefect_spec.rb
new file mode 100644
index 00000000000..f7da6c19d68
--- /dev/null
+++ b/spec/lib/gitlab/setup_helper/praefect_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SetupHelper::Praefect do
+ describe '.configuration_toml' do
+ let(:opt_per_repo) do
+ { per_repository: true,
+ pghost: 'my-host',
+ pgport: 555432,
+ pguser: 'me' }
+ end
+
+ it 'defaults to in memory queue' do
+ toml = described_class.configuration_toml('/here', nil, {})
+
+ expect(toml).to match(/i_understand_my_election_strategy_is_unsupported_and_will_be_removed_without_warning/)
+ expect(toml).to match(/memory_queue_enabled = true/)
+ expect(toml).to match(/election_strategy = "local"/)
+ expect(toml).not_to match(/\[database\]/)
+ end
+
+ it 'provides database details if wanted' do
+ toml = described_class.configuration_toml('/here', nil, opt_per_repo)
+
+ expect(toml).not_to match(/i_understand_my_election_strategy_is_unsupported_and_will_be_removed_without_warning/)
+ expect(toml).not_to match(/memory_queue_enabled = true/)
+ expect(toml).to match(/\[database\]/)
+ expect(toml).to match(/election_strategy = "per_repository"/)
+ end
+
+ %i[pghost pgport pguser].each do |pg_key|
+ it "fails when #{pg_key} is missing" do
+ opt = opt_per_repo.dup
+ opt.delete(pg_key)
+
+ expect do
+ described_class.configuration_toml('/here', nil, opt)
+ end.to raise_error(KeyError)
+ end
+
+ it "uses the provided #{pg_key}" do
+ toml = described_class.configuration_toml('/here', nil, opt_per_repo)
+
+ expect(toml).to match(/#{pg_key.to_s.delete_prefix('pg')} = "?#{opt_per_repo[pg_key]}"?/)
+ end
+ end
+
+ it 'defaults to praefect_test if dbname is missing' do
+ toml = described_class.configuration_toml('/here', nil, opt_per_repo)
+
+ expect(toml).to match(/dbname = "praefect_test"/)
+ end
+
+ it 'uses the provided dbname' do
+ opt = opt_per_repo.merge(dbname: 'my_db')
+
+ toml = described_class.configuration_toml('/here', nil, opt)
+
+ expect(toml).to match(/dbname = "my_db"/)
+ end
+ end
+
+ describe '.get_config_path' do
+ it 'defaults to praefect.config.toml' do
+ expect(described_class).to receive(:generate_configuration).with(anything, '/tmp/praefect.config.toml', anything)
+
+ described_class.create_configuration('/tmp', {})
+ end
+
+ it 'takes the provided config_filename' do
+ opt = { config_filename: 'yo.toml' }
+
+ expect(described_class).to receive(:generate_configuration).with(anything, '/tmp/yo.toml', anything)
+
+ described_class.create_configuration('/tmp', {}, options: opt)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_config_spec.rb b/spec/lib/gitlab/sidekiq_config_spec.rb
index da135f202f6..4a1a9beb21a 100644
--- a/spec/lib/gitlab/sidekiq_config_spec.rb
+++ b/spec/lib/gitlab/sidekiq_config_spec.rb
@@ -3,6 +3,11 @@
require 'spec_helper'
RSpec.describe Gitlab::SidekiqConfig do
+ before do
+ # Remove cache
+ described_class.instance_variable_set(:@workers, nil)
+ end
+
describe '.workers' do
it 'includes all workers' do
worker_classes = described_class.workers.map(&:klass)
@@ -44,9 +49,10 @@ RSpec.describe Gitlab::SidekiqConfig do
before do
allow(described_class).to receive(:workers).and_return(workers)
allow(Gitlab).to receive(:ee?).and_return(false)
+ allow(Gitlab).to receive(:jh?).and_return(false)
end
- it 'returns true if the YAML file does not matcph the application code' do
+ it 'returns true if the YAML file does not match the application code' do
allow(YAML).to receive(:load_file)
.with(described_class::FOSS_QUEUE_CONFIG_PATH)
.and_return(workers.first(2).map(&:to_yaml))
@@ -96,6 +102,7 @@ RSpec.describe Gitlab::SidekiqConfig do
].map { |worker| described_class::Worker.new(worker, ee: false) }
allow(described_class).to receive(:workers).and_return(workers)
+ allow(Gitlab).to receive(:jh?).and_return(false)
end
let(:expected_queues) do
@@ -161,4 +168,35 @@ RSpec.describe Gitlab::SidekiqConfig do
expect(mappings).not_to include('AdminEmailWorker' => 'cronjob:admin_email')
end
end
+
+ describe '.routing_queues' do
+ let(:test_routes) do
+ [
+ ['tags=needs_own_queue', nil],
+ ['urgency=high', 'high_urgency'],
+ ['feature_category=gitaly', 'gitaly'],
+ ['feature_category=not_exist', 'not_exist'],
+ ['*', 'default']
+ ]
+ end
+
+ before do
+ described_class.instance_variable_set(:@routing_queues, nil)
+ allow(::Gitlab::SidekiqConfig::WorkerRouter)
+ .to receive(:global).and_return(::Gitlab::SidekiqConfig::WorkerRouter.new(test_routes))
+ end
+
+ after do
+ described_class.instance_variable_set(:@routing_queues, nil)
+ end
+
+ it 'returns worker queue mappings that have queues in the current Sidekiq options' do
+ queues = described_class.routing_queues
+
+ expect(queues).to match_array(%w[
+ default mailers high_urgency gitaly email_receiver service_desk_email_receiver
+ ])
+ expect(queues).not_to include('not_exist')
+ end
+ end
end
diff --git a/spec/lib/gitlab/sidekiq_death_handler_spec.rb b/spec/lib/gitlab/sidekiq_death_handler_spec.rb
index 96fef88de4e..e3f9f8277a0 100644
--- a/spec/lib/gitlab/sidekiq_death_handler_spec.rb
+++ b/spec/lib/gitlab/sidekiq_death_handler_spec.rb
@@ -23,9 +23,9 @@ RSpec.describe Gitlab::SidekiqDeathHandler, :clean_gitlab_redis_queues do
it 'uses the attributes from the worker' do
expect(described_class.counter)
.to receive(:increment)
- .with(queue: 'test_queue', worker: 'TestWorker',
+ .with({ queue: 'test_queue', worker: 'TestWorker',
urgency: 'low', external_dependencies: 'yes',
- feature_category: 'users', boundary: 'cpu')
+ feature_category: 'users', boundary: 'cpu' })
described_class.handler({ 'class' => 'TestWorker', 'queue' => 'test_queue' }, nil)
end
@@ -39,9 +39,9 @@ RSpec.describe Gitlab::SidekiqDeathHandler, :clean_gitlab_redis_queues do
it 'uses blank attributes' do
expect(described_class.counter)
.to receive(:increment)
- .with(queue: 'test_queue', worker: 'TestWorker',
+ .with({ queue: 'test_queue', worker: 'TestWorker',
urgency: '', external_dependencies: 'no',
- feature_category: '', boundary: '')
+ feature_category: '', boundary: '' })
described_class.handler({ 'class' => 'TestWorker', 'queue' => 'test_queue' }, nil)
end
diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
index 210b9162be0..00ae55237e9 100644
--- a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
+++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
@@ -287,7 +287,8 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
'job_status' => 'done',
'duration_s' => 0.0,
'completed_at' => timestamp.to_f,
- 'cpu_s' => 1.111112
+ 'cpu_s' => 1.111112,
+ 'rate_limiting_gates' => []
)
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
index ffa92126cc9..7d31979a393 100644
--- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
@@ -21,40 +21,40 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
.and_return('MergeWorker' => 'merge', 'Ci::BuildFinishedWorker' => 'default')
expect(completion_seconds_metric)
- .to receive(:get).with(queue: 'merge',
+ .to receive(:get).with({ queue: 'merge',
worker: 'MergeWorker',
urgency: 'high',
external_dependencies: 'no',
feature_category: 'source_code_management',
boundary: '',
- job_status: 'done')
+ job_status: 'done' })
expect(completion_seconds_metric)
- .to receive(:get).with(queue: 'merge',
+ .to receive(:get).with({ queue: 'merge',
worker: 'MergeWorker',
urgency: 'high',
external_dependencies: 'no',
feature_category: 'source_code_management',
boundary: '',
- job_status: 'fail')
+ job_status: 'fail' })
expect(completion_seconds_metric)
- .to receive(:get).with(queue: 'default',
+ .to receive(:get).with({ queue: 'default',
worker: 'Ci::BuildFinishedWorker',
urgency: 'high',
external_dependencies: 'no',
feature_category: 'continuous_integration',
boundary: 'cpu',
- job_status: 'done')
+ job_status: 'done' })
expect(completion_seconds_metric)
- .to receive(:get).with(queue: 'default',
+ .to receive(:get).with({ queue: 'default',
worker: 'Ci::BuildFinishedWorker',
urgency: 'high',
external_dependencies: 'no',
feature_category: 'continuous_integration',
boundary: 'cpu',
- job_status: 'fail')
+ job_status: 'fail' })
described_class.initialize_process_metrics
end
diff --git a/spec/lib/gitlab/subscription_portal_spec.rb b/spec/lib/gitlab/subscription_portal_spec.rb
index fd3654afee0..8d5a39baf77 100644
--- a/spec/lib/gitlab/subscription_portal_spec.rb
+++ b/spec/lib/gitlab/subscription_portal_spec.rb
@@ -56,6 +56,7 @@ RSpec.describe ::Gitlab::SubscriptionPortal do
where(:method_name, :result) do
:default_subscriptions_url | 'https://customers.staging.gitlab.com'
:payment_form_url | 'https://customers.staging.gitlab.com/payment_forms/cc_validation'
+ :payment_validation_form_id | 'payment_method_validation'
:registration_validation_form_url | 'https://customers.staging.gitlab.com/payment_forms/cc_registration_validation'
:subscriptions_graphql_url | 'https://customers.staging.gitlab.com/graphql'
:subscriptions_more_minutes_url | 'https://customers.staging.gitlab.com/buy_pipeline_minutes'
diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
index 226fdb9c948..26c83ed6793 100644
--- a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
+++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb
@@ -21,55 +21,6 @@ RSpec.describe Gitlab::Template::GitlabCiYmlTemplate do
end
end
- describe '.find' do
- let_it_be(:project) { create(:project) }
- let_it_be(:other_project) { create(:project) }
-
- described_class::TEMPLATES_WITH_LATEST_VERSION.keys.each do |key|
- it "finds the latest template for #{key}" do
- result = described_class.find(key, project)
- expect(result.full_name).to eq("#{key}.latest.gitlab-ci.yml")
- expect(result.content).to be_present
- end
-
- context 'when `redirect_to_latest_template` feature flag is disabled' do
- before do
- stub_feature_flags("redirect_to_latest_template_#{key.underscore.tr('/', '_')}".to_sym => false)
- end
-
- it "finds the stable template for #{key}" do
- result = described_class.find(key, project)
- expect(result.full_name).to eq("#{key}.gitlab-ci.yml")
- expect(result.content).to be_present
- end
- end
-
- context 'when `redirect_to_latest_template` feature flag is enabled on the project' do
- before do
- stub_feature_flags("redirect_to_latest_template_#{key.underscore.tr('/', '_')}".to_sym => project)
- end
-
- it "finds the latest template for #{key}" do
- result = described_class.find(key, project)
- expect(result.full_name).to eq("#{key}.latest.gitlab-ci.yml")
- expect(result.content).to be_present
- end
- end
-
- context 'when `redirect_to_latest_template` feature flag is enabled on the other project' do
- before do
- stub_feature_flags("redirect_to_latest_template_#{key.underscore.tr('/', '_')}".to_sym => other_project)
- end
-
- it "finds the stable template for #{key}" do
- result = described_class.find(key, project)
- expect(result.full_name).to eq("#{key}.gitlab-ci.yml")
- expect(result.content).to be_present
- end
- end
- end
- end
-
describe '#content' do
it 'loads the full file' do
gitignore = subject.new(Rails.root.join('lib/gitlab/ci/templates/Ruby.gitlab-ci.yml'))
diff --git a/spec/lib/gitlab/tracking/event_definition_spec.rb b/spec/lib/gitlab/tracking/event_definition_spec.rb
index 51c62840819..623009e9a30 100644
--- a/spec/lib/gitlab/tracking/event_definition_spec.rb
+++ b/spec/lib/gitlab/tracking/event_definition_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe Gitlab::Tracking::EventDefinition do
end
it 'has all definitions valid' do
- expect { described_class.definitions }.not_to raise_error(Gitlab::Tracking::InvalidEventError)
+ expect { described_class.definitions }.not_to raise_error
end
describe '#validate' do
diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb
index 8e372ba795b..d4f96f1a37f 100644
--- a/spec/lib/gitlab/url_builder_spec.rb
+++ b/spec/lib/gitlab/url_builder_spec.rb
@@ -22,6 +22,8 @@ RSpec.describe Gitlab::UrlBuilder do
:group_board | ->(board) { "/groups/#{board.group.full_path}/-/boards/#{board.id}" }
:commit | ->(commit) { "/#{commit.project.full_path}/-/commit/#{commit.id}" }
:issue | ->(issue) { "/#{issue.project.full_path}/-/issues/#{issue.iid}" }
+ [:issue, :task] | ->(issue) { "/#{issue.project.full_path}/-/work_items/#{issue.id}" }
+ :work_item | ->(work_item) { "/#{work_item.project.full_path}/-/work_items/#{work_item.id}" }
:merge_request | ->(merge_request) { "/#{merge_request.project.full_path}/-/merge_requests/#{merge_request.iid}" }
:project_milestone | ->(milestone) { "/#{milestone.project.full_path}/-/milestones/#{milestone.iid}" }
:project_snippet | ->(snippet) { "/#{snippet.project.full_path}/-/snippets/#{snippet.id}" }
@@ -57,7 +59,7 @@ RSpec.describe Gitlab::UrlBuilder do
end
with_them do
- let(:object) { build_stubbed(factory) }
+ let(:object) { build_stubbed(*Array(factory)) }
let(:path) { path_generator.call(object) }
it 'returns the full URL' do
@@ -69,6 +71,18 @@ RSpec.describe Gitlab::UrlBuilder do
end
end
+ context 'when work_items feature flag is disabled' do
+ before do
+ stub_feature_flags(work_items: false)
+ end
+
+ it 'returns an issue path for an issue of type task' do
+ task = create(:issue, :task)
+
+ expect(subject.build(task, only_path: true)).to eq("/#{task.project.full_path}/-/issues/#{task.iid}")
+ end
+ end
+
context 'when passing a compare' do
# NOTE: The Compare requires an actual repository, which isn't available
# with the `build_stubbed` strategy used by the table tests above
diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb
index 1127d1cd477..070586319a5 100644
--- a/spec/lib/gitlab/usage/metric_definition_spec.rb
+++ b/spec/lib/gitlab/usage/metric_definition_spec.rb
@@ -20,7 +20,8 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
distribution: %w(ee ce),
tier: %w(free starter premium ultimate bronze silver gold),
name: 'uuid',
- data_category: 'standard'
+ data_category: 'standard',
+ removed_by_url: 'http://gdk.test'
}
end
@@ -132,6 +133,7 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
:tier | %w(test ee)
:name | 'count_<adjective_describing>_boards'
:repair_issue_url | nil
+ :removed_by_url | 1
:instrumentation_class | 'Metric_Class'
:instrumentation_class | 'metricClass'
@@ -177,6 +179,24 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
end
end
+ describe '#valid_service_ping_status?' do
+ context 'when metric has active status' do
+ it 'has to return true' do
+ attributes[:status] = 'active'
+
+ expect(described_class.new(path, attributes).valid_service_ping_status?).to be_truthy
+ end
+ end
+
+ context 'when metric has removed status' do
+ it 'has to return false' do
+ attributes[:status] = 'removed'
+
+ expect(described_class.new(path, attributes).valid_service_ping_status?).to be_falsey
+ end
+ end
+ end
+
describe 'statuses' do
using RSpec::Parameterized::TableSyntax
diff --git a/spec/lib/gitlab/usage/metric_spec.rb b/spec/lib/gitlab/usage/metric_spec.rb
index 19d2d3048eb..10ae94e746b 100644
--- a/spec/lib/gitlab/usage/metric_spec.rb
+++ b/spec/lib/gitlab/usage/metric_spec.rb
@@ -51,4 +51,31 @@ RSpec.describe Gitlab::Usage::Metric do
expect(described_class.new(issue_count_metric_definiton).with_suggested_name).to eq({ counts: { issues: 'count_issues' } })
end
end
+
+ context 'unavailable metric' do
+ let(:instrumentation_class) { "UnavailableMetric" }
+ let(:issue_count_metric_definiton) do
+ double(:issue_count_metric_definiton,
+ attributes.merge({ attributes: attributes, instrumentation_class: instrumentation_class })
+ )
+ end
+
+ before do
+ unavailable_metric_class = Class.new(Gitlab::Usage::Metrics::Instrumentations::CountIssuesMetric) do
+ def available?
+ false
+ end
+ end
+
+ stub_const("Gitlab::Usage::Metrics::Instrumentations::#{instrumentation_class}", unavailable_metric_class)
+ end
+
+ [:with_value, :with_instrumentation, :with_suggested_name].each do |method_name|
+ describe "##{method_name}" do
+ it 'returns an empty hash' do
+ expect(described_class.new(issue_count_metric_definiton).public_send(method_name)).to eq({})
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/collected_data_categories_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/collected_data_categories_metric_spec.rb
index 1b2170baf17..92d4de3c462 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/collected_data_categories_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/collected_data_categories_metric_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CollectedDataCategories
let(:expected_value) { %w[standard subscription operational optional] }
before do
- allow_next_instance_of(ServicePing::PermitDataCategoriesService) do |instance|
+ allow_next_instance_of(ServicePing::PermitDataCategories) do |instance|
expect(instance).to receive(:execute).and_return(expected_value)
end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb
new file mode 100644
index 00000000000..b85d5a3ebf9
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountBulkImportsEntitiesMetric do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:bulk_import_projects) do
+ create_list(:bulk_import_entity, 3, source_type: 'project_entity', created_at: 3.weeks.ago)
+ end
+
+ let_it_be(:bulk_import_groups) do
+ create_list(:bulk_import_entity, 3, source_type: 'group_entity', created_at: 3.weeks.ago)
+ end
+
+ let_it_be(:old_bulk_import_project) do
+ create(:bulk_import_entity, source_type: 'project_entity', created_at: 2.months.ago)
+ end
+
+ context 'with no source_type' do
+ context 'with all time frame' do
+ let(:expected_value) { 7 }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\""
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query', time_frame: 'all', options: {}
+ end
+
+ context 'for 28d time frame' do
+ let(:expected_value) { 6 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\""\
+ " WHERE \"bulk_import_entities\".\"created_at\" BETWEEN '#{start}' AND '#{finish}'"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query', time_frame: '28d', options: {}
+ end
+ end
+
+ context 'with invalid source_type' do
+ it 'raises ArgumentError' do
+ expect { described_class.new(time_frame: 'all', options: { source_type: 'random' }) }
+ .to raise_error(ArgumentError, /source_type/)
+ end
+ end
+
+ context 'with source_type project_entity' do
+ context 'with all time frame' do
+ let(:expected_value) { 4 }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\""\
+ " WHERE \"bulk_import_entities\".\"source_type\" = 1"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: 'all',
+ options: { source_type: 'project_entity' }
+ end
+
+ context 'for 28d time frame' do
+ let(:expected_value) { 3 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\""\
+ " WHERE \"bulk_import_entities\".\"created_at\" BETWEEN '#{start}' AND '#{finish}'"\
+ " AND \"bulk_import_entities\".\"source_type\" = 1"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: '28d',
+ options: { source_type: 'project_entity' }
+ end
+ end
+
+ context 'with source_type group_entity' do
+ context 'with all time frame' do
+ let(:expected_value) { 3 }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\""\
+ " WHERE \"bulk_import_entities\".\"source_type\" = 0"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: 'all',
+ options: { source_type: 'group_entity' }
+ end
+
+ context 'for 28d time frame' do
+ let(:expected_value) { 3 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\""\
+ " WHERE \"bulk_import_entities\".\"created_at\" BETWEEN '#{start}' AND '#{finish}'"\
+ " AND \"bulk_import_entities\".\"source_type\" = 0"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: '28d',
+ options: { source_type: 'group_entity' }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_metric_spec.rb
new file mode 100644
index 00000000000..4c86410d609
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_metric_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountImportedProjectsMetric do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:gitea_imports) do
+ create_list(:project, 3, import_type: 'gitea', creator_id: user.id, created_at: 3.weeks.ago)
+ end
+
+ let_it_be(:bitbucket_imports) do
+ create_list(:project, 2, import_type: 'bitbucket', creator_id: user.id, created_at: 3.weeks.ago)
+ end
+
+ let_it_be(:old_import) { create(:project, import_type: 'gitea', creator_id: user.id, created_at: 2.months.ago) }
+
+ context 'with import_type gitea' do
+ context 'with all time frame' do
+ let(:expected_value) { 4 }
+ let(:expected_query) do
+ "SELECT COUNT(\"projects\".\"id\") FROM \"projects\" WHERE \"projects\".\"import_type\" = 'gitea'"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: 'all',
+ options: { import_type: 'gitea' }
+ end
+
+ context 'for 28d time frame' do
+ let(:expected_value) { 3 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT COUNT(\"projects\".\"id\") FROM \"projects\" WHERE \"projects\".\"created_at\""\
+ " BETWEEN '#{start}' AND '#{finish}' AND \"projects\".\"import_type\" = 'gitea'"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: '28d',
+ options: { import_type: 'gitea' }
+ end
+ end
+
+ context 'with import_type bitbucket' do
+ context 'with all time frame' do
+ let(:expected_value) { 2 }
+ let(:expected_query) do
+ "SELECT COUNT(\"projects\".\"id\") FROM \"projects\" WHERE \"projects\".\"import_type\" = 'bitbucket'"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: 'all',
+ options: { import_type: 'bitbucket' }
+ end
+
+ context 'for 28d time frame' do
+ let(:expected_value) { 2 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT COUNT(\"projects\".\"id\") FROM \"projects\" WHERE \"projects\".\"created_at\""\
+ " BETWEEN '#{start}' AND '#{finish}' AND \"projects\".\"import_type\" = 'bitbucket'"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query',
+ time_frame: '28d',
+ options: { import_type: 'bitbucket' }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb
index ea5ae1970de..8e7bd7b84e6 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb
@@ -71,6 +71,33 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::DatabaseMetric do
end
end
+ context 'with availability defined' do
+ subject do
+ described_class.tap do |metric_class|
+ metric_class.relation { Issue }
+ metric_class.operation :count
+ metric_class.available? { false }
+ end.new(time_frame: 'all')
+ end
+
+ it 'responds to #available? properly' do
+ expect(subject.available?).to eq(false)
+ end
+ end
+
+ context 'with availability not defined' do
+ subject do
+ Class.new(described_class) do
+ relation { Issue }
+ operation :count
+ end.new(time_frame: 'all')
+ end
+
+ it 'responds to #available? properly' do
+ expect(subject.available?).to eq(true)
+ end
+ end
+
context 'with cache_start_and_finish_as called' do
subject do
described_class.tap do |metric_class|
@@ -134,4 +161,17 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::DatabaseMetric do
end
end
end
+
+ context 'with unimplemented operation method used' do
+ subject do
+ described_class.tap do |metric_class|
+ metric_class.relation { Issue }
+ metric_class.operation :invalid_operation
+ end.new(time_frame: 'all')
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(described_class::UnimplementedOperationError)
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/redis_hll_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/redis_hll_metric_spec.rb
index 347a2c779cb..97306051533 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/redis_hll_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/redis_hll_metric_spec.rb
@@ -25,4 +25,28 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::RedisHLLMetric, :clean_
it 'raise exception if events options is not present' do
expect { described_class.new(time_frame: '28d') }.to raise_error(ArgumentError)
end
+
+ describe 'children classes' do
+ let(:options) { { events: ['i_quickactions_approve'] } }
+
+ context 'availability not defined' do
+ subject { Class.new(described_class).new(time_frame: nil, options: options) }
+
+ it 'returns default availability' do
+ expect(subject.available?).to eq(true)
+ end
+ end
+
+ context 'availability defined' do
+ subject do
+ Class.new(described_class) do
+ available? { false }
+ end.new(time_frame: nil, options: options)
+ end
+
+ it 'returns defined availability' do
+ expect(subject.available?).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/redis_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/redis_metric_spec.rb
index fb3bd1ba834..831f775ec9a 100644
--- a/spec/lib/gitlab/usage/metrics/instrumentations/redis_metric_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/redis_metric_spec.rb
@@ -20,4 +20,28 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::RedisMetric, :clean_git
it 'raises an exception if counter_class option is not present' do
expect { described_class.new(event: 'pushes') }.to raise_error(ArgumentError)
end
+
+ describe 'children classes' do
+ let(:options) { { event: 'pushes', counter_class: 'SourceCodeCounter' } }
+
+ context 'availability not defined' do
+ subject { Class.new(described_class).new(time_frame: nil, options: options) }
+
+ it 'returns default availability' do
+ expect(subject.available?).to eq(true)
+ end
+ end
+
+ context 'availability defined' do
+ subject do
+ Class.new(described_class) do
+ available? { false }
+ end.new(time_frame: nil, options: options)
+ end
+
+ it 'returns defined availability' do
+ expect(subject.available?).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage/metrics/query_spec.rb b/spec/lib/gitlab/usage/metrics/query_spec.rb
index 60c8d044a64..65b8a7a046b 100644
--- a/spec/lib/gitlab/usage/metrics/query_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/query_spec.rb
@@ -11,6 +11,22 @@ RSpec.describe Gitlab::Usage::Metrics::Query do
it 'does not mix a nil column with keyword arguments' do
expect(described_class.for(:count, User, nil)).to eq('SELECT COUNT("users"."id") FROM "users"')
end
+
+ it 'removes order from passed relation' do
+ expect(described_class.for(:count, User.order(:email), nil)).to eq('SELECT COUNT("users"."id") FROM "users"')
+ end
+
+ it 'returns valid raw SQL for join relations' do
+ expect(described_class.for(:count, User.joins(:issues), :email)).to eq(
+ 'SELECT COUNT("users"."email") FROM "users" INNER JOIN "issues" ON "issues"."author_id" = "users"."id"'
+ )
+ end
+
+ it 'returns valid raw SQL for join relations with joined columns' do
+ expect(described_class.for(:count, User.joins(:issues), 'issue.weight')).to eq(
+ 'SELECT COUNT("issue"."weight") FROM "users" INNER JOIN "issues" ON "issues"."author_id" = "users"."id"'
+ )
+ end
end
describe '.distinct_count' do
@@ -21,6 +37,22 @@ RSpec.describe Gitlab::Usage::Metrics::Query do
it 'does not mix a nil column with keyword arguments' do
expect(described_class.for(:distinct_count, Issue, nil)).to eq('SELECT COUNT(DISTINCT "issues"."id") FROM "issues"')
end
+
+ it 'removes order from passed relation' do
+ expect(described_class.for(:distinct_count, User.order(:email), nil)).to eq('SELECT COUNT(DISTINCT "users"."id") FROM "users"')
+ end
+
+ it 'returns valid raw SQL for join relations' do
+ expect(described_class.for(:distinct_count, User.joins(:issues), :email)).to eq(
+ 'SELECT COUNT(DISTINCT "users"."email") FROM "users" INNER JOIN "issues" ON "issues"."author_id" = "users"."id"'
+ )
+ end
+
+ it 'returns valid raw SQL for join relations with joined columns' do
+ expect(described_class.for(:distinct_count, User.joins(:issues), 'issue.weight')).to eq(
+ 'SELECT COUNT(DISTINCT "issue"."weight") FROM "users" INNER JOIN "issues" ON "issues"."author_id" = "users"."id"'
+ )
+ end
end
describe '.sum' do
diff --git a/spec/lib/gitlab/usage/service_ping/legacy_metric_timing_decorator_spec.rb b/spec/lib/gitlab/usage/service_ping/legacy_metric_timing_decorator_spec.rb
new file mode 100644
index 00000000000..46592379b3d
--- /dev/null
+++ b/spec/lib/gitlab/usage/service_ping/legacy_metric_timing_decorator_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::ServicePing::LegacyMetricTimingDecorator do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:duration) { 123 }
+
+ where(:metric_value, :metric_class) do
+ 1 | Integer
+ "value" | String
+ true | TrueClass
+ false | FalseClass
+ nil | NilClass
+ end
+
+ with_them do
+ let(:decorated_object) { described_class.new(metric_value, duration) }
+
+ it 'exposes a duration with the correct value' do
+ expect(decorated_object.duration).to eq(duration)
+ end
+
+ it 'imitates wrapped class', :aggregate_failures do
+ expect(decorated_object).to eq metric_value
+ expect(decorated_object.class).to eq metric_class
+ expect(decorated_object.is_a?(metric_class)).to be_truthy
+ # rubocop:disable Style/ClassCheck
+ expect(decorated_object.kind_of?(metric_class)).to be_truthy
+ # rubocop:enable Style/ClassCheck
+ expect({ metric: decorated_object }.to_json).to eql({ metric: metric_value }.to_json)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage/service_ping_report_spec.rb b/spec/lib/gitlab/usage/service_ping_report_spec.rb
index b6119ab52ec..e7096988035 100644
--- a/spec/lib/gitlab/usage/service_ping_report_spec.rb
+++ b/spec/lib/gitlab/usage/service_ping_report_spec.rb
@@ -92,49 +92,6 @@ RSpec.describe Gitlab::Usage::ServicePingReport, :use_clean_rails_memory_store_c
end
context 'cross test values against queries' do
- # TODO: fix failing metrics https://gitlab.com/gitlab-org/gitlab/-/issues/353559
- let(:failing_todo_metrics) do
- ["counts.labels",
- "counts.jira_imports_total_imported_issues_count",
- "counts.in_product_marketing_email_create_0_sent",
- "counts.in_product_marketing_email_create_0_cta_clicked",
- "counts.in_product_marketing_email_create_1_sent",
- "counts.in_product_marketing_email_create_1_cta_clicked",
- "counts.in_product_marketing_email_create_2_sent",
- "counts.in_product_marketing_email_create_2_cta_clicked",
- "counts.in_product_marketing_email_verify_0_sent",
- "counts.in_product_marketing_email_verify_0_cta_clicked",
- "counts.in_product_marketing_email_verify_1_sent",
- "counts.in_product_marketing_email_verify_1_cta_clicked",
- "counts.in_product_marketing_email_verify_2_sent",
- "counts.in_product_marketing_email_verify_2_cta_clicked",
- "counts.in_product_marketing_email_trial_0_sent",
- "counts.in_product_marketing_email_trial_0_cta_clicked",
- "counts.in_product_marketing_email_trial_1_sent",
- "counts.in_product_marketing_email_trial_1_cta_clicked",
- "counts.in_product_marketing_email_trial_2_sent",
- "counts.in_product_marketing_email_trial_2_cta_clicked",
- "counts.in_product_marketing_email_team_0_sent",
- "counts.in_product_marketing_email_team_0_cta_clicked",
- "counts.in_product_marketing_email_team_1_sent",
- "counts.in_product_marketing_email_team_1_cta_clicked",
- "counts.in_product_marketing_email_team_2_sent",
- "counts.in_product_marketing_email_team_2_cta_clicked",
- "counts.in_product_marketing_email_experience_0_sent",
- "counts.in_product_marketing_email_team_short_0_sent",
- "counts.in_product_marketing_email_team_short_0_cta_clicked",
- "counts.in_product_marketing_email_trial_short_0_sent",
- "counts.in_product_marketing_email_trial_short_0_cta_clicked",
- "counts.in_product_marketing_email_admin_verify_0_sent",
- "counts.in_product_marketing_email_admin_verify_0_cta_clicked",
- "counts.ldap_users",
- "usage_activity_by_stage.create.projects_with_sectional_code_owner_rules",
- "usage_activity_by_stage.monitor.clusters_integrations_prometheus",
- "usage_activity_by_stage.monitor.projects_with_enabled_alert_integrations_histogram",
- "usage_activity_by_stage_monthly.create.projects_with_sectional_code_owner_rules",
- "usage_activity_by_stage_monthly.monitor.clusters_integrations_prometheus"]
- end
-
def fetch_value_by_query(query)
# Because test cases are run inside a transaction, if any query raise and error all queries that follows
# it are automatically canceled by PostgreSQL, to avoid that problem, and to provide exhaustive information
@@ -157,6 +114,24 @@ RSpec.describe Gitlab::Usage::ServicePingReport, :use_clean_rails_memory_store_c
accumulator
end
+ def type_cast_to_defined_type(value, metric_definition)
+ case metric_definition&.attributes&.fetch(:value_type)
+ when "string"
+ value.to_s
+ when "number"
+ value.to_i
+ when "object"
+ case metric_definition&.json_schema&.fetch("type")
+ when "array"
+ value.to_a
+ else
+ value.to_h
+ end
+ else
+ value
+ end
+ end
+
before do
stub_usage_data_connections
stub_object_store_settings
@@ -169,12 +144,13 @@ RSpec.describe Gitlab::Usage::ServicePingReport, :use_clean_rails_memory_store_c
let(:service_ping_payload) { described_class.for(output: :all_metrics_values) }
let(:metrics_queries_with_values) { build_payload_from_queries(described_class.for(output: :metrics_queries)) }
+ let(:metric_definitions) { ::Gitlab::Usage::MetricDefinition.definitions }
it 'generates queries that match collected data', :aggregate_failures do
message = "Expected %{query} result to match %{value} for %{key_path} metric"
metrics_queries_with_values.each do |key_path, query, value|
- next if failing_todo_metrics.include?(key_path.join('.'))
+ value = type_cast_to_defined_type(value, metric_definitions[key_path.join('.')])
expect(value).to(
eq(service_ping_payload.dig(*key_path)),
diff --git a/spec/lib/gitlab/usage_counters/pod_logs_spec.rb b/spec/lib/gitlab/usage_counters/pod_logs_spec.rb
deleted file mode 100644
index 1059c519b19..00000000000
--- a/spec/lib/gitlab/usage_counters/pod_logs_spec.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::UsageCounters::PodLogs, :clean_gitlab_redis_shared_state do
- it_behaves_like 'a usage counter'
-end
diff --git a/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
index 5f66387c82b..9aecb8f8b25 100644
--- a/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
@@ -80,10 +80,13 @@ RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_red
it 'can return the count of actions per user deduplicated' do
described_class.track_web_ide_edit_action(author: user1)
+ described_class.track_live_preview_edit_action(author: user1)
described_class.track_snippet_editor_edit_action(author: user1)
described_class.track_sfe_edit_action(author: user1)
described_class.track_web_ide_edit_action(author: user2, time: time - 2.days)
described_class.track_web_ide_edit_action(author: user3, time: time - 3.days)
+ described_class.track_live_preview_edit_action(author: user2, time: time - 2.days)
+ described_class.track_live_preview_edit_action(author: user3, time: time - 3.days)
described_class.track_snippet_editor_edit_action(author: user3, time: time - 3.days)
described_class.track_sfe_edit_action(author: user3, time: time - 3.days)
diff --git a/spec/lib/gitlab/usage_data_counters/ipynb_diff_activity_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/ipynb_diff_activity_counter_spec.rb
new file mode 100644
index 00000000000..60c4424d2ae
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/ipynb_diff_activity_counter_spec.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UsageDataCounters::IpynbDiffActivityCounter, :clean_gitlab_redis_shared_state do
+ let(:user) { build(:user, id: 1) }
+ let(:for_mr) { false }
+ let(:for_commit) { false }
+ let(:first_note) { build(:note, author: user, id: 1) }
+ let(:second_note) { build(:note, author: user, id: 2) }
+
+ before do
+ allow(first_note).to receive(:for_merge_request?).and_return(for_mr)
+ allow(second_note).to receive(:for_merge_request?).and_return(for_mr)
+ allow(first_note).to receive(:for_commit?).and_return(for_commit)
+ allow(second_note).to receive(:for_commit?).and_return(for_commit)
+ end
+
+ subject do
+ described_class.note_created(first_note)
+ described_class.note_created(first_note)
+ described_class.note_created(second_note)
+ end
+
+ shared_examples_for 'an action that tracks events' do
+ specify do
+ expect { 2.times { subject } }
+ .to change { event_count(action) }.by(2)
+ .and change { event_count(per_user_action) }.by(1)
+ end
+ end
+
+ shared_examples_for 'an action that does not track events' do
+ specify do
+ expect { 2.times { subject } }
+ .to change { event_count(action) }.by(0)
+ .and change { event_count(per_user_action) }.by(0)
+ end
+ end
+
+ describe '#track_note_created_in_ipynb_diff' do
+ context 'note is for commit' do
+ let(:for_commit) { true }
+
+ it_behaves_like 'an action that tracks events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_ACTION}
+ end
+
+ it_behaves_like 'an action that tracks events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_COMMIT_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_COMMIT_ACTION}
+ end
+
+ it_behaves_like 'an action that does not track events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_MR_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_MR_ACTION}
+ end
+ end
+
+ context 'note is for MR' do
+ let(:for_mr) { true }
+
+ it_behaves_like 'an action that tracks events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_MR_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_MR_ACTION}
+ end
+
+ it_behaves_like 'an action that tracks events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_ACTION}
+ end
+
+ it_behaves_like 'an action that does not track events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_COMMIT_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_COMMIT_ACTION}
+ end
+ end
+
+ context 'note is for neither MR nor Commit' do
+ it_behaves_like 'an action that does not track events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_ACTION}
+ end
+
+ it_behaves_like 'an action that does not track events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_MR_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_MR_ACTION}
+ end
+
+ it_behaves_like 'an action that does not track events' do
+ let(:action) {described_class::NOTE_CREATED_IN_IPYNB_DIFF_COMMIT_ACTION}
+ let(:per_user_action) {described_class::USER_CREATED_NOTE_IN_IPYNB_DIFF_COMMIT_ACTION}
+ end
+ end
+ end
+
+ private
+
+ def event_count(event_name)
+ Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(
+ event_names: event_name,
+ start_date: 2.weeks.ago,
+ end_date: 2.weeks.from_now
+ )
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_queries_spec.rb b/spec/lib/gitlab/usage_data_queries_spec.rb
index 88322e1b971..7c64a31c499 100644
--- a/spec/lib/gitlab/usage_data_queries_spec.rb
+++ b/spec/lib/gitlab/usage_data_queries_spec.rb
@@ -11,6 +11,12 @@ RSpec.describe Gitlab::UsageDataQueries do
end
end
+ describe '.with_duration' do
+ it 'yields passed block' do
+ expect { |block| described_class.with_duration(&block) }.to yield_with_no_args
+ end
+ end
+
describe '.count' do
it 'returns the raw SQL' do
expect(described_class.count(User)).to start_with('SELECT COUNT("users"."id") FROM "users"')
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 8a919a0a72e..7edec6d13f4 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -1080,7 +1080,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
it 'reports collected data categories' do
expected_value = %w[standard subscription operational optional]
- allow_next_instance_of(ServicePing::PermitDataCategoriesService) do |instance|
+ allow_next_instance_of(ServicePing::PermitDataCategories) do |instance|
expect(instance).to receive(:execute).and_return(expected_value)
end
@@ -1470,4 +1470,31 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
end
+
+ describe ".with_duration" do
+ context 'with feature flag measure_service_ping_metric_collection turned off' do
+ before do
+ stub_feature_flags(measure_service_ping_metric_collection: false)
+ end
+
+ it 'does NOT record duration and return block response' do
+ expect(::Gitlab::Usage::ServicePing::LegacyMetricTimingDecorator).not_to receive(:new)
+
+ expect(described_class.with_duration { 1 + 1 }).to be 2
+ end
+ end
+
+ context 'with feature flag measure_service_ping_metric_collection turned off' do
+ before do
+ stub_feature_flags(measure_service_ping_metric_collection: true)
+ end
+
+ it 'records duration' do
+ expect(::Gitlab::Usage::ServicePing::LegacyMetricTimingDecorator)
+ .to receive(:new).with(2, kind_of(Float))
+
+ described_class.with_duration { 1 + 1 }
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb
index 01890305df4..b1de3e21b77 100644
--- a/spec/lib/gitlab/user_access_spec.rb
+++ b/spec/lib/gitlab/user_access_spec.rb
@@ -30,17 +30,6 @@ RSpec.describe Gitlab::UserAccess do
end
end
- describe 'push to branch in an internal project' do
- it 'will not infinitely loop when a project is internal' do
- project.visibility_level = Gitlab::VisibilityLevel::INTERNAL
- project.save!
-
- expect(project).not_to receive(:branch_allows_collaboration?)
-
- access.can_push_to_branch?('master')
- end
- end
-
describe 'push to empty project' do
let(:empty_project) { create(:project_empty_repo) }
let(:project_access) { described_class.new(user, container: empty_project) }
diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb
index b44c6565538..a74a9f06c6f 100644
--- a/spec/lib/gitlab/utils/usage_data_spec.rb
+++ b/spec/lib/gitlab/utils/usage_data_spec.rb
@@ -31,6 +31,12 @@ RSpec.describe Gitlab::Utils::UsageData do
end
end
+ describe '.with_duration' do
+ it 'yields passed block' do
+ expect { |block| described_class.with_duration(&block) }.to yield_with_no_args
+ end
+ end
+
describe '#add_metric' do
let(:metric) { 'UuidMetric'}
@@ -48,6 +54,13 @@ RSpec.describe Gitlab::Utils::UsageData do
expect(described_class.count(relation, batch: false)).to eq(1)
end
+ it 'records duration' do
+ expect(described_class).to receive(:with_duration)
+ allow(relation).to receive(:count).and_return(1)
+
+ described_class.count(relation, batch: false)
+ end
+
context 'when counting fails' do
subject { described_class.count(relation, batch: false) }
@@ -68,6 +81,13 @@ RSpec.describe Gitlab::Utils::UsageData do
expect(described_class.distinct_count(relation, batch: false)).to eq(1)
end
+ it 'records duration' do
+ expect(described_class).to receive(:with_duration)
+ allow(relation).to receive(:distinct_count_by).and_return(1)
+
+ described_class.distinct_count(relation, batch: false)
+ end
+
context 'when counting fails' do
subject { described_class.distinct_count(relation, batch: false) }
@@ -206,14 +226,6 @@ RSpec.describe Gitlab::Utils::UsageData do
it_behaves_like 'failing hardening method'
end
-
- it 'logs error and returns DISTRIBUTED_HLL_FALLBACK value when counting raises any error', :aggregate_failures do
- error = StandardError.new('')
- allow(Gitlab::Database::PostgresHll::BatchDistinctCounter).to receive(:new).and_raise(error)
-
- expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(error)
- expect(described_class.estimate_batch_distinct_count(relation)).to eq(4)
- end
end
end
@@ -229,6 +241,13 @@ RSpec.describe Gitlab::Utils::UsageData do
expect(described_class.sum(relation, :column, batch_size: 100, start: 2, finish: 3)).to eq(1)
end
+ it 'records duration' do
+ expect(described_class).to receive(:with_duration)
+ allow(Gitlab::Database::BatchCount).to receive(:batch_sum).and_return(1)
+
+ described_class.sum(relation, :column)
+ end
+
context 'when counting fails' do
subject { described_class.sum(relation, :column) }
@@ -316,6 +335,12 @@ RSpec.describe Gitlab::Utils::UsageData do
expect(histogram).to eq('2' => 1)
end
+ it 'records duration' do
+ expect(described_class).to receive(:with_duration)
+
+ described_class.histogram(relation, column, buckets: 1..100)
+ end
+
context 'when query timeout' do
subject do
with_statement_timeout(0.001) do
@@ -368,6 +393,12 @@ RSpec.describe Gitlab::Utils::UsageData do
expect(described_class.add).to eq(0)
end
+ it 'records duration' do
+ expect(described_class).to receive(:with_duration)
+
+ described_class.add
+ end
+
context 'when adding fails' do
subject { described_class.add(nil, 3) }
@@ -392,6 +423,12 @@ RSpec.describe Gitlab::Utils::UsageData do
it_behaves_like 'failing hardening method', StandardError
end
+ it 'records duration' do
+ expect(described_class).to receive(:with_duration)
+
+ described_class.alt_usage_data
+ end
+
it 'returns the evaluated block when give' do
expect(described_class.alt_usage_data { Gitlab::CurrentSettings.uuid } ).to eq(Gitlab::CurrentSettings.uuid)
end
@@ -402,6 +439,12 @@ RSpec.describe Gitlab::Utils::UsageData do
end
describe '#redis_usage_data' do
+ it 'records duration' do
+ expect(described_class).to receive(:with_duration)
+
+ described_class.redis_usage_data
+ end
+
context 'with block given' do
context 'when method fails' do
subject { described_class.redis_usage_data { raise ::Redis::CommandError } }
@@ -445,6 +488,12 @@ RSpec.describe Gitlab::Utils::UsageData do
end
describe '#with_prometheus_client' do
+ it 'records duration' do
+ expect(described_class).to receive(:with_duration)
+
+ described_class.with_prometheus_client { |client| client }
+ end
+
it 'returns fallback with for an exception in yield block' do
allow(described_class).to receive(:prometheus_client).and_return(Gitlab::PrometheusClient.new('http://localhost:9090'))
result = described_class.with_prometheus_client(fallback: -42) { |client| raise StandardError }
diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb
index 6b12fb4a84a..0648d276a6b 100644
--- a/spec/lib/gitlab/utils_spec.rb
+++ b/spec/lib/gitlab/utils_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Utils do
using RSpec::Parameterized::TableSyntax
- delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which,
+ delegate :to_boolean, :boolean_to_yes_no, :slugify, :which,
:ensure_array_from_string, :to_exclusive_sentence, :bytes_to_megabytes,
:append_path, :check_path_traversal!, :allowlisted?, :check_allowed_absolute_path!, :decode_path, :ms_to_round_sec, :check_allowed_absolute_path_and_path_traversal!, to: :described_class
@@ -311,12 +311,6 @@ RSpec.describe Gitlab::Utils do
end
end
- describe '.random_string' do
- it 'generates a string' do
- expect(random_string).to be_kind_of(String)
- end
- end
-
describe '.which' do
before do
stub_env('PATH', '/sbin:/usr/bin:/home/joe/bin')
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index 3bab9aec454..703a4b5399e 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -244,15 +244,13 @@ RSpec.describe Gitlab::Workhorse do
GitalyServer: {
features: { 'gitaly-feature-enforce-requests-limits' => 'true' },
address: Gitlab::GitalyClient.address('default'),
- token: Gitlab::GitalyClient.token('default'),
- sidechannel: false
+ token: Gitlab::GitalyClient.token('default')
}
}
end
before do
allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true)
- stub_feature_flags(workhorse_use_sidechannel: false)
end
it 'includes a Repository param' do
@@ -334,46 +332,6 @@ RSpec.describe Gitlab::Workhorse do
it { expect { subject }.to raise_exception('Unsupported action: download') }
end
-
- context 'when workhorse_use_sidechannel flag is set' do
- context 'when a feature flag is set globally' do
- before do
- stub_feature_flags(workhorse_use_sidechannel: true)
- end
-
- it 'sets the flag to true' do
- response = described_class.git_http_ok(repository, Gitlab::GlRepository::PROJECT, user, action)
-
- expect(response.dig(:GitalyServer, :sidechannel)).to eq(true)
- end
- end
-
- context 'when a feature flag is set for a single project' do
- before do
- stub_feature_flags(workhorse_use_sidechannel: project)
- end
-
- it 'sets the flag to true for that project' do
- response = described_class.git_http_ok(repository, Gitlab::GlRepository::PROJECT, user, action)
-
- expect(response.dig(:GitalyServer, :sidechannel)).to eq(true)
- end
-
- it 'sets the flag to false for other projects' do
- other_project = create(:project, :public, :repository)
- response = described_class.git_http_ok(other_project.repository, Gitlab::GlRepository::PROJECT, user, action)
-
- expect(response.dig(:GitalyServer, :sidechannel)).to eq(false)
- end
-
- it 'sets the flag to false when there is no project' do
- snippet = create(:personal_snippet, :repository)
- response = described_class.git_http_ok(snippet.repository, Gitlab::GlRepository::SNIPPET, user, action)
-
- expect(response.dig(:GitalyServer, :sidechannel)).to eq(false)
- end
- end
- end
end
context 'when receive_max_input_size has been updated' do
@@ -448,6 +406,14 @@ RSpec.describe Gitlab::Workhorse do
end
end
+ describe '.detect_content_type' do
+ subject { described_class.detect_content_type }
+
+ it 'returns array setting detect content type in workhorse' do
+ expect(subject).to eq(%w[Gitlab-Workhorse-Detect-Content-Type true])
+ end
+ end
+
describe '.send_git_blob' do
include FakeBlobHelpers
diff --git a/spec/lib/gitlab/zentao/client_spec.rb b/spec/lib/gitlab/zentao/client_spec.rb
index 86b310fe417..135f13e6265 100644
--- a/spec/lib/gitlab/zentao/client_spec.rb
+++ b/spec/lib/gitlab/zentao/client_spec.rb
@@ -130,4 +130,36 @@ RSpec.describe Gitlab::Zentao::Client do
end
end
end
+
+ describe '#url' do
+ context 'api url' do
+ shared_examples 'joins api_url correctly' do
+ it 'verify url' do
+ expect(integration.send(:url, "products/1").to_s)
+ .to eq("https://jihudemo.zentao.net/zentao/api.php/v1/products/1")
+ end
+ end
+
+ context 'no ends slash' do
+ let(:zentao_integration) { create(:zentao_integration, api_url: 'https://jihudemo.zentao.net/zentao') }
+
+ include_examples 'joins api_url correctly'
+ end
+
+ context 'ends slash' do
+ let(:zentao_integration) { create(:zentao_integration, api_url: 'https://jihudemo.zentao.net/zentao/') }
+
+ include_examples 'joins api_url correctly'
+ end
+ end
+
+ context 'no api url' do
+ let(:zentao_integration) { create(:zentao_integration, url: 'https://jihudemo.zentao.net') }
+
+ it 'joins url correctly' do
+ expect(integration.send(:url, "products/1").to_s)
+ .to eq("https://jihudemo.zentao.net/api.php/v1/products/1")
+ end
+ end
+ end
end
diff --git a/spec/services/service_ping/build_payload_service_spec.rb b/spec/lib/service_ping/build_payload_spec.rb
index cd2685069c9..6cce07262b2 100644
--- a/spec/services/service_ping/build_payload_service_spec.rb
+++ b/spec/lib/service_ping/build_payload_spec.rb
@@ -2,14 +2,14 @@
require 'spec_helper'
-RSpec.describe ServicePing::BuildPayloadService do
+RSpec.describe ServicePing::BuildPayload do
describe '#execute', :without_license do
subject(:service_ping_payload) { described_class.new.execute }
include_context 'stubbed service ping metrics definitions' do
let(:subscription_metrics) do
[
- metric_attributes('active_user_count', "Subscription")
+ metric_attributes('active_user_count', "subscription")
]
end
end
@@ -35,7 +35,8 @@ RSpec.describe ServicePing::BuildPayloadService do
context 'with require stats consent enabled' do
before do
- allow(User).to receive(:single_user).and_return(double(:user, requires_usage_stats_consent?: true))
+ allow(User).to receive(:single_user)
+ .and_return(instance_double(User, :user, requires_usage_stats_consent?: true))
end
it 'returns empty service ping payload' do
diff --git a/spec/lib/service_ping/devops_report_spec.rb b/spec/lib/service_ping/devops_report_spec.rb
new file mode 100644
index 00000000000..793f3066097
--- /dev/null
+++ b/spec/lib/service_ping/devops_report_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ServicePing::DevopsReport do
+ let_it_be(:data) { { "conv_index": {} }.to_json }
+ let_it_be(:subject) { ServicePing::DevopsReport.new(Gitlab::Json.parse(data)) }
+ let_it_be(:devops_report) { DevOpsReport::Metric.new }
+
+ describe '#execute' do
+ context 'when metric is persisted' do
+ before do
+ allow(DevOpsReport::Metric).to receive(:create).and_return(devops_report)
+ allow(devops_report).to receive(:persisted?).and_return(true)
+ end
+
+ it 'does not call `track_and_raise_for_dev_exception`' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+ subject.execute
+ end
+ end
+
+ context 'when metric is not persisted' do
+ before do
+ allow(DevOpsReport::Metric).to receive(:create).and_return(devops_report)
+ allow(devops_report).to receive(:persisted?).and_return(false)
+ end
+
+ it 'calls `track_and_raise_for_dev_exception`' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ subject.execute
+ end
+ end
+ end
+end
diff --git a/spec/services/service_ping/permit_data_categories_service_spec.rb b/spec/lib/service_ping/permit_data_categories_spec.rb
index 550c0ea5e13..d1027a6f1ab 100644
--- a/spec/services/service_ping/permit_data_categories_service_spec.rb
+++ b/spec/lib/service_ping/permit_data_categories_spec.rb
@@ -2,13 +2,14 @@
require 'spec_helper'
-RSpec.describe ServicePing::PermitDataCategoriesService do
+RSpec.describe ServicePing::PermitDataCategories do
describe '#execute', :without_license do
subject(:permitted_categories) { described_class.new.execute }
context 'when usage ping setting is set to true' do
before do
- allow(User).to receive(:single_user).and_return(double(:user, requires_usage_stats_consent?: false))
+ allow(User).to receive(:single_user)
+ .and_return(instance_double(User, :user, requires_usage_stats_consent?: false))
stub_config_setting(usage_ping_enabled: true)
end
@@ -19,7 +20,8 @@ RSpec.describe ServicePing::PermitDataCategoriesService do
context 'when usage ping setting is set to false' do
before do
- allow(User).to receive(:single_user).and_return(double(:user, requires_usage_stats_consent?: false))
+ allow(User).to receive(:single_user)
+ .and_return(instance_double(User, :user, requires_usage_stats_consent?: false))
stub_config_setting(usage_ping_enabled: false)
end
@@ -30,7 +32,8 @@ RSpec.describe ServicePing::PermitDataCategoriesService do
context 'when User.single_user&.requires_usage_stats_consent? is required' do
before do
- allow(User).to receive(:single_user).and_return(double(:user, requires_usage_stats_consent?: true))
+ allow(User).to receive(:single_user)
+ .and_return(instance_double(User, :user, requires_usage_stats_consent?: true))
stub_config_setting(usage_ping_enabled: true)
end
diff --git a/spec/services/service_ping/service_ping_settings_spec.rb b/spec/lib/service_ping/service_ping_settings_spec.rb
index 90a5c6b30eb..040a5027274 100644
--- a/spec/services/service_ping/service_ping_settings_spec.rb
+++ b/spec/lib/service_ping/service_ping_settings_spec.rb
@@ -18,7 +18,8 @@ RSpec.describe ServicePing::ServicePingSettings do
with_them do
before do
- allow(User).to receive(:single_user).and_return(double(:user, requires_usage_stats_consent?: requires_usage_stats_consent))
+ allow(User).to receive(:single_user)
+ .and_return(instance_double(User, :user, requires_usage_stats_consent?: requires_usage_stats_consent))
stub_config_setting(usage_ping_enabled: usage_ping_enabled)
end
diff --git a/spec/lib/sidebars/groups/menus/ci_cd_menu_spec.rb b/spec/lib/sidebars/groups/menus/ci_cd_menu_spec.rb
index 1ba89af1b02..246df2e409b 100644
--- a/spec/lib/sidebars/groups/menus/ci_cd_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/ci_cd_menu_spec.rb
@@ -22,14 +22,6 @@ RSpec.describe Sidebars::Groups::Menus::CiCdMenu do
specify { is_expected.not_to be_nil }
- describe 'when feature flag :runner_list_group_view_vue_ui is disabled' do
- before do
- stub_feature_flags(runner_list_group_view_vue_ui: false)
- end
-
- specify { is_expected.to be_nil }
- end
-
describe 'when the user does not have access' do
let(:user) { nil }
diff --git a/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb b/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
index 36d5b3376b7..5bf8be9d6e5 100644
--- a/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sidebars::Groups::Menus::KubernetesMenu do
+RSpec.describe Sidebars::Groups::Menus::KubernetesMenu, :request_store do
let_it_be(:owner) { create(:user) }
let_it_be(:group) do
build(:group, :private).tap do |g|
diff --git a/spec/lib/sidebars/groups/menus/settings_menu_spec.rb b/spec/lib/sidebars/groups/menus/settings_menu_spec.rb
index 71b696516b6..252da8ea699 100644
--- a/spec/lib/sidebars/groups/menus/settings_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/settings_menu_spec.rb
@@ -72,18 +72,6 @@ RSpec.describe Sidebars::Groups::Menus::SettingsMenu do
let(:item_id) { :ci_cd }
it_behaves_like 'access rights checks'
-
- describe 'when runner list group view is disabled' do
- before do
- stub_feature_flags(runner_list_group_view_vue_ui: false)
- end
-
- it_behaves_like 'access rights checks'
-
- it 'has group runners as active_routes' do
- expect(subject.active_routes[:path]).to match_array %w[ci_cd#show groups/runners#show groups/runners#edit]
- end
- end
end
describe 'Applications menu' do
diff --git a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
index 81114f5a0b3..2da7d324708 100644
--- a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
@@ -39,27 +39,17 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do
subject.renderable_items.delete(find_menu_item(:kubernetes))
end
- it 'menu link points to Serverless page' do
- expect(subject.link).to eq find_menu_item(:serverless).link
+ it 'menu link points to Terraform page' do
+ expect(subject.link).to eq find_menu_item(:terraform).link
end
- context 'when Serverless menu is not visible' do
+ context 'when Terraform menu is not visible' do
before do
- subject.renderable_items.delete(find_menu_item(:serverless))
+ subject.renderable_items.delete(find_menu_item(:terraform))
end
- it 'menu link points to Terraform page' do
- expect(subject.link).to eq find_menu_item(:terraform).link
- end
-
- context 'when Terraform menu is not visible' do
- before do
- subject.renderable_items.delete(find_menu_item(:terraform))
- end
-
- it 'menu link points to Google Cloud page' do
- expect(subject.link).to eq find_menu_item(:google_cloud).link
- end
+ it 'menu link points to Google Cloud page' do
+ expect(subject.link).to eq find_menu_item(:google_cloud).link
end
end
end
@@ -88,20 +78,6 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do
it_behaves_like 'access rights checks'
end
- describe 'Serverless' do
- let(:item_id) { :serverless }
-
- it_behaves_like 'access rights checks'
-
- context 'when feature :deprecated_serverless is disabled' do
- before do
- stub_feature_flags(deprecated_serverless: false)
- end
-
- it { is_expected.to be_nil }
- end
- end
-
describe 'Terraform' do
let(:item_id) { :terraform }
diff --git a/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb b/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
index e8c6fb790c3..b11c9db4e46 100644
--- a/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
@@ -72,12 +72,28 @@ RSpec.describe Sidebars::Projects::Menus::MonitorMenu do
let(:item_id) { :logs }
it_behaves_like 'access rights checks'
+
+ context 'when feature disabled' do
+ before do
+ stub_feature_flags(monitor_logging: false)
+ end
+
+ specify { is_expected.to be_nil }
+ end
end
describe 'Tracing' do
let(:item_id) { :tracing }
it_behaves_like 'access rights checks'
+
+ context 'when feature disabled' do
+ before do
+ stub_feature_flags(monitor_tracing: false)
+ end
+
+ specify { is_expected.to be_nil }
+ end
end
describe 'Error Tracking' do
diff --git a/spec/lib/tasks/gitlab/metrics_exporter_task_spec.rb b/spec/lib/tasks/gitlab/metrics_exporter_task_spec.rb
new file mode 100644
index 00000000000..dfb3c511470
--- /dev/null
+++ b/spec/lib/tasks/gitlab/metrics_exporter_task_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+require_relative '../../../support/helpers/next_instance_of'
+
+RSpec.describe 'gitlab:metrics_exporter:install' do
+ before do
+ Rake.application.rake_require 'tasks/gitlab/metrics_exporter'
+ end
+
+ subject(:task) do
+ Rake::Task['gitlab:metrics_exporter:install']
+ end
+
+ context 'when no target directory is specified' do
+ it 'aborts with an error message' do
+ expect do
+ expect { task.execute }.to output(/Please specify the directory/).to_stdout
+ end.to raise_error(SystemExit)
+ end
+ end
+
+ context 'when target directory is specified' do
+ let(:args) { Rake::TaskArguments.new(%w(dir), %w(path/to/exporter)) }
+ let(:context) { TOPLEVEL_BINDING.eval('self') }
+ let(:expected_clone_params) do
+ {
+ repo: 'https://gitlab.com/gitlab-org/gitlab-metrics-exporter.git',
+ version: 'main',
+ target_dir: 'path/to/exporter'
+ }
+ end
+
+ context 'when dependencies are missing' do
+ it 'aborts with an error message' do
+ expect(Gitlab::Utils).to receive(:which).with('gmake').ordered
+ expect(Gitlab::Utils).to receive(:which).with('make').ordered
+
+ expect do
+ expect { task.execute(args) }.to output(/Couldn't find a 'make' binary/).to_stdout
+ end.to raise_error(SystemExit)
+ end
+ end
+
+ it 'installs the exporter with gmake' do
+ expect(Gitlab::Utils).to receive(:which).with('gmake').and_return('path/to/gmake').ordered
+ expect(context).to receive(:checkout_or_clone_version).with(hash_including(expected_clone_params)).ordered
+ expect(Dir).to receive(:chdir).with('path/to/exporter').and_yield.ordered
+ expect(context).to receive(:run_command!).with(['path/to/gmake']).ordered
+
+ task.execute(args)
+ end
+
+ it 'installs the exporter with make' do
+ expect(Gitlab::Utils).to receive(:which).with('gmake').ordered
+ expect(Gitlab::Utils).to receive(:which).with('make').and_return('path/to/make').ordered
+ expect(context).to receive(:checkout_or_clone_version).with(hash_including(expected_clone_params)).ordered
+ expect(Dir).to receive(:chdir).with('path/to/exporter').and_yield.ordered
+ expect(context).to receive(:run_command!).with(['path/to/make']).ordered
+
+ task.execute(args)
+ end
+
+ context 'when overriding version via environment variable' do
+ before do
+ stub_env('GITLAB_METRICS_EXPORTER_VERSION', '1.0')
+ end
+
+ it 'clones from repository with that version instead' do
+ expect(Gitlab::Utils).to receive(:which).with('gmake').and_return('path/to/gmake').ordered
+ expect(context).to receive(:checkout_or_clone_version).with(
+ hash_including(expected_clone_params.merge(version: '1.0'))
+ ).ordered
+ expect(Dir).to receive(:chdir).with('path/to/exporter').and_yield.ordered
+ expect(context).to receive(:run_command!).with(['path/to/gmake']).ordered
+
+ task.execute(args)
+ end
+ end
+ end
+end
diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb
index e62719f4283..7f3896a3d51 100644
--- a/spec/mailers/emails/in_product_marketing_spec.rb
+++ b/spec/mailers/emails/in_product_marketing_spec.rb
@@ -103,4 +103,28 @@ RSpec.describe Emails::InProductMarketing do
end
end
end
+
+ describe '#build_ios_app_guide_email' do
+ subject { Notify.build_ios_app_guide_email(user.notification_email_or_default) }
+
+ it 'sends to the right user' do
+ expect(subject).to deliver_to(user.notification_email_or_default)
+ end
+
+ it 'has the correct subject and content' do
+ message = Gitlab::Email::Message::BuildIosAppGuide.new
+ cta_url = 'https://about.gitlab.com/blog/2019/03/06/ios-publishing-with-gitlab-and-fastlane/'
+ cta2_url = 'https://www.youtube.com/watch?v=325FyJt7ZG8'
+
+ aggregate_failures do
+ is_expected.to have_subject(message.subject_line)
+ is_expected.to have_body_text(message.title)
+ is_expected.to have_body_text(message.body_line1)
+ is_expected.to have_body_text(CGI.unescapeHTML(message.cta_link))
+ is_expected.to have_body_text(CGI.unescapeHTML(message.cta2_link))
+ is_expected.to have_body_text(cta_url)
+ is_expected.to have_body_text(cta2_url)
+ end
+ end
+ end
end
diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb
index dea54f7315d..7682cf39450 100644
--- a/spec/mailers/emails/merge_requests_spec.rb
+++ b/spec/mailers/emails/merge_requests_spec.rb
@@ -65,7 +65,9 @@ RSpec.describe Emails::MergeRequests do
is_expected.to have_body_text('due to conflict.')
is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
is_expected.to have_text_part_content(assignee.name)
+ is_expected.to have_html_part_content(assignee.name)
is_expected.to have_text_part_content(reviewer.name)
+ is_expected.to have_html_part_content(reviewer.name)
end
end
end
@@ -182,6 +184,36 @@ RSpec.describe Emails::MergeRequests do
end
end
+ describe '#approved_merge_request_email' do
+ subject { Notify.approved_merge_request_email(recipient.id, merge_request.id, current_user.id) }
+
+ it 'has the correct body' do
+ aggregate_failures do
+ is_expected.to have_body_text('was approved by')
+ is_expected.to have_body_text(current_user.name)
+ is_expected.to have_text_part_content(assignee.name)
+ is_expected.to have_html_part_content(assignee.name)
+ is_expected.to have_text_part_content(reviewer.name)
+ is_expected.to have_html_part_content(reviewer.name)
+ end
+ end
+ end
+
+ describe '#unapproved_merge_request_email' do
+ subject { Notify.unapproved_merge_request_email(recipient.id, merge_request.id, current_user.id) }
+
+ it 'has the correct body' do
+ aggregate_failures do
+ is_expected.to have_body_text('was unapproved by')
+ is_expected.to have_body_text(current_user.name)
+ is_expected.to have_text_part_content(assignee.name)
+ is_expected.to have_html_part_content(assignee.name)
+ is_expected.to have_text_part_content(reviewer.name)
+ is_expected.to have_html_part_content(reviewer.name)
+ end
+ end
+ end
+
describe "#resolved_all_discussions_email" do
subject { Notify.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id) }
diff --git a/spec/mailers/emails/projects_spec.rb b/spec/mailers/emails/projects_spec.rb
index b9c71e35bc6..ef3c21b32ce 100644
--- a/spec/mailers/emails/projects_spec.rb
+++ b/spec/mailers/emails/projects_spec.rb
@@ -180,4 +180,32 @@ RSpec.describe Emails::Projects do
end
end
end
+
+ describe '.inactive_project_deletion_warning_email' do
+ let(:recipient) { user }
+ let(:deletion_date) { "2022-01-10" }
+
+ subject { Notify.inactive_project_deletion_warning_email(project, user, deletion_date) }
+
+ it_behaves_like 'an email sent to a user'
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+
+ it 'has the correct subject and body' do
+ project_link = "<a href=\"#{project.http_url_to_repo}\">#{project.name}</a>"
+
+ is_expected.to have_subject("#{project.name} | Action required: Project #{project.name} is scheduled to be " \
+ "deleted on 2022-01-10 due to inactivity")
+ is_expected.to have_body_text(project.http_url_to_repo)
+ is_expected.to have_body_text("Due to inactivity, the #{project_link} project is scheduled to be deleted " \
+ "on <b>2022-01-10</b>")
+ is_expected.to have_body_text("To ensure #{project_link} is unscheduled for deletion, check that activity has " \
+ "been logged by GitLab")
+ is_expected.to have_body_text("This email supersedes any previous emails about scheduled deletion you may " \
+ "have received for #{project_link}.")
+ end
+ end
end
diff --git a/spec/metrics_server/metrics_server_spec.rb b/spec/metrics_server/metrics_server_spec.rb
index 591840dcba2..4c188a6ba29 100644
--- a/spec/metrics_server/metrics_server_spec.rb
+++ b/spec/metrics_server/metrics_server_spec.rb
@@ -15,6 +15,8 @@ RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath
let(:ruby_sampler_double) { double(Gitlab::Metrics::Samplers::RubySampler) }
before do
+ # Make sure we never actually spawn any new processes in a unit test.
+ %i(spawn fork detach).each { |m| allow(Process).to receive(m) }
# We do not want this to have knock-on effects on the test process.
allow(Gitlab::ProcessManagement).to receive(:modify_signals)
@@ -67,35 +69,107 @@ RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath
end
describe '.spawn' do
- let(:expected_env) do
- {
- 'METRICS_SERVER_TARGET' => target,
- 'WIPE_METRICS_DIR' => '0'
- }
- end
+ context 'for legacy Ruby server' do
+ let(:expected_env) do
+ {
+ 'METRICS_SERVER_TARGET' => target,
+ 'WIPE_METRICS_DIR' => '0',
+ 'GITLAB_CONFIG' => 'path/to/config/gitlab.yml'
+ }
+ end
+
+ before do
+ stub_env('GITLAB_CONFIG', 'path/to/config/gitlab.yml')
+ end
- it 'spawns a new server process and returns its PID' do
- expect(Process).to receive(:spawn).with(
- expected_env,
- end_with('bin/metrics-server'),
- hash_including(pgroup: true)
- ).and_return(99)
- expect(Process).to receive(:detach).with(99)
+ it 'spawns a new server process and returns its PID' do
+ expect(Process).to receive(:spawn).with(
+ expected_env,
+ end_with('bin/metrics-server'),
+ hash_including(pgroup: true)
+ ).and_return(99)
+ expect(Process).to receive(:detach).with(99)
- pid = described_class.spawn(target, metrics_dir: metrics_dir)
+ pid = described_class.spawn(target, metrics_dir: metrics_dir)
- expect(pid).to eq(99)
+ expect(pid).to eq(99)
+ end
end
- context 'when path to gitlab.yml is passed' do
- it 'sets the GITLAB_CONFIG environment variable' do
+ context 'for Golang server' do
+ let(:log_enabled) { false }
+ let(:settings) do
+ {
+ 'web_exporter' => {
+ 'enabled' => true,
+ 'address' => 'localhost',
+ 'port' => '8083',
+ 'log_enabled' => log_enabled
+ },
+ 'sidekiq_exporter' => {
+ 'enabled' => true,
+ 'address' => 'localhost',
+ 'port' => '8082',
+ 'log_enabled' => log_enabled
+ }
+ }
+ end
+
+ let(:expected_port) { target == 'puma' ? '8083' : '8082' }
+ let(:expected_env) do
+ {
+ 'GME_MMAP_METRICS_DIR' => metrics_dir,
+ 'GME_PROBES' => 'self,mmap',
+ 'GME_SERVER_HOST' => 'localhost',
+ 'GME_SERVER_PORT' => expected_port,
+ 'GME_LOG_LEVEL' => 'quiet'
+ }
+ end
+
+ before do
+ stub_env('GITLAB_GOLANG_METRICS_SERVER', '1')
+ allow(::Settings).to receive(:monitoring).and_return(settings)
+ end
+
+ it 'spawns a new server process and returns its PID' do
expect(Process).to receive(:spawn).with(
- expected_env.merge('GITLAB_CONFIG' => 'path/to/config/gitlab.yml'),
- end_with('bin/metrics-server'),
+ expected_env,
+ 'gitlab-metrics-exporter',
+ hash_including(pgroup: true)
+ ).and_return(99)
+ expect(Process).to receive(:detach).with(99)
+
+ pid = described_class.spawn(target, metrics_dir: metrics_dir)
+
+ expect(pid).to eq(99)
+ end
+
+ it 'can launch from explicit path instead of PATH' do
+ expect(Process).to receive(:spawn).with(
+ expected_env,
+ '/path/to/gme/gitlab-metrics-exporter',
hash_including(pgroup: true)
).and_return(99)
- described_class.spawn(target, metrics_dir: metrics_dir, gitlab_config: 'path/to/config/gitlab.yml')
+ described_class.spawn(target, metrics_dir: metrics_dir, path: '/path/to/gme/')
+ end
+
+ context 'when logs are enabled' do
+ let(:log_enabled) { true }
+ let(:expected_log_file) { target == 'puma' ? 'web_exporter.log' : 'sidekiq_exporter.log' }
+
+ it 'sets log related environment variables' do
+ expect(Process).to receive(:spawn).with(
+ expected_env.merge(
+ 'GME_LOG_LEVEL' => 'info',
+ 'GME_LOG_FILE' => File.join(Rails.root, 'log', expected_log_file)
+ ),
+ 'gitlab-metrics-exporter',
+ hash_including(pgroup: true)
+ ).and_return(99)
+
+ described_class.spawn(target, metrics_dir: metrics_dir)
+ end
end
end
end
@@ -112,10 +186,21 @@ RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath
end
describe '.spawn' do
- it 'raises an error' do
- expect { described_class.spawn('unsupported', metrics_dir: metrics_dir) }.to(
- raise_error('Target must be one of [puma,sidekiq]')
- )
+ context 'for legacy Ruby server' do
+ it 'raises an error' do
+ expect { described_class.spawn('unsupported', metrics_dir: metrics_dir) }.to(
+ raise_error('Target must be one of [puma,sidekiq]')
+ )
+ end
+ end
+
+ context 'for Golang server' do
+ it 'raises an error' do
+ stub_env('GITLAB_GOLANG_METRICS_SERVER', '1')
+ expect { described_class.spawn('unsupported', metrics_dir: metrics_dir) }.to(
+ raise_error('Target must be one of [puma,sidekiq]')
+ )
+ end
end
end
end
@@ -220,28 +305,32 @@ RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath
end
context 'when the supervisor callback is invoked' do
- context 'and the supervisor is alive' do
- it 'restarts the metrics server' do
- expect(supervisor).to receive(:alive).and_return(true)
- expect(supervisor).to receive(:supervise).and_yield
- expect(Process).to receive(:spawn).with(
- include('METRICS_SERVER_TARGET' => 'puma'), end_with('bin/metrics-server'), anything
- ).twice.and_return(42)
-
- described_class.start_for_puma
- end
+ it 'restarts the metrics server' do
+ expect(supervisor).to receive(:supervise).and_yield
+ expect(Process).to receive(:spawn).with(
+ include('METRICS_SERVER_TARGET' => 'puma'), end_with('bin/metrics-server'), anything
+ ).twice.and_return(42)
+
+ described_class.start_for_puma
end
+ end
+ end
- context 'and the supervisor is not alive' do
- it 'does not restart the server' do
- expect(supervisor).to receive(:alive).and_return(false)
- expect(supervisor).to receive(:supervise).and_yield
- expect(Process).to receive(:spawn).with(
- include('METRICS_SERVER_TARGET' => 'puma'), end_with('bin/metrics-server'), anything
- ).once.and_return(42)
+ describe '.start_for_sidekiq' do
+ context 'for legacy Ruby server' do
+ it 'forks the parent process' do
+ expect(Process).to receive(:fork).and_return(42)
- described_class.start_for_puma
- end
+ described_class.start_for_sidekiq(metrics_dir: '/path/to/metrics')
+ end
+ end
+
+ context 'for Golang server' do
+ it 'spawns the server process' do
+ stub_env('GITLAB_GOLANG_METRICS_SERVER', '1')
+ expect(Process).to receive(:spawn).and_return(42)
+
+ described_class.start_for_sidekiq(metrics_dir: '/path/to/metrics')
end
end
end
diff --git a/spec/migrations/20210413132500_reschedule_artifact_expiry_backfill_again_spec.rb b/spec/migrations/20210413132500_reschedule_artifact_expiry_backfill_again_spec.rb
deleted file mode 100644
index e1dc7487222..00000000000
--- a/spec/migrations/20210413132500_reschedule_artifact_expiry_backfill_again_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-require_migration!
-
-RSpec.describe RescheduleArtifactExpiryBackfillAgain, :migration do
- let(:migration_class) { Gitlab::BackgroundMigration::BackfillArtifactExpiryDate }
- let(:migration_name) { migration_class.to_s.demodulize }
-
- before do
- table(:namespaces).create!(id: 123, name: 'test_namespace', path: 'test_namespace')
- table(:projects).create!(id: 123, name: 'sample_project', path: 'sample_project', namespace_id: 123)
- end
-
- it 'correctly schedules background migrations' do
- first_artifact = create_artifact(job_id: 0, expire_at: nil, created_at: Date.new(2020, 06, 21))
- second_artifact = create_artifact(job_id: 1, expire_at: nil, created_at: Date.new(2020, 06, 21))
- create_artifact(job_id: 2, expire_at: Date.yesterday, created_at: Date.new(2020, 06, 21))
- create_artifact(job_id: 3, expire_at: nil, created_at: Date.new(2020, 06, 23))
-
- Sidekiq::Testing.fake! do
- freeze_time do
- migrate!
-
- expect(BackgroundMigrationWorker.jobs.size).to eq(1)
- expect(migration_name).to be_scheduled_migration_with_multiple_args(first_artifact.id, second_artifact.id)
- end
- end
- end
-
- private
-
- def create_artifact(params)
- table(:ci_builds).create!(id: params[:job_id], project_id: 123)
- table(:ci_job_artifacts).create!(project_id: 123, file_type: 1, **params)
- end
-end
diff --git a/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb
index 9addaaf2551..d1c04c5d320 100644
--- a/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb
+++ b/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
+
require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid.rb')
+require_migration!
def create_background_migration_jobs(ids, status, created_at)
proper_status = case status
diff --git a/spec/migrations/20220124130028_dedup_runner_projects_spec.rb b/spec/migrations/20220124130028_dedup_runner_projects_spec.rb
index 2698af6f6f5..127f4798f33 100644
--- a/spec/migrations/20220124130028_dedup_runner_projects_spec.rb
+++ b/spec/migrations/20220124130028_dedup_runner_projects_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20220124130028_dedup_runner_projects.rb')
+require_migration!
RSpec.describe DedupRunnerProjects, :migration, schema: 20220120085655 do
let(:namespaces) { table(:namespaces) }
diff --git a/spec/migrations/20220213103859_remove_integrations_type_spec.rb b/spec/migrations/20220213103859_remove_integrations_type_spec.rb
new file mode 100644
index 00000000000..b1a4370700a
--- /dev/null
+++ b/spec/migrations/20220213103859_remove_integrations_type_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe RemoveIntegrationsType, :migration do
+ subject(:migration) { described_class.new }
+
+ let(:integrations) { table(:integrations) }
+ let(:bg_migration) { instance_double(bg_migration_class) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+ end
+
+ it 'performs remaining background migrations', :aggregate_failures do
+ # Already migrated
+ integrations.create!(type: 'SlackService', type_new: 'Integrations::Slack')
+ # update required
+ record1 = integrations.create!(type: 'SlackService')
+ record2 = integrations.create!(type: 'JiraService')
+ record3 = integrations.create!(type: 'SlackService')
+
+ migrate!
+
+ expect(record1.reload.type_new).to eq 'Integrations::Slack'
+ expect(record2.reload.type_new).to eq 'Integrations::Jira'
+ expect(record3.reload.type_new).to eq 'Integrations::Slack'
+ end
+end
diff --git a/spec/migrations/20220324165436_schedule_backfill_project_settings_spec.rb b/spec/migrations/20220324165436_schedule_backfill_project_settings_spec.rb
new file mode 100644
index 00000000000..a8014e73bf0
--- /dev/null
+++ b/spec/migrations/20220324165436_schedule_backfill_project_settings_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ScheduleBackfillProjectSettings do
+ let_it_be(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules background jobs for each batch of projects' do
+ migrate!
+
+ expect(migration).to(
+ have_scheduled_batched_migration(
+ table_name: :projects,
+ column_name: :id,
+ interval: described_class::INTERVAL
+ )
+ )
+ end
+ end
+end
diff --git a/spec/migrations/20220331133802_schedule_backfill_topics_title_spec.rb b/spec/migrations/20220331133802_schedule_backfill_topics_title_spec.rb
new file mode 100644
index 00000000000..13e8c42269b
--- /dev/null
+++ b/spec/migrations/20220331133802_schedule_backfill_topics_title_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ScheduleBackfillTopicsTitle do
+ let(:topics) { table(:topics) }
+
+ let!(:topic1) { topics.create!(name: 'topic1') }
+ let!(:topic2) { topics.create!(name: 'topic2') }
+ let!(:topic3) { topics.create!(name: 'topic3') }
+
+ it 'correctly schedules background migrations', :aggregate_failures do
+ stub_const("#{Gitlab::Database::Migrations::BackgroundMigrationHelpers}::BATCH_SIZE", 2)
+
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, topic1.id, topic2.id)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, topic3.id, topic3.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20220420135946_update_batched_background_migration_arguments_spec.rb b/spec/migrations/20220420135946_update_batched_background_migration_arguments_spec.rb
new file mode 100644
index 00000000000..6dbee483e15
--- /dev/null
+++ b/spec/migrations/20220420135946_update_batched_background_migration_arguments_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe UpdateBatchedBackgroundMigrationArguments do
+ let(:batched_migrations) { table(:batched_background_migrations) }
+
+ before do
+ common_attributes = {
+ max_value: 10,
+ batch_size: 5,
+ sub_batch_size: 2,
+ interval: 2.minutes,
+ table_name: 'events',
+ column_name: 'id'
+ }
+
+ batched_migrations.create!(common_attributes.merge(job_class_name: 'Job1', job_arguments: '[]'))
+ batched_migrations.create!(common_attributes.merge(job_class_name: 'Job2', job_arguments: '["some_argument"]'))
+ batched_migrations.create!(common_attributes.merge(job_class_name: 'Job3', job_arguments: '[]'))
+ end
+
+ describe '#up' do
+ it 'updates batched migration arguments to have an empty jsonb array' do
+ expect { migrate! }
+ .to change { batched_migrations.where("job_arguments = '[]'").count }.from(0).to(2)
+ .and change { batched_migrations.where("job_arguments = '\"[]\"'").count }.from(2).to(0)
+ end
+ end
+
+ describe '#down' do
+ before do
+ migrate!
+ end
+
+ it 'reverts batched migration arguments to have the previous default' do
+ expect { schema_migrate_down! }
+ .to change { batched_migrations.where("job_arguments = '\"[]\"'").count }.from(0).to(2)
+ .and change { batched_migrations.where("job_arguments = '[]'").count }.from(2).to(0)
+ end
+ end
+end
diff --git a/spec/migrations/20220426185933_backfill_deployments_finished_at_spec.rb b/spec/migrations/20220426185933_backfill_deployments_finished_at_spec.rb
new file mode 100644
index 00000000000..c79325c5077
--- /dev/null
+++ b/spec/migrations/20220426185933_backfill_deployments_finished_at_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe BackfillDeploymentsFinishedAt, :migration do
+ let(:deployments) { table(:deployments) }
+ let(:namespaces) { table(:namespaces) }
+
+ let(:namespace) { namespaces.create!(name: 'user', path: 'user') }
+ let(:project_namespace) { namespaces.create!(name: 'project', path: 'project', type: 'Project') }
+ let(:project) { table(:projects).create!(namespace_id: namespace.id, project_namespace_id: project_namespace.id) }
+ let(:environment) { table(:environments).create!(name: 'production', slug: 'production', project_id: project.id) }
+
+ describe '#up' do
+ context 'when a deployment row does not have a value for finished_at' do
+ context 'and deployment succeeded' do
+ before do
+ create_deployment!(status: described_class::DEPLOYMENT_STATUS_SUCCESS, finished_at: nil)
+ end
+
+ it 'copies created_at to finished_at' do
+ expect { migrate! }
+ .to change { deployments.last.finished_at }.from(nil).to(deployments.last.created_at)
+ .and not_change { deployments.last.created_at }
+ end
+ end
+
+ context 'and deployment does not have status: success' do
+ before do
+ create_deployment!(status: 0, finished_at: nil)
+ create_deployment!(status: 1, finished_at: nil)
+ create_deployment!(status: 3, finished_at: nil)
+ create_deployment!(status: 4, finished_at: nil)
+ create_deployment!(status: 5, finished_at: nil)
+ create_deployment!(status: 6, finished_at: nil)
+ end
+
+ it 'does not fill finished_at' do
+ expect { migrate! }.to not_change { deployments.where(finished_at: nil).count }
+ end
+ end
+ end
+
+ context 'when a deployment row has value for finished_at' do
+ let(:finished_at) { '2018-10-30 11:12:02 UTC' }
+
+ before do
+ create_deployment!(status: described_class::DEPLOYMENT_STATUS_SUCCESS, finished_at: finished_at)
+ end
+
+ it 'does not affect existing value' do
+ expect { migrate! }
+ .to not_change { deployments.last.finished_at }
+ .and not_change { deployments.last.created_at }
+ end
+ end
+ end
+
+ def create_deployment!(status:, finished_at:)
+ deployments.create!(
+ environment_id: environment.id,
+ project_id: project.id,
+ ref: 'master',
+ tag: false,
+ sha: 'x',
+ status: status,
+ iid: deployments.count + 1,
+ finished_at: finished_at
+ )
+ end
+end
diff --git a/spec/migrations/20220502015011_clean_up_fix_merge_request_diff_commit_users_spec.rb b/spec/migrations/20220502015011_clean_up_fix_merge_request_diff_commit_users_spec.rb
new file mode 100644
index 00000000000..769c0993b67
--- /dev/null
+++ b/spec/migrations/20220502015011_clean_up_fix_merge_request_diff_commit_users_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration! 'clean_up_fix_merge_request_diff_commit_users'
+
+RSpec.describe CleanUpFixMergeRequestDiffCommitUsers, :migration do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:project_namespace) { namespaces.create!(name: 'project2', path: 'project2', type: 'Project') }
+ let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
+
+ describe '#up' do
+ it 'finalizes the background migration' do
+ expect(described_class).to be_finalize_background_migration_of('FixMergeRequestDiffCommitUsers')
+
+ migrate!
+ end
+
+ it 'processes pending background jobs' do
+ project = projects.create!(name: 'p1', namespace_id: namespace.id, project_namespace_id: project_namespace.id)
+
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: 'FixMergeRequestDiffCommitUsers',
+ arguments: [project.id]
+ )
+
+ migrate!
+
+ background_migrations = Gitlab::Database::BackgroundMigrationJob
+ .where(class_name: 'FixMergeRequestDiffCommitUsers')
+
+ expect(background_migrations.count).to eq(0)
+ end
+ end
+end
diff --git a/spec/migrations/20220502173045_reset_too_many_tags_skipped_registry_imports_spec.rb b/spec/migrations/20220502173045_reset_too_many_tags_skipped_registry_imports_spec.rb
new file mode 100644
index 00000000000..cc4041fe151
--- /dev/null
+++ b/spec/migrations/20220502173045_reset_too_many_tags_skipped_registry_imports_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ResetTooManyTagsSkippedRegistryImports, :aggregate_failures do
+ let(:migration) { described_class::MIGRATION }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:container_repositories) { table(:container_repositories) }
+
+ let!(:namespace) { namespaces.create!(id: 1, name: 'namespace', path: 'namespace') }
+ let!(:project) { projects.create!(id: 1, name: 'project', path: 'project', project_namespace_id: 1, namespace_id: 1) }
+
+ let!(:container_repository1) do
+ container_repositories.create!(
+ name: 'container_repository1',
+ project_id: 1,
+ migration_state: 'import_skipped',
+ migration_skipped_reason: 2
+ )
+ end
+
+ let!(:container_repository2) do
+ container_repositories.create!(
+ name: 'container_repository2',
+ project_id: 1,
+ migration_state: 'import_skipped',
+ migration_skipped_reason: 2
+ )
+ end
+
+ let!(:container_repository3) do
+ container_repositories.create!(
+ name: 'container_repository3',
+ project_id: 1,
+ migration_state: 'import_skipped',
+ migration_skipped_reason: 2
+ )
+ end
+
+ # this should not qualify for the migration
+ let!(:container_repository4) do
+ container_repositories.create!(
+ name: 'container_repository4',
+ project_id: 1,
+ migration_state: 'default'
+ )
+ end
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+ end
+
+ it 'schedules jobs to reset skipped registry imports' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ expect(migration).to be_scheduled_delayed_migration(
+ 2.minutes, container_repository1.id, container_repository2.id)
+ expect(migration).to be_scheduled_delayed_migration(
+ 4.minutes, container_repository3.id, container_repository3.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb b/spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb
new file mode 100644
index 00000000000..8bc336a6b26
--- /dev/null
+++ b/spec/migrations/20220505044348_fix_automatic_iterations_cadences_start_date_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe FixAutomaticIterationsCadencesStartDate,
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/362446' do
+ let(:migration) { described_class.new }
+ let(:namespaces) { table(:namespaces) }
+ let(:sprints) { table(:sprints) }
+ let(:iterations_cadences) { table(:iterations_cadences) }
+
+ let!(:group1) { namespaces.create!(name: 'abc', path: 'abc') }
+ let!(:group2) { namespaces.create!(name: 'def', path: 'def') }
+
+ let(:jan2022) { Date.new(2022, 1, 1) }
+ let(:feb2022) { Date.new(2022, 2, 1) }
+ let(:may2022) { Date.new(2022, 5, 1) }
+ let(:dec2022) { Date.new(2022, 12, 1) }
+
+ let!(:cadence1) { iterations_cadences.create!(start_date: jan2022, title: "ic 1", group_id: group1.id) }
+ let!(:cadence2) { iterations_cadences.create!(start_date: may2022, group_id: group1.id, title: "ic 2") }
+ let!(:cadence3) do
+ iterations_cadences.create!(start_date: jan2022, automatic: false, group_id: group2.id, title: "ic 3 (invalid)")
+ end
+
+ let!(:cadence4) { iterations_cadences.create!(start_date: jan2022, group_id: group2.id, title: "ic 4 (invalid)") }
+
+ before do
+ sprints.create!(id: 2, start_date: jan2022, due_date: jan2022 + 1.week, iterations_cadence_id: cadence1.id,
+ group_id: group1.id, iid: 1)
+ sprints.create!(id: 1, start_date: dec2022, due_date: dec2022 + 1.week, iterations_cadence_id: cadence1.id,
+ group_id: group1.id, iid: 2)
+
+ sprints.create!(id: 4, start_date: feb2022, due_date: feb2022 + 1.week, iterations_cadence_id: cadence3.id,
+ group_id: group2.id, iid: 1)
+ sprints.create!(id: 3, start_date: may2022, due_date: may2022 + 1.week, iterations_cadence_id: cadence3.id,
+ group_id: group2.id, iid: 2)
+
+ sprints.create!(id: 5, start_date: may2022, due_date: may2022 + 1.week, iterations_cadence_id: cadence4.id,
+ group_id: group2.id, iid: 4)
+ sprints.create!(id: 6, start_date: feb2022, due_date: feb2022 + 1.week, iterations_cadence_id: cadence4.id,
+ group_id: group2.id, iid: 3)
+ end
+
+ describe '#up' do
+ it "updates automatic iterations_cadence records to use start dates of their earliest sprint records" do
+ migrate!
+
+ # This cadence has a valid start date. Its start date should be left as it is
+ expect(cadence1.reload.start_date).to eq jan2022
+
+ # This cadence doesn't have an iteration. Its start date should be left as it is.
+ expect(cadence2.reload.start_date).to eq may2022
+
+ # This cadence has an invalid start date but it isn't automatic. Its start date should be left as it is.
+ expect(cadence3.reload.start_date).to eq jan2022
+
+ # This cadence has an invalid start date. Its start date should be fixed.
+ expect(cadence4.reload.start_date).to eq feb2022
+ end
+ end
+end
diff --git a/spec/migrations/20220505174658_update_index_on_alerts_to_exclude_null_fingerprints_spec.rb b/spec/migrations/20220505174658_update_index_on_alerts_to_exclude_null_fingerprints_spec.rb
new file mode 100644
index 00000000000..0c4d0e86789
--- /dev/null
+++ b/spec/migrations/20220505174658_update_index_on_alerts_to_exclude_null_fingerprints_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe UpdateIndexOnAlertsToExcludeNullFingerprints do
+ let(:alerts) { 'alert_management_alerts'}
+ let(:old_index) { described_class::OLD_INDEX_NAME }
+ let(:new_index) { described_class::NEW_INDEX_NAME }
+
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(subject.index_exists_by_name?(alerts, old_index)).to be_truthy
+ expect(subject.index_exists_by_name?(alerts, new_index)).to be_falsey
+ }
+
+ migration.after -> {
+ expect(subject.index_exists_by_name?(alerts, old_index)).to be_falsey
+ expect(subject.index_exists_by_name?(alerts, new_index)).to be_truthy
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb b/spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb
new file mode 100644
index 00000000000..63fff279acc
--- /dev/null
+++ b/spec/migrations/20220513043344_reschedule_expire_o_auth_tokens_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RescheduleExpireOAuthTokens do
+ let_it_be(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules background jobs for each batch of oauth tokens' do
+ migrate!
+
+ expect(migration).to(
+ have_scheduled_batched_migration(
+ table_name: :oauth_access_tokens,
+ column_name: :id,
+ interval: described_class::INTERVAL
+ )
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/associate_existing_dast_builds_with_variables_spec.rb b/spec/migrations/associate_existing_dast_builds_with_variables_spec.rb
index ce0ab4223e8..74429e498df 100644
--- a/spec/migrations/associate_existing_dast_builds_with_variables_spec.rb
+++ b/spec/migrations/associate_existing_dast_builds_with_variables_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-require Rails.root.join('db', 'migrate', '20210629031900_associate_existing_dast_builds_with_variables.rb')
+require_migration!
RSpec.describe AssociateExistingDastBuildsWithVariables do
subject(:migration) { described_class.new }
diff --git a/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb b/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb
index 1a64de8d0db..16a08ec47c4 100644
--- a/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb
+++ b/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb
@@ -2,7 +2,6 @@
require 'spec_helper'
require_migration!
-# require Rails.root.join('db', 'post_migrate', '20210825193652_backfill_candence_id_for_boards_scoped_to_iteration.rb')
RSpec.describe BackfillCadenceIdForBoardsScopedToIteration, :migration do
let(:projects) { table(:projects) }
diff --git a/spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb b/spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb
new file mode 100644
index 00000000000..28578a3d79a
--- /dev/null
+++ b/spec/migrations/backfill_integrations_enable_ssl_verification_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe BackfillIntegrationsEnableSslVerification do
+ let_it_be(:migration) { described_class::MIGRATION }
+ let_it_be(:integrations) { described_class::Integration }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+
+ integrations.create!(id: 1, type_new: 'Integrations::DroneCi')
+ integrations.create!(id: 2, type_new: 'Integrations::DroneCi', properties: {})
+ integrations.create!(id: 3, type_new: 'Integrations::Bamboo', properties: {})
+ integrations.create!(id: 4, type_new: 'Integrations::Teamcity', properties: {})
+ integrations.create!(id: 5, type_new: 'Integrations::DroneCi', properties: {})
+ integrations.create!(id: 6, type_new: 'Integrations::Teamcity', properties: {})
+ end
+
+ describe '#up' do
+ it 'schedules background jobs for each batch of integrations', :freeze_time do
+ Sidekiq::Testing.fake! do
+ migrate!
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ expect(migration).to be_scheduled_delayed_migration(5.minutes, 2, 4)
+ expect(migration).to be_scheduled_delayed_migration(10.minutes, 5, 6)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb b/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb
deleted file mode 100644
index 6798b0cc7e8..00000000000
--- a/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe BackfillWorkItemTypeIdOnIssues, :migration do
- let_it_be(:migration) { described_class::MIGRATION }
- let_it_be(:interval) { 2.minutes }
- let_it_be(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } }
- let_it_be(:base_work_item_type_ids) do
- table(:work_item_types).where(namespace_id: nil).order(:base_type).each_with_object({}) do |type, hash|
- hash[type.base_type] = type.id
- end
- end
-
- describe '#up' do
- it 'correctly schedules background migrations' do
- Sidekiq::Testing.fake! do
- freeze_time do
- migrate!
-
- scheduled_migrations = Gitlab::Database::BackgroundMigration::BatchedMigration.where(job_class_name: migration)
- work_item_types = table(:work_item_types).where(namespace_id: nil)
-
- expect(scheduled_migrations.count).to eq(work_item_types.count)
-
- [:issue, :incident, :test_case, :requirement, :task].each do |issue_type|
- expect(migration).to have_scheduled_batched_migration(
- table_name: :issues,
- column_name: :id,
- job_arguments: [issue_type_enum[issue_type], base_work_item_type_ids[issue_type_enum[issue_type]]],
- interval: interval,
- batch_size: described_class::BATCH_SIZE,
- max_batch_size: described_class::MAX_BATCH_SIZE,
- sub_batch_size: described_class::SUB_BATCH_SIZE,
- batch_class_name: described_class::BATCH_CLASS_NAME
- )
- end
- end
- end
- end
- end
-
- describe '#down' do
- it 'deletes all batched migration records' do
- migrate!
- schema_migrate_down!
-
- expect(migration).not_to have_scheduled_batched_migration
- end
- end
-end
diff --git a/spec/migrations/cleanup_after_fixing_regression_with_new_users_emails_spec.rb b/spec/migrations/cleanup_after_fixing_regression_with_new_users_emails_spec.rb
new file mode 100644
index 00000000000..043bb091df3
--- /dev/null
+++ b/spec/migrations/cleanup_after_fixing_regression_with_new_users_emails_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe CleanupAfterFixingRegressionWithNewUsersEmails, :sidekiq do
+ let(:migration) { described_class.new }
+ let(:users) { table(:users) }
+ let(:emails) { table(:emails) }
+
+ # rubocop: disable Layout/LineLength
+ let!(:user_1) { users.create!(name: 'confirmed-user-1', email: 'confirmed-1@example.com', confirmed_at: 3.days.ago, projects_limit: 100) }
+ let!(:user_2) { users.create!(name: 'confirmed-user-2', email: 'confirmed-2@example.com', confirmed_at: 1.day.ago, projects_limit: 100) }
+ let!(:user_3) { users.create!(name: 'confirmed-user-3', email: 'confirmed-3@example.com', confirmed_at: 1.day.ago, projects_limit: 100) }
+ let!(:user_4) { users.create!(name: 'unconfirmed-user', email: 'unconfirmed@example.com', confirmed_at: nil, projects_limit: 100) }
+
+ let!(:email_1) { emails.create!(email: 'confirmed-1@example.com', user_id: user_1.id, confirmed_at: 1.day.ago) }
+ let!(:email_2) { emails.create!(email: 'other_2@example.com', user_id: user_2.id, confirmed_at: 1.day.ago) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+ end
+
+ it 'adds primary email to emails for confirmed users that do not have their primary email in emails table', :aggregate_failures do
+ original_email_1_confirmed_at = email_1.reload.confirmed_at
+
+ expect { migration.up }.to change { emails.count }.by(2)
+
+ expect(emails.find_by(user_id: user_2.id, email: 'confirmed-2@example.com').confirmed_at).to eq(user_2.reload.confirmed_at)
+ expect(emails.find_by(user_id: user_3.id, email: 'confirmed-3@example.com').confirmed_at).to eq(user_3.reload.confirmed_at)
+ expect(email_1.reload.confirmed_at).to eq(original_email_1_confirmed_at)
+
+ expect(emails.exists?(user_id: user_4.id)).to be(false)
+ end
+ # rubocop: enable Layout/LineLength
+
+ it 'continues in case of errors with one email' do
+ allow(Email).to receive(:create) { raise 'boom!' }
+
+ expect { migration.up }.not_to raise_error
+ end
+end
diff --git a/spec/migrations/finalize_project_namespaces_backfill_spec.rb b/spec/migrations/finalize_project_namespaces_backfill_spec.rb
index 3d0b0ec13fe..56f3b0f6ba5 100644
--- a/spec/migrations/finalize_project_namespaces_backfill_spec.rb
+++ b/spec/migrations/finalize_project_namespaces_backfill_spec.rb
@@ -9,9 +9,11 @@ RSpec.describe FinalizeProjectNamespacesBackfill, :migration do
let_it_be(:migration) { described_class::MIGRATION }
describe '#up' do
- shared_examples 'raises migration not finished exception' do
- it 'raises exception' do
- expect { migrate! }.to raise_error(/Expected batched background migration for the given configuration to be marked as 'finished'/)
+ shared_examples 'finalizes the migration' do
+ it 'finalizes the migration' do
+ allow_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner) do |runner|
+ expect(runner).to receive(:finalize).with('"ProjectNamespaces::BackfillProjectNamespaces"', :projects, :id, [nil, "up"])
+ end
end
end
@@ -42,7 +44,7 @@ RSpec.describe FinalizeProjectNamespacesBackfill, :migration do
context 'when project namespace backfilling migration finished successfully' do
it 'does not raise exception' do
- expect { migrate! }.not_to raise_error(/Expected batched background migration for the given configuration to be marked as 'finished'/)
+ expect { migrate! }.not_to raise_error
end
end
@@ -61,7 +63,7 @@ RSpec.describe FinalizeProjectNamespacesBackfill, :migration do
project_namespace_backfill.update!(status: status)
end
- it_behaves_like 'raises migration not finished exception'
+ it_behaves_like 'finalizes the migration'
end
end
end
diff --git a/spec/migrations/insert_ci_daily_pipeline_schedule_triggers_plan_limits_spec.rb b/spec/migrations/insert_ci_daily_pipeline_schedule_triggers_plan_limits_spec.rb
index 4b8d3641247..1b6cb6a86a0 100644
--- a/spec/migrations/insert_ci_daily_pipeline_schedule_triggers_plan_limits_spec.rb
+++ b/spec/migrations/insert_ci_daily_pipeline_schedule_triggers_plan_limits_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-require Rails.root.join('db', 'migrate', '20210526190553_insert_ci_daily_pipeline_schedule_triggers_plan_limits.rb')
+require_migration!
RSpec.describe InsertCiDailyPipelineScheduleTriggersPlanLimits do
let_it_be(:plans) { table(:plans) }
diff --git a/spec/migrations/migrate_protected_attribute_to_pending_builds_spec.rb b/spec/migrations/migrate_protected_attribute_to_pending_builds_spec.rb
index e838476a650..2108adcc973 100644
--- a/spec/migrations/migrate_protected_attribute_to_pending_builds_spec.rb
+++ b/spec/migrations/migrate_protected_attribute_to_pending_builds_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20210610102413_migrate_protected_attribute_to_pending_builds.rb')
+require_migration!
RSpec.describe MigrateProtectedAttributeToPendingBuilds do
let(:namespaces) { table(:namespaces) }
diff --git a/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb b/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb
deleted file mode 100644
index 5e22fc06973..00000000000
--- a/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require_migration!
-
-RSpec.describe ReplaceWorkItemTypeBackfillNextBatchStrategy, :migration do
- describe '#up' do
- it 'sets the new strategy for existing migrations' do
- migrations = create_migrations(described_class::OLD_STRATEGY_CLASS, 2)
-
- expect do
- migrate!
-
- migrations.each(&:reload)
- end.to change { migrations.pluck(:batch_class_name).uniq }.from([described_class::OLD_STRATEGY_CLASS])
- .to([described_class::NEW_STRATEGY_CLASS])
- end
- end
-
- describe '#down' do
- it 'sets the old strategy for existing migrations' do
- migrations = create_migrations(described_class::NEW_STRATEGY_CLASS, 2)
-
- expect do
- migrate!
- schema_migrate_down!
-
- migrations.each(&:reload)
- end.to change { migrations.pluck(:batch_class_name).uniq }.from([described_class::NEW_STRATEGY_CLASS])
- .to([described_class::OLD_STRATEGY_CLASS])
- end
- end
-
- def create_migrations(batch_class_name, count)
- Array.new(2) { |index| create_background_migration(batch_class_name, [index]) }
- end
-
- def create_background_migration(batch_class_name, job_arguments)
- migrations_table = table(:batched_background_migrations)
-
- migrations_table.create!(
- batch_class_name: batch_class_name,
- job_class_name: described_class::JOB_CLASS_NAME,
- max_value: 10,
- batch_size: 5,
- sub_batch_size: 1,
- interval: 2.minutes,
- table_name: :issues,
- column_name: :id,
- total_tuple_count: 10_000,
- pause_ms: 100,
- job_arguments: job_arguments
- )
- end
-end
diff --git a/spec/migrations/retry_backfill_traversal_ids_spec.rb b/spec/migrations/retry_backfill_traversal_ids_spec.rb
index e5ebd4228ca..910be9f2c69 100644
--- a/spec/migrations/retry_backfill_traversal_ids_spec.rb
+++ b/spec/migrations/retry_backfill_traversal_ids_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20210604070207_retry_backfill_traversal_ids.rb')
+require_migration!
RSpec.describe RetryBackfillTraversalIds, :migration do
include ReloadHelpers
diff --git a/spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb b/spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb
new file mode 100644
index 00000000000..9d7651d01ed
--- /dev/null
+++ b/spec/migrations/schedule_backfill_draft_status_on_merge_requests_corrected_regex_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe ScheduleBackfillDraftStatusOnMergeRequestsCorrectedRegex, :sidekiq do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:merge_requests) { table(:merge_requests) }
+
+ let!(:namespace) { namespaces.create!(name: 'namespace', path: 'namespace') }
+ let(:proj_namespace) { namespaces.create!(name: 'proj1', path: 'proj1', type: 'Project', parent_id: namespace.id) }
+ let!(:project) { projects.create!(namespace_id: namespace.id, project_namespace_id: proj_namespace.id) }
+
+ let(:draft_prefixes) { ["[Draft]", "(Draft)", "Draft:", "Draft", "[WIP]", "WIP:", "WIP"] }
+
+ def create_merge_request(params)
+ common_params = {
+ target_project_id: project.id,
+ target_branch: 'feature1',
+ source_branch: 'master'
+ }
+
+ merge_requests.create!(common_params.merge(params))
+ end
+
+ before do
+ draft_prefixes.each do |prefix|
+ (1..4).each do |n|
+ create_merge_request(
+ title: "#{prefix} This is a title",
+ draft: false,
+ state_id: n
+ )
+
+ create_merge_request(
+ title: "This is a title with the #{prefix} in a weird spot",
+ draft: false,
+ state_id: n
+ )
+ end
+ end
+
+ stub_const("#{described_class}::BATCH_SIZE", 1)
+ end
+
+ it 'schedules BackfillDraftStatusOnMergeRequests background jobs' do
+ Sidekiq::Testing.fake! do
+ draft_mrs = MergeRequest.where(state_id: 1)
+ .where(draft: false)
+ .where("title ~* ?", described_class::CORRECTED_REGEXP_STR)
+
+ first_mr_id = draft_mrs.first.id
+ second_mr_id = draft_mrs.second.id
+
+ freeze_time do
+ migrate!
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(7)
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(2.minutes, first_mr_id, first_mr_id)
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(4.minutes, second_mr_id, second_mr_id)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/toggle_vsa_aggregations_enable_spec.rb b/spec/migrations/toggle_vsa_aggregations_enable_spec.rb
new file mode 100644
index 00000000000..a6850d493b7
--- /dev/null
+++ b/spec/migrations/toggle_vsa_aggregations_enable_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ToggleVsaAggregationsEnable, :migration do
+ let(:aggregations) { table(:analytics_cycle_analytics_aggregations) }
+ let(:groups) { table(:namespaces) }
+
+ let!(:group1) { groups.create!(name: 'aaa', path: 'aaa') }
+ let!(:group2) { groups.create!(name: 'aaa', path: 'aaa') }
+ let!(:group3) { groups.create!(name: 'aaa', path: 'aaa') }
+
+ let!(:aggregation1) { aggregations.create!(group_id: group1.id, enabled: false) }
+ let!(:aggregation2) { aggregations.create!(group_id: group2.id, enabled: true) }
+ let!(:aggregation3) { aggregations.create!(group_id: group3.id, enabled: false) }
+
+ it 'makes all aggregations enabled' do
+ migrate!
+
+ expect(aggregation1.reload).to be_enabled
+ expect(aggregation2.reload).to be_enabled
+ expect(aggregation3.reload).to be_enabled
+ end
+end
diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb
index e87996fc1f0..3871b18fdd5 100644
--- a/spec/models/abuse_report_spec.rb
+++ b/spec/models/abuse_report_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe AbuseReport do
end
it 'lets a worker delete the user' do
- expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, hard_delete: true)
+ expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, { hard_delete: true })
subject.remove_user(deleted_by: user)
end
diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb
index 40bdfd4bc92..685ed81ec84 100644
--- a/spec/models/alert_management/alert_spec.rb
+++ b/spec/models/alert_management/alert_spec.rb
@@ -233,6 +233,17 @@ RSpec.describe AlertManagement::Alert do
end
end
+ describe '.find_unresolved_alert' do
+ let_it_be(:fingerprint) { SecureRandom.hex }
+ let_it_be(:resolved_alert_with_fingerprint) { create(:alert_management_alert, :resolved, project: project, fingerprint: fingerprint) }
+ let_it_be(:alert_with_fingerprint_in_other_project) { create(:alert_management_alert, project: project2, fingerprint: fingerprint) }
+ let_it_be(:alert_with_fingerprint) { create(:alert_management_alert, project: project, fingerprint: fingerprint) }
+
+ subject { described_class.find_unresolved_alert(project, fingerprint) }
+
+ it { is_expected.to eq(alert_with_fingerprint) }
+ end
+
describe '.last_prometheus_alert_by_project_id' do
subject { described_class.last_prometheus_alert_by_project_id }
diff --git a/spec/models/alert_management/metric_image_spec.rb b/spec/models/alert_management/metric_image_spec.rb
index dedbd6e501e..ca910474423 100644
--- a/spec/models/alert_management/metric_image_spec.rb
+++ b/spec/models/alert_management/metric_image_spec.rb
@@ -15,12 +15,4 @@ RSpec.describe AlertManagement::MetricImage do
it { is_expected.to validate_length_of(:url).is_at_most(255) }
it { is_expected.to validate_length_of(:url_text).is_at_most(128) }
end
-
- describe '.available_for?' do
- subject { described_class.available_for?(issue.project) }
-
- let_it_be_with_refind(:issue) { create(:issue) }
-
- it { is_expected.to eq(true) }
- end
end
diff --git a/spec/models/analytics/cycle_analytics/aggregation_spec.rb b/spec/models/analytics/cycle_analytics/aggregation_spec.rb
index 6071e4b3d21..2fb40852791 100644
--- a/spec/models/analytics/cycle_analytics/aggregation_spec.rb
+++ b/spec/models/analytics/cycle_analytics/aggregation_spec.rb
@@ -43,6 +43,37 @@ RSpec.describe Analytics::CycleAnalytics::Aggregation, type: :model do
end
end
+ describe '#consistency_check_cursor_for' do
+ it 'returns empty cursor' do
+ expect(aggregation.consistency_check_cursor_for(Analytics::CycleAnalytics::IssueStageEvent)).to eq({})
+ expect(aggregation.consistency_check_cursor_for(Analytics::CycleAnalytics::MergeRequestStageEvent)).to eq({})
+ end
+
+ it 'returns the cursor value for IssueStageEvent' do
+ aggregation.last_consistency_check_issues_start_event_timestamp = 2.weeks.ago
+ aggregation.last_consistency_check_issues_end_event_timestamp = 1.week.ago
+ aggregation.last_consistency_check_issues_issuable_id = 42
+
+ expect(aggregation.consistency_check_cursor_for(Analytics::CycleAnalytics::IssueStageEvent)).to eq({
+ start_event_timestamp: aggregation.last_consistency_check_issues_start_event_timestamp,
+ end_event_timestamp: aggregation.last_consistency_check_issues_end_event_timestamp,
+ issue_id: aggregation.last_consistency_check_issues_issuable_id
+ })
+ end
+
+ it 'returns the cursor value for MergeRequestStageEvent' do
+ aggregation.last_consistency_check_merge_requests_start_event_timestamp = 2.weeks.ago
+ aggregation.last_consistency_check_merge_requests_end_event_timestamp = 1.week.ago
+ aggregation.last_consistency_check_merge_requests_issuable_id = 42
+
+ expect(aggregation.consistency_check_cursor_for(Analytics::CycleAnalytics::MergeRequestStageEvent)).to eq({
+ start_event_timestamp: aggregation.last_consistency_check_merge_requests_start_event_timestamp,
+ end_event_timestamp: aggregation.last_consistency_check_merge_requests_end_event_timestamp,
+ merge_request_id: aggregation.last_consistency_check_merge_requests_issuable_id
+ })
+ end
+ end
+
describe '#refresh_last_run' do
it 'updates the run_at column' do
freeze_time do
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 541fa1ac77a..20cd96e831c 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -83,10 +83,14 @@ RSpec.describe ApplicationSetting do
it { is_expected.to validate_numericality_of(:container_registry_import_max_retries).only_integer.is_greater_than_or_equal_to(0) }
it { is_expected.to validate_numericality_of(:container_registry_import_start_max_retries).only_integer.is_greater_than_or_equal_to(0) }
it { is_expected.to validate_numericality_of(:container_registry_import_max_step_duration).only_integer.is_greater_than_or_equal_to(0) }
+ it { is_expected.to validate_numericality_of(:container_registry_pre_import_timeout).only_integer.is_greater_than_or_equal_to(0) }
+ it { is_expected.to validate_numericality_of(:container_registry_import_timeout).only_integer.is_greater_than_or_equal_to(0) }
it { is_expected.not_to allow_value(nil).for(:container_registry_import_max_tags_count) }
it { is_expected.not_to allow_value(nil).for(:container_registry_import_max_retries) }
it { is_expected.not_to allow_value(nil).for(:container_registry_import_start_max_retries) }
it { is_expected.not_to allow_value(nil).for(:container_registry_import_max_step_duration) }
+ it { is_expected.not_to allow_value(nil).for(:container_registry_pre_import_timeout) }
+ it { is_expected.not_to allow_value(nil).for(:container_registry_import_timeout) }
it { is_expected.to validate_presence_of(:container_registry_import_target_plan) }
it { is_expected.to validate_presence_of(:container_registry_import_created_before) }
@@ -132,6 +136,12 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value(10.5).for(:raw_blob_request_limit) }
it { is_expected.not_to allow_value(-1).for(:raw_blob_request_limit) }
+ it { is_expected.to allow_value(0).for(:pipeline_limit_per_project_user_sha) }
+ it { is_expected.not_to allow_value('abc').for(:pipeline_limit_per_project_user_sha) }
+ it { is_expected.not_to allow_value(nil).for(:pipeline_limit_per_project_user_sha) }
+ it { is_expected.not_to allow_value(10.5).for(:pipeline_limit_per_project_user_sha) }
+ it { is_expected.not_to allow_value(-1).for(:pipeline_limit_per_project_user_sha) }
+
it { is_expected.not_to allow_value(false).for(:hashed_storage_enabled) }
it { is_expected.to allow_value('default' => 0).for(:repository_storages_weighted) }
@@ -417,6 +427,14 @@ RSpec.describe ApplicationSetting do
.is_greater_than(0)
end
+ it { is_expected.to validate_presence_of(:max_export_size) }
+
+ specify do
+ is_expected.to validate_numericality_of(:max_export_size)
+ .only_integer
+ .is_greater_than_or_equal_to(0)
+ end
+
it { is_expected.to validate_presence_of(:max_import_size) }
specify do
@@ -1336,5 +1354,17 @@ RSpec.describe ApplicationSetting do
it { is_expected.to validate_numericality_of(:inactive_projects_delete_after_months).is_greater_than(0) }
it { is_expected.to validate_numericality_of(:inactive_projects_min_size_mb).is_greater_than_or_equal_to(0) }
+
+ it "deletes the redis key used for tracking inactive projects deletion warning emails when setting is updated",
+ :clean_gitlab_redis_shared_state do
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.hset("inactive_projects_deletion_warning_email_notified", "project:1", "2020-01-01")
+ end
+
+ Gitlab::Redis::SharedState.with do |redis|
+ expect { setting.update!(inactive_projects_delete_after_months: 6) }
+ .to change { redis.hgetall('inactive_projects_deletion_warning_email_notified') }.to({})
+ end
+ end
end
end
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index 5ee560c4925..6409ea9fc3d 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -31,7 +31,37 @@ RSpec.describe Ci::Bridge do
end
describe '#retryable?' do
+ let(:bridge) { create(:ci_bridge, :success) }
+
+ it 'returns true' do
+ expect(bridge.retryable?).to eq(true)
+ end
+
+ context 'without ci_recreate_downstream_pipeline ff' do
+ before do
+ stub_feature_flags(ci_recreate_downstream_pipeline: false)
+ end
+
+ it 'returns false' do
+ expect(bridge.retryable?).to eq(false)
+ end
+ end
+ end
+
+ context 'when there is a pipeline loop detected' do
+ let(:bridge) { create(:ci_bridge, :failed, failure_reason: :pipeline_loop_detected) }
+
+ it 'returns false' do
+ expect(bridge.failure_reason).to eq('pipeline_loop_detected')
+ expect(bridge.retryable?).to eq(false)
+ end
+ end
+
+ context 'when the pipeline depth has reached the max descendents' do
+ let(:bridge) { create(:ci_bridge, :failed, failure_reason: :reached_max_descendant_pipelines_depth) }
+
it 'returns false' do
+ expect(bridge.failure_reason).to eq('reached_max_descendant_pipelines_depth')
expect(bridge.retryable?).to eq(false)
end
end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 12e65974270..dcf6915a01e 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -743,7 +743,7 @@ RSpec.describe Ci::Build do
it { is_expected.to be_falsey }
end
- context 'when there are runners' do
+ context 'when there is a runner' do
let(:runner) { create(:ci_runner, :project, projects: [build.project]) }
before do
@@ -752,19 +752,28 @@ RSpec.describe Ci::Build do
it { is_expected.to be_truthy }
- it 'that is inactive' do
- runner.update!(active: false)
- is_expected.to be_falsey
+ context 'that is inactive' do
+ before do
+ runner.update!(active: false)
+ end
+
+ it { is_expected.to be_falsey }
end
- it 'that is not online' do
- runner.update!(contacted_at: nil)
- is_expected.to be_falsey
+ context 'that is not online' do
+ before do
+ runner.update!(contacted_at: nil)
+ end
+
+ it { is_expected.to be_falsey }
end
- it 'that cannot handle build' do
- expect_any_instance_of(Ci::Runner).to receive(:matches_build?).with(build).and_return(false)
- is_expected.to be_falsey
+ context 'that cannot handle build' do
+ before do
+ expect_any_instance_of(Gitlab::Ci::Matching::RunnerMatcher).to receive(:matches?).with(build.build_matcher).and_return(false)
+ end
+
+ it { is_expected.to be_falsey }
end
end
@@ -1069,6 +1078,32 @@ RSpec.describe Ci::Build do
is_expected.to all(a_hash_including(key: a_string_matching(/-non_protected$/)))
end
end
+
+ context 'when separated caches are disabled' do
+ before do
+ allow_any_instance_of(Project).to receive(:ci_separated_caches).and_return(false)
+ end
+
+ context 'running on protected ref' do
+ before do
+ allow(build.pipeline).to receive(:protected_ref?).and_return(true)
+ end
+
+ it 'is expected to have no type suffix' do
+ is_expected.to match([a_hash_including(key: 'key-1'), a_hash_including(key: 'key2-1')])
+ end
+ end
+
+ context 'running on not protected ref' do
+ before do
+ allow(build.pipeline).to receive(:protected_ref?).and_return(false)
+ end
+
+ it 'is expected to have no type suffix' do
+ is_expected.to match([a_hash_including(key: 'key-1'), a_hash_including(key: 'key2-1')])
+ end
+ end
+ end
end
context 'when project has jobs_cache_index' do
@@ -1123,36 +1158,6 @@ RSpec.describe Ci::Build do
end
end
- describe '#coverage_regex' do
- subject { build.coverage_regex }
-
- context 'when project has build_coverage_regex set' do
- let(:project_regex) { '\(\d+\.\d+\) covered' }
-
- before do
- project.update_column(:build_coverage_regex, project_regex)
- end
-
- context 'and coverage_regex attribute is not set' do
- it { is_expected.to eq(project_regex) }
- end
-
- context 'but coverage_regex attribute is also set' do
- let(:build_regex) { 'Code coverage: \d+\.\d+' }
-
- before do
- build.coverage_regex = build_regex
- end
-
- it { is_expected.to eq(build_regex) }
- end
- end
-
- context 'when neither project nor build has coverage regex set' do
- it { is_expected.to be_nil }
- end
- end
-
describe '#update_coverage' do
context "regarding coverage_regex's value," do
before do
@@ -1476,6 +1481,44 @@ RSpec.describe Ci::Build do
expect(deployment).to be_canceled
end
end
+
+ # Mimic playing a manual job that needs another job.
+ # `needs + when:manual` scenario, see: https://gitlab.com/gitlab-org/gitlab/-/issues/347502
+ context 'when transits from skipped to created to running' do
+ before do
+ build.skip!
+ end
+
+ context 'during skipped to created' do
+ let(:event) { :process! }
+
+ it 'transitions to created' do
+ subject
+
+ expect(deployment).to be_created
+ end
+ end
+
+ context 'during created to running' do
+ let(:event) { :run! }
+
+ before do
+ build.process!
+ build.enqueue!
+ end
+
+ it 'transitions to running and calls webhook' do
+ freeze_time do
+ expect(Deployments::HooksWorker)
+ .to receive(:perform_async).with(deployment_id: deployment.id, status_changed_at: Time.current)
+
+ subject
+ end
+
+ expect(deployment).to be_running
+ end
+ end
+ end
end
describe '#on_stop' do
@@ -3755,6 +3798,7 @@ RSpec.describe Ci::Build do
context 'for pipeline ref existence' do
it 'ensures pipeline ref creation' do
+ expect(job.pipeline).to receive(:ensure_persistent_ref).once.and_call_original
expect(job.pipeline.persistent_ref).to receive(:create).once
run_job_without_exception
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index 24c318d0218..24265242172 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -206,8 +206,8 @@ RSpec.describe Ci::JobArtifact do
end
end
- describe '#archived_trace_exists?' do
- subject { artifact.archived_trace_exists? }
+ describe '#stored?' do
+ subject { artifact.stored? }
context 'when the file exists' do
it { is_expected.to be_truthy }
@@ -270,15 +270,6 @@ RSpec.describe Ci::JobArtifact do
end
end
- describe '.order_expired_desc' do
- let_it_be(:first_artifact) { create(:ci_job_artifact, expire_at: 2.days.ago) }
- let_it_be(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
-
- it 'returns ordered artifacts' do
- expect(described_class.order_expired_desc).to eq([second_artifact, first_artifact])
- end
- end
-
describe '.order_expired_asc' do
let_it_be(:first_artifact) { create(:ci_job_artifact, expire_at: 2.days.ago) }
let_it_be(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 45b51d5bf44..8dc041814fa 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
let_it_be(:namespace) { create_default(:namespace).freeze }
let_it_be(:project) { create_default(:project, :repository).freeze }
- it 'paginates 15 pipeleines per page' do
+ it 'paginates 15 pipelines per page' do
expect(described_class.default_per_page).to eq(15)
end
@@ -552,7 +552,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
it { is_expected.to be_truthy }
end
- context 'when both sha and source_sha do not matche' do
+ context 'when both sha and source_sha do not match' do
let(:pipeline) { build(:ci_pipeline, sha: 'test', source_sha: 'test') }
it { is_expected.to be_falsy }
@@ -1423,7 +1423,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
let(:build_b) { create_build('build2', queued_at: 0) }
let(:build_c) { create_build('build3', queued_at: 0) }
- %w[succeed! drop! cancel! skip!].each do |action|
+ %w[succeed! drop! cancel! skip! block! delay!].each do |action|
context "when the pipeline recieved #{action} event" do
it 'deletes a persistent ref' do
expect(pipeline.persistent_ref).to receive(:delete).once
@@ -1534,6 +1534,21 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
expect(pipeline.started_at).to be_nil
end
end
+
+ context 'from success' do
+ let(:started_at) { 2.days.ago }
+ let(:from_status) { :success }
+
+ before do
+ pipeline.update!(started_at: started_at)
+ end
+
+ it 'does not update on transitioning to running' do
+ pipeline.run
+
+ expect(pipeline.started_at).to eq started_at
+ end
+ end
end
describe '#finished_at' do
@@ -1813,6 +1828,32 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
+ describe '#ensure_persistent_ref' do
+ subject { pipeline.ensure_persistent_ref }
+
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ context 'when the persistent ref does not exist' do
+ it 'creates a ref' do
+ expect(pipeline.persistent_ref).to receive(:create).once
+
+ subject
+ end
+ end
+
+ context 'when the persistent ref exists' do
+ before do
+ pipeline.persistent_ref.create # rubocop:disable Rails/SaveBang
+ end
+
+ it 'does not create a ref' do
+ expect(pipeline.persistent_ref).not_to receive(:create)
+
+ subject
+ end
+ end
+ end
+
describe '#branch?' do
subject { pipeline.branch? }
@@ -3428,6 +3469,46 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
+ describe '#upstream_root' do
+ subject { pipeline.upstream_root }
+
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+
+ context 'when pipeline is child of child pipeline' do
+ let!(:root_ancestor) { create(:ci_pipeline) }
+ let!(:parent_pipeline) { create(:ci_pipeline, child_of: root_ancestor) }
+ let!(:pipeline) { create(:ci_pipeline, child_of: parent_pipeline) }
+
+ it 'returns the root ancestor' do
+ expect(subject).to eq(root_ancestor)
+ end
+ end
+
+ context 'when pipeline is root ancestor' do
+ let!(:child_pipeline) { create(:ci_pipeline, child_of: pipeline) }
+
+ it 'returns itself' do
+ expect(subject).to eq(pipeline)
+ end
+ end
+
+ context 'when pipeline is standalone' do
+ it 'returns itself' do
+ expect(subject).to eq(pipeline)
+ end
+ end
+
+ context 'when pipeline is multi-project downstream pipeline' do
+ let!(:upstream_pipeline) do
+ create(:ci_pipeline, project: create(:project), upstream_of: pipeline)
+ end
+
+ it 'returns the upstream pipeline' do
+ expect(subject).to eq(upstream_pipeline)
+ end
+ end
+ end
+
describe '#stuck?' do
let(:pipeline) { create(:ci_empty_pipeline, :created) }
diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb
index 71fef3c1b5b..cdd96d45561 100644
--- a/spec/models/ci/processable_spec.rb
+++ b/spec/models/ci/processable_spec.rb
@@ -14,6 +14,223 @@ RSpec.describe Ci::Processable do
it { is_expected.to delegate_method(:legacy_detached_merge_request_pipeline?).to(:pipeline) }
end
+ describe '#clone' do
+ let(:user) { create(:user) }
+
+ let(:new_processable) do
+ new_proc = processable.clone(current_user: user)
+ new_proc.save!
+
+ new_proc
+ end
+
+ let_it_be(:stage) { create(:ci_stage_entity, project: project, pipeline: pipeline, name: 'test') }
+
+ shared_context 'processable bridge' do
+ let_it_be(:downstream_project) { create(:project, :repository) }
+
+ let_it_be_with_refind(:processable) do
+ create(
+ :ci_bridge, :success, pipeline: pipeline, downstream: downstream_project,
+ description: 'a trigger job', stage_id: stage.id
+ )
+ end
+
+ let(:clone_accessors) { ::Ci::Bridge.clone_accessors }
+ let(:reject_accessors) { [] }
+ let(:ignore_accessors) { [] }
+ end
+
+ shared_context 'processable build' do
+ let_it_be(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
+
+ let_it_be_with_refind(:processable) do
+ create(:ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags,
+ :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group,
+ description: 'my-job', stage: 'test', stage_id: stage.id,
+ pipeline: pipeline, auto_canceled_by: another_pipeline,
+ scheduled_at: 10.seconds.since)
+ end
+
+ let_it_be(:internal_job_variable) { create(:ci_job_variable, job: processable) }
+
+ let(:clone_accessors) { ::Ci::Build.clone_accessors.without(::Ci::Build.extra_accessors) }
+
+ let(:reject_accessors) do
+ %i[id status user token token_encrypted coverage trace runner
+ artifacts_expire_at
+ created_at updated_at started_at finished_at queued_at erased_by
+ erased_at auto_canceled_by job_artifacts job_artifacts_archive
+ job_artifacts_metadata job_artifacts_trace job_artifacts_junit
+ job_artifacts_sast job_artifacts_secret_detection job_artifacts_dependency_scanning
+ job_artifacts_container_scanning job_artifacts_cluster_image_scanning job_artifacts_dast
+ job_artifacts_license_scanning
+ job_artifacts_performance job_artifacts_browser_performance job_artifacts_load_performance
+ job_artifacts_lsif job_artifacts_terraform job_artifacts_cluster_applications
+ job_artifacts_codequality job_artifacts_metrics scheduled_at
+ job_variables waiting_for_resource_at job_artifacts_metrics_referee
+ job_artifacts_network_referee job_artifacts_dotenv
+ job_artifacts_cobertura needs job_artifacts_accessibility
+ job_artifacts_requirements job_artifacts_coverage_fuzzing
+ job_artifacts_api_fuzzing terraform_state_versions].freeze
+ end
+
+ let(:ignore_accessors) do
+ %i[type namespace lock_version target_url base_tags trace_sections
+ commit_id deployment erased_by_id project_id
+ runner_id tag_taggings taggings tags trigger_request_id
+ user_id auto_canceled_by_id retried failure_reason
+ sourced_pipelines artifacts_file_store artifacts_metadata_store
+ metadata runner_session trace_chunks upstream_pipeline_id
+ artifacts_file artifacts_metadata artifacts_size commands
+ resource resource_group_id processed security_scans author
+ pipeline_id report_results pending_state pages_deployments
+ queuing_entry runtime_metadata trace_metadata
+ dast_site_profile dast_scanner_profile].freeze
+ end
+
+ before_all do
+ # Create artifacts to check that the associations are rejected when cloning
+ Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.each do |file_type, file_format|
+ create(:ci_job_artifact, file_format,
+ file_type: file_type, job: processable, expire_at: processable.artifacts_expire_at)
+ end
+
+ create(:ci_job_variable, :dotenv_source, job: processable)
+ create(:terraform_state_version, build: processable)
+ end
+
+ before do
+ processable.update!(retried: false, status: :success)
+ end
+ end
+
+ shared_examples_for 'clones the processable' do
+ before_all do
+ processable.update!(stage: 'test', stage_id: stage.id)
+
+ create(:ci_build_need, build: processable)
+ end
+
+ describe 'clone accessors' do
+ let(:forbidden_associations) do
+ Ci::Build.reflect_on_all_associations.each_with_object(Set.new) do |assoc, memo|
+ memo << assoc.name unless assoc.macro == :belongs_to
+ end
+ end
+
+ it 'clones the processable attributes', :aggregate_failures do
+ clone_accessors.each do |attribute|
+ expect(attribute).not_to be_in(forbidden_associations), "association #{attribute} must be `belongs_to`"
+ expect(processable.send(attribute)).not_to be_nil, "old processable attribute #{attribute} should not be nil"
+ expect(new_processable.send(attribute)).not_to be_nil, "new processable attribute #{attribute} should not be nil"
+ expect(new_processable.send(attribute)).to eq(processable.send(attribute)), "new processable attribute #{attribute} should match old processable"
+ end
+ end
+
+ it 'clones only the needs attributes' do
+ expect(new_processable.needs.size).to be(1)
+ expect(processable.needs.exists?).to be_truthy
+
+ expect(new_processable.needs_attributes).to match(processable.needs_attributes)
+ expect(new_processable.needs).not_to match(processable.needs)
+ end
+
+ context 'when the processable has protected: nil' do
+ before do
+ processable.update_attribute(:protected, nil)
+ end
+
+ it 'clones the protected job attribute' do
+ expect(new_processable.protected).to be_nil
+ expect(new_processable.protected).to eq processable.protected
+ end
+ end
+ end
+
+ describe 'reject accessors' do
+ it 'does not clone rejected attributes' do
+ reject_accessors.each do |attribute|
+ expect(new_processable.send(attribute)).not_to eq(processable.send(attribute)), "processable attribute #{attribute} should not have been cloned"
+ end
+ end
+ end
+
+ it 'creates a new processable that represents the old processable' do
+ expect(new_processable.name).to eq processable.name
+ end
+ end
+
+ context 'when the processable to be cloned is a bridge' do
+ include_context 'processable bridge'
+
+ it_behaves_like 'clones the processable'
+ end
+
+ context 'when the processable to be cloned is a build' do
+ include_context 'processable build'
+
+ it_behaves_like 'clones the processable'
+
+ it 'has the correct number of known attributes', :aggregate_failures do
+ processed_accessors = clone_accessors + reject_accessors
+ known_accessors = processed_accessors + ignore_accessors
+
+ current_accessors =
+ Ci::Build.attribute_names.map(&:to_sym) +
+ Ci::Build.attribute_aliases.keys.map(&:to_sym) +
+ Ci::Build.reflect_on_all_associations.map(&:name) +
+ [:tag_list, :needs_attributes, :job_variables_attributes] -
+ # ToDo: Move EE accessors to ee/
+ ::Ci::Build.extra_accessors -
+ [:dast_site_profiles_build, :dast_scanner_profiles_build]
+
+ current_accessors.uniq!
+
+ expect(current_accessors).to include(*processed_accessors)
+ expect(known_accessors).to include(*current_accessors)
+ end
+
+ context 'when it has a deployment' do
+ let!(:processable) do
+ create(:ci_build, :with_deployment, :deploy_to_production,
+ pipeline: pipeline, stage_id: stage.id, project: project)
+ end
+
+ it 'persists the expanded environment name' do
+ expect(new_processable.metadata.expanded_environment_name).to eq('production')
+ end
+ end
+
+ context 'when it has a dynamic environment' do
+ let_it_be(:other_developer) { create(:user).tap { |u| project.add_developer(u) } }
+
+ let(:environment_name) { 'review/$CI_COMMIT_REF_SLUG-$GITLAB_USER_ID' }
+
+ let!(:processable) do
+ create(:ci_build, :with_deployment, environment: environment_name,
+ options: { environment: { name: environment_name } },
+ pipeline: pipeline, stage_id: stage.id, project: project,
+ user: other_developer)
+ end
+
+ it 're-uses the previous persisted environment' do
+ expect(processable.persisted_environment.name).to eq("review/#{processable.ref}-#{other_developer.id}")
+
+ expect(new_processable.persisted_environment.name).to eq("review/#{processable.ref}-#{other_developer.id}")
+ end
+ end
+
+ context 'when the processable has job variables' do
+ it 'only clones the internal job variables' do
+ expect(new_processable.job_variables.size).to eq(1)
+ expect(new_processable.job_variables.first.key).to eq(internal_job_variable.key)
+ expect(new_processable.job_variables.first.value).to eq(internal_job_variable.value)
+ end
+ end
+ end
+ end
+
describe '#retryable' do
shared_examples_for 'retryable processable' do
context 'when processable is successful' do
@@ -69,6 +286,12 @@ RSpec.describe Ci::Processable do
end
end
+ context 'when the processable is a bridge' do
+ subject(:processable) { create(:ci_bridge, pipeline: pipeline) }
+
+ it_behaves_like 'retryable processable'
+ end
+
context 'when the processable is a build' do
subject(:processable) { create(:ci_build, pipeline: pipeline) }
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index 05b7bc39a74..8a1dcbfbdeb 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -412,12 +412,9 @@ RSpec.describe Ci::Runner do
context 'with shared_runner' do
let(:runner) { create(:ci_runner, :instance) }
- it 'transitions shared runner to project runner and assigns project' do
- expect(subject).to be_truthy
-
- expect(runner).to be_project_type
- expect(runner.runner_projects.pluck(:project_id)).to match_array([project.id])
- expect(runner.only_for?(project)).to be_truthy
+ it 'raises an error' do
+ expect { subject }
+ .to raise_error(ArgumentError, 'Transitioning an instance runner to a project runner is not supported')
end
end
@@ -430,6 +427,18 @@ RSpec.describe Ci::Runner do
.to raise_error(ArgumentError, 'Transitioning a group runner to a project runner is not supported')
end
end
+
+ context 'with project runner' do
+ let(:other_project) { create(:project) }
+ let(:runner) { create(:ci_runner, :project, projects: [other_project]) }
+
+ it 'assigns runner to project' do
+ expect(subject).to be_truthy
+
+ expect(runner).to be_project_type
+ expect(runner.runner_projects.pluck(:project_id)).to contain_exactly(project.id, other_project.id)
+ end
+ end
end
describe '.recent' do
@@ -829,7 +838,7 @@ RSpec.describe Ci::Runner do
context 'with legacy_mode enabled' do
let(:legacy_mode) { '14.5' }
- it { is_expected.to eq(:not_connected) }
+ it { is_expected.to eq(:stale) }
end
context 'with legacy_mode disabled' do
@@ -886,7 +895,7 @@ RSpec.describe Ci::Runner do
context 'with legacy_mode enabled' do
let(:legacy_mode) { '14.5' }
- it { is_expected.to eq(:offline) }
+ it { is_expected.to eq(:stale) }
end
context 'with legacy_mode disabled' do
@@ -896,7 +905,7 @@ RSpec.describe Ci::Runner do
end
describe '#deprecated_rest_status' do
- let(:runner) { build(:ci_runner, :instance, contacted_at: 1.second.ago) }
+ let(:runner) { create(:ci_runner, :instance, contacted_at: 1.second.ago) }
subject { runner.deprecated_rest_status }
@@ -905,7 +914,7 @@ RSpec.describe Ci::Runner do
runner.contacted_at = nil
end
- it { is_expected.to eq(:not_connected) }
+ it { is_expected.to eq(:never_contacted) }
end
context 'contacted 1s ago' do
@@ -918,10 +927,11 @@ RSpec.describe Ci::Runner do
context 'contacted long time ago' do
before do
+ runner.created_at = 1.year.ago
runner.contacted_at = 1.year.ago
end
- it { is_expected.to eq(:offline) }
+ it { is_expected.to eq(:stale) }
end
context 'inactive' do
diff --git a/spec/models/ci/secure_file_spec.rb b/spec/models/ci/secure_file_spec.rb
index f92db3fe8db..40ddafad013 100644
--- a/spec/models/ci/secure_file_spec.rb
+++ b/spec/models/ci/secure_file_spec.rb
@@ -25,7 +25,6 @@ RSpec.describe Ci::SecureFile do
it { is_expected.to validate_presence_of(:checksum) }
it { is_expected.to validate_presence_of(:file_store) }
it { is_expected.to validate_presence_of(:name) }
- it { is_expected.to validate_presence_of(:permissions) }
it { is_expected.to validate_presence_of(:project_id) }
context 'unique filename' do
let_it_be(:project1) { create(:project) }
@@ -49,12 +48,6 @@ RSpec.describe Ci::SecureFile do
end
end
- describe '#permissions' do
- it 'defaults to read_only file permssions' do
- expect(subject.permissions).to eq('read_only')
- end
- end
-
describe '#checksum' do
it 'computes SHA256 checksum on the file before encrypted' do
expect(subject.checksum).to eq(Digest::SHA256.hexdigest(sample_file))
diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb
index b298bf2c8bb..a0ede9fb0d9 100644
--- a/spec/models/clusters/platforms/kubernetes_spec.rb
+++ b/spec/models/clusters/platforms/kubernetes_spec.rb
@@ -450,6 +450,42 @@ RSpec.describe Clusters::Platforms::Kubernetes do
it { is_expected.to be_nil }
end
+ context 'when there are ignored K8s connections errors' do
+ described_class::IGNORED_CONNECTION_EXCEPTIONS.each do |exception|
+ context "#{exception}" do
+ before do
+ exception_args = ['arg1']
+ exception_args.push('arg2', 'arg3') if exception.name == 'Kubeclient::HttpError'
+ exception_instance = exception.new(*exception_args)
+
+ allow_next_instance_of(Gitlab::Kubernetes::KubeClient) do |kube_client|
+ allow(kube_client).to receive(:get_pods).with(namespace: namespace).and_raise(exception_instance)
+ allow(kube_client).to receive(:get_deployments).with(namespace: namespace).and_raise(exception_instance)
+ allow(kube_client).to receive(:get_ingresses).with(namespace: namespace).and_raise(exception_instance)
+ end
+ end
+
+ it 'does not raise error' do
+ expect { subject }.not_to raise_error
+ end
+
+ it 'returns empty array for the K8s component keys' do
+ expect(subject).to include({ pods: [], deployments: [], ingresses: [] })
+ end
+
+ it 'logs the error' do
+ expect_next_instance_of(Gitlab::Kubernetes::Logger) do |logger|
+ expect(logger).to receive(:error)
+ .with(hash_including(event: :kube_connection_error))
+ .and_call_original
+ end
+
+ subject
+ end
+ end
+ end
+ end
+
context 'when kubernetes responds with 500s' do
before do
stub_kubeclient_pods(namespace, status: 500)
@@ -457,7 +493,9 @@ RSpec.describe Clusters::Platforms::Kubernetes do
stub_kubeclient_ingresses(namespace, status: 500)
end
- it { expect { subject }.to raise_error(Kubeclient::HttpError) }
+ it 'does not raise kubeclient http error' do
+ expect { subject }.not_to raise_error
+ end
end
context 'when kubernetes responds with 404s' do
@@ -755,6 +793,18 @@ RSpec.describe Clusters::Platforms::Kubernetes do
expect(rollout_status.instances.map { |p| p[:pod_name] }).to eq(['pod-a-1', 'pod-a-2', 'pod-b-1', 'pod-b-2'])
end
end
+
+ # Scenario when there are K8s connection errors.
+ context 'when cache keys are defaulted' do
+ let(:cache_data) { Hash(deployments: [], pods: [], ingresses: []) }
+
+ it 'does not raise error' do
+ expect { rollout_status }.not_to raise_error
+
+ expect(rollout_status).to be_kind_of(::Gitlab::Kubernetes::RolloutStatus)
+ expect(rollout_status).to be_not_found
+ end
+ end
end
describe '#ingresses' do
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index d158a99ef9f..dbb15fad246 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -142,6 +142,26 @@ RSpec.describe CommitStatus do
end
end
+ describe '.cancelable' do
+ subject { described_class.cancelable }
+
+ %i[running pending waiting_for_resource preparing created scheduled].each do |status|
+ context "when #{status} commit status" do
+ let!(:commit_status) { create(:commit_status, status, pipeline: pipeline) }
+
+ it { is_expected.to contain_exactly(commit_status) }
+ end
+ end
+
+ %i[failed success skipped canceled manual].each do |status|
+ context "when #{status} commit status" do
+ let!(:commit_status) { create(:commit_status, status, pipeline: pipeline) }
+
+ it { is_expected.to be_empty }
+ end
+ end
+ end
+
describe '#started?' do
subject { commit_status.started? }
@@ -150,26 +170,28 @@ RSpec.describe CommitStatus do
commit_status.started_at = nil
end
- it { is_expected.to be_falsey }
+ it { is_expected.to be(false) }
end
- %w[running success failed].each do |status|
- context "if commit status is #{status}" do
- before do
- commit_status.status = status
- end
+ context 'with started_at' do
+ described_class::STARTED_STATUSES.each do |status|
+ context "if commit status is #{status}" do
+ before do
+ commit_status.status = status
+ end
- it { is_expected.to be_truthy }
+ it { is_expected.to eq(true) }
+ end
end
- end
- %w[pending canceled].each do |status|
- context "if commit status is #{status}" do
- before do
- commit_status.status = status
- end
+ (described_class::AVAILABLE_STATUSES - described_class::STARTED_STATUSES).each do |status|
+ context "if commit status is #{status}" do
+ before do
+ commit_status.status = status
+ end
- it { is_expected.to be_falsey }
+ it { is_expected.to be(false) }
+ end
end
end
end
diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb
index 1c1efab2889..d46f22b2216 100644
--- a/spec/models/concerns/cache_markdown_field_spec.rb
+++ b/spec/models/concerns/cache_markdown_field_spec.rb
@@ -149,7 +149,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do
it 'saves the changes' do
expect(thing)
.to receive(:save_markdown)
- .with("description_html" => updated_html, "title_html" => "", "cached_markdown_version" => cache_version)
+ .with({ "description_html" => updated_html, "title_html" => "", "cached_markdown_version" => cache_version })
thing.refresh_markdown_cache!
end
diff --git a/spec/models/concerns/integrations/reset_secret_fields_spec.rb b/spec/models/concerns/integrations/reset_secret_fields_spec.rb
new file mode 100644
index 00000000000..a372550c70f
--- /dev/null
+++ b/spec/models/concerns/integrations/reset_secret_fields_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::ResetSecretFields do
+ let(:described_class) do
+ Class.new(Integration) do
+ field :username, type: 'text'
+ field :url, type: 'text', exposes_secrets: true
+ field :api_url, type: 'text', exposes_secrets: true
+ field :password, type: 'password'
+ field :token, type: 'password'
+ end
+ end
+
+ let(:integration) { described_class.new }
+
+ it_behaves_like Integrations::ResetSecretFields
+end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index b38135fc0b2..e8e9c263d23 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -474,11 +474,11 @@ RSpec.describe Issuable do
issue.update!(labels: [label])
issue.assignees << user
issue.spend_time(duration: 2, user_id: user.id, spent_at: Time.current)
- expect(Gitlab::HookData::IssuableBuilder)
+ expect(Gitlab::DataBuilder::Issuable)
.to receive(:new).with(issue).and_return(builder)
end
- it 'delegates to Gitlab::HookData::IssuableBuilder#build and does not set labels, assignees, nor total_time_spent' do
+ it 'delegates to Gitlab::DataBuilder::Issuable#build and does not set labels, assignees, nor total_time_spent' do
expect(builder).to receive(:build).with(
user: user,
changes: {})
@@ -493,11 +493,11 @@ RSpec.describe Issuable do
before do
issue.update!(labels: [labels[1]])
- expect(Gitlab::HookData::IssuableBuilder)
+ expect(Gitlab::DataBuilder::Issuable)
.to receive(:new).with(issue).and_return(builder)
end
- it 'delegates to Gitlab::HookData::IssuableBuilder#build' do
+ it 'delegates to Gitlab::DataBuilder::Issuable#build' do
expect(builder).to receive(:build).with(
user: user,
changes: hash_including(
@@ -512,11 +512,11 @@ RSpec.describe Issuable do
before do
issue.spend_time(duration: 2, user_id: user.id, spent_at: Time.current)
issue.save!
- expect(Gitlab::HookData::IssuableBuilder)
+ expect(Gitlab::DataBuilder::Issuable)
.to receive(:new).with(issue).and_return(builder)
end
- it 'delegates to Gitlab::HookData::IssuableBuilder#build' do
+ it 'delegates to Gitlab::DataBuilder::Issuable#build' do
expect(builder).to receive(:build).with(
user: user,
changes: hash_including(
@@ -532,11 +532,11 @@ RSpec.describe Issuable do
before do
issue.assignees << user << user2
- expect(Gitlab::HookData::IssuableBuilder)
+ expect(Gitlab::DataBuilder::Issuable)
.to receive(:new).with(issue).and_return(builder)
end
- it 'delegates to Gitlab::HookData::IssuableBuilder#build' do
+ it 'delegates to Gitlab::DataBuilder::Issuable#build' do
expect(builder).to receive(:build).with(
user: user,
changes: hash_including(
@@ -554,11 +554,11 @@ RSpec.describe Issuable do
before do
merge_request.update!(assignees: [user])
merge_request.update!(assignees: [user, user2])
- expect(Gitlab::HookData::IssuableBuilder)
+ expect(Gitlab::DataBuilder::Issuable)
.to receive(:new).with(merge_request).and_return(builder)
end
- it 'delegates to Gitlab::HookData::IssuableBuilder#build' do
+ it 'delegates to Gitlab::DataBuilder::Issuable#build' do
expect(builder).to receive(:build).with(
user: user,
changes: hash_including(
@@ -574,11 +574,11 @@ RSpec.describe Issuable do
before do
issue.update!(issuable_severity_attributes: { severity: 'low' })
- expect(Gitlab::HookData::IssuableBuilder)
+ expect(Gitlab::DataBuilder::Issuable)
.to receive(:new).with(issue).and_return(builder)
end
- it 'delegates to Gitlab::HookData::IssuableBuilder#build' do
+ it 'delegates to Gitlab::DataBuilder::Issuable#build' do
expect(builder).to receive(:build).with(
user: user,
changes: hash_including(
@@ -596,10 +596,10 @@ RSpec.describe Issuable do
before do
issue.escalation_status.update!(status: acknowledged)
- expect(Gitlab::HookData::IssuableBuilder).to receive(:new).with(issue).and_return(builder)
+ expect(Gitlab::DataBuilder::Issuable).to receive(:new).with(issue).and_return(builder)
end
- it 'delegates to Gitlab::HookData::IssuableBuilder#build' do
+ it 'delegates to Gitlab::DataBuilder::Issuable#build' do
expect(builder).to receive(:build).with(
user: user,
changes: hash_including(
diff --git a/spec/models/concerns/pg_full_text_searchable_spec.rb b/spec/models/concerns/pg_full_text_searchable_spec.rb
index db7f652f494..b6da481024a 100644
--- a/spec/models/concerns/pg_full_text_searchable_spec.rb
+++ b/spec/models/concerns/pg_full_text_searchable_spec.rb
@@ -54,12 +54,23 @@ RSpec.describe PgFullTextSearchable do
end
context 'when specified columns are not changed' do
- it 'does not enqueue worker' do
+ it 'does not call update_search_data!' do
expect(model).not_to receive(:update_search_data!)
model.update!(description: 'A new description')
end
end
+
+ context 'when model is updated twice within a transaction' do
+ it 'calls update_search_data!' do
+ expect(model).to receive(:update_search_data!)
+
+ model.transaction do
+ model.update!(title: 'A new title')
+ model.update!(updated_at: Time.current)
+ end
+ end
+ end
end
describe '.pg_full_text_search' do
diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb
index 4f3b95e43cd..5468699f9dd 100644
--- a/spec/models/concerns/reactive_caching_spec.rb
+++ b/spec/models/concerns/reactive_caching_spec.rb
@@ -237,7 +237,7 @@ RSpec.describe ReactiveCaching, :use_clean_rails_memory_store_caching do
end
it 'does not raise the exception' do
- expect { go! }.not_to raise_exception(ReactiveCaching::ExceededReactiveCacheLimit)
+ expect { go! }.not_to raise_exception
end
end
diff --git a/spec/models/concerns/schedulable_spec.rb b/spec/models/concerns/schedulable_spec.rb
index 62acd12e267..b98dcf1c174 100644
--- a/spec/models/concerns/schedulable_spec.rb
+++ b/spec/models/concerns/schedulable_spec.rb
@@ -57,6 +57,16 @@ RSpec.describe Schedulable do
it_behaves_like '.runnable_schedules'
end
+ context 'for a packages cleanup policy' do
+ # let! is used to reset the next_run_at value before each spec
+ let(:object) { create(:packages_cleanup_policy, :runnable) }
+ let(:non_runnable_object) { create(:packages_cleanup_policy) }
+
+ it_behaves_like '#schedule_next_run!'
+ it_behaves_like 'before_save callback'
+ it_behaves_like '.runnable_schedules'
+ end
+
describe '#next_run_at' do
let(:schedulable_instance) do
Class.new(ActiveRecord::Base) do
diff --git a/spec/models/concerns/sha256_attribute_spec.rb b/spec/models/concerns/sha256_attribute_spec.rb
deleted file mode 100644
index 02947325bf4..00000000000
--- a/spec/models/concerns/sha256_attribute_spec.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Sha256Attribute do
- let(:model) { Class.new(ApplicationRecord) { include Sha256Attribute } }
-
- before do
- columns = [
- double(:column, name: 'name', type: :text),
- double(:column, name: 'sha256', type: :binary)
- ]
-
- allow(model).to receive(:columns).and_return(columns)
- end
-
- describe '#sha_attribute' do
- context 'when in non-production' do
- before do
- stub_rails_env('development')
- end
-
- context 'when the table exists' do
- before do
- allow(model).to receive(:table_exists?).and_return(true)
- end
-
- it 'defines a SHA attribute for a binary column' do
- expect(model).to receive(:attribute)
- .with(:sha256, an_instance_of(Gitlab::Database::Sha256Attribute))
-
- model.sha256_attribute(:sha256)
- end
-
- it 'raises ArgumentError when the column type is not :binary' do
- expect { model.sha256_attribute(:name) }.to raise_error(ArgumentError)
- end
- end
-
- context 'when the table does not exist' do
- it 'allows the attribute to be added and issues a warning' do
- allow(model).to receive(:table_exists?).and_return(false)
-
- expect(model).not_to receive(:columns)
- expect(model).to receive(:attribute)
- expect(model).to receive(:warn)
-
- model.sha256_attribute(:name)
- end
- end
-
- context 'when the column does not exist' do
- it 'allows the attribute to be added and issues a warning' do
- allow(model).to receive(:table_exists?).and_return(true)
-
- expect(model).to receive(:columns)
- expect(model).to receive(:attribute)
- expect(model).to receive(:warn)
-
- model.sha256_attribute(:no_name)
- end
- end
-
- context 'when other execeptions are raised' do
- it 'logs and re-rasises the error' do
- allow(model).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError.new('does not exist'))
-
- expect(model).not_to receive(:columns)
- expect(model).not_to receive(:attribute)
- expect(Gitlab::AppLogger).to receive(:error)
-
- expect { model.sha256_attribute(:name) }.to raise_error(ActiveRecord::NoDatabaseError)
- end
- end
- end
-
- context 'when in production' do
- before do
- stub_rails_env('production')
- end
-
- it 'defines a SHA attribute' do
- expect(model).not_to receive(:table_exists?)
- expect(model).not_to receive(:columns)
- expect(model).to receive(:attribute).with(:sha256, an_instance_of(Gitlab::Database::Sha256Attribute))
-
- model.sha256_attribute(:sha256)
- end
- end
- end
-end
diff --git a/spec/models/concerns/sha_attribute_spec.rb b/spec/models/concerns/sha_attribute_spec.rb
index 1bcf3dc8b61..790e6936803 100644
--- a/spec/models/concerns/sha_attribute_spec.rb
+++ b/spec/models/concerns/sha_attribute_spec.rb
@@ -3,86 +3,101 @@
require 'spec_helper'
RSpec.describe ShaAttribute do
- let(:model) { Class.new(ActiveRecord::Base) { include ShaAttribute } }
+ let(:model) do
+ Class.new(ActiveRecord::Base) do
+ include ShaAttribute
- before do
- columns = [
- double(:column, name: 'name', type: :text),
- double(:column, name: 'sha1', type: :binary)
- ]
-
- allow(model).to receive(:columns).and_return(columns)
+ self.table_name = 'merge_requests'
+ end
end
- describe '#sha_attribute' do
- context 'when in development' do
- before do
- stub_rails_env('development')
- end
+ let(:binary_column) { :merge_ref_sha }
+ let(:text_column) { :target_branch }
- context 'when the table exists' do
- before do
- allow(model).to receive(:table_exists?).and_return(true)
- end
+ describe '.sha_attribute' do
+ it 'defines a SHA attribute with Gitlab::Database::ShaAttribute type' do
+ expect(model).to receive(:attribute)
+ .with(binary_column, an_instance_of(Gitlab::Database::ShaAttribute))
+ .and_call_original
- it 'defines a SHA attribute for a binary column' do
- expect(model).to receive(:attribute)
- .with(:sha1, an_instance_of(Gitlab::Database::ShaAttribute))
+ model.sha_attribute(binary_column)
+ end
+ end
- model.sha_attribute(:sha1)
- end
+ describe '.sha256_attribute' do
+ it 'defines a SHA256 attribute with Gitlab::Database::ShaAttribute type' do
+ expect(model).to receive(:attribute)
+ .with(binary_column, an_instance_of(Gitlab::Database::Sha256Attribute))
+ .and_call_original
- it 'raises ArgumentError when the column type is not :binary' do
- expect { model.sha_attribute(:name) }.to raise_error(ArgumentError)
- end
- end
+ model.sha256_attribute(binary_column)
+ end
+ end
- context 'when the table does not exist' do
- it 'allows the attribute to be added' do
- allow(model).to receive(:table_exists?).and_return(false)
+ describe '.load_schema!' do
+ # load_schema! is not a documented class method, so use a documented method
+ # that we know will call load_schema!
+ def load_schema!
+ expect(model).to receive(:load_schema!).and_call_original
- expect(model).not_to receive(:columns)
- expect(model).to receive(:attribute)
+ model.new
+ end
- model.sha_attribute(:name)
- end
- end
+ using RSpec::Parameterized::TableSyntax
- context 'when the column does not exist' do
- it 'allows the attribute to be added' do
- allow(model).to receive(:table_exists?).and_return(true)
+ where(:column_name, :environment, :expected_error) do
+ ref(:binary_column) | 'development' | :no_error
+ ref(:binary_column) | 'production' | :no_error
+ ref(:text_column) | 'development' | :sha_mismatch_error
+ ref(:text_column) | 'production' | :no_error
+ :__non_existent_column | 'development' | :no_error
+ :__non_existent_column | 'production' | :no_error
+ end
- expect(model).to receive(:columns)
- expect(model).to receive(:attribute)
+ let(:sha_mismatch_error) do
+ [
+ described_class::ShaAttributeTypeMismatchError,
+ /#{column_name}.* should be a :binary column/
+ ]
+ end
- model.sha_attribute(:no_name)
- end
+ with_them do
+ before do
+ stub_rails_env(environment)
end
- context 'when other execeptions are raised' do
- it 'logs and re-rasises the error' do
- allow(model).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError.new('does not exist'))
-
- expect(model).not_to receive(:columns)
- expect(model).not_to receive(:attribute)
- expect(Gitlab::AppLogger).to receive(:error)
-
- expect { model.sha_attribute(:name) }.to raise_error(ActiveRecord::NoDatabaseError)
+ context 'with sha_attribute' do
+ before do
+ model.sha_attribute(column_name)
end
- end
- end
- context 'when in production' do
- before do
- stub_rails_env('production')
+ it 'validates column type' do
+ if expected_error == :no_error
+ expect { load_schema! }.not_to raise_error
+ elsif expected_error == :sha_mismatch_error
+ expect { load_schema! }.to raise_error(
+ described_class::ShaAttributeTypeMismatchError,
+ /sha_attribute.*#{column_name}.* should be a :binary column/
+ )
+ end
+ end
end
- it 'defines a SHA attribute' do
- expect(model).not_to receive(:table_exists?)
- expect(model).not_to receive(:columns)
- expect(model).to receive(:attribute).with(:sha1, an_instance_of(Gitlab::Database::ShaAttribute))
+ context 'with sha256_attribute' do
+ before do
+ model.sha256_attribute(column_name)
+ end
- model.sha_attribute(:sha1)
+ it 'validates column type' do
+ if expected_error == :no_error
+ expect { load_schema! }.not_to raise_error
+ elsif expected_error == :sha_mismatch_error
+ expect { load_schema! }.to raise_error(
+ described_class::Sha256AttributeTypeMismatchError,
+ /sha256_attribute.*#{column_name}.* should be a :binary column/
+ )
+ end
+ end
end
end
end
diff --git a/spec/models/container_registry/event_spec.rb b/spec/models/container_registry/event_spec.rb
index 21a3ab5363a..6b544c95cc8 100644
--- a/spec/models/container_registry/event_spec.rb
+++ b/spec/models/container_registry/event_spec.rb
@@ -26,11 +26,65 @@ RSpec.describe ContainerRegistry::Event do
end
describe '#handle!' do
- let(:raw_event) { { 'action' => 'push', 'target' => { 'mediaType' => ContainerRegistry::Client::DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE } } }
+ let(:action) { 'push' }
+ let(:repository) { project.full_path }
+ let(:target) do
+ {
+ 'mediaType' => ContainerRegistry::Client::DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE,
+ 'tag' => 'latest',
+ 'repository' => repository
+ }
+ end
+
+ let(:raw_event) { { 'action' => action, 'target' => target } }
+
+ subject(:handle!) { described_class.new(raw_event).handle! }
+
+ it 'enqueues a project statistics update' do
+ expect(ProjectCacheWorker).to receive(:perform_async).with(project.id, [], [:container_registry_size])
+
+ handle!
+ end
- subject { described_class.new(raw_event).handle! }
+ shared_examples 'event without project statistics update' do
+ it 'does not queue a project statistics update' do
+ expect(ProjectCacheWorker).not_to receive(:perform_async)
- it { is_expected.to eq nil }
+ handle!
+ end
+ end
+
+ context 'with :container_registry_project_statistics feature flag disabled' do
+ before do
+ stub_feature_flags(container_registry_project_statistics: false)
+ end
+
+ it_behaves_like 'event without project statistics update'
+ end
+
+ context 'with no target tag' do
+ let(:target) { super().without('tag') }
+
+ it_behaves_like 'event without project statistics update'
+ end
+
+ context 'with an unsupported action' do
+ let(:action) { 'pull' }
+
+ it_behaves_like 'event without project statistics update'
+ end
+
+ context 'with an invalid project repository path' do
+ let(:repository) { 'does/not/exist' }
+
+ it_behaves_like 'event without project statistics update'
+ end
+
+ context 'with no project repository path' do
+ let(:repository) { nil }
+
+ it_behaves_like 'event without project statistics update'
+ end
end
describe '#track!' do
diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb
index 2ea042fb767..af4e40cecb7 100644
--- a/spec/models/container_repository_spec.rb
+++ b/spec/models/container_repository_spec.rb
@@ -208,9 +208,23 @@ RSpec.describe ContainerRepository, :aggregate_failures do
shared_examples 'queueing the next import' do
it 'starts the worker' do
expect(::ContainerRegistry::Migration::EnqueuerWorker).to receive(:perform_async)
+ expect(::ContainerRegistry::Migration::EnqueuerWorker).to receive(:perform_in)
subject
end
+
+ context 'enqueue_twice feature flag disabled' do
+ before do
+ stub_feature_flags(container_registry_migration_phase2_enqueue_twice: false)
+ end
+
+ it 'starts the worker only once' do
+ expect(::ContainerRegistry::Migration::EnqueuerWorker).to receive(:perform_async)
+ expect(::ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_in)
+
+ subject
+ end
+ end
end
describe '#start_pre_import' do
@@ -354,6 +368,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
subject { repository.skip_import(reason: :too_many_retries) }
it_behaves_like 'transitioning from allowed states', ContainerRepository::SKIPPABLE_MIGRATION_STATES
+ it_behaves_like 'queueing the next import'
it 'sets migration_skipped_at and migration_skipped_reason' do
expect { subject }.to change { repository.reload.migration_skipped_at }
@@ -630,10 +645,15 @@ RSpec.describe ContainerRepository, :aggregate_failures do
describe '#start_expiration_policy!' do
subject { repository.start_expiration_policy! }
+ before do
+ repository.update_column(:last_cleanup_deleted_tags_count, 10)
+ end
+
it 'sets the expiration policy started at to now' do
freeze_time do
expect { subject }
.to change { repository.expiration_policy_started_at }.from(nil).to(Time.zone.now)
+ .and change { repository.last_cleanup_deleted_tags_count }.from(10).to(nil)
end
end
end
@@ -690,22 +710,6 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
- describe '#reset_expiration_policy_started_at!' do
- subject { repository.reset_expiration_policy_started_at! }
-
- before do
- repository.start_expiration_policy!
- end
-
- it 'resets the expiration policy started at' do
- started_at = repository.expiration_policy_started_at
-
- expect(started_at).not_to be_nil
- expect { subject }
- .to change { repository.expiration_policy_started_at }.from(started_at).to(nil)
- end
- end
-
context 'registry migration' do
before do
allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(true)
@@ -1307,6 +1311,38 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
+ describe '#nearing_or_exceeded_retry_limit?' do
+ subject { repository.nearing_or_exceeded_retry_limit? }
+
+ before do
+ stub_application_setting(container_registry_import_max_retries: 3)
+ end
+
+ context 'migration_retries_count is 1 less than max_retries' do
+ before do
+ repository.update_column(:migration_retries_count, 2)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'migration_retries_count is lower than max_retries' do
+ before do
+ repository.update_column(:migration_retries_count, 1)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'migration_retries_count equal to or higher than max_retries' do
+ before do
+ repository.update_column(:migration_retries_count, 3)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
context 'with repositories' do
let_it_be_with_reload(:repository) { create(:container_repository, :cleanup_unscheduled) }
let_it_be(:other_repository) { create(:container_repository, :cleanup_unscheduled) }
diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb
index c48f1fab3c6..635326eeadc 100644
--- a/spec/models/deploy_token_spec.rb
+++ b/spec/models/deploy_token_spec.rb
@@ -73,10 +73,10 @@ RSpec.describe DeployToken do
describe '#ensure_token' do
it 'ensures a token' do
- deploy_token.token = nil
+ deploy_token.token_encrypted = nil
deploy_token.save!
- expect(deploy_token.token).not_to be_empty
+ expect(deploy_token.token_encrypted).not_to be_empty
end
end
@@ -469,4 +469,10 @@ RSpec.describe DeployToken do
end
end
end
+
+ describe '.impersonated?' do
+ it 'returns false' do
+ expect(subject.impersonated?).to be(false)
+ end
+ end
end
diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb
index 705b9b4cc65..409353bdbcf 100644
--- a/spec/models/deployment_spec.rb
+++ b/spec/models/deployment_spec.rb
@@ -858,12 +858,24 @@ RSpec.describe Deployment do
end
end
- it 'tracks an exception if an invalid status transition is detected' do
- expect(Gitlab::ErrorTracking)
+ context 'tracks an exception if an invalid status transition is detected' do
+ it do
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(instance_of(described_class::StatusUpdateError), deployment_id: deploy.id)
+
+ expect(deploy.update_status('running')).to eq(false)
+ end
+
+ it do
+ deploy.update_status('success')
+
+ expect(Gitlab::ErrorTracking)
.to receive(:track_exception)
.with(instance_of(described_class::StatusUpdateError), deployment_id: deploy.id)
- expect(deploy.update_status('running')).to eq(false)
+ expect(deploy.update_status('created')).to eq(false)
+ end
end
it 'tracks an exception if an invalid argument' do
@@ -871,7 +883,7 @@ RSpec.describe Deployment do
.to receive(:track_exception)
.with(instance_of(described_class::StatusUpdateError), deployment_id: deploy.id)
- expect(deploy.update_status('created')).to eq(false)
+ expect(deploy.update_status('recreate')).to eq(false)
end
context 'mapping status to event' do
@@ -893,6 +905,16 @@ RSpec.describe Deployment do
deploy.update_status(status)
end
end
+
+ context 'for created status update' do
+ let(:deploy) { create(:deployment, status: :created) }
+
+ it 'calls the correct method' do
+ expect(deploy).to receive(:create!)
+
+ deploy.update_status('created')
+ end
+ end
end
end
@@ -974,7 +996,9 @@ RSpec.describe Deployment do
context 'with created build' do
let(:build_status) { :created }
- it_behaves_like 'ignoring build'
+ it_behaves_like 'gracefully handling error' do
+ let(:error_message) { %Q{Status cannot transition via \"create\"} }
+ end
end
context 'with running build' do
@@ -1002,7 +1026,9 @@ RSpec.describe Deployment do
context 'with created build' do
let(:build_status) { :created }
- it_behaves_like 'ignoring build'
+ it_behaves_like 'gracefully handling error' do
+ let(:error_message) { %Q{Status cannot transition via \"create\"} }
+ end
end
context 'with running build' do
diff --git a/spec/models/design_management/action_spec.rb b/spec/models/design_management/action_spec.rb
index 0a8bbc8d26e..f2b8fcaa256 100644
--- a/spec/models/design_management/action_spec.rb
+++ b/spec/models/design_management/action_spec.rb
@@ -49,6 +49,15 @@ RSpec.describe DesignManagement::Action do
end
end
+ describe '.with_version' do
+ it 'preloads the version' do
+ actions = described_class.with_version
+
+ expect { actions.map(&:version) }.not_to exceed_query_limit(2)
+ expect(actions.count).to be > 2
+ end
+ end
+
describe '.by_event' do
it 'returns the actions by event type' do
expect(described_class.by_event(:deletion)).to match_array([action_a_2, action_c])
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index b42e73e6d93..34dfc7a1fce 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -621,7 +621,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
expect(close_action.processed).to be_falsey
# it encounters the StaleObjectError at first, but reloads the object and runs `build.play`
- expect { subject }.not_to raise_error(ActiveRecord::StaleObjectError)
+ expect { subject }.not_to raise_error
# Now the build should be processed.
expect(close_action.reload.processed).to be_truthy
@@ -683,19 +683,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
expect(actions.count).to eq(environment.successful_deployments.count)
end
end
-
- context 'when the feature is disabled' do
- before do
- stub_feature_flags(environment_multiple_stop_actions: false)
- end
-
- it 'returns the last deployment job stop action' do
- stop_actions = subject
-
- expect(stop_actions.first).to eq(close_actions[1])
- expect(stop_actions.count).to eq(1)
- end
- end
end
end
@@ -886,22 +873,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
is_expected.to eq(deployment)
end
- context 'env_last_deployment_by_finished_at feature flag' do
- it 'when enabled it returns the deployment with the latest finished_at' do
- stub_feature_flags(env_last_deployment_by_finished_at: true)
+ it 'returns the deployment with the latest finished_at' do
+ expect(old_deployment.finished_at < deployment.finished_at).to be_truthy
- expect(old_deployment.finished_at < deployment.finished_at).to be_truthy
-
- is_expected.to eq(deployment)
- end
-
- it 'when disabled it returns the deployment with the highest id' do
- stub_feature_flags(env_last_deployment_by_finished_at: false)
-
- expect(old_deployment.finished_at < deployment.finished_at).to be_truthy
-
- is_expected.to eq(old_deployment)
- end
+ is_expected.to eq(deployment)
end
end
end
@@ -1845,7 +1820,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
it 'fetches the rollout status from the deployment platform' do
expect(environment.deployment_platform).to receive(:rollout_status)
- .with(environment, pods: pods, deployments: deployments)
+ .with(environment, { pods: pods, deployments: deployments })
.and_return(:mock_rollout_status)
is_expected.to eq(:mock_rollout_status)
diff --git a/spec/models/event_collection_spec.rb b/spec/models/event_collection_spec.rb
index 036072aab76..67b58c7bf6f 100644
--- a/spec/models/event_collection_spec.rb
+++ b/spec/models/event_collection_spec.rb
@@ -5,138 +5,188 @@ require 'spec_helper'
RSpec.describe EventCollection do
include DesignManagementTestHelpers
- describe '#to_a' do
- let_it_be(:group) { create(:group) }
- let_it_be(:project) { create(:project_empty_repo, group: group) }
- let_it_be(:projects) { Project.where(id: project.id) }
- let_it_be(:user) { create(:user) }
- let_it_be(:merge_request) { create(:merge_request) }
-
- before do
- enable_design_management
- end
+ shared_examples 'EventCollection examples' do
+ describe '#to_a' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project_empty_repo, group: group) }
+ let_it_be(:projects) { Project.where(id: project.id) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request) }
+
+ before do
+ enable_design_management
+ end
- context 'with project events' do
- let_it_be(:push_event_payloads) do
- Array.new(9) do
- create(:push_event_payload,
- event: create(:push_event, project: project, author: user))
+ context 'with project events' do
+ let_it_be(:push_event_payloads) do
+ Array.new(9) do
+ create(:push_event_payload,
+ event: create(:push_event, project: project, author: user))
+ end
end
- end
- let_it_be(:merge_request_events) { create_list(:event, 10, :commented, project: project, target: merge_request) }
- let_it_be(:closed_issue_event) { create(:closed_issue_event, project: project, author: user) }
- let_it_be(:wiki_page_event) { create(:wiki_page_event, project: project) }
- let_it_be(:design_event) { create(:design_event, project: project) }
-
- let(:push_events) { push_event_payloads.map(&:event) }
-
- it 'returns an Array of events', :aggregate_failures do
- most_recent_20_events = [
- wiki_page_event,
- design_event,
- closed_issue_event,
- *push_events,
- *merge_request_events
- ].sort_by(&:id).reverse.take(20)
- events = described_class.new(projects).to_a
-
- expect(events).to be_an_instance_of(Array)
- expect(events).to match_array(most_recent_20_events)
- end
+ let_it_be(:merge_request_events) { create_list(:event, 10, :merged, project: project, target: merge_request) }
+ let_it_be(:closed_issue_event) { create(:closed_issue_event, project: project, author: user) }
+ let_it_be(:wiki_page_event) { create(:wiki_page_event, project: project) }
+ let_it_be(:design_event) { create(:design_event, project: project) }
+
+ let(:push_events) { push_event_payloads.map(&:event) }
+
+ it 'returns an Array of all event types when no filter is passed', :aggregate_failures do
+ most_recent_20_events = [
+ wiki_page_event,
+ design_event,
+ closed_issue_event,
+ *push_events,
+ *merge_request_events
+ ].sort_by(&:id).reverse.take(20)
+ events = described_class.new(projects).to_a
+
+ expect(events).to be_an_instance_of(Array)
+ expect(events).to match_array(most_recent_20_events)
+ end
- it 'includes the wiki page events when using to_a' do
- events = described_class.new(projects).to_a
+ it 'includes the wiki page events when using to_a' do
+ events = described_class.new(projects).to_a
- expect(events).to include(wiki_page_event)
- end
+ expect(events).to include(wiki_page_event)
+ end
- it 'includes the design events' do
- collection = described_class.new(projects)
+ it 'includes the design events' do
+ collection = described_class.new(projects)
- expect(collection.to_a).to include(design_event)
- expect(collection.all_project_events).to include(design_event)
- end
+ expect(collection.to_a).to include(design_event)
+ expect(collection.all_project_events).to include(design_event)
+ end
- it 'includes the wiki page events when using all_project_events' do
- events = described_class.new(projects).all_project_events
+ it 'includes the wiki page events when using all_project_events' do
+ events = described_class.new(projects).all_project_events
- expect(events).to include(wiki_page_event)
- end
+ expect(events).to include(wiki_page_event)
+ end
- it 'applies a limit to the number of events' do
- events = described_class.new(projects).to_a
+ it 'applies a limit to the number of events' do
+ events = described_class.new(projects).to_a
- expect(events.length).to eq(20)
- end
+ expect(events.length).to eq(20)
+ end
- it 'can paginate through events' do
- events = described_class.new(projects, limit: 5, offset: 15).to_a
+ it 'can paginate through events' do
+ events = described_class.new(projects, limit: 5, offset: 15).to_a
- expect(events.length).to eq(5)
- end
+ expect(events.length).to eq(5)
+ end
- it 'returns an empty Array when crossing the maximum page number' do
- events = described_class.new(projects, limit: 1, offset: 15).to_a
+ it 'returns an empty Array when crossing the maximum page number' do
+ events = described_class.new(projects, limit: 1, offset: 15).to_a
- expect(events).to be_empty
- end
+ expect(events).to be_empty
+ end
- it 'allows filtering of events using an EventFilter, returning single item' do
- filter = EventFilter.new(EventFilter::ISSUE)
- events = described_class.new(projects, filter: filter).to_a
+ it 'allows filtering of events using an EventFilter, returning single item' do
+ filter = EventFilter.new(EventFilter::ISSUE)
+ events = described_class.new(projects, filter: filter).to_a
- expect(events).to contain_exactly(closed_issue_event)
- end
+ expect(events).to contain_exactly(closed_issue_event)
+ end
- it 'allows filtering of events using an EventFilter, returning several items' do
- filter = EventFilter.new(EventFilter::COMMENTS)
- events = described_class.new(projects, filter: filter).to_a
+ it 'allows filtering of events using an EventFilter, returning several items' do
+ filter = EventFilter.new(EventFilter::MERGED)
+ events = described_class.new(projects, filter: filter).to_a
- expect(events).to match_array(merge_request_events)
- end
+ expect(events).to match_array(merge_request_events)
+ end
- it 'allows filtering of events using an EventFilter, returning pushes' do
- filter = EventFilter.new(EventFilter::PUSH)
- events = described_class.new(projects, filter: filter).to_a
+ it 'allows filtering of events using an EventFilter, returning pushes' do
+ filter = EventFilter.new(EventFilter::PUSH)
+ events = described_class.new(projects, filter: filter).to_a
- expect(events).to match_array(push_events)
+ expect(events).to match_array(push_events)
+ end
end
- end
- context 'with group events' do
- let(:groups) { group.self_and_descendants.public_or_visible_to_user(user) }
- let(:subject) { described_class.new(projects, groups: groups).to_a }
+ context 'with group events' do
+ let(:groups) { group.self_and_descendants.public_or_visible_to_user(user) }
+ let(:subject) { described_class.new(projects, groups: groups).to_a }
- it 'includes also group events' do
- subgroup = create(:group, parent: group)
- event1 = create(:event, project: project, author: user)
- event2 = create(:event, project: nil, group: group, author: user)
- event3 = create(:event, project: nil, group: subgroup, author: user)
+ it 'includes also group events' do
+ subgroup = create(:group, parent: group)
+ event1 = create(:event, project: project, author: user)
+ event2 = create(:event, project: nil, group: group, author: user)
+ event3 = create(:event, project: nil, group: subgroup, author: user)
- expect(subject).to eq([event3, event2, event1])
- end
+ expect(subject).to eq([event3, event2, event1])
+ end
- it 'does not include events from inaccessible groups' do
- subgroup = create(:group, :private, parent: group)
- event1 = create(:event, project: nil, group: group, author: user)
- create(:event, project: nil, group: subgroup, author: user)
+ it 'does not include events from inaccessible groups' do
+ subgroup = create(:group, :private, parent: group)
+ event1 = create(:event, project: nil, group: group, author: user)
+ create(:event, project: nil, group: subgroup, author: user)
- expect(subject).to eq([event1])
- end
+ expect(subject).to match_array([event1])
+ end
+
+ context 'pagination through events' do
+ let_it_be(:project_events) { create_list(:event, 10, project: project) }
+ let_it_be(:group_events) { create_list(:event, 10, group: group, author: user) }
- context 'pagination through events' do
- let_it_be(:project_events) { create_list(:event, 10, project: project) }
- let_it_be(:group_events) { create_list(:event, 10, group: group, author: user) }
+ let(:subject) { described_class.new(projects, limit: 10, offset: 5, groups: groups).to_a }
- let(:subject) { described_class.new(projects, limit: 10, offset: 5, groups: groups).to_a }
+ it 'returns recent groups and projects events' do
+ recent_events_with_offset = (project_events[5..] + group_events[..4]).reverse
- it 'returns recent groups and projects events' do
- recent_events_with_offset = (project_events[5..] + group_events[..4]).reverse
+ expect(subject).to eq(recent_events_with_offset)
+ end
+ end
- expect(subject).to eq(recent_events_with_offset)
+ context 'project exclusive event types' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:filter, :event) do
+ EventFilter::PUSH | lazy { create(:push_event, project: project) }
+ EventFilter::MERGED | lazy { create(:event, :merged, project: project, target: merge_request) }
+ EventFilter::TEAM | lazy { create(:event, :joined, project: project) }
+ EventFilter::ISSUE | lazy { create(:closed_issue_event, project: project) }
+ EventFilter::DESIGNS | lazy { create(:design_event, project: project) }
+ end
+
+ with_them do
+ let(:subject) do
+ described_class.new(projects, groups: Group.where(id: group.id), filter: EventFilter.new(filter))
+ end
+
+ it "queries only project events" do
+ expected_event = event # Forcing lazy evaluation
+ expect(subject).to receive(:project_events).with(no_args).and_call_original
+ expect(subject).not_to receive(:group_events)
+
+ expect(subject.to_a).to match_array(expected_event)
+ end
+ end
end
end
end
end
+
+ context 'when the optimized_project_and_group_activity_queries FF is on' do
+ before do
+ stub_feature_flags(optimized_project_and_group_activity_queries: true)
+ end
+
+ it_behaves_like 'EventCollection examples'
+
+ it 'returns no events if no projects are passed' do
+ events = described_class.new(Project.none).to_a
+
+ expect(events).to be_empty
+ end
+ end
+
+ context 'when the optimized_project_and_group_activity_queries FF is off' do
+ before do
+ stub_feature_flags(optimized_project_and_group_activity_queries: false)
+ end
+
+ it_behaves_like 'EventCollection examples'
+ end
end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index f099015e63e..2c1bbfcb35f 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -834,7 +834,13 @@ RSpec.describe Event do
end
end
- context 'when a project was updated more than 1 hour ago' do
+ context 'when a project was updated more than 1 hour ago', :clean_gitlab_redis_shared_state do
+ before do
+ ::Gitlab::Redis::SharedState.with do |redis|
+ redis.hset('inactive_projects_deletion_warning_email_notified', "project:#{project.id}", Date.current)
+ end
+ end
+
it 'updates the project' do
project.touch(:last_activity_at, time: 1.year.ago) # rubocop: disable Rails/SkipsModelValidations
@@ -845,6 +851,17 @@ RSpec.describe Event do
expect(project.last_activity_at).to be_like_time(event.created_at)
expect(project.updated_at).to be_like_time(event.created_at)
end
+
+ it "deletes the redis key for if the project was inactive" do
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis).to receive(:hdel).with('inactive_projects_deletion_warning_email_notified',
+ "project:#{project.id}")
+ end
+
+ project.touch(:last_activity_at, time: 1.year.ago) # rubocop: disable Rails/SkipsModelValidations
+
+ create_push_event(project, project.first_owner)
+ end
end
end
@@ -1040,6 +1057,36 @@ RSpec.describe Event do
end
end
+ describe '#has_no_project_and_group' do
+ context 'with project event' do
+ it 'returns false when the event has project' do
+ event = build(:event, project: create(:project))
+
+ expect(event.has_no_project_and_group?).to be false
+ end
+
+ it 'returns true when the event has no project' do
+ event = build(:event, project: nil)
+
+ expect(event.has_no_project_and_group?).to be true
+ end
+ end
+
+ context 'with group event' do
+ it 'returns false when the event has group' do
+ event = build(:event, group: create(:group))
+
+ expect(event.has_no_project_and_group?).to be false
+ end
+
+ it 'returns true when the event has no group' do
+ event = build(:event, group: nil)
+
+ expect(event.has_no_project_and_group?).to be true
+ end
+ end
+ end
+
def create_push_event(project, user)
event = create(:push_event, project: project, author: user)
diff --git a/spec/models/incident_management/timeline_event_spec.rb b/spec/models/incident_management/timeline_event_spec.rb
new file mode 100644
index 00000000000..17150fc9266
--- /dev/null
+++ b/spec/models/incident_management/timeline_event_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::TimelineEvent do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:timeline_event) { create(:incident_management_timeline_event, project: project, incident: incident) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:author) }
+ it { is_expected.to belong_to(:incident) }
+ it { is_expected.to belong_to(:updated_by_user) }
+ it { is_expected.to belong_to(:promoted_from_note) }
+ end
+
+ describe 'validations' do
+ subject { build(:incident_management_timeline_event) }
+
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:incident) }
+ it { is_expected.to validate_presence_of(:note) }
+ it { is_expected.to validate_length_of(:note).is_at_most(10_000) }
+ it { is_expected.to validate_presence_of(:note_html) }
+ it { is_expected.to validate_length_of(:note_html).is_at_most(10_000) }
+ it { is_expected.to validate_presence_of(:occurred_at) }
+ it { is_expected.to validate_presence_of(:action) }
+ it { is_expected.to validate_length_of(:action).is_at_most(128) }
+ end
+
+ describe '.order_occurred_at_asc' do
+ let_it_be(:occurred_3mins_ago) do
+ create(:incident_management_timeline_event, project: project, occurred_at: 3.minutes.ago)
+ end
+
+ let_it_be(:occurred_2mins_ago) do
+ create(:incident_management_timeline_event, project: project, occurred_at: 2.minutes.ago)
+ end
+
+ subject(:order) { described_class.order_occurred_at_asc }
+
+ it 'sorts timeline events by occurred_at' do
+ is_expected.to eq([occurred_3mins_ago, occurred_2mins_ago, timeline_event])
+ end
+ end
+
+ describe '#cache_markdown_field' do
+ let(:note) { 'note **bold** _italic_ `code` ![image](/path/img.png) :+1:👍' }
+ let(:expected_note_html) do
+ # rubocop:disable Layout/LineLength
+ '<p>note <strong>bold</strong> <em>italic</em> <code>code</code> <a class="with-attachment-icon" href="/path/img.png" target="_blank" rel="noopener noreferrer">image</a> 👍👍</p>'
+ # rubocop:enable Layout/LineLength
+ end
+
+ before do
+ allow(Banzai::Renderer).to receive(:cacheless_render_field).and_call_original
+ end
+
+ context 'on create' do
+ let(:timeline_event) do
+ build(:incident_management_timeline_event, project: project, incident: incident, note: note)
+ end
+
+ it 'updates note_html', :aggregate_failures do
+ expect(Banzai::Renderer).to receive(:cacheless_render_field)
+ .with(timeline_event, :note, { skip_project_check: false })
+
+ expect { timeline_event.save! }.to change { timeline_event.note_html }.to(expected_note_html)
+ end
+ end
+
+ context 'on update' do
+ let(:timeline_event) { create(:incident_management_timeline_event, project: project, incident: incident) }
+
+ it 'updates note_html', :aggregate_failures do
+ expect(Banzai::Renderer).to receive(:cacheless_render_field)
+ .with(timeline_event, :note, { skip_project_check: false })
+
+ expect { timeline_event.update!(note: note) }.to change { timeline_event.note_html }.to(expected_note_html)
+ end
+ end
+ end
+end
diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb
index 3af717798c3..f57667cc5d6 100644
--- a/spec/models/instance_configuration_spec.rb
+++ b/spec/models/instance_configuration_spec.rb
@@ -99,6 +99,7 @@ RSpec.describe InstanceConfiguration do
max_attachment_size: 10,
receive_max_input_size: 20,
max_import_size: 30,
+ max_export_size: 40,
diff_max_patch_bytes: 409600,
max_artifacts_size: 50,
max_pages_size: 60,
@@ -112,6 +113,7 @@ RSpec.describe InstanceConfiguration do
expect(size_limits[:max_attachment_size]).to eq(10.megabytes)
expect(size_limits[:receive_max_input_size]).to eq(20.megabytes)
expect(size_limits[:max_import_size]).to eq(30.megabytes)
+ expect(size_limits[:max_export_size]).to eq(40.megabytes)
expect(size_limits[:diff_max_patch_bytes]).to eq(400.kilobytes)
expect(size_limits[:max_artifacts_size]).to eq(50.megabytes)
expect(size_limits[:max_pages_size]).to eq(60.megabytes)
@@ -127,11 +129,16 @@ RSpec.describe InstanceConfiguration do
end
it 'returns nil if set to 0 (unlimited)' do
- Gitlab::CurrentSettings.current_application_settings.update!(max_import_size: 0, max_pages_size: 0)
+ Gitlab::CurrentSettings.current_application_settings.update!(
+ max_import_size: 0,
+ max_export_size: 0,
+ max_pages_size: 0
+ )
size_limits = subject.settings[:size_limits]
expect(size_limits[:max_import_size]).to be_nil
+ expect(size_limits[:max_export_size]).to be_nil
expect(size_limits[:max_pages_size]).to be_nil
end
end
@@ -173,6 +180,61 @@ RSpec.describe InstanceConfiguration do
end
end
+ describe '#ci_cd_limits' do
+ let_it_be(:plan1) { create(:plan, name: 'plan1', title: 'Plan 1') }
+ let_it_be(:plan2) { create(:plan, name: 'plan2', title: 'Plan 2') }
+
+ before do
+ create(:plan_limits,
+ plan: plan1,
+ ci_pipeline_size: 1001,
+ ci_active_jobs: 1002,
+ ci_active_pipelines: 1003,
+ ci_project_subscriptions: 1004,
+ ci_pipeline_schedules: 1005,
+ ci_needs_size_limit: 1006,
+ ci_registered_group_runners: 1007,
+ ci_registered_project_runners: 1008
+ )
+ create(:plan_limits,
+ plan: plan2,
+ ci_pipeline_size: 1101,
+ ci_active_jobs: 1102,
+ ci_active_pipelines: 1103,
+ ci_project_subscriptions: 1104,
+ ci_pipeline_schedules: 1105,
+ ci_needs_size_limit: 1106,
+ ci_registered_group_runners: 1107,
+ ci_registered_project_runners: 1108
+ )
+ end
+
+ it 'returns CI/CD limits' do
+ ci_cd_size_limits = subject.settings[:ci_cd_limits]
+
+ expect(ci_cd_size_limits[:Plan1]).to eq({
+ ci_active_jobs: 1002,
+ ci_active_pipelines: 1003,
+ ci_needs_size_limit: 1006,
+ ci_pipeline_schedules: 1005,
+ ci_pipeline_size: 1001,
+ ci_project_subscriptions: 1004,
+ ci_registered_group_runners: 1007,
+ ci_registered_project_runners: 1008
+ })
+ expect(ci_cd_size_limits[:Plan2]).to eq({
+ ci_active_jobs: 1102,
+ ci_active_pipelines: 1103,
+ ci_needs_size_limit: 1106,
+ ci_pipeline_schedules: 1105,
+ ci_pipeline_size: 1101,
+ ci_project_subscriptions: 1104,
+ ci_registered_group_runners: 1107,
+ ci_registered_project_runners: 1108
+ })
+ end
+ end
+
describe '#rate_limits' do
before do
Gitlab::CurrentSettings.current_application_settings.update!(
diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb
index 0f596d3908d..0567a8bd386 100644
--- a/spec/models/integration_spec.rb
+++ b/spec/models/integration_spec.rb
@@ -249,18 +249,24 @@ RSpec.describe Integration do
it_behaves_like 'integration instances'
context 'with all existing instances' do
+ def integration_hash(type)
+ Integration.new(instance: true, type: type).to_integration_hash
+ end
+
before do
- Integration.insert_all(
- Integration.available_integration_types(include_project_specific: false).map { |type| { instance: true, type: type } }
- )
+ attrs = Integration.available_integration_types(include_project_specific: false).map do
+ integration_hash(_1)
+ end
+
+ Integration.insert_all(attrs)
end
it_behaves_like 'integration instances'
- context 'with a previous existing integration (MockCiService) and a new integration (Asana)' do
+ context 'with a previous existing integration (:mock_ci) and a new integration (:asana)' do
before do
- Integration.insert({ type: 'MockCiService', instance: true })
- Integration.delete_by(type: 'AsanaService', instance: true)
+ Integration.insert(integration_hash(:mock_ci))
+ Integration.delete_by(**integration_hash(:asana))
end
it_behaves_like 'integration instances'
@@ -681,7 +687,7 @@ RSpec.describe Integration do
integration.properties = { foo: 1, bar: 2 }
- expect { integration.properties[:foo] = 3 }.to raise_error
+ expect { integration.properties[:foo] = 3 }.to raise_error(FrozenError)
end
end
@@ -782,8 +788,16 @@ RSpec.describe Integration do
end
end
- describe '#api_field_names' do
- shared_examples 'api field names' do
+ describe 'field definitions' do
+ shared_examples '#fields' do
+ it 'does not return the same array' do
+ integration = fake_integration.new
+
+ expect(integration.fields).not_to be(integration.fields)
+ end
+ end
+
+ shared_examples '#api_field_names' do
it 'filters out secret fields' do
safe_fields = %w[some_safe_field safe_field url trojan_gift]
@@ -816,7 +830,8 @@ RSpec.describe Integration do
end
end
- it_behaves_like 'api field names'
+ it_behaves_like '#fields'
+ it_behaves_like '#api_field_names'
end
context 'when the class uses the field DSL' do
@@ -839,7 +854,8 @@ RSpec.describe Integration do
end
end
- it_behaves_like 'api field names'
+ it_behaves_like '#fields'
+ it_behaves_like '#api_field_names'
end
end
@@ -848,7 +864,8 @@ RSpec.describe Integration do
let(:test_message) { "test message" }
let(:arguments) do
{
- service_class: integration.class.name,
+ integration_class: integration.class.name,
+ integration_id: integration.id,
project_path: project.full_path,
project_id: project.id,
message: test_message,
@@ -857,13 +874,13 @@ RSpec.describe Integration do
end
it 'logs info messages using json logger' do
- expect(Gitlab::JsonLogger).to receive(:info).with(arguments)
+ expect(Gitlab::IntegrationsLogger).to receive(:info).with(arguments)
integration.log_info(test_message, additional_argument: 'some argument')
end
it 'logs error messages using json logger' do
- expect(Gitlab::JsonLogger).to receive(:error).with(arguments)
+ expect(Gitlab::IntegrationsLogger).to receive(:error).with(arguments)
integration.log_error(test_message, additional_argument: 'some argument')
end
@@ -872,7 +889,8 @@ RSpec.describe Integration do
let(:project) { nil }
let(:arguments) do
{
- service_class: integration.class.name,
+ integration_class: integration.class.name,
+ integration_id: integration.id,
project_path: nil,
project_id: nil,
message: test_message,
@@ -881,11 +899,33 @@ RSpec.describe Integration do
end
it 'logs info messages using json logger' do
- expect(Gitlab::JsonLogger).to receive(:info).with(arguments)
+ expect(Gitlab::IntegrationsLogger).to receive(:info).with(arguments)
integration.log_info(test_message, additional_argument: 'some argument')
end
end
+
+ context 'logging exceptions' do
+ let(:error) { RuntimeError.new('exception message') }
+ let(:arguments) do
+ super().merge(
+ 'exception.class' => 'RuntimeError',
+ 'exception.message' => 'exception message'
+ )
+ end
+
+ it 'logs exceptions using json logger' do
+ expect(Gitlab::IntegrationsLogger).to receive(:error).with(arguments.merge(message: 'exception message'))
+
+ integration.log_exception(error, additional_argument: 'some argument')
+ end
+
+ it 'logs exceptions using json logger with a custom message' do
+ expect(Gitlab::IntegrationsLogger).to receive(:error).with(arguments.merge(message: 'custom message'))
+
+ integration.log_exception(error, message: 'custom message', additional_argument: 'some argument')
+ end
+ end
end
describe '.available_integration_names' do
diff --git a/spec/models/integrations/bamboo_spec.rb b/spec/models/integrations/bamboo_spec.rb
index b5684d153f2..574b87d6c60 100644
--- a/spec/models/integrations/bamboo_spec.rb
+++ b/spec/models/integrations/bamboo_spec.rb
@@ -227,7 +227,7 @@ RSpec.describe Integrations::Bamboo, :use_clean_rails_memory_store_caching do
expect(Gitlab::ErrorTracking)
.to receive(:log_exception)
- .with(instance_of(http_error), project_id: project.id)
+ .with(instance_of(http_error), { project_id: project.id })
is_expected.to eq(:error)
end
diff --git a/spec/models/integrations/base_chat_notification_spec.rb b/spec/models/integrations/base_chat_notification_spec.rb
index ac4031a9b7d..672d8de1e14 100644
--- a/spec/models/integrations/base_chat_notification_spec.rb
+++ b/spec/models/integrations/base_chat_notification_spec.rb
@@ -3,15 +3,14 @@
require 'spec_helper'
RSpec.describe Integrations::BaseChatNotification do
- describe 'Associations' do
+ describe 'validations' do
before do
allow(subject).to receive(:activated?).and_return(true)
+ allow(subject).to receive(:default_channel_placeholder).and_return('placeholder')
+ allow(subject).to receive(:webhook_placeholder).and_return('placeholder')
end
it { is_expected.to validate_presence_of :webhook }
- end
-
- describe 'validations' do
it { is_expected.to validate_inclusion_of(:labels_to_be_notified_behavior).in_array(%w[match_any match_all]).allow_blank }
end
@@ -274,4 +273,16 @@ RSpec.describe Integrations::BaseChatNotification do
it_behaves_like 'with channel specified', 'slack-integration, #slack-test, @UDLP91W0A', ['slack-integration', '#slack-test', '@UDLP91W0A']
end
end
+
+ describe '#default_channel_placeholder' do
+ it 'raises an error' do
+ expect { subject.default_channel_placeholder }.to raise_error(NotImplementedError)
+ end
+ end
+
+ describe '#webhook_placeholder' do
+ it 'raises an error' do
+ expect { subject.webhook_placeholder }.to raise_error(NotImplementedError)
+ end
+ end
end
diff --git a/spec/models/integrations/buildkite_spec.rb b/spec/models/integrations/buildkite_spec.rb
index 4207ae0d555..af2e587dc7b 100644
--- a/spec/models/integrations/buildkite_spec.rb
+++ b/spec/models/integrations/buildkite_spec.rb
@@ -129,7 +129,7 @@ RSpec.describe Integrations::Buildkite, :use_clean_rails_memory_store_caching do
expect(Gitlab::ErrorTracking)
.to receive(:log_exception)
- .with(instance_of(http_error), project_id: project.id)
+ .with(instance_of(http_error), { project_id: project.id })
is_expected.to eq(:error)
end
diff --git a/spec/models/integrations/drone_ci_spec.rb b/spec/models/integrations/drone_ci_spec.rb
index dd64dcfc52c..78d55c49e7b 100644
--- a/spec/models/integrations/drone_ci_spec.rb
+++ b/spec/models/integrations/drone_ci_spec.rb
@@ -163,7 +163,7 @@ RSpec.describe Integrations::DroneCi, :use_clean_rails_memory_store_caching do
expect(Gitlab::ErrorTracking)
.to receive(:log_exception)
- .with(instance_of(http_error), project_id: project.id)
+ .with(instance_of(http_error), { project_id: project.id })
is_expected.to eq(:error)
end
diff --git a/spec/models/integrations/jenkins_spec.rb b/spec/models/integrations/jenkins_spec.rb
index 3d6393f2793..200de1305e2 100644
--- a/spec/models/integrations/jenkins_spec.rb
+++ b/spec/models/integrations/jenkins_spec.rb
@@ -4,11 +4,12 @@ require 'spec_helper'
RSpec.describe Integrations::Jenkins do
let(:project) { create(:project) }
+ let(:jenkins_integration) { described_class.new(jenkins_params) }
let(:jenkins_url) { 'http://jenkins.example.com/' }
let(:jenkins_hook_url) { jenkins_url + 'project/my_project' }
let(:jenkins_username) { 'u$er name%2520' }
let(:jenkins_password) { 'pas$ word' }
-
+ let(:jenkins_authorization) { 'Basic ' + ::Base64.strict_encode64(jenkins_username + ':' + jenkins_password) }
let(:jenkins_params) do
{
active: true,
@@ -22,17 +23,21 @@ RSpec.describe Integrations::Jenkins do
}
end
- let(:jenkins_authorization) { "Basic " + ::Base64.strict_encode64(jenkins_username + ':' + jenkins_password) }
-
include_context Integrations::EnableSslVerification do
- let(:integration) { described_class.new(jenkins_params) }
+ let(:integration) { jenkins_integration }
end
it_behaves_like Integrations::HasWebHook do
- let(:integration) { described_class.new(jenkins_params) }
+ let(:integration) { jenkins_integration }
let(:hook_url) { "http://#{ERB::Util.url_encode jenkins_username}:#{ERB::Util.url_encode jenkins_password}@jenkins.example.com/project/my_project" }
end
+ it 'sets the default values', :aggregate_failures do
+ expect(jenkins_integration.push_events).to eq(true)
+ expect(jenkins_integration.merge_requests_events).to eq(false)
+ expect(jenkins_integration.tag_push_events).to eq(false)
+ end
+
describe 'username validation' do
let(:jenkins_integration) do
described_class.create!(
diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb
index d244b1d33d5..061c770a61a 100644
--- a/spec/models/integrations/jira_spec.rb
+++ b/spec/models/integrations/jira_spec.rb
@@ -27,6 +27,10 @@ RSpec.describe Integrations::Jira do
WebMock.stub_request(:get, /serverInfo/).to_return(body: server_info_results.to_json )
end
+ it_behaves_like Integrations::ResetSecretFields do
+ let(:integration) { jira_integration }
+ end
+
describe '#options' do
let(:options) do
{
@@ -122,6 +126,11 @@ RSpec.describe Integrations::Jira do
it 'includes SECTION_TYPE_JIRA_ISSUES' do
expect(sections).to include(described_class::SECTION_TYPE_JIRA_ISSUES)
end
+
+ it 'section SECTION_TYPE_JIRA_ISSUES has `plan` attribute' do
+ jira_issues_section = integration.sections.find { |s| s[:type] == described_class::SECTION_TYPE_JIRA_ISSUES }
+ expect(jira_issues_section[:plan]).to eq('premium')
+ end
end
context 'when project_level? is false' do
@@ -301,7 +310,7 @@ RSpec.describe Integrations::Jira do
let_it_be(:new_url) { 'http://jira-new.example.com' }
before do
- integration.update!(username: new_username, url: new_url)
+ integration.update!(username: new_username, url: new_url, password: password)
end
it 'stores updated data in jira_tracker_data table' do
@@ -318,7 +327,7 @@ RSpec.describe Integrations::Jira do
context 'when updating the url, api_url, username, or password' do
context 'when updating the integration' do
it 'updates deployment type' do
- integration.update!(url: 'http://first.url')
+ integration.update!(url: 'http://first.url', password: password)
integration.jira_tracker_data.update!(deployment_type: 'server')
expect(integration.jira_tracker_data.deployment_server?).to be_truthy
@@ -376,135 +385,6 @@ RSpec.describe Integrations::Jira do
expect(WebMock).not_to have_requested(:get, /serverInfo/)
end
end
-
- context 'stored password invalidation' do
- context 'when a password was previously set' do
- context 'when only web url present' do
- let(:data_params) do
- {
- url: url, api_url: nil,
- username: username, password: password,
- jira_issue_transition_id: transition_id
- }
- end
-
- it 'resets password if url changed' do
- integration
- integration.url = 'http://jira_edited.example.com'
-
- expect(integration).not_to be_valid
- expect(integration.url).to eq('http://jira_edited.example.com')
- expect(integration.password).to be_nil
- end
-
- it 'does not reset password if url "changed" to the same url as before' do
- integration.url = 'http://jira.example.com'
-
- expect(integration).to be_valid
- expect(integration.url).to eq('http://jira.example.com')
- expect(integration.password).not_to be_nil
- end
-
- it 'resets password if url not changed but api url added' do
- integration.api_url = 'http://jira_edited.example.com/rest/api/2'
-
- expect(integration).not_to be_valid
- expect(integration.api_url).to eq('http://jira_edited.example.com/rest/api/2')
- expect(integration.password).to be_nil
- end
-
- it 'does not reset password if new url is set together with password, even if it\'s the same password' do
- integration.url = 'http://jira_edited.example.com'
- integration.password = password
-
- expect(integration).to be_valid
- expect(integration.password).to eq(password)
- expect(integration.url).to eq('http://jira_edited.example.com')
- end
-
- it 'resets password if url changed, even if setter called multiple times' do
- integration.url = 'http://jira1.example.com/rest/api/2'
- integration.url = 'http://jira1.example.com/rest/api/2'
-
- expect(integration).not_to be_valid
- expect(integration.password).to be_nil
- end
-
- it 'does not reset password if username changed' do
- integration.username = 'some_name'
-
- expect(integration).to be_valid
- expect(integration.password).to eq(password)
- end
-
- it 'does not reset password if password changed' do
- integration.url = 'http://jira_edited.example.com'
- integration.password = 'new_password'
-
- expect(integration).to be_valid
- expect(integration.password).to eq('new_password')
- end
-
- it 'does not reset password if the password is touched and same as before' do
- integration.url = 'http://jira_edited.example.com'
- integration.password = password
-
- expect(integration).to be_valid
- expect(integration.password).to eq(password)
- end
- end
-
- context 'when both web and api url present' do
- let(:data_params) do
- {
- url: url, api_url: 'http://jira.example.com/rest/api/2',
- username: username, password: password,
- jira_issue_transition_id: transition_id
- }
- end
-
- it 'resets password if api url changed' do
- integration.api_url = 'http://jira_edited.example.com/rest/api/2'
-
- expect(integration).not_to be_valid
- expect(integration.password).to be_nil
- end
-
- it 'does not reset password if url changed' do
- integration.url = 'http://jira_edited.example.com'
-
- expect(integration).to be_valid
- expect(integration.password).to eq(password)
- end
-
- it 'resets password if api url set to empty' do
- integration.api_url = ''
-
- expect(integration).not_to be_valid
- expect(integration.password).to be_nil
- end
- end
- end
-
- context 'when no password was previously set' do
- let(:data_params) do
- {
- url: url, username: username
- }
- end
-
- it 'saves password if new url is set together with password' do
- integration.url = 'http://jira_edited.example.com/rest/api/2'
- integration.password = 'password'
- integration.save!
-
- expect(integration.reload).to have_attributes(
- url: 'http://jira_edited.example.com/rest/api/2',
- password: 'password'
- )
- end
- end
- end
end
end
@@ -539,8 +419,7 @@ RSpec.describe Integrations::Jira do
end
describe '#client' do
- it 'uses the default GitLab::HTTP timeouts' do
- timeouts = Gitlab::HTTP::DEFAULT_TIMEOUT_OPTIONS
+ subject do
stub_request(:get, 'http://jira.example.com/foo')
expect(Gitlab::HTTP).to receive(:httparty_perform_request)
@@ -548,6 +427,32 @@ RSpec.describe Integrations::Jira do
jira_integration.client.get('/foo')
end
+
+ context 'when the FF :jira_raise_timeouts is enabled' do
+ let(:timeouts) do
+ {
+ open_timeout: 2.minutes,
+ read_timeout: 2.minutes,
+ write_timeout: 2.minutes
+ }
+ end
+
+ it 'uses custom timeouts' do
+ subject
+ end
+ end
+
+ context 'when the FF :jira_raise_timeouts is disabled' do
+ before do
+ stub_feature_flags(jira_raise_timeouts: false)
+ end
+
+ let(:timeouts) { Gitlab::HTTP::DEFAULT_TIMEOUT_OPTIONS }
+
+ it 'uses the default GitLab::HTTP timeouts' do
+ subject
+ end
+ end
end
describe '#find_issue' do
@@ -746,17 +651,14 @@ RSpec.describe Integrations::Jira do
end
it 'logs exception when transition id is not valid' do
- allow(jira_integration).to receive(:log_error)
+ allow(jira_integration).to receive(:log_exception)
WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)).and_raise("Bad Request")
close_issue
- expect(jira_integration).to have_received(:log_error).with(
- "Issue transition failed",
- error: hash_including(
- exception_class: 'StandardError',
- exception_message: "Bad Request"
- ),
+ expect(jira_integration).to have_received(:log_exception).with(
+ kind_of(StandardError),
+ message: 'Issue transition failed',
client_url: "http://jira.example.com"
)
end
@@ -1054,12 +956,10 @@ RSpec.describe Integrations::Jira do
WebMock.stub_request(:get, test_url).with(basic_auth: [username, password])
.to_raise(JIRA::HTTPError.new(double(message: error_message)))
- expect(jira_integration).to receive(:log_error).with(
- 'Error sending message',
- client_url: 'http://jira.example.com',
- 'exception.class' => anything,
- 'exception.message' => error_message,
- 'exception.backtrace' => anything
+ expect(jira_integration).to receive(:log_exception).with(
+ kind_of(JIRA::HTTPError),
+ message: 'Error sending message',
+ client_url: 'http://jira.example.com'
)
expect(jira_integration.test(nil)).to eq(success: false, result: error_message)
diff --git a/spec/models/integrations/microsoft_teams_spec.rb b/spec/models/integrations/microsoft_teams_spec.rb
index 06b285a855c..af6c142525c 100644
--- a/spec/models/integrations/microsoft_teams_spec.rb
+++ b/spec/models/integrations/microsoft_teams_spec.rb
@@ -123,7 +123,7 @@ RSpec.describe Integrations::MicrosoftTeams do
{
title: "Awesome wiki_page",
content: "Some text describing some thing or another",
- format: "md",
+ format: :markdown,
message: "user created page: Awesome wiki_page"
}
end
diff --git a/spec/models/integrations/prometheus_spec.rb b/spec/models/integrations/prometheus_spec.rb
index 76e20f20a00..a7495cb9574 100644
--- a/spec/models/integrations/prometheus_spec.rb
+++ b/spec/models/integrations/prometheus_spec.rb
@@ -122,34 +122,6 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching,
end
end
- describe 'callbacks' do
- context 'after_create' do
- let(:project) { create(:project) }
- let(:integration) { build(:prometheus_integration, project: project) }
-
- subject(:create_integration) { integration.save! }
-
- it 'creates default alerts' do
- expect(Prometheus::CreateDefaultAlertsWorker)
- .to receive(:perform_async)
- .with(project.id)
-
- create_integration
- end
-
- context 'no project exists' do
- let(:integration) { build(:prometheus_integration, :instance) }
-
- it 'does not create default alerts' do
- expect(Prometheus::CreateDefaultAlertsWorker)
- .not_to receive(:perform_async)
-
- create_integration
- end
- end
- end
- end
-
describe '#test' do
before do
integration.manual_configuration = true
diff --git a/spec/models/integrations/teamcity_spec.rb b/spec/models/integrations/teamcity_spec.rb
index e1f4e577503..046476225a6 100644
--- a/spec/models/integrations/teamcity_spec.rb
+++ b/spec/models/integrations/teamcity_spec.rb
@@ -210,7 +210,7 @@ RSpec.describe Integrations::Teamcity, :use_clean_rails_memory_store_caching do
expect(Gitlab::ErrorTracking)
.to receive(:log_exception)
- .with(instance_of(Errno::ECONNREFUSED), project_id: project.id)
+ .with(instance_of(Errno::ECONNREFUSED), { project_id: project.id })
is_expected.to eq(teamcity_url)
end
@@ -260,7 +260,7 @@ RSpec.describe Integrations::Teamcity, :use_clean_rails_memory_store_caching do
expect(Gitlab::ErrorTracking)
.to receive(:log_exception)
- .with(instance_of(Errno::ECONNREFUSED), project_id: project.id)
+ .with(instance_of(Errno::ECONNREFUSED), { project_id: project.id })
is_expected.to eq(:error)
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index bd75d95080f..c77c0a5504a 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -37,6 +37,7 @@ RSpec.describe Issue do
it { is_expected.to have_one(:incident_management_issuable_escalation_status) }
it { is_expected.to have_many(:issue_customer_relations_contacts) }
it { is_expected.to have_many(:customer_relations_contacts) }
+ it { is_expected.to have_many(:incident_management_timeline_events) }
describe 'versions.most_recent' do
it 'returns the most recent version' do
@@ -1257,23 +1258,11 @@ RSpec.describe Issue do
end
describe '.public_only' do
- let_it_be(:banned_user) { create(:user, :banned) }
- let_it_be(:public_issue) { create(:issue, project: reusable_project) }
- let_it_be(:confidential_issue) { create(:issue, project: reusable_project, confidential: true) }
- let_it_be(:hidden_issue) { create(:issue, project: reusable_project, author: banned_user) }
-
it 'only returns public issues' do
- expect(described_class.public_only).to eq([public_issue])
- end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
+ public_issue = create(:issue, project: reusable_project)
+ create(:issue, project: reusable_project, confidential: true)
- it 'returns public and hidden issues' do
- expect(described_class.public_only).to contain_exactly(public_issue, hidden_issue)
- end
+ expect(described_class.public_only).to eq([public_issue])
end
end
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index e1135aa440b..225c9714187 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -102,15 +102,15 @@ RSpec.describe Key, :mailer do
context 'expiration scopes' do
let_it_be(:user) { create(:user) }
- let_it_be(:expired_today_not_notified) { create(:key, expires_at: Time.current, user: user) }
- let_it_be(:expired_today_already_notified) { create(:key, expires_at: Time.current, user: user, expiry_notification_delivered_at: Time.current) }
- let_it_be(:expired_yesterday) { create(:key, expires_at: 1.day.ago, user: user) }
+ let_it_be(:expired_today_not_notified) { create(:key, :expired_today, user: user) }
+ let_it_be(:expired_today_already_notified) { create(:key, :expired_today, user: user, expiry_notification_delivered_at: Time.current) }
+ let_it_be(:expired_yesterday) { create(:key, :expired, user: user) }
let_it_be(:expiring_soon_unotified) { create(:key, expires_at: 3.days.from_now, user: user) }
let_it_be(:expiring_soon_notified) { create(:key, expires_at: 4.days.from_now, user: user, before_expiry_notification_delivered_at: Time.current) }
let_it_be(:future_expiry) { create(:key, expires_at: 1.month.from_now, user: user) }
describe '.expired_today_and_not_notified' do
- it 'returns keys that expire today and in the past' do
+ it 'returns keys that expire today and have not been notified' do
expect(described_class.expired_today_and_not_notified).to contain_exactly(expired_today_not_notified)
end
end
@@ -126,32 +126,22 @@ RSpec.describe Key, :mailer do
context 'validation of uniqueness (based on fingerprint uniqueness)' do
let(:user) { create(:user) }
- shared_examples 'fingerprint uniqueness' do
- it 'accepts the key once' do
- expect(build(:rsa_key_4096, user: user)).to be_valid
- end
-
- it 'does not accept the exact same key twice' do
- first_key = create(:rsa_key_4096, user: user)
-
- expect(build(:key, user: user, key: first_key.key)).not_to be_valid
- end
+ it 'accepts the key once' do
+ expect(build(:rsa_key_4096, user: user)).to be_valid
+ end
- it 'does not accept a duplicate key with a different comment' do
- first_key = create(:rsa_key_4096, user: user)
- duplicate = build(:key, user: user, key: first_key.key)
- duplicate.key << ' extra comment'
+ it 'does not accept the exact same key twice' do
+ first_key = create(:rsa_key_4096, user: user)
- expect(duplicate).not_to be_valid
- end
+ expect(build(:key, user: user, key: first_key.key)).not_to be_valid
end
- context 'with FIPS mode off' do
- it_behaves_like 'fingerprint uniqueness'
- end
+ it 'does not accept a duplicate key with a different comment' do
+ first_key = create(:rsa_key_4096, user: user)
+ duplicate = build(:key, user: user, key: first_key.key)
+ duplicate.key << ' extra comment'
- context 'with FIPS mode', :fips_mode do
- it_behaves_like 'fingerprint uniqueness'
+ expect(duplicate).not_to be_valid
end
end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 4ab17ee1e6d..286167c918f 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -383,6 +383,75 @@ RSpec.describe Member do
end
end
+ describe '.by_access_level' do
+ subject { described_class.by_access_level(access_levels) }
+
+ context 'by owner' do
+ let(:access_levels) { [Gitlab::Access::OWNER] }
+
+ it { is_expected.to include @owner }
+ it { is_expected.not_to include @maintainer }
+ it { is_expected.not_to include @invited_member }
+ it { is_expected.not_to include @accepted_invite_member }
+ it { is_expected.not_to include @requested_member }
+ it { is_expected.not_to include @accepted_requested_member }
+ it { is_expected.not_to include @blocked_maintainer }
+ it { is_expected.not_to include @blocked_developer }
+ end
+
+ context 'by maintainer' do
+ let(:access_levels) { [Gitlab::Access::MAINTAINER] }
+
+ it { is_expected.not_to include @owner }
+ it { is_expected.to include @maintainer }
+ it { is_expected.not_to include @invited_member }
+ it { is_expected.not_to include @accepted_invite_member }
+ it { is_expected.not_to include @requested_member }
+ it { is_expected.not_to include @accepted_requested_member }
+ it { is_expected.not_to include @blocked_maintainer }
+ it { is_expected.not_to include @blocked_developer }
+ end
+
+ context 'by developer' do
+ let(:access_levels) { [Gitlab::Access::DEVELOPER] }
+
+ it { is_expected.not_to include @owner }
+ it { is_expected.not_to include @maintainer }
+ it { is_expected.to include @invited_member }
+ it { is_expected.to include @accepted_invite_member }
+ it { is_expected.not_to include @requested_member }
+ it { is_expected.not_to include @accepted_requested_member }
+ it { is_expected.not_to include @blocked_maintainer }
+ it { is_expected.not_to include @blocked_developer }
+ end
+
+ context 'by owner and maintainer' do
+ let(:access_levels) { [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER] }
+
+ it { is_expected.to include @owner }
+ it { is_expected.to include @maintainer }
+ it { is_expected.not_to include @invited_member }
+ it { is_expected.not_to include @accepted_invite_member }
+ it { is_expected.not_to include @requested_member }
+ it { is_expected.not_to include @accepted_requested_member }
+ it { is_expected.not_to include @blocked_maintainer }
+ it { is_expected.not_to include @blocked_developer }
+ end
+
+ context 'by owner, maintainer and developer' do
+ let(:access_levels) { [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER] }
+
+ it { is_expected.to include @owner }
+ it { is_expected.to include @maintainer }
+ it { is_expected.to include @invited_member }
+ it { is_expected.to include @accepted_invite_member }
+ it { is_expected.not_to include @requested_member }
+ it { is_expected.not_to include @accepted_requested_member }
+ it { is_expected.not_to include @blocked_maintainer }
+ it { is_expected.not_to include @blocked_developer }
+ end
+ end
+
describe '.developers' do
subject { described_class.developers.to_a }
@@ -582,6 +651,15 @@ RSpec.describe Member do
expect(project.members.active_state).not_to include awaiting_project_member
end
end
+
+ describe '.excluding_users' do
+ let_it_be(:active_group_member) { create(:group_member, group: group) }
+
+ it 'excludes members with given user ids' do
+ expect(group.members.excluding_users([])).to include active_group_member
+ expect(group.members.excluding_users(active_group_member.user_id)).not_to include active_group_member
+ end
+ end
end
describe 'Delegate methods' do
diff --git a/spec/models/merge_request/metrics_spec.rb b/spec/models/merge_request/metrics_spec.rb
index a4bdac39074..8d1d503b323 100644
--- a/spec/models/merge_request/metrics_spec.rb
+++ b/spec/models/merge_request/metrics_spec.rb
@@ -54,4 +54,43 @@ RSpec.describe MergeRequest::Metrics do
let!(:parent) { create(:ci_pipeline, project: merge_request.target_project) }
let!(:model) { merge_request.metrics.tap { |metrics| metrics.update!(pipeline: parent) } }
end
+
+ describe 'update' do
+ let(:merge_request) { create(:merge_request) }
+ let(:metrics) { merge_request.metrics }
+
+ before do
+ metrics.update!(
+ pipeline_id: 1,
+ latest_build_started_at: Time.current,
+ latest_build_finished_at: Time.current
+ )
+ end
+
+ context 'when pipeline_id is nullified' do
+ before do
+ metrics.update!(pipeline_id: nil)
+ end
+
+ it 'nullifies build related columns via DB trigger' do
+ metrics.reload
+
+ expect(metrics.latest_build_started_at).to be_nil
+ expect(metrics.latest_build_finished_at).to be_nil
+ end
+ end
+
+ context 'when updated but pipeline_id is not nullified' do
+ before do
+ metrics.update!(latest_closed_at: Time.current)
+ end
+
+ it 'does not nullify build related columns' do
+ metrics.reload
+
+ expect(metrics.latest_build_started_at).not_to be_nil
+ expect(metrics.latest_build_finished_at).not_to be_nil
+ end
+ end
+ end
end
diff --git a/spec/models/merge_request_assignee_spec.rb b/spec/models/merge_request_assignee_spec.rb
index 1591c517049..387d17d7823 100644
--- a/spec/models/merge_request_assignee_spec.rb
+++ b/spec/models/merge_request_assignee_spec.rb
@@ -41,22 +41,11 @@ RSpec.describe MergeRequestAssignee do
it_behaves_like 'having unique enum values'
- it_behaves_like 'having reviewer state'
-
- describe 'syncs to reviewer state' do
- before do
- reviewer = merge_request.merge_request_reviewers.build(reviewer: assignee)
- reviewer.update!(state: :reviewed)
- end
-
- it { is_expected.to have_attributes(state: 'reviewed') }
- end
-
describe '#attention_requested_by' do
let(:current_user) { create(:user) }
before do
- subject.update!(updated_state_by: current_user)
+ subject.update!(updated_state_by: current_user, state: :attention_requested)
end
context 'attention requested' do
diff --git a/spec/models/merge_request_reviewer_spec.rb b/spec/models/merge_request_reviewer_spec.rb
index dd00c4d8627..4df2dba3a7d 100644
--- a/spec/models/merge_request_reviewer_spec.rb
+++ b/spec/models/merge_request_reviewer_spec.rb
@@ -10,17 +10,6 @@ RSpec.describe MergeRequestReviewer do
it_behaves_like 'having unique enum values'
- it_behaves_like 'having reviewer state'
-
- describe 'syncs to assignee state' do
- before do
- assignee = merge_request.merge_request_assignees.build(assignee: reviewer)
- assignee.update!(state: :reviewed)
- end
-
- it { is_expected.to have_attributes(state: 'reviewed') }
- end
-
describe 'associations' do
it { is_expected.to belong_to(:merge_request).class_name('MergeRequest') }
it { is_expected.to belong_to(:reviewer).class_name('User').inverse_of(:merge_request_reviewers) }
@@ -30,7 +19,7 @@ RSpec.describe MergeRequestReviewer do
let(:current_user) { create(:user) }
before do
- subject.update!(updated_state_by: current_user)
+ subject.update!(updated_state_by: current_user, state: :attention_requested)
end
context 'attention requested' do
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 8545c7bc6c7..d40c78b5b60 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -151,6 +151,8 @@ RSpec.describe MergeRequest, factory_default: :keep do
before do
assignee = merge_request6.find_assignee(user2)
assignee.update!(state: :reviewed)
+ merge_request2.find_reviewer(user2).update!(state: :attention_requested)
+ merge_request5.find_assignee(user2).update!(state: :attention_requested)
end
it 'returns MRs that have any attention requests' do
@@ -3538,50 +3540,6 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
- describe "#legacy_environments" do
- subject { merge_request.legacy_environments }
-
- let(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master') }
- let(:project) { merge_request.project }
-
- let(:pipeline) do
- create(:ci_pipeline,
- source: :merge_request_event,
- merge_request: merge_request, project: project,
- sha: merge_request.diff_head_sha,
- merge_requests_as_head_pipeline: [merge_request])
- end
-
- let!(:job) { create(:ci_build, :with_deployment, :start_review_app, pipeline: pipeline, project: project) }
-
- it 'returns environments' do
- is_expected.to eq(pipeline.environments_in_self_and_descendants.to_a)
- expect(subject.count).to be(1)
- end
-
- context 'when pipeline is not associated with environments' do
- let!(:job) { create(:ci_build, pipeline: pipeline, project: project) }
-
- it 'returns empty array' do
- is_expected.to be_empty
- end
- end
-
- context 'when pipeline is not a pipeline for merge request' do
- let(:pipeline) do
- create(:ci_pipeline,
- project: project,
- ref: 'feature',
- sha: merge_request.diff_head_sha,
- merge_requests_as_head_pipeline: [merge_request])
- end
-
- it 'returns empty relation' do
- is_expected.to be_empty
- end
- end
- end
-
describe "#reload_diff" do
it 'calls MergeRequests::ReloadDiffsService#execute with correct params' do
user = create(:user)
diff --git a/spec/models/namespace_ci_cd_setting_spec.rb b/spec/models/namespace_ci_cd_setting_spec.rb
new file mode 100644
index 00000000000..9031d45221a
--- /dev/null
+++ b/spec/models/namespace_ci_cd_setting_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe NamespaceCiCdSetting do
+ describe "associations" do
+ it { is_expected.to belong_to(:namespace).inverse_of(:ci_cd_settings) }
+ end
+end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 09ac15429a5..4373d9a0b24 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -32,6 +32,10 @@ RSpec.describe Namespace do
it { is_expected.to have_one :namespace_route }
it { is_expected.to have_many :namespace_members }
+ it do
+ is_expected.to have_one(:ci_cd_settings).class_name('NamespaceCiCdSetting').inverse_of(:namespace).autosave(true)
+ end
+
describe '#children' do
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
@@ -334,6 +338,15 @@ RSpec.describe Namespace do
describe 'delegate' do
it { is_expected.to delegate_method(:name).to(:owner).with_prefix.with_arguments(allow_nil: true) }
it { is_expected.to delegate_method(:avatar_url).to(:owner).with_arguments(allow_nil: true) }
+ it do
+ is_expected.to delegate_method(:prevent_sharing_groups_outside_hierarchy)
+ .to(:namespace_settings).with_arguments(allow_nil: true)
+ end
+
+ it do
+ is_expected.to delegate_method(:prevent_sharing_groups_outside_hierarchy=)
+ .to(:namespace_settings).with_arguments(allow_nil: true)
+ end
end
describe "Respond to" do
@@ -2236,4 +2249,40 @@ RSpec.describe Namespace do
it_behaves_like 'blocks unsafe serialization'
end
+
+ describe '#certificate_based_clusters_enabled?' do
+ it 'does not call Feature.enabled? twice with request_store', :request_store do
+ expect(Feature).to receive(:enabled?).once
+
+ namespace.certificate_based_clusters_enabled?
+ namespace.certificate_based_clusters_enabled?
+ end
+
+ it 'call Feature.enabled? twice without request_store' do
+ expect(Feature).to receive(:enabled?).twice
+
+ namespace.certificate_based_clusters_enabled?
+ namespace.certificate_based_clusters_enabled?
+ end
+
+ context 'with ff disabled' do
+ before do
+ stub_feature_flags(certificate_based_clusters: false)
+ end
+
+ it 'is truthy' do
+ expect(namespace.certificate_based_clusters_enabled?).to be_falsy
+ end
+ end
+
+ context 'with ff enabled' do
+ before do
+ stub_feature_flags(certificate_based_clusters: true)
+ end
+
+ it 'is truthy' do
+ expect(namespace.certificate_based_clusters_enabled?).to be_truthy
+ end
+ end
+ end
end
diff --git a/spec/models/packages/cleanup/policy_spec.rb b/spec/models/packages/cleanup/policy_spec.rb
new file mode 100644
index 00000000000..972071aa0ad
--- /dev/null
+++ b/spec/models/packages/cleanup/policy_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Cleanup::Policy, type: :model do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:project) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ it do
+ is_expected
+ .to validate_inclusion_of(:keep_n_duplicated_package_files)
+ .in_array(described_class::KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES)
+ .with_message('keep_n_duplicated_package_files is invalid')
+ end
+ end
+
+ describe '.active' do
+ let_it_be(:active_policy) { create(:packages_cleanup_policy) }
+ let_it_be(:inactive_policy) { create(:packages_cleanup_policy, keep_n_duplicated_package_files: 'all') }
+
+ subject { described_class.active }
+
+ it { is_expected.to contain_exactly(active_policy) }
+ end
+end
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 6c86db1197f..a9ed811e77d 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -1182,7 +1182,7 @@ RSpec.describe Packages::Package, type: :model do
it "plan_limits includes column #{plan_limit_name}" do
expect { package.project.actual_limits.send(plan_limit_name) }
- .not_to raise_error(NoMethodError)
+ .not_to raise_error
end
end
end
diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb
index 2ebc9864d9b..7fde8d63947 100644
--- a/spec/models/pages_domain_spec.rb
+++ b/spec/models/pages_domain_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe PagesDomain do
'0123123' => true,
'a-reserved.com' => true,
'a.b-reserved.com' => true,
- 'reserved.com' => false,
+ 'reserved.com' => true,
'_foo.com' => false,
'a.reserved.com' => false,
'a.b.reserved.com' => false,
diff --git a/spec/models/performance_monitoring/prometheus_dashboard_spec.rb b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb
index 634690d5d0b..ee2407f21b6 100644
--- a/spec/models/performance_monitoring/prometheus_dashboard_spec.rb
+++ b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb
@@ -185,7 +185,7 @@ RSpec.describe PerformanceMonitoring::PrometheusDashboard do
context 'dashboard has been found' do
it 'uses dashboard finder to find and load dashboard data and returns dashboard instance', :aggregate_failures do
- expect(Gitlab::Metrics::Dashboard::Finder).to receive(:find).with(project, user, environment: environment, dashboard_path: path).and_return(status: :success, dashboard: json_content)
+ expect(Gitlab::Metrics::Dashboard::Finder).to receive(:find).with(project, user, { environment: environment, dashboard_path: path }).and_return(status: :success, dashboard: json_content)
dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment })
diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb
index 125ac7fb102..69866d497a1 100644
--- a/spec/models/personal_access_token_spec.rb
+++ b/spec/models/personal_access_token_spec.rb
@@ -94,14 +94,6 @@ RSpec.describe PersonalAccessToken do
end
end
- describe '#expired_but_not_enforced?' do
- let(:token) { build(:personal_access_token) }
-
- it 'returns false', :aggregate_failures do
- expect(token).not_to be_expired_but_not_enforced
- end
- end
-
describe 'Redis storage' do
let(:user_id) { 123 }
let(:token) { 'KS3wegQYXBLYhQsciwsj' }
diff --git a/spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb b/spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb
index 16e699b7e0e..eefe5bfc6c4 100644
--- a/spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb
+++ b/spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb
@@ -9,29 +9,47 @@ RSpec.describe Preloaders::UserMaxAccessLevelInProjectsPreloader do
let_it_be(:project_3) { create(:project) }
let(:projects) { [project_1, project_2, project_3] }
+ let(:query) { projects.each { |project| user.can?(:read_project, project) } }
before do
project_1.add_developer(user)
project_2.add_developer(user)
end
- context 'preload maximum access level to avoid querying project_authorizations', :request_store do
- it 'avoids N+1 queries', :request_store do
- Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, user).execute
+ context 'without preloader' do
+ it 'runs N queries' do
+ expect { query }.to make_queries(projects.size)
+ end
+ end
+
+ describe '#execute', :request_store do
+ let(:projects_arg) { projects }
+
+ before do
+ Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects_arg, user).execute
+ end
+
+ it 'avoids N+1 queries' do
+ expect { query }.not_to make_queries
+ end
- query_count = ActiveRecord::QueryRecorder.new do
- projects.each { |project| user.can?(:read_project, project) }
- end.count
+ context 'when projects is an array of IDs' do
+ let(:projects_arg) { [project_1.id, project_2.id, project_3.id] }
- expect(query_count).to eq(0)
+ it 'avoids N+1 queries' do
+ expect { query }.not_to make_queries
+ end
end
- it 'runs N queries without preloading' do
- query_count = ActiveRecord::QueryRecorder.new do
- projects.each { |project| user.can?(:read_project, project) }
- end.count
+ # Test for handling of SQL table name clashes.
+ context 'when projects is a relation including project_authorizations' do
+ let(:projects_arg) do
+ Project.where(id: ProjectAuthorization.where(project_id: projects).select(:project_id))
+ end
- expect(query_count).to eq(projects.size)
+ it 'avoids N+1 queries' do
+ expect { query }.not_to make_queries
+ end
end
end
end
diff --git a/spec/models/project_import_state_spec.rb b/spec/models/project_import_state_spec.rb
index 42ca8130734..f6e398bd23c 100644
--- a/spec/models/project_import_state_spec.rb
+++ b/spec/models/project_import_state_spec.rb
@@ -65,9 +65,11 @@ RSpec.describe ProjectImportState, type: :model do
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger).to receive(:error).with(
- error: 'ActiveRecord::ActiveRecordError',
- message: 'Error setting import status to failed',
- original_error: error_message
+ {
+ error: 'ActiveRecord::ActiveRecordError',
+ message: 'Error setting import status to failed',
+ original_error: error_message
+ }
)
end
@@ -131,20 +133,6 @@ RSpec.describe ProjectImportState, type: :model do
describe 'import state transitions' do
context 'state transition: [:started] => [:finished]' do
- let(:after_import_service) { spy(:after_import_service) }
- let(:housekeeping_service) { spy(:housekeeping_service) }
-
- before do
- allow(Projects::AfterImportService)
- .to receive(:new) { after_import_service }
-
- allow(after_import_service)
- .to receive(:execute) { housekeeping_service.execute }
-
- allow(Repositories::HousekeepingService)
- .to receive(:new) { housekeeping_service }
- end
-
it 'resets last_error' do
error_message = 'Some error'
import_state = create(:import_state, :started, last_error: error_message)
@@ -152,29 +140,28 @@ RSpec.describe ProjectImportState, type: :model do
expect { import_state.finish }.to change { import_state.last_error }.from(error_message).to(nil)
end
- it 'performs housekeeping when an import of a fresh project is completed' do
+ it 'enqueues housekeeping when an import of a fresh project is completed' do
project = create(:project_empty_repo, :import_started, import_type: :github)
- project.import_state.finish
+ expect(Projects::AfterImportWorker).to receive(:perform_async).with(project.id)
- expect(after_import_service).to have_received(:execute)
- expect(housekeeping_service).to have_received(:execute)
+ project.import_state.finish
end
it 'does not perform housekeeping when project repository does not exist' do
project = create(:project, :import_started, import_type: :github)
- project.import_state.finish
+ expect(Projects::AfterImportWorker).not_to receive(:perform_async)
- expect(housekeeping_service).not_to have_received(:execute)
+ project.import_state.finish
end
- it 'does not perform housekeeping when project does not have a valid import type' do
+ it 'does not qneueue housekeeping when project does not have a valid import type' do
project = create(:project, :import_started, import_type: nil)
- project.import_state.finish
+ expect(Projects::AfterImportWorker).not_to receive(:perform_async)
- expect(housekeeping_service).not_to have_received(:execute)
+ project.import_state.finish
end
end
end
diff --git a/spec/models/project_setting_spec.rb b/spec/models/project_setting_spec.rb
index d03eb3c8bfe..867ad843406 100644
--- a/spec/models/project_setting_spec.rb
+++ b/spec/models/project_setting_spec.rb
@@ -3,6 +3,7 @@
require 'spec_helper'
RSpec.describe ProjectSetting, type: :model do
+ using RSpec::Parameterized::TableSyntax
it { is_expected.to belong_to(:project) }
describe 'validations' do
@@ -27,6 +28,23 @@ RSpec.describe ProjectSetting, type: :model do
end
end
+ describe '#human_squash_option' do
+ where(:squash_option, :human_squash_option) do
+ 'never' | 'Do not allow'
+ 'always' | 'Require'
+ 'default_on' | 'Encourage'
+ 'default_off' | 'Allow'
+ end
+
+ with_them do
+ let(:project_setting) { create(:project_setting, squash_option: ProjectSetting.squash_options[squash_option]) }
+
+ subject { project_setting.human_squash_option }
+
+ it { is_expected.to eq(human_squash_option) }
+ end
+ end
+
def valid_target_platform_combinations
target_platforms = described_class::ALLOWED_TARGET_PLATFORMS
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 0bb584845c2..ed5b3d4e0be 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe Project, factory_default: :keep do
include ExternalAuthorizationServiceHelpers
include ReloadHelpers
include StubGitlabCalls
+ include ProjectHelpers
using RSpec::Parameterized::TableSyntax
let_it_be(:namespace) { create_default(:namespace).freeze }
@@ -135,10 +136,10 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_many(:packages).class_name('Packages::Package') }
it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') }
it { is_expected.to have_many(:debian_distributions).class_name('Packages::Debian::ProjectDistribution').dependent(:destroy) }
+ it { is_expected.to have_one(:packages_cleanup_policy).class_name('Packages::Cleanup::Policy').inverse_of(:project) }
it { is_expected.to have_many(:pipeline_artifacts).dependent(:restrict_with_error) }
it { is_expected.to have_many(:terraform_states).class_name('Terraform::State').inverse_of(:project) }
it { is_expected.to have_many(:timelogs) }
- it { is_expected.to have_many(:error_tracking_errors).class_name('ErrorTracking::Error') }
it { is_expected.to have_many(:error_tracking_client_keys).class_name('ErrorTracking::ClientKey') }
it { is_expected.to have_many(:pending_builds).class_name('Ci::PendingBuild') }
it { is_expected.to have_many(:ci_feature_usages).class_name('Projects::CiFeatureUsage') }
@@ -832,6 +833,7 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to delegate_method(:members).to(:team).with_prefix(true) }
it { is_expected.to delegate_method(:name).to(:owner).with_prefix(true).with_arguments(allow_nil: true) }
it { is_expected.to delegate_method(:root_ancestor).to(:namespace).with_arguments(allow_nil: true) }
+ it { is_expected.to delegate_method(:certificate_based_clusters_enabled?).to(:namespace).with_arguments(allow_nil: true) }
it { is_expected.to delegate_method(:last_pipeline).to(:commit).with_arguments(allow_nil: true) }
it { is_expected.to delegate_method(:container_registry_enabled?).to(:project_feature) }
it { is_expected.to delegate_method(:container_registry_access_level).to(:project_feature) }
@@ -844,6 +846,9 @@ RSpec.describe Project, factory_default: :keep do
warn_about_potentially_unwanted_characters
warn_about_potentially_unwanted_characters=
warn_about_potentially_unwanted_characters?
+ enforce_auth_checks_on_uploads
+ enforce_auth_checks_on_uploads=
+ enforce_auth_checks_on_uploads?
).each do |method|
it { is_expected.to delegate_method(method).to(:project_setting).with_arguments(allow_nil: true) }
end
@@ -2025,7 +2030,7 @@ RSpec.describe Project, factory_default: :keep do
it 'returns nil if the path detection throws an error' do
expect(Rails.application.routes).to receive(:recognize_path).with(url) { raise ActionController::RoutingError, 'test' }
- expect { subject }.not_to raise_error(ActionController::RoutingError)
+ expect { subject }.not_to raise_error
expect(subject).to be_nil
end
end
@@ -7153,11 +7158,33 @@ RSpec.describe Project, factory_default: :keep do
end
describe '#add_export_job' do
- context 'if not already present' do
- it 'starts project export job' do
- user = create(:user)
- project = build(:project)
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ context 'when project storage_size does not exceed the application setting max_export_size' do
+ it 'starts project export worker' do
+ stub_application_setting(max_export_size: 1)
+ allow(project.statistics).to receive(:storage_size).and_return(0.megabytes)
+
+ expect(ProjectExportWorker).to receive(:perform_async).with(user.id, project.id, nil, {})
+
+ project.add_export_job(current_user: user)
+ end
+ end
+
+ context 'when project storage_size exceeds the application setting max_export_size' do
+ it 'raises Project::ExportLimitExceeded' do
+ stub_application_setting(max_export_size: 1)
+ allow(project.statistics).to receive(:storage_size).and_return(2.megabytes)
+ expect(ProjectExportWorker).not_to receive(:perform_async).with(user.id, project.id, nil, {})
+ expect { project.add_export_job(current_user: user) }.to raise_error(Project::ExportLimitExceeded)
+ end
+ end
+
+ context 'when application setting max_export_size is not set' do
+ it 'starts project export worker' do
+ allow(project.statistics).to receive(:storage_size).and_return(2.megabytes)
expect(ProjectExportWorker).to receive(:perform_async).with(user.id, project.id, nil, {})
project.add_export_job(current_user: user)
@@ -8241,6 +8268,28 @@ RSpec.describe Project, factory_default: :keep do
it_behaves_like 'returns true if project is inactive'
end
+ describe '.inactive' do
+ before do
+ stub_application_setting(inactive_projects_min_size_mb: 5)
+ stub_application_setting(inactive_projects_send_warning_email_after_months: 12)
+ end
+
+ it 'returns projects that are inactive' do
+ create_project_with_statistics.tap do |project|
+ project.update!(last_activity_at: Time.current)
+ end
+ create_project_with_statistics.tap do |project|
+ project.update!(last_activity_at: 13.months.ago)
+ end
+ inactive_large_project = create_project_with_statistics(with_data: true, size_multiplier: 2.gigabytes)
+ .tap { |project| project.update!(last_activity_at: 2.years.ago) }
+ create_project_with_statistics(with_data: true, size_multiplier: 2.gigabytes)
+ .tap { |project| project.update!(last_activity_at: 1.month.ago) }
+
+ expect(described_class.inactive).to contain_exactly(inactive_large_project)
+ end
+ end
+
private
def finish_job(export_job)
diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb
index 20fc14113ef..2c29d4c42f4 100644
--- a/spec/models/project_statistics_spec.rb
+++ b/spec/models/project_statistics_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe ProjectStatistics do
snippets_size: 1.exabyte,
pipeline_artifacts_size: 512.petabytes - 1,
uploads_size: 512.petabytes,
- container_registry_size: 8.exabytes - 1
+ container_registry_size: 12.petabytes
)
statistics.reload
@@ -50,7 +50,7 @@ RSpec.describe ProjectStatistics do
expect(statistics.snippets_size).to eq(1.exabyte)
expect(statistics.pipeline_artifacts_size).to eq(512.petabytes - 1)
expect(statistics.uploads_size).to eq(512.petabytes)
- expect(statistics.container_registry_size).to eq(8.exabytes - 1)
+ expect(statistics.container_registry_size).to eq(12.petabytes)
end
end
@@ -62,6 +62,7 @@ RSpec.describe ProjectStatistics do
statistics.build_artifacts_size = 4
statistics.snippets_size = 5
statistics.uploads_size = 3
+ statistics.container_registry_size = 8
expect(statistics.total_repository_size).to eq 5
end
@@ -104,6 +105,7 @@ RSpec.describe ProjectStatistics do
allow(statistics).to receive(:update_snippets_size)
allow(statistics).to receive(:update_storage_size)
allow(statistics).to receive(:update_uploads_size)
+ allow(statistics).to receive(:update_container_registry_size)
end
context "without arguments" do
@@ -118,6 +120,7 @@ RSpec.describe ProjectStatistics do
expect(statistics).to have_received(:update_lfs_objects_size)
expect(statistics).to have_received(:update_snippets_size)
expect(statistics).to have_received(:update_uploads_size)
+ expect(statistics).to have_received(:update_container_registry_size)
end
end
@@ -133,6 +136,7 @@ RSpec.describe ProjectStatistics do
expect(statistics).not_to have_received(:update_wiki_size)
expect(statistics).not_to have_received(:update_snippets_size)
expect(statistics).not_to have_received(:update_uploads_size)
+ expect(statistics).not_to have_received(:update_container_registry_size)
end
end
@@ -148,11 +152,13 @@ RSpec.describe ProjectStatistics do
expect(statistics).to have_received(:update_wiki_size)
expect(statistics).to have_received(:update_snippets_size)
expect(statistics).to have_received(:update_uploads_size)
+ expect(statistics).to have_received(:update_container_registry_size)
expect(statistics.repository_size).to eq(0)
expect(statistics.commit_count).to eq(0)
expect(statistics.wiki_size).to eq(0)
expect(statistics.snippets_size).to eq(0)
expect(statistics.uploads_size).to eq(0)
+ expect(statistics.container_registry_size).to eq(0)
end
end
@@ -174,11 +180,13 @@ RSpec.describe ProjectStatistics do
expect(statistics).to have_received(:update_wiki_size)
expect(statistics).to have_received(:update_snippets_size)
expect(statistics).to have_received(:update_uploads_size)
+ expect(statistics).to have_received(:update_container_registry_size)
expect(statistics.repository_size).to eq(0)
expect(statistics.commit_count).to eq(0)
expect(statistics.wiki_size).to eq(0)
expect(statistics.snippets_size).to eq(0)
expect(statistics.uploads_size).to eq(0)
+ expect(statistics.container_registry_size).to eq(0)
end
end
@@ -224,6 +232,7 @@ RSpec.describe ProjectStatistics do
expect(statistics).not_to receive(:update_lfs_objects_size)
expect(statistics).not_to receive(:update_snippets_size)
expect(statistics).not_to receive(:update_uploads_size)
+ expect(statistics).not_to receive(:update_container_registry_size)
expect(statistics).not_to receive(:save!)
expect(Namespaces::ScheduleAggregationWorker)
.not_to receive(:perform_async)
@@ -319,8 +328,42 @@ RSpec.describe ProjectStatistics do
end
end
+ describe '#update_container_registry_size' do
+ subject(:update_container_registry_size) { statistics.update_container_registry_size }
+
+ it 'stores the project container registry repositories size' do
+ allow(project).to receive(:container_repositories_size).and_return(10)
+
+ update_container_registry_size
+
+ expect(statistics.container_registry_size).to eq(10)
+ end
+
+ it 'handles nil values for the repositories size' do
+ allow(project).to receive(:container_repositories_size).and_return(nil)
+
+ update_container_registry_size
+
+ expect(statistics.container_registry_size).to eq(0)
+ end
+
+ context 'with container_registry_project_statistics FF disabled' do
+ before do
+ stub_feature_flags(container_registry_project_statistics: false)
+ end
+
+ it 'does not update the container_registry_size' do
+ expect(project).not_to receive(:container_repositories_size)
+
+ update_container_registry_size
+
+ expect(statistics.container_registry_size).to eq(0)
+ end
+ end
+ end
+
describe '#update_storage_size' do
- it "sums all storage counters" do
+ it "sums the relevant storage counters" do
statistics.update!(
repository_size: 2,
wiki_size: 4,
@@ -337,6 +380,18 @@ RSpec.describe ProjectStatistics do
expect(statistics.storage_size).to eq 28
end
+ it 'excludes the container_registry_size' do
+ statistics.update!(
+ repository_size: 2,
+ uploads_size: 5,
+ container_registry_size: 10
+ )
+
+ statistics.reload
+
+ expect(statistics.storage_size).to eq 7
+ end
+
it 'works during wiki_size backfill' do
statistics.update!(
repository_size: 2,
diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb
index 5b11f9d828a..2ddbab7779e 100644
--- a/spec/models/project_team_spec.rb
+++ b/spec/models/project_team_spec.rb
@@ -410,6 +410,22 @@ RSpec.describe ProjectTeam do
end
end
+ describe '#purge_member_access_cache_for_user_id', :request_store do
+ let(:project) { create(:project) }
+ let(:user_id) { 1 }
+ let(:resource_data) { { user_id => 50, 42 => 50 } }
+
+ before do
+ Gitlab::SafeRequestStore[project.max_member_access_for_resource_key(User)] = resource_data
+ end
+
+ it 'removes cached max access for user from store' do
+ project.team.purge_member_access_cache_for_user_id(user_id)
+
+ expect(Gitlab::SafeRequestStore[project.max_member_access_for_resource_key(User)]).to eq({ 42 => 50 })
+ end
+ end
+
describe '#member?' do
let(:group) { create(:group) }
let(:developer) { create(:user) }
diff --git a/spec/models/projects/topic_spec.rb b/spec/models/projects/topic_spec.rb
index 8fc4d11f0d9..fc9d9bef437 100644
--- a/spec/models/projects/topic_spec.rb
+++ b/spec/models/projects/topic_spec.rb
@@ -25,6 +25,8 @@ RSpec.describe Projects::Topic do
it { is_expected.to validate_uniqueness_of(:name).case_insensitive }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(1024) }
+ it { expect(Projects::Topic.new).to validate_presence_of(:title) }
+ it { expect(Projects::Topic.new).to validate_length_of(:title).is_at_most(255) }
end
describe 'scopes' do
@@ -104,4 +106,16 @@ RSpec.describe Projects::Topic do
end
end
end
+
+ describe '#title_or_name' do
+ it 'returns title if set' do
+ topic.title = 'My title'
+ expect(topic.title_or_name).to eq('My title')
+ end
+
+ it 'returns name if title not set' do
+ topic.title = nil
+ expect(topic.title_or_name).to eq('topic')
+ end
+ end
end
diff --git a/spec/models/protected_branch/push_access_level_spec.rb b/spec/models/protected_branch/push_access_level_spec.rb
index 13d33b95b16..008ae6275f0 100644
--- a/spec/models/protected_branch/push_access_level_spec.rb
+++ b/spec/models/protected_branch/push_access_level_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe ProtectedBranch::PushAccessLevel do
it 'checks that a deploy key is enabled for the same project as the protected branch\'s' do
level = build(:protected_branch_push_access_level, deploy_key: create(:deploy_key))
- expect { level.save! }.to raise_error
+ expect { level.save! }.to raise_error(ActiveRecord::RecordInvalid)
expect(level.errors.full_messages).to contain_exactly('Deploy key is not enabled for this project')
end
end
diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb
index f7c723cd134..366de809bed 100644
--- a/spec/models/protected_branch_spec.rb
+++ b/spec/models/protected_branch_spec.rb
@@ -324,4 +324,10 @@ RSpec.describe ProtectedBranch do
.to match_array([branch_id])
end
end
+
+ describe '.downcase_humanized_name' do
+ it 'returns downcase humanized name' do
+ expect(described_class.downcase_humanized_name).to eq 'protected branch'
+ end
+ end
end
diff --git a/spec/models/raw_usage_data_spec.rb b/spec/models/raw_usage_data_spec.rb
index 6ff4c6eb19b..95b98279a27 100644
--- a/spec/models/raw_usage_data_spec.rb
+++ b/spec/models/raw_usage_data_spec.rb
@@ -3,6 +3,31 @@
require 'spec_helper'
RSpec.describe RawUsageData do
+ context 'scopes' do
+ describe '.for_current_reporting_cycle' do
+ subject(:recent_service_ping_reports) { described_class.for_current_reporting_cycle }
+
+ before_all do
+ create(:raw_usage_data, created_at: (described_class::REPORTING_CADENCE + 1.day).ago)
+ end
+
+ it 'returns nil where no records match filter criteria' do
+ expect(recent_service_ping_reports).to be_empty
+ end
+
+ context 'with records matching filtering criteria' do
+ let_it_be(:fresh_record) { create(:raw_usage_data) }
+ let_it_be(:record_at_edge_of_time_range) do
+ create(:raw_usage_data, created_at: described_class::REPORTING_CADENCE.ago)
+ end
+
+ it 'return records within reporting cycle time range ordered by creation time' do
+ expect(recent_service_ping_reports).to eq [fresh_record, record_at_edge_of_time_range]
+ end
+ end
+ end
+ end
+
describe 'validations' do
it { is_expected.to validate_presence_of(:payload) }
it { is_expected.to validate_presence_of(:recorded_at) }
diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb
index 125fec61d72..4ae1927dcca 100644
--- a/spec/models/release_spec.rb
+++ b/spec/models/release_spec.rb
@@ -53,7 +53,10 @@ RSpec.describe Release do
context 'when a release is tied to a milestone for another project' do
it 'creates a validation error' do
milestone = build(:milestone, project: create(:project))
- expect { release.milestones << milestone }.to raise_error
+
+ expect { release.milestones << milestone }
+ .to raise_error(ActiveRecord::RecordInvalid,
+ 'Validation failed: Release does not have the same project as the milestone')
end
end
diff --git a/spec/models/shard_spec.rb b/spec/models/shard_spec.rb
index 38729fa1758..298441fb4c4 100644
--- a/spec/models/shard_spec.rb
+++ b/spec/models/shard_spec.rb
@@ -38,12 +38,12 @@ RSpec.describe Shard do
expect(described_class)
.to receive(:find_by)
- .with(name: 'new_shard')
+ .with({ name: 'new_shard' })
.and_return(nil, shard_created_by_others)
expect(described_class)
.to receive(:create)
- .with(name: 'new_shard')
+ .with({ name: 'new_shard' })
.and_raise(ActiveRecord::RecordNotUnique, 'fail')
.once
diff --git a/spec/models/system_note_metadata_spec.rb b/spec/models/system_note_metadata_spec.rb
index 144c65d2f62..36bcb3499b3 100644
--- a/spec/models/system_note_metadata_spec.rb
+++ b/spec/models/system_note_metadata_spec.rb
@@ -19,12 +19,14 @@ RSpec.describe SystemNoteMetadata do
it { is_expected.to be_invalid }
end
- context 'when action type is valid' do
- subject do
- build(:system_note_metadata, note: build(:note), action: 'merge')
- end
+ %i[merge timeline_event].each do |action|
+ context 'when action type is valid' do
+ subject do
+ build(:system_note_metadata, note: build(:note), action: action)
+ end
- it { is_expected.to be_valid }
+ it { is_expected.to be_valid }
+ end
end
context 'when importing' do
diff --git a/spec/models/user_custom_attribute_spec.rb b/spec/models/user_custom_attribute_spec.rb
index 1a51ad662b0..67c144d7caa 100644
--- a/spec/models/user_custom_attribute_spec.rb
+++ b/spec/models/user_custom_attribute_spec.rb
@@ -15,4 +15,61 @@ RSpec.describe UserCustomAttribute do
it { is_expected.to validate_presence_of(:value) }
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:user_id) }
end
+
+ describe 'scopes' do
+ let(:user) { create(:user) }
+ let(:blocked_at) { DateTime.now }
+ let(:custom_attribute) { create(:user_custom_attribute, key: 'blocked_at', value: blocked_at, user_id: user.id) }
+
+ describe '.by_user_id' do
+ subject { UserCustomAttribute.by_user_id(user.id) }
+
+ it { is_expected.to match_array([custom_attribute]) }
+ end
+
+ describe '.by_updated_at' do
+ subject { UserCustomAttribute.by_updated_at(Date.today.all_day) }
+
+ it { is_expected.to match_array([custom_attribute]) }
+ end
+
+ describe '.by_key' do
+ subject { UserCustomAttribute.by_key('blocked_at') }
+
+ it { is_expected.to match_array([custom_attribute]) }
+ end
+ end
+
+ describe '#upsert_custom_attributes' do
+ subject { UserCustomAttribute.upsert_custom_attributes(custom_attributes) }
+
+ let_it_be_with_reload(:user) { create(:user) }
+
+ let(:arkose_session) { '22612c147bb418c8.2570749403' }
+ let(:risk_band) { 'Low' }
+ let(:global_score) { '0' }
+ let(:custom_score) { '0' }
+
+ let(:custom_attributes) do
+ custom_attributes = []
+ custom_attributes.push({ key: 'arkose_session', value: arkose_session })
+ custom_attributes.push({ key: 'arkose_risk_band', value: risk_band })
+ custom_attributes.push({ key: 'arkose_global_score', value: global_score })
+ custom_attributes.push({ key: 'arkose_custom_score', value: custom_score })
+
+ custom_attributes.map! { |custom_attribute| custom_attribute.merge({ user_id: user.id }) }
+ custom_attributes
+ end
+
+ it 'adds arkose data to custom attributes' do
+ subject
+
+ expect(user.custom_attributes.count).to eq(4)
+
+ expect(user.custom_attributes.find_by(key: 'arkose_session').value).to eq(arkose_session)
+ expect(user.custom_attributes.find_by(key: 'arkose_risk_band').value).to eq(risk_band)
+ expect(user.custom_attributes.find_by(key: 'arkose_global_score').value).to eq(global_score)
+ expect(user.custom_attributes.find_by(key: 'arkose_custom_score').value).to eq(custom_score)
+ end
+ end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index bc425b15c6e..71171f98492 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -1048,8 +1048,8 @@ RSpec.describe User do
context 'SSH key expiration scopes' do
let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
- let_it_be(:expired_today_not_notified) { create(:key, expires_at: Time.current, user: user1) }
- let_it_be(:expired_today_already_notified) { create(:key, expires_at: Time.current, user: user2, expiry_notification_delivered_at: Time.current) }
+ let_it_be(:expired_today_not_notified) { create(:key, :expired_today, user: user1) }
+ let_it_be(:expired_today_already_notified) { create(:key, :expired_today, user: user2, expiry_notification_delivered_at: Time.current) }
let_it_be(:expiring_soon_not_notified) { create(:key, expires_at: 2.days.from_now, user: user2) }
let_it_be(:expiring_soon_notified) { create(:key, expires_at: 2.days.from_now, user: user1, before_expiry_notification_delivered_at: Time.current) }
@@ -5006,9 +5006,13 @@ RSpec.describe User do
let(:archived_project) { create(:project, :public, :archived) }
before do
- create(:merge_request, source_project: project, author: user, reviewers: [user])
- create(:merge_request, :closed, source_project: project, author: user, reviewers: [user])
- create(:merge_request, source_project: archived_project, author: user, reviewers: [user])
+ mr1 = create(:merge_request, source_project: project, author: user, reviewers: [user])
+ mr2 = create(:merge_request, :closed, source_project: project, author: user, reviewers: [user])
+ mr3 = create(:merge_request, source_project: archived_project, author: user, reviewers: [user])
+
+ mr1.find_reviewer(user).update!(state: :attention_requested)
+ mr2.find_reviewer(user).update!(state: :attention_requested)
+ mr3.find_reviewer(user).update!(state: :attention_requested)
end
it 'returns number of open merge requests from non-archived projects' do
@@ -5335,7 +5339,7 @@ RSpec.describe User do
let(:deleted_by) { create(:user) }
it 'blocks the user then schedules them for deletion if a hard delete is specified' do
- expect(DeleteUserWorker).to receive(:perform_async).with(deleted_by.id, user.id, hard_delete: true)
+ expect(DeleteUserWorker).to receive(:perform_async).with(deleted_by.id, user.id, { hard_delete: true })
user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
@@ -6817,4 +6821,23 @@ RSpec.describe User do
it_behaves_like 'it has loose foreign keys' do
let(:factory_name) { :user }
end
+
+ describe 'mr_attention_requests_enabled?' do
+ let(:user) { create(:user) }
+
+ before do
+ stub_feature_flags(mr_attention_requests: false)
+ end
+
+ it { expect(user.mr_attention_requests_enabled?).to be(false) }
+
+ it 'feature flag is enabled for user' do
+ stub_feature_flags(mr_attention_requests: user)
+
+ another_user = create(:user)
+
+ expect(user.mr_attention_requests_enabled?).to be(true)
+ expect(another_user.mr_attention_requests_enabled?).to be(false)
+ end
+ end
end
diff --git a/spec/models/users/in_product_marketing_email_spec.rb b/spec/models/users/in_product_marketing_email_spec.rb
index ca03c3e645d..7796b54babc 100644
--- a/spec/models/users/in_product_marketing_email_spec.rb
+++ b/spec/models/users/in_product_marketing_email_spec.rb
@@ -14,9 +14,35 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do
subject { build(:in_product_marketing_email) }
it { is_expected.to validate_presence_of(:user) }
- it { is_expected.to validate_presence_of(:track) }
- it { is_expected.to validate_presence_of(:series) }
- it { is_expected.to validate_uniqueness_of(:user_id).scoped_to([:track, :series]).with_message('has already been sent') }
+
+ context 'for a track+series email' do
+ it { is_expected.to validate_presence_of(:track) }
+ it { is_expected.to validate_presence_of(:series) }
+ it {
+ is_expected.to validate_uniqueness_of(:user_id)
+ .scoped_to([:track, :series]).with_message('track series email has already been sent')
+ }
+ end
+
+ context 'for a campaign email' do
+ subject { build(:in_product_marketing_email, :campaign) }
+
+ it { is_expected.to validate_presence_of(:campaign) }
+ it { is_expected.not_to validate_presence_of(:track) }
+ it { is_expected.not_to validate_presence_of(:series) }
+ it {
+ is_expected.to validate_uniqueness_of(:user_id)
+ .scoped_to(:campaign).with_message('campaign email has already been sent')
+ }
+ it { is_expected.to validate_inclusion_of(:campaign).in_array(described_class::CAMPAIGNS) }
+ end
+
+ context 'when mixing campaign and track+series' do
+ it 'is not valid' do
+ expect(build(:in_product_marketing_email, :campaign, track: :create)).not_to be_valid
+ expect(build(:in_product_marketing_email, :campaign, series: 0)).not_to be_valid
+ end
+ end
end
describe '.without_track_and_series' do
@@ -58,6 +84,27 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do
end
end
+ describe '.without_campaign' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:other_user) { create(:user) }
+
+ let(:campaign) { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
+
+ subject(:without_campaign) { User.merge(described_class.without_campaign(campaign)) }
+
+ context 'when record for campaign already exists' do
+ before do
+ create(:in_product_marketing_email, :campaign, campaign: campaign, user: user)
+ end
+
+ it { is_expected.to match_array [other_user] }
+ end
+
+ context 'when record for campaign does not exist' do
+ it { is_expected.to match_array [user, other_user] }
+ end
+ end
+
describe '.for_user_with_track_and_series' do
let_it_be(:user) { create(:user) }
let_it_be(:in_product_marketing_email) { create(:in_product_marketing_email, series: 0, track: 0, user: user) }
diff --git a/spec/models/users/merge_request_interaction_spec.rb b/spec/models/users/merge_request_interaction_spec.rb
index 12c7fa43a60..a499a7c68e8 100644
--- a/spec/models/users/merge_request_interaction_spec.rb
+++ b/spec/models/users/merge_request_interaction_spec.rb
@@ -59,6 +59,7 @@ RSpec.describe ::Users::MergeRequestInteraction do
context 'when the user has been asked to review the MR' do
before do
merge_request.reviewers << user
+ merge_request.find_reviewer(user).update!(state: :attention_requested)
end
it { is_expected.to eq(Types::MergeRequestReviewStateEnum.values['ATTENTION_REQUESTED'].value) }
diff --git a/spec/policies/container_expiration_policy_policy_spec.rb b/spec/policies/container_expiration_policy_policy_spec.rb
new file mode 100644
index 00000000000..4b39dd8dace
--- /dev/null
+++ b/spec/policies/container_expiration_policy_policy_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ContainerExpirationPolicyPolicy do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project, reload: true) { create(:project) }
+
+ subject { described_class.new(user, project.container_expiration_policy) }
+
+ where(:user_type, :allowed_to_destroy_container_image) do
+ :anonymous | false
+ :guest | false
+ :developer | false
+ :maintainer | true
+ end
+
+ with_them do
+ context "for user type #{params[:user_type]}" do
+ before do
+ project.public_send("add_#{user_type}", user) unless user_type == :anonymous
+ end
+
+ if params[:allowed_to_destroy_container_image]
+ it { is_expected.to be_allowed(:admin_container_image) }
+ else
+ it { is_expected.not_to be_allowed(:admin_container_image) }
+ end
+ end
+ end
+end
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index ff59a2e04a7..05bba167bd3 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -242,6 +242,24 @@ RSpec.describe GroupPolicy do
end
end
+ context 'migration bot' do
+ let_it_be(:migration_bot) { User.migration_bot }
+ let_it_be(:current_user) { migration_bot }
+
+ it :aggregate_failures do
+ expect_allowed(:read_resource_access_tokens, :destroy_resource_access_tokens)
+ expect_disallowed(*guest_permissions)
+ expect_disallowed(*reporter_permissions)
+ expect_disallowed(*developer_permissions)
+ expect_disallowed(*maintainer_permissions)
+ expect_disallowed(*owner_permissions)
+ end
+
+ it_behaves_like 'deploy token does not get confused with user' do
+ let(:user_id) { migration_bot.id }
+ end
+ end
+
describe 'private nested group use the highest access level from the group and inherited permissions' do
let_it_be(:nested_group) do
create(:group, :private, :owner_subgroup_creation_only, :crm_enabled, parent: group)
@@ -914,12 +932,21 @@ RSpec.describe GroupPolicy do
context 'reporter' do
let(:current_user) { reporter }
+ it { is_expected.to be_allowed(:read_dependency_proxy) }
it { is_expected.to be_disallowed(:admin_dependency_proxy) }
end
context 'developer' do
let(:current_user) { developer }
+ it { is_expected.to be_allowed(:read_dependency_proxy) }
+ it { is_expected.to be_disallowed(:admin_dependency_proxy) }
+ end
+
+ context 'maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(:read_dependency_proxy) }
it { is_expected.to be_allowed(:admin_dependency_proxy) }
end
end
@@ -1171,6 +1198,24 @@ RSpec.describe GroupPolicy do
end
end
+ describe 'change_prevent_sharing_groups_outside_hierarchy' do
+ context 'with owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:change_prevent_sharing_groups_outside_hierarchy) }
+ end
+
+ context 'with non-owner roles' do
+ where(role: %w[admin maintainer reporter developer guest])
+
+ with_them do
+ let(:current_user) { public_send role }
+
+ it { is_expected.to be_disallowed(:change_prevent_sharing_groups_outside_hierarchy) }
+ end
+ end
+ end
+
context 'with customer relations feature flag disabled' do
let(:current_user) { owner }
diff --git a/spec/policies/issuable_policy_spec.rb b/spec/policies/issuable_policy_spec.rb
index eeb298e853e..5e2a307e959 100644
--- a/spec/policies/issuable_policy_spec.rb
+++ b/spec/policies/issuable_policy_spec.rb
@@ -3,11 +3,25 @@
require 'spec_helper'
RSpec.describe IssuablePolicy, models: true do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+
let(:issue) { create(:issue, project: project) }
let(:policies) { described_class.new(user, issue) }
+ before do
+ project.add_developer(developer)
+ project.add_guest(guest)
+ project.add_reporter(reporter)
+ end
+
+ def permissions(user, issue)
+ described_class.new(user, issue)
+ end
+
describe '#rules' do
context 'when user is author of issuable' do
let(:merge_request) { create(:merge_request, source_project: project, author: user) }
@@ -23,6 +37,20 @@ RSpec.describe IssuablePolicy, models: true do
end
end
+ context 'Timeline events' do
+ it 'allows non-members to read time line events' do
+ expect(permissions(guest, issue)).to be_allowed(:read_incident_management_timeline_event)
+ end
+
+ it 'disallows reporters from managing timeline events' do
+ expect(permissions(reporter, issue)).to be_disallowed(:admin_incident_management_timeline_event)
+ end
+
+ it 'allows developers to manage timeline events' do
+ expect(permissions(developer, issue)).to be_allowed(:admin_incident_management_timeline_event)
+ end
+ end
+
context 'when project is private' do
let(:project) { create(:project, :private) }
@@ -37,6 +65,24 @@ RSpec.describe IssuablePolicy, models: true do
it 'disallows user from reading and updating issuables from that project' do
expect(policies).to be_disallowed(:read_issue, :update_issue, :reopen_issue, :read_merge_request, :update_merge_request, :reopen_merge_request)
end
+
+ context 'Timeline events' do
+ it 'disallows non-members from reading timeline events' do
+ expect(permissions(user, issue)).to be_disallowed(:read_incident_management_timeline_event)
+ end
+
+ it 'allows guests to read time line events' do
+ expect(permissions(guest, issue)).to be_allowed(:read_incident_management_timeline_event)
+ end
+
+ it 'disallows reporters from managing timeline events' do
+ expect(permissions(reporter, issue)).to be_disallowed(:admin_incident_management_timeline_event)
+ end
+
+ it 'allows developers to manage timeline events' do
+ expect(permissions(developer, issue)).to be_allowed(:admin_incident_management_timeline_event)
+ end
+ end
end
end
diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb
index 1fe9e430011..557bda985af 100644
--- a/spec/policies/issue_policy_spec.rb
+++ b/spec/policies/issue_policy_spec.rb
@@ -397,7 +397,7 @@ RSpec.describe IssuePolicy do
end
end
- describe 'set_issue_crm_contacts' do
+ describe 'crm permissions' do
let(:user) { create(:user) }
let(:subgroup) { create(:group, :crm_enabled, parent: create(:group, :crm_enabled)) }
let(:project) { create(:project, group: subgroup) }
@@ -408,6 +408,7 @@ RSpec.describe IssuePolicy do
it 'is disallowed' do
project.add_reporter(user)
+ expect(policies).to be_disallowed(:read_crm_contacts)
expect(policies).to be_disallowed(:set_issue_crm_contacts)
end
end
@@ -416,6 +417,7 @@ RSpec.describe IssuePolicy do
it 'is allowed' do
subgroup.add_reporter(user)
+ expect(policies).to be_disallowed(:read_crm_contacts)
expect(policies).to be_disallowed(:set_issue_crm_contacts)
end
end
@@ -424,8 +426,31 @@ RSpec.describe IssuePolicy do
it 'is allowed' do
subgroup.parent.add_reporter(user)
+ expect(policies).to be_allowed(:read_crm_contacts)
expect(policies).to be_allowed(:set_issue_crm_contacts)
end
end
+
+ context 'when crm disabled on subgroup' do
+ let(:subgroup) { create(:group, parent: create(:group, :crm_enabled)) }
+
+ it 'is disallowed' do
+ subgroup.parent.add_reporter(user)
+
+ expect(policies).to be_disallowed(:read_crm_contacts)
+ expect(policies).to be_disallowed(:set_issue_crm_contacts)
+ end
+ end
+
+ context 'when peronsal namespace' do
+ let(:project) { create(:project) }
+
+ it 'is disallowed' do
+ project.add_reporter(user)
+
+ expect(policies).to be_disallowed(:read_crm_contacts)
+ expect(policies).to be_disallowed(:set_issue_crm_contacts)
+ end
+ end
end
end
diff --git a/spec/policies/namespaces/project_namespace_policy_spec.rb b/spec/policies/namespaces/project_namespace_policy_spec.rb
index f1022747fab..5ceea9dfb9d 100644
--- a/spec/policies/namespaces/project_namespace_policy_spec.rb
+++ b/spec/policies/namespaces/project_namespace_policy_spec.rb
@@ -9,8 +9,8 @@ RSpec.describe Namespaces::ProjectNamespacePolicy do
let(:permissions) do
[:owner_access, :create_projects, :admin_namespace, :read_namespace,
- :read_statistics, :transfer_projects, :create_package_settings,
- :read_package_settings, :create_jira_connect_subscription]
+ :read_statistics, :transfer_projects, :admin_package,
+ :create_jira_connect_subscription]
end
subject { described_class.new(current_user, namespace) }
diff --git a/spec/policies/namespaces/user_namespace_policy_spec.rb b/spec/policies/namespaces/user_namespace_policy_spec.rb
index 06db2f6e243..22c3f6a6d67 100644
--- a/spec/policies/namespaces/user_namespace_policy_spec.rb
+++ b/spec/policies/namespaces/user_namespace_policy_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Namespaces::UserNamespacePolicy do
let_it_be(:admin) { create(:admin) }
let_it_be(:namespace) { create(:user_namespace, owner: owner) }
- let(:owner_permissions) { [:owner_access, :create_projects, :admin_namespace, :read_namespace, :read_statistics, :transfer_projects, :create_package_settings, :read_package_settings] }
+ let(:owner_permissions) { [:owner_access, :create_projects, :admin_namespace, :read_namespace, :read_statistics, :transfer_projects, :admin_package] }
subject { described_class.new(current_user, namespace) }
diff --git a/spec/policies/timelog_policy_spec.rb b/spec/policies/timelog_policy_spec.rb
new file mode 100644
index 00000000000..97e61cfe5ce
--- /dev/null
+++ b/spec/policies/timelog_policy_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe TimelogPolicy, models: true do
+ let_it_be(:author) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:timelog) { create(:timelog, user: author, issue: issue, time_spent: 1800)}
+
+ let(:user) { nil }
+ let(:policy) { described_class.new(user, timelog) }
+
+ describe '#rules' do
+ context 'when user is anonymus' do
+ it 'prevents adimistration of timelog' do
+ expect(policy).to be_disallowed(:admin_timelog)
+ end
+ end
+
+ context 'when user is the author of the timelog' do
+ let(:user) { author }
+
+ it 'allows adimistration of timelog' do
+ expect(policy).to be_allowed(:admin_timelog)
+ end
+ end
+
+ context 'when user is not the author of the timelog but maintainer of the project' do
+ let(:user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'allows adimistration of timelog' do
+ expect(policy).to be_allowed(:admin_timelog)
+ end
+ end
+
+ context 'when user is not the timelog\'s author, not a maintainer but an administrator', :enable_admin_mode do
+ let(:user) { create(:user, :admin) }
+
+ it 'allows adimistration of timelog' do
+ expect(policy).to be_allowed(:admin_timelog)
+ end
+ end
+
+ context 'when user is not the author of the timelog nor a maintainer of the project nor an administrator' do
+ let(:user) { create(:user) }
+
+ it 'prevents adimistration of timelog' do
+ expect(policy).to be_disallowed(:admin_timelog)
+ end
+ end
+ end
+end
diff --git a/spec/policies/work_item_policy_spec.rb b/spec/policies/work_item_policy_spec.rb
index 08a22a95540..b19f7d2557d 100644
--- a/spec/policies/work_item_policy_spec.rb
+++ b/spec/policies/work_item_policy_spec.rb
@@ -3,11 +3,13 @@
require 'spec_helper'
RSpec.describe WorkItemPolicy do
- let_it_be(:project) { create(:project) }
- let_it_be(:public_project) { create(:project, :public) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:public_project) { create(:project, :public, group: group) }
let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } }
let_it_be(:guest_author) { create(:user).tap { |user| project.add_guest(user) } }
let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } }
+ let_it_be(:group_reporter) { create(:user).tap { |user| group.add_reporter(user) } }
let_it_be(:non_member_user) { create(:user) }
let_it_be(:work_item) { create(:work_item, project: project) }
let_it_be(:authored_work_item) { create(:work_item, project: project, author: guest_author) }
@@ -81,7 +83,9 @@ RSpec.describe WorkItemPolicy do
let(:work_item_subject) { work_item }
let(:current_user) { reporter }
- it { is_expected.to be_disallowed(:delete_work_item) }
+ context 'when the user is not the author of the work item' do
+ it { is_expected.to be_disallowed(:delete_work_item) }
+ end
context 'when guest authored the work item' do
let(:work_item_subject) { authored_work_item }
@@ -90,5 +94,35 @@ RSpec.describe WorkItemPolicy do
it { is_expected.to be_allowed(:delete_work_item) }
end
end
+
+ context 'when user is member of the project\'s group' do
+ let(:current_user) { group_reporter }
+
+ context 'when the user is not the author of the work item' do
+ it { is_expected.to be_disallowed(:delete_work_item) }
+ end
+
+ context 'when user authored the work item' do
+ let(:work_item_subject) { create(:work_item, project: project, author: current_user) }
+
+ it { is_expected.to be_allowed(:delete_work_item) }
+ end
+ end
+
+ context 'when user is not a member of the project' do
+ let(:current_user) { non_member_user }
+
+ context 'when the user authored the work item' do
+ let(:work_item_subject) { create(:work_item, project: public_project, author: current_user) }
+
+ it { is_expected.to be_disallowed(:delete_work_item) }
+ end
+
+ context 'when the user is not the author of the work item' do
+ let(:work_item_subject) { public_work_item }
+
+ it { is_expected.to be_disallowed(:delete_work_item) }
+ end
+ end
end
end
diff --git a/spec/presenters/clusters/cluster_presenter_spec.rb b/spec/presenters/clusters/cluster_presenter_spec.rb
index 49126ed8e5f..6570ab56ed0 100644
--- a/spec/presenters/clusters/cluster_presenter_spec.rb
+++ b/spec/presenters/clusters/cluster_presenter_spec.rb
@@ -121,7 +121,7 @@ RSpec.describe Clusters::ClusterPresenter do
it do
is_expected.to include('clusters-path': clusterable_presenter.index_path,
'dashboard-endpoint': clusterable_presenter.metrics_dashboard_path(cluster),
- 'documentation-path': help_page_path('user/project/clusters/index', anchor: 'monitoring-your-kubernetes-cluster'),
+ 'documentation-path': help_page_path('user/infrastructure/clusters/manage/clusters_health'),
'add-dashboard-documentation-path': help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'),
'empty-getting-started-svg-path': match_asset_path('/assets/illustrations/monitoring/getting_started.svg'),
'empty-loading-svg-path': match_asset_path('/assets/illustrations/monitoring/loading.svg'),
diff --git a/spec/presenters/group_clusterable_presenter_spec.rb b/spec/presenters/group_clusterable_presenter_spec.rb
index f6389ba723e..0b8a7cb5003 100644
--- a/spec/presenters/group_clusterable_presenter_spec.rb
+++ b/spec/presenters/group_clusterable_presenter_spec.rb
@@ -37,36 +37,18 @@ RSpec.describe GroupClusterablePresenter do
it { is_expected.to eq(group_clusters_path(group)) }
end
- describe '#new_path' do
- subject { presenter.new_path }
-
- it { is_expected.to eq(new_group_cluster_path(group)) }
- end
-
describe '#connect_path' do
subject { presenter.connect_path }
it { is_expected.to eq(connect_group_clusters_path(group)) }
end
- describe '#authorize_aws_role_path' do
- subject { presenter.authorize_aws_role_path }
-
- it { is_expected.to eq(authorize_aws_role_group_clusters_path(group)) }
- end
-
describe '#create_user_clusters_path' do
subject { presenter.create_user_clusters_path }
it { is_expected.to eq(create_user_group_clusters_path(group)) }
end
- describe '#create_gcp_clusters_path' do
- subject { presenter.create_gcp_clusters_path }
-
- it { is_expected.to eq(create_gcp_group_clusters_path(group)) }
- end
-
describe '#cluster_status_cluster_path' do
subject { presenter.cluster_status_cluster_path(cluster) }
diff --git a/spec/presenters/instance_clusterable_presenter_spec.rb b/spec/presenters/instance_clusterable_presenter_spec.rb
index 3e871bf7ba5..52379091b4e 100644
--- a/spec/presenters/instance_clusterable_presenter_spec.rb
+++ b/spec/presenters/instance_clusterable_presenter_spec.rb
@@ -9,24 +9,12 @@ RSpec.describe InstanceClusterablePresenter do
let(:cluster) { create(:cluster, :provided_by_gcp, :instance) }
let(:instance) { cluster.instance }
- describe '#create_aws_clusters_path' do
- subject { described_class.new(instance).create_aws_clusters_path }
-
- it { is_expected.to eq(create_aws_admin_clusters_path) }
- end
-
describe '#connect_path' do
subject { described_class.new(instance).connect_path }
it { is_expected.to eq(connect_admin_clusters_path) }
end
- describe '#authorize_aws_role_path' do
- subject { described_class.new(instance).authorize_aws_role_path }
-
- it { is_expected.to eq(authorize_aws_role_admin_clusters_path) }
- end
-
describe '#clear_cluster_cache_path' do
subject { presenter.clear_cluster_cache_path(cluster) }
diff --git a/spec/presenters/project_clusterable_presenter_spec.rb b/spec/presenters/project_clusterable_presenter_spec.rb
index bd4319c9411..dfe4a191ae5 100644
--- a/spec/presenters/project_clusterable_presenter_spec.rb
+++ b/spec/presenters/project_clusterable_presenter_spec.rb
@@ -37,12 +37,6 @@ RSpec.describe ProjectClusterablePresenter do
it { is_expected.to eq(project_clusters_path(project)) }
end
- describe '#new_path' do
- subject { presenter.new_path }
-
- it { is_expected.to eq(new_project_cluster_path(project)) }
- end
-
describe '#connect_path' do
subject { presenter.connect_path }
@@ -55,24 +49,12 @@ RSpec.describe ProjectClusterablePresenter do
it { is_expected.to eq(new_cluster_docs_project_clusters_path(project)) }
end
- describe '#authorize_aws_role_path' do
- subject { presenter.authorize_aws_role_path }
-
- it { is_expected.to eq(authorize_aws_role_project_clusters_path(project)) }
- end
-
describe '#create_user_clusters_path' do
subject { presenter.create_user_clusters_path }
it { is_expected.to eq(create_user_project_clusters_path(project)) }
end
- describe '#create_gcp_clusters_path' do
- subject { presenter.create_gcp_clusters_path }
-
- it { is_expected.to eq(create_gcp_project_clusters_path(project)) }
- end
-
describe '#cluster_status_cluster_path' do
subject { presenter.cluster_status_cluster_path(cluster) }
diff --git a/spec/presenters/projects/security/configuration_presenter_spec.rb b/spec/presenters/projects/security/configuration_presenter_spec.rb
index 779d6b88fd5..05e5a9d4f1d 100644
--- a/spec/presenters/projects/security/configuration_presenter_spec.rb
+++ b/spec/presenters/projects/security/configuration_presenter_spec.rb
@@ -263,7 +263,7 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
end
it 'includes a link to CI pipeline docs' do
- expect(html_data[:latest_pipeline_path]).to eq(help_page_path('ci/pipelines'))
+ expect(html_data[:latest_pipeline_path]).to eq(help_page_path('ci/pipelines/index'))
end
context 'when gathering feature data' do
diff --git a/spec/requests/admin/background_migrations_controller_spec.rb b/spec/requests/admin/background_migrations_controller_spec.rb
index 9933008502f..0fd2ba26cb8 100644
--- a/spec/requests/admin/background_migrations_controller_spec.rb
+++ b/spec/requests/admin/background_migrations_controller_spec.rb
@@ -9,6 +9,90 @@ RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode do
sign_in(admin)
end
+ describe 'GET #show' do
+ context 'when the migration is valid' do
+ let(:migration) { create(:batched_background_migration) }
+ let!(:failed_job) { create(:batched_background_migration_job, :failed, batched_migration: migration) }
+
+ it 'fetches the migration' do
+ get admin_background_migration_path(migration)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns failed jobs' do
+ get admin_background_migration_path(migration)
+
+ expect(assigns(:failed_jobs)).to match_array([failed_job])
+ end
+ end
+
+ context 'when the migration does not exist' do
+ let(:invalid_migration) { non_existing_record_id }
+
+ it 'returns not found' do
+ get admin_background_migration_path(invalid_migration)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET #index' do
+ let(:default_model) { ActiveRecord::Base }
+
+ before do
+ allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
+ end
+
+ let!(:main_database_migration) { create(:batched_background_migration, :active) }
+
+ context 'when no database is provided' do
+ let(:base_models) { { 'fake_db' => default_model } }
+
+ before do
+ stub_const('Gitlab::Database::MAIN_DATABASE_NAME', 'fake_db')
+ end
+
+ it 'uses the default connection' do
+ expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(default_model.connection).and_yield
+
+ get admin_background_migrations_path
+ end
+
+ it 'returns default database records' do
+ get admin_background_migrations_path
+
+ expect(assigns(:migrations)).to match_array([main_database_migration])
+ end
+ end
+
+ context 'when multiple database is enabled', :add_ci_connection do
+ let(:base_models) { { 'fake_db' => default_model, 'ci' => ci_model } }
+ let(:ci_model) { Ci::ApplicationRecord }
+
+ context 'when CI database is provided' do
+ it "uses CI database connection" do
+ expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(ci_model.connection).and_yield
+
+ get admin_background_migrations_path, params: { database: 'ci' }
+ end
+
+ it 'returns CI database records' do
+ # If we only have one DB we'll see both migrations
+ skip_if_multiple_databases_not_setup
+
+ ci_database_migration = Gitlab::Database::SharedModel.using_connection(ci_model.connection) { create(:batched_background_migration, :active) }
+
+ get admin_background_migrations_path, params: { database: 'ci' }
+
+ expect(assigns(:migrations)).to match_array([ci_database_migration])
+ expect(assigns(:migrations)).not_to include(main_database_migration)
+ end
+ end
+ end
+ end
+
describe 'POST #retry' do
let(:migration) { create(:batched_background_migration, :failed) }
diff --git a/spec/requests/admin/batched_jobs_controller_spec.rb b/spec/requests/admin/batched_jobs_controller_spec.rb
new file mode 100644
index 00000000000..9a0654c64b4
--- /dev/null
+++ b/spec/requests/admin/batched_jobs_controller_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::BatchedJobsController, :enable_admin_mode do
+ let(:admin) { create(:admin) }
+
+ before do
+ sign_in(admin)
+ end
+
+ describe 'GET #show' do
+ let(:main_database_job) { create(:batched_background_migration_job) }
+ let(:default_model) { ActiveRecord::Base }
+
+ it 'fetches the job' do
+ get admin_background_migration_batched_job_path(main_database_job.batched_migration, main_database_job)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'uses the default connection' do
+ expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(default_model.connection).and_yield
+
+ get admin_background_migration_batched_job_path(main_database_job.batched_migration, main_database_job)
+ end
+
+ it 'returns a default database record' do
+ get admin_background_migration_batched_job_path(main_database_job.batched_migration, main_database_job)
+
+ expect(assigns(:job)).to eql(main_database_job)
+ end
+
+ context 'when the job does not exist' do
+ let(:invalid_job) { non_existing_record_id }
+
+ it 'returns not found' do
+ get admin_background_migration_batched_job_path(main_database_job.batched_migration, invalid_job)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when multiple database is enabled', :add_ci_connection do
+ let(:base_models) { { 'fake_db' => default_model, 'ci' => ci_model } }
+ let(:ci_model) { Ci::ApplicationRecord }
+
+ before do
+ allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
+ end
+
+ context 'when CI database is provided' do
+ it "uses CI database connection" do
+ expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(ci_model.connection).and_yield
+
+ get admin_background_migration_batched_job_path(main_database_job.batched_migration, main_database_job,
+ database: 'ci')
+ end
+
+ it 'returns a CI database record' do
+ ci_database_job = Gitlab::Database::SharedModel.using_connection(ci_model.connection) do
+ create(:batched_background_migration_job, :failed)
+ end
+
+ get admin_background_migration_batched_job_path(ci_database_job.batched_migration,
+ ci_database_job, database: 'ci')
+
+ expect(assigns(:job)).to eql(ci_database_job)
+ expect(assigns(:job)).not_to eql(main_database_job)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/admin/plan_limits_spec.rb b/spec/requests/api/admin/plan_limits_spec.rb
index 03642ad617e..74ea3b0973f 100644
--- a/spec/requests/api/admin/plan_limits_spec.rb
+++ b/spec/requests/api/admin/plan_limits_spec.rb
@@ -23,6 +23,14 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
+ expect(json_response['ci_pipeline_size']).to eq(Plan.default.actual_limits.ci_pipeline_size)
+ expect(json_response['ci_active_jobs']).to eq(Plan.default.actual_limits.ci_active_jobs)
+ expect(json_response['ci_active_pipelines']).to eq(Plan.default.actual_limits.ci_active_pipelines)
+ expect(json_response['ci_project_subscriptions']).to eq(Plan.default.actual_limits.ci_project_subscriptions)
+ expect(json_response['ci_pipeline_schedules']).to eq(Plan.default.actual_limits.ci_pipeline_schedules)
+ expect(json_response['ci_needs_size_limit']).to eq(Plan.default.actual_limits.ci_needs_size_limit)
+ expect(json_response['ci_registered_group_runners']).to eq(Plan.default.actual_limits.ci_registered_group_runners)
+ expect(json_response['ci_registered_project_runners']).to eq(Plan.default.actual_limits.ci_registered_project_runners)
expect(json_response['conan_max_file_size']).to eq(Plan.default.actual_limits.conan_max_file_size)
expect(json_response['generic_packages_max_file_size']).to eq(Plan.default.actual_limits.generic_packages_max_file_size)
expect(json_response['helm_max_file_size']).to eq(Plan.default.actual_limits.helm_max_file_size)
@@ -31,6 +39,7 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
expect(json_response['nuget_max_file_size']).to eq(Plan.default.actual_limits.nuget_max_file_size)
expect(json_response['pypi_max_file_size']).to eq(Plan.default.actual_limits.pypi_max_file_size)
expect(json_response['terraform_module_max_file_size']).to eq(Plan.default.actual_limits.terraform_module_max_file_size)
+ expect(json_response['storage_size_limit']).to eq(Plan.default.actual_limits.storage_size_limit)
end
end
@@ -44,6 +53,14 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
+ expect(json_response['ci_pipeline_size']).to eq(Plan.default.actual_limits.ci_pipeline_size)
+ expect(json_response['ci_active_jobs']).to eq(Plan.default.actual_limits.ci_active_jobs)
+ expect(json_response['ci_active_pipelines']).to eq(Plan.default.actual_limits.ci_active_pipelines)
+ expect(json_response['ci_project_subscriptions']).to eq(Plan.default.actual_limits.ci_project_subscriptions)
+ expect(json_response['ci_pipeline_schedules']).to eq(Plan.default.actual_limits.ci_pipeline_schedules)
+ expect(json_response['ci_needs_size_limit']).to eq(Plan.default.actual_limits.ci_needs_size_limit)
+ expect(json_response['ci_registered_group_runners']).to eq(Plan.default.actual_limits.ci_registered_group_runners)
+ expect(json_response['ci_registered_project_runners']).to eq(Plan.default.actual_limits.ci_registered_project_runners)
expect(json_response['conan_max_file_size']).to eq(Plan.default.actual_limits.conan_max_file_size)
expect(json_response['generic_packages_max_file_size']).to eq(Plan.default.actual_limits.generic_packages_max_file_size)
expect(json_response['helm_max_file_size']).to eq(Plan.default.actual_limits.helm_max_file_size)
@@ -52,6 +69,7 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
expect(json_response['nuget_max_file_size']).to eq(Plan.default.actual_limits.nuget_max_file_size)
expect(json_response['pypi_max_file_size']).to eq(Plan.default.actual_limits.pypi_max_file_size)
expect(json_response['terraform_module_max_file_size']).to eq(Plan.default.actual_limits.terraform_module_max_file_size)
+ expect(json_response['storage_size_limit']).to eq(Plan.default.actual_limits.storage_size_limit)
end
end
@@ -84,6 +102,14 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
it 'updates multiple plan limits' do
put api('/application/plan_limits', admin), params: {
'plan_name': 'default',
+ 'ci_pipeline_size': 101,
+ 'ci_active_jobs': 102,
+ 'ci_active_pipelines': 103,
+ 'ci_project_subscriptions': 104,
+ 'ci_pipeline_schedules': 105,
+ 'ci_needs_size_limit': 106,
+ 'ci_registered_group_runners': 107,
+ 'ci_registered_project_runners': 108,
'conan_max_file_size': 10,
'generic_packages_max_file_size': 20,
'helm_max_file_size': 25,
@@ -91,11 +117,20 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
'npm_max_file_size': 40,
'nuget_max_file_size': 50,
'pypi_max_file_size': 60,
- 'terraform_module_max_file_size': 70
+ 'terraform_module_max_file_size': 70,
+ 'storage_size_limit': 80
}
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
+ expect(json_response['ci_pipeline_size']).to eq(101)
+ expect(json_response['ci_active_jobs']).to eq(102)
+ expect(json_response['ci_active_pipelines']).to eq(103)
+ expect(json_response['ci_project_subscriptions']).to eq(104)
+ expect(json_response['ci_pipeline_schedules']).to eq(105)
+ expect(json_response['ci_needs_size_limit']).to eq(106)
+ expect(json_response['ci_registered_group_runners']).to eq(107)
+ expect(json_response['ci_registered_project_runners']).to eq(108)
expect(json_response['conan_max_file_size']).to eq(10)
expect(json_response['generic_packages_max_file_size']).to eq(20)
expect(json_response['helm_max_file_size']).to eq(25)
@@ -104,6 +139,7 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
expect(json_response['nuget_max_file_size']).to eq(50)
expect(json_response['pypi_max_file_size']).to eq(60)
expect(json_response['terraform_module_max_file_size']).to eq(70)
+ expect(json_response['storage_size_limit']).to eq(80)
end
it 'updates single plan limits' do
@@ -131,6 +167,14 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
it 'fails to update plan limits' do
put api('/application/plan_limits', admin), params: {
'plan_name': 'default',
+ 'ci_pipeline_size': 'z',
+ 'ci_active_jobs': 'y',
+ 'ci_active_pipelines': 'x',
+ 'ci_project_subscriptions': 'w',
+ 'ci_pipeline_schedules': 'v',
+ 'ci_needs_size_limit': 'u',
+ 'ci_registered_group_runners': 't',
+ 'ci_registered_project_runners': 's',
'conan_max_file_size': 'a',
'generic_packages_max_file_size': 'b',
'helm_max_file_size': 'h',
@@ -138,11 +182,20 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
'npm_max_file_size': 'd',
'nuget_max_file_size': 'e',
'pypi_max_file_size': 'f',
- 'terraform_module_max_file_size': 'g'
+ 'terraform_module_max_file_size': 'g',
+ 'storage_size_limit': 'j'
}
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to include(
+ 'ci_pipeline_size is invalid',
+ 'ci_active_jobs is invalid',
+ 'ci_active_pipelines is invalid',
+ 'ci_project_subscriptions is invalid',
+ 'ci_pipeline_schedules is invalid',
+ 'ci_needs_size_limit is invalid',
+ 'ci_registered_group_runners is invalid',
+ 'ci_registered_project_runners is invalid',
'conan_max_file_size is invalid',
'generic_packages_max_file_size is invalid',
'helm_max_file_size is invalid',
@@ -150,7 +203,8 @@ RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
'npm_max_file_size is invalid',
'nuget_max_file_size is invalid',
'pypi_max_file_size is invalid',
- 'terraform_module_max_file_size is invalid'
+ 'terraform_module_max_file_size is invalid',
+ 'storage_size_limit is invalid'
)
end
end
diff --git a/spec/requests/api/ci/job_artifacts_spec.rb b/spec/requests/api/ci/job_artifacts_spec.rb
index 68b44bb89e0..1dd1ca4e115 100644
--- a/spec/requests/api/ci/job_artifacts_spec.rb
+++ b/spec/requests/api/ci/job_artifacts_spec.rb
@@ -263,6 +263,9 @@ RSpec.describe API::Ci::JobArtifacts do
'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) }
end
+ let(:expected_params) { { artifact_size: job.artifacts_file.size } }
+ let(:subject_proc) { proc { subject } }
+
it 'returns specific job artifacts' do
subject
@@ -270,6 +273,9 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response.headers.to_h).to include(download_headers)
expect(response.body).to match_file(job.artifacts_file.file.file)
end
+
+ it_behaves_like 'storing arguments in the application context'
+ it_behaves_like 'not executing any extra queries for the application context'
end
context 'normal authentication' do
@@ -558,7 +564,8 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers.to_h)
.to include('Content-Type' => 'application/json',
- 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/,
+ 'Gitlab-Workhorse-Detect-Content-Type' => 'true')
end
end
@@ -628,7 +635,8 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers.to_h)
.to include('Content-Type' => 'application/json',
- 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/,
+ 'Gitlab-Workhorse-Detect-Content-Type' => 'true')
expect(response.parsed_body).to be_empty
end
end
@@ -646,7 +654,8 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers.to_h)
.to include('Content-Type' => 'application/json',
- 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/,
+ 'Gitlab-Workhorse-Detect-Content-Type' => 'true')
end
end
diff --git a/spec/requests/api/ci/jobs_spec.rb b/spec/requests/api/ci/jobs_spec.rb
index d3820e4948e..4bd9f81fd1d 100644
--- a/spec/requests/api/ci/jobs_spec.rb
+++ b/spec/requests/api/ci/jobs_spec.rb
@@ -471,7 +471,7 @@ RSpec.describe API::Ci::Jobs do
end
context 'authorized user' do
- context 'when trace is in ObjectStorage' do
+ context 'when log is in ObjectStorage' do
let!(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) }
let(:url) { 'http://object-storage/trace' }
let(:file_path) { expand_fixture_path('trace/sample_trace') }
@@ -485,49 +485,49 @@ RSpec.describe API::Ci::Jobs do
end
end
- it 'returns specific job trace' do
+ it 'returns specific job logs' do
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq(job.trace.raw)
end
end
- context 'when trace is artifact' do
+ context 'when log is artifact' do
let(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) }
- it 'returns specific job trace' do
+ it 'returns specific job log' do
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq(job.trace.raw)
end
end
- context 'when live trace and uploadless trace artifact' do
+ context 'when incremental logging and uploadless log artifact' do
let(:job) { create(:ci_build, :trace_live, :unarchived_trace_artifact, pipeline: pipeline) }
- it 'returns specific job trace' do
+ it 'returns specific job log' do
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq(job.trace.raw)
end
end
- context 'when trace is live' do
+ context 'when log is incremental' do
let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) }
- it 'returns specific job trace' do
+ it 'returns specific job log' do
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq(job.trace.raw)
end
end
- context 'when no trace' do
+ context 'when no log' do
let(:job) { create(:ci_build, pipeline: pipeline) }
- it 'returns empty trace' do
+ it 'returns empty log' do
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to be_empty
end
end
- context 'when trace artifact record exists with no stored file' do
+ context 'when log artifact record exists with no stored file' do
let(:job) { create(:ci_build, pipeline: pipeline) }
before do
@@ -544,7 +544,7 @@ RSpec.describe API::Ci::Jobs do
context 'unauthorized user' do
let(:api_user) { nil }
- it 'does not return specific job trace' do
+ it 'does not return specific job log' do
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
diff --git a/spec/requests/api/ci/resource_groups_spec.rb b/spec/requests/api/ci/resource_groups_spec.rb
index f5b68557a0d..864c363e6d3 100644
--- a/spec/requests/api/ci/resource_groups_spec.rb
+++ b/spec/requests/api/ci/resource_groups_spec.rb
@@ -9,6 +9,36 @@ RSpec.describe API::Ci::ResourceGroups do
let(:user) { developer }
+ describe 'GET /projects/:id/resource_groups' do
+ subject { get api("/projects/#{project.id}/resource_groups", user) }
+
+ let!(:resource_groups) { create_list(:ci_resource_group, 3, project: project) }
+
+ it 'returns all resource groups for this project', :aggregate_failures do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ resource_groups.each_index do |i|
+ expect(json_response[i]['id']).to eq(resource_groups[i].id)
+ expect(json_response[i]['key']).to eq(resource_groups[i].key)
+ expect(json_response[i]['process_mode']).to eq(resource_groups[i].process_mode)
+ expect(Time.parse(json_response[i]['created_at'])).to be_like_time(resource_groups[i].created_at)
+ expect(Time.parse(json_response[i]['updated_at'])).to be_like_time(resource_groups[i].updated_at)
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'returns forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
describe 'GET /projects/:id/resource_groups/:key' do
subject { get api("/projects/#{project.id}/resource_groups/#{key}", user) }
diff --git a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
index f627f207d98..5767fa4326e 100644
--- a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
@@ -7,8 +7,20 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
include RedisHelpers
include WorkhorseHelpers
+ let_it_be_with_reload(:parent_group) { create(:group) }
+ let_it_be_with_reload(:group) { create(:group, parent: parent_group) }
+ let_it_be_with_reload(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
+
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
+ let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) }
+ let_it_be(:user) { create(:user) }
+
let(:registration_token) { 'abcdefg123456' }
+ before_all do
+ project.add_developer(user)
+ end
+
before do
stub_feature_flags(ci_enable_live_trace: true)
stub_gitlab_calls
@@ -17,12 +29,6 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
describe '/api/v4/jobs' do
- let(:parent_group) { create(:group) }
- let(:group) { create(:group, parent: parent_group) }
- let(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
- let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
- let(:runner) { create(:ci_runner, :project, projects: [project]) }
- let(:user) { create(:user) }
let(:job) do
create(:ci_build, :artifacts, :extended_options,
pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0)
@@ -571,14 +577,21 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
context 'when artifact_type is archive' do
context 'when artifact_format is zip' do
+ subject(:request) { upload_artifacts(file_upload, headers_with_token, params) }
+
let(:params) { { artifact_type: :archive, artifact_format: :zip } }
+ let(:expected_params) { { artifact_size: job.reload.artifacts_size } }
+ let(:subject_proc) { proc { subject } }
it 'stores junit test report' do
- upload_artifacts(file_upload, headers_with_token, params)
+ subject
expect(response).to have_gitlab_http_status(:created)
expect(job.reload.job_artifacts_archive).not_to be_nil
end
+
+ it_behaves_like 'storing arguments in the application context'
+ it_behaves_like 'not executing any extra queries for the application context'
end
context 'when artifact_format is gzip' do
@@ -817,25 +830,23 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when job has artifacts' do
- let(:job) { create(:ci_build) }
+ let(:job) { create(:ci_build, pipeline: pipeline, user: user) }
let(:store) { JobArtifactUploader::Store::LOCAL }
before do
create(:ci_job_artifact, :archive, file_store: store, job: job)
end
- context 'when using job token' do
+ shared_examples 'successful artifact download' do
context 'when artifacts are stored locally' do
let(:download_headers) do
{ 'Content-Transfer-Encoding' => 'binary',
'Content-Disposition' => %q(attachment; filename="ci_build_artifacts.zip"; filename*=UTF-8''ci_build_artifacts.zip) }
end
- before do
+ it 'downloads artifacts' do
download_artifact
- end
- it 'download artifacts' do
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers.to_h).to include download_headers
end
@@ -843,26 +854,20 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
context 'when artifacts are stored remotely' do
let(:store) { JobArtifactUploader::Store::REMOTE }
- let!(:job) { create(:ci_build) }
context 'when proxy download is being used' do
- before do
+ it 'uses workhorse send-url' do
download_artifact(direct_download: false)
- end
- it 'uses workhorse send-url' do
expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers.to_h).to include(
- 'Gitlab-Workhorse-Send-Data' => /send-url:/)
+ expect(response.headers.to_h).to include('Gitlab-Workhorse-Send-Data' => /send-url:/)
end
end
context 'when direct download is being used' do
- before do
+ it 'receives redirect for downloading artifacts' do
download_artifact(direct_download: true)
- end
- it 'receive redirect for downloading artifacts' do
expect(response).to have_gitlab_http_status(:found)
expect(response.headers).to include('Location')
end
@@ -870,16 +875,119 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
end
- context 'when using runnners token' do
- let(:token) { job.project.runners_token }
+ shared_examples 'forbidden request' do
+ it 'responds with forbidden' do
+ download_artifact
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when using job token' do
+ let(:token) { job.token }
+
+ it_behaves_like 'successful artifact download'
+
+ context 'when the job is no longer running' do
+ before do
+ job.success!
+ end
+
+ it_behaves_like 'successful artifact download'
+ end
+ end
+
+ context 'when using token belonging to the dependent job' do
+ let!(:dependent_job) { create(:ci_build, :running, :dependent, user: user, pipeline: pipeline) }
+ let!(:job) { dependent_job.all_dependencies.first }
+
+ let(:token) { dependent_job.token }
+
+ it_behaves_like 'successful artifact download'
+
+ context 'when the dependent job is no longer running' do
+ before do
+ dependent_job.success!
+ end
+
+ it_behaves_like 'forbidden request'
+ end
+ end
+
+ context 'when using token belonging to another job created by another project member' do
+ let!(:ci_build) { create(:ci_build, :running, :dependent, user: user, pipeline: pipeline) }
+ let!(:job) { ci_build.all_dependencies.first }
+
+ let!(:another_dev) { create(:user) }
+
+ let(:token) { ci_build.token }
before do
- download_artifact
+ project.add_developer(another_dev)
+ ci_build.update!(user: another_dev)
end
- it 'responds with forbidden' do
- expect(response).to have_gitlab_http_status(:forbidden)
+ it_behaves_like 'successful artifact download'
+ end
+
+ context 'when using token belonging to a pending dependent job' do
+ let!(:ci_build) { create(:ci_build, :pending, :dependent, user: user, project: project, pipeline: pipeline) }
+ let!(:job) { ci_build.all_dependencies.first }
+
+ let(:token) { ci_build.token }
+
+ it_behaves_like 'forbidden request'
+ end
+
+ context 'when using a token from a cross pipeline build' do
+ let!(:ci_build) { create(:ci_build, :pending, :dependent, user: user, project: project, pipeline: pipeline) }
+ let!(:job) { ci_build.all_dependencies.first }
+
+ let!(:options) do
+ {
+ cross_dependencies: [
+ {
+ pipeline: pipeline.id,
+ job: job.name,
+ artifacts: true
+ }
+ ]
+
+ }
end
+
+ let!(:cross_pipeline) { create(:ci_pipeline, project: project, child_of: pipeline) }
+ let!(:cross_pipeline_build) { create(:ci_build, :running, project: project, user: user, options: options, pipeline: cross_pipeline) }
+
+ let(:token) { cross_pipeline_build.token }
+
+ before do
+ job.success!
+ end
+
+ it_behaves_like 'successful artifact download'
+ end
+
+ context 'when using a token from an unrelated project' do
+ let!(:ci_build) { create(:ci_build, :running, :dependent, user: user, project: project, pipeline: pipeline) }
+ let!(:job) { ci_build.all_dependencies.first }
+
+ let!(:unrelated_ci_build) { create(:ci_build, :running, user: create(:user)) }
+ let(:token) { unrelated_ci_build.token }
+
+ it_behaves_like 'forbidden request'
+ end
+
+ context 'when using runnners token' do
+ let(:token) { job.project.runners_token }
+
+ it_behaves_like 'forbidden request'
+ end
+
+ context 'when using an invalid token' do
+ let(:token) { 'invalid-token' }
+
+ it_behaves_like 'forbidden request'
end
end
diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
index a662c77e5a2..dbc5f0e74e2 100644
--- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
@@ -496,15 +496,15 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
job2.success
end
- it 'returns dependent jobs' do
+ it 'returns dependent jobs with the token of the test job' do
request_job
expect(response).to have_gitlab_http_status(:created)
expect(json_response['id']).to eq(test_job.id)
expect(json_response['dependencies'].count).to eq(2)
expect(json_response['dependencies']).to include(
- { 'id' => job.id, 'name' => job.name, 'token' => job.token },
- { 'id' => job2.id, 'name' => job2.name, 'token' => job2.token })
+ { 'id' => job.id, 'name' => job.name, 'token' => test_job.token },
+ { 'id' => job2.id, 'name' => job2.name, 'token' => test_job.token })
end
describe 'preloading job_artifacts_archive' do
@@ -526,14 +526,14 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
job.success
end
- it 'returns dependent jobs' do
+ it 'returns dependent jobs with the token of the test job' do
request_job
expect(response).to have_gitlab_http_status(:created)
expect(json_response['id']).to eq(test_job.id)
expect(json_response['dependencies'].count).to eq(1)
expect(json_response['dependencies']).to include(
- { 'id' => job.id, 'name' => job.name, 'token' => job.token,
+ { 'id' => job.id, 'name' => job.name, 'token' => test_job.token,
'artifacts_file' => { 'filename' => 'ci_build_artifacts.zip', 'size' => 107464 } })
end
end
@@ -552,13 +552,13 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
job2.success
end
- it 'returns dependent jobs' do
+ it 'returns dependent jobs with the token of the test job' do
request_job
expect(response).to have_gitlab_http_status(:created)
expect(json_response['id']).to eq(test_job.id)
expect(json_response['dependencies'].count).to eq(1)
- expect(json_response['dependencies'][0]).to include('id' => job2.id, 'name' => job2.name, 'token' => job2.token)
+ expect(json_response['dependencies'][0]).to include('id' => job2.id, 'name' => job2.name, 'token' => test_job.token)
end
end
diff --git a/spec/requests/api/ci/runner/jobs_trace_spec.rb b/spec/requests/api/ci/runner/jobs_trace_spec.rb
index d6928969beb..c3c074d80d9 100644
--- a/spec/requests/api/ci/runner/jobs_trace_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_trace_spec.rb
@@ -272,7 +272,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_trace_chunks do
it { expect(response).to have_gitlab_http_status(:forbidden) }
end
- context 'when the job trace is too big' do
+ context 'when the job log is too big' do
before do
project.actual_limits.update!(ci_jobs_trace_size_limit: 1)
end
diff --git a/spec/requests/api/ci/runners_spec.rb b/spec/requests/api/ci/runners_spec.rb
index d6ebc197ab0..3000bdc2ce7 100644
--- a/spec/requests/api/ci/runners_spec.rb
+++ b/spec/requests/api/ci/runners_spec.rb
@@ -274,7 +274,7 @@ RSpec.describe API::Ci::Runners do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['description']).to eq(shared_runner.description)
expect(json_response['maximum_timeout']).to be_nil
- expect(json_response['status']).to eq("not_connected")
+ expect(json_response['status']).to eq('never_contacted')
expect(json_response['active']).to eq(true)
expect(json_response['paused']).to eq(false)
end
@@ -1216,15 +1216,6 @@ RSpec.describe API::Ci::Runners do
end
end
end
-
- it 'enables a instance type runner' do
- expect do
- post api("/projects/#{project.id}/runners", admin), params: { runner_id: shared_runner.id }
- end.to change { project.runners.count }.by(1)
-
- expect(shared_runner.reload).not_to be_instance_type
- expect(response).to have_gitlab_http_status(:created)
- end
end
it 'raises an error when no runner_id param is provided' do
diff --git a/spec/requests/api/ci/secure_files_spec.rb b/spec/requests/api/ci/secure_files_spec.rb
index 6de6d1ef222..6f16fe5460b 100644
--- a/spec/requests/api/ci/secure_files_spec.rb
+++ b/spec/requests/api/ci/secure_files_spec.rb
@@ -143,7 +143,6 @@ RSpec.describe API::Ci::SecureFiles do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq(secure_file.name)
- expect(json_response['permissions']).to eq(secure_file.permissions)
end
it 'responds with 404 Not Found if requesting non-existing secure file' do
@@ -159,7 +158,6 @@ RSpec.describe API::Ci::SecureFiles do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq(secure_file.name)
- expect(json_response['permissions']).to eq(secure_file.permissions)
end
end
@@ -250,12 +248,11 @@ RSpec.describe API::Ci::SecureFiles do
context 'authenticated user with admin permissions' do
it 'creates a secure file' do
expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: file_params.merge(permissions: 'execute')
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
end.to change {project.secure_files.count}.by(1)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq('upload-keystore.jks')
- expect(json_response['permissions']).to eq('execute')
expect(json_response['checksum']).to eq(secure_file.checksum)
expect(json_response['checksum_algorithm']).to eq('sha256')
@@ -267,14 +264,6 @@ RSpec.describe API::Ci::SecureFiles do
expect(Time.parse(json_response['created_at'])).to be_like_time(secure_file.created_at)
end
- it 'creates a secure file with read_only permissions by default' do
- expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
- end.to change {project.secure_files.count}.by(1)
-
- expect(json_response['permissions']).to eq('read_only')
- end
-
it 'uploads and downloads a secure file' do
post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
@@ -327,15 +316,6 @@ RSpec.describe API::Ci::SecureFiles do
expect(json_response['message']['name']).to include('has already been taken')
end
- it 'returns an error when an unexpected permission is supplied' do
- expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: file_params.merge(permissions: 'foo')
- end.not_to change { project.secure_files.count }
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq('permissions does not have a valid value')
- end
-
it 'returns an error when an unexpected validation failure happens' do
allow_next_instance_of(Ci::SecureFile) do |instance|
allow(instance).to receive(:valid?).and_return(false)
diff --git a/spec/requests/api/clusters/agent_tokens_spec.rb b/spec/requests/api/clusters/agent_tokens_spec.rb
new file mode 100644
index 00000000000..ba26faa45a3
--- /dev/null
+++ b/spec/requests/api/clusters/agent_tokens_spec.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Clusters::AgentTokens do
+ let_it_be(:agent) { create(:cluster_agent) }
+ let_it_be(:agent_token_one) { create(:cluster_agent_token, agent: agent) }
+ let_it_be(:agent_token_two) { create(:cluster_agent_token, agent: agent) }
+ let_it_be(:project) { agent.project }
+ let_it_be(:user) { agent.created_by_user }
+ let_it_be(:unauthorized_user) { create(:user) }
+
+ before_all do
+ project.add_maintainer(user)
+ project.add_guest(unauthorized_user)
+ end
+
+ describe 'GET /projects/:id/cluster_agents/:agent_id/tokens' do
+ context 'with authorized user' do
+ it 'returns tokens' do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", user)
+
+ aggregate_failures "testing response" do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(response).to match_response_schema('public_api/v4/agent_tokens')
+ expect(json_response.count).to eq(2)
+ expect(json_response.first['name']).to eq(agent_token_one.name)
+ expect(json_response.first['agent_id']).to eq(agent.id)
+ expect(json_response.second['name']).to eq(agent_token_two.name)
+ expect(json_response.second['agent_id']).to eq(agent.id)
+ end
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'cannot access agent tokens' do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ it 'avoids N+1 queries', :request_store do
+ # Establish baseline
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", user)
+
+ control = ActiveRecord::QueryRecorder.new do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", user)
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ # Now create a second record and ensure that the API does not execute
+ # any more queries than before
+ create(:cluster_agent_token, agent: agent)
+
+ expect do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", user)
+ end.not_to exceed_query_limit(control)
+ end
+ end
+
+ describe 'GET /projects/:id/cluster_agents/:agent_id/tokens/:token_id' do
+ context 'with authorized user' do
+ it 'returns an agent token' do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens/#{agent_token_one.id}", user)
+
+ aggregate_failures "testing response" do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/agent_token')
+ expect(json_response['id']).to eq(agent_token_one.id)
+ expect(json_response['name']).to eq(agent_token_one.name)
+ expect(json_response['agent_id']).to eq(agent.id)
+ end
+ end
+
+ it 'returns a 404 error if agent token id is not available' do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens/#{non_existing_record_id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'with unauthorized user' do
+ it 'cannot access single agent token' do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens/#{agent_token_one.id}", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'cannot access token from agent of another project' do
+ another_project = create(:project, namespace: unauthorized_user.namespace)
+ another_agent = create(:cluster_agent, project: another_project, created_by_user: unauthorized_user)
+
+ get api("/projects/#{another_project.id}/cluster_agents/#{another_agent.id}/tokens/#{agent_token_one.id}",
+ unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/cluster_agents/:agent_id/tokens' do
+ it 'creates a new agent token' do
+ params = {
+ name: 'test-token',
+ description: 'Test description'
+ }
+ post(api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", user), params: params)
+
+ aggregate_failures "testing response" do
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/agent_token_with_token')
+ expect(json_response['name']).to eq(params[:name])
+ expect(json_response['description']).to eq(params[:description])
+ expect(json_response['agent_id']).to eq(agent.id)
+ end
+ end
+
+ it 'returns a 400 error if name not given' do
+ post api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", user)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 404 error if project does not exist' do
+ post api("/projects/#{non_existing_record_id}/cluster_agents/tokens", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns 404 error if agent does not exist' do
+ post api("/projects/#{project.id}/cluster_agents/#{non_existing_record_id}/tokens", user),
+ params: { name: "some" }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'with unauthorized user' do
+ it 'prevents to create agent token' do
+ post api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", unauthorized_user),
+ params: { name: "some" }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ describe 'DELETE /projects/:id/cluster_agents/:agent_id/tokens/:token_id' do
+ it 'revokes agent token' do
+ delete api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens/#{agent_token_one.id}", user)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(agent_token_one.reload).to be_revoked
+ end
+
+ it 'returns a 404 error when revoking non existent agent token' do
+ delete api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens/#{non_existing_record_id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns a 404 if the user is unauthorized to revoke' do
+ delete api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens/#{agent_token_one.id}", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'cannot revoke token from agent of another project' do
+ another_project = create(:project, namespace: unauthorized_user.namespace)
+ another_agent = create(:cluster_agent, project: another_project, created_by_user: unauthorized_user)
+
+ delete api("/projects/#{another_project.id}/cluster_agents/#{another_agent.id}/tokens/#{agent_token_one.id}",
+ unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/requests/api/container_registry_event_spec.rb b/spec/requests/api/container_registry_event_spec.rb
index 4d38ddddffd..767e6e0b2ff 100644
--- a/spec/requests/api/container_registry_event_spec.rb
+++ b/spec/requests/api/container_registry_event_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe API::ContainerRegistryEvent do
allow(Gitlab.config.registry).to receive(:notification_secret) { secret_token }
end
- subject do
+ subject(:post_events) do
post api('/container_registry_event/events'),
params: { events: events }.to_json,
headers: registry_headers.merge('Authorization' => secret_token)
@@ -23,7 +23,7 @@ RSpec.describe API::ContainerRegistryEvent do
allow(::ContainerRegistry::Event).to receive(:new).and_return(event)
expect(event).to receive(:supported?).and_return(true)
- subject
+ post_events
expect(event).to have_received(:handle!).once
expect(event).to have_received(:track!).once
@@ -37,5 +37,37 @@ RSpec.describe API::ContainerRegistryEvent do
expect(response).to have_gitlab_http_status(:unauthorized)
end
+
+ context 'when the event should update project statistics' do
+ let_it_be(:project) { create(:project) }
+
+ let(:events) do
+ [
+ {
+ action: 'push',
+ target: {
+ tag: 'latest',
+ repository: project.full_path
+ }
+ },
+ {
+ action: 'delete',
+ target: {
+ tag: 'latest',
+ repository: project.full_path
+ }
+ }
+ ]
+ end
+
+ it 'enqueues a project statistics update twice' do
+ expect(ProjectCacheWorker)
+ .to receive(:perform_async)
+ .with(project.id, [], [:container_registry_size])
+ .twice.and_call_original
+
+ expect { post_events }.to change { ProjectCacheWorker.jobs.size }.from(0).to(1)
+ end
+ end
end
end
diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb
index 5fb24dc91a4..8328b454122 100644
--- a/spec/requests/api/environments_spec.rb
+++ b/spec/requests/api/environments_spec.rb
@@ -23,6 +23,7 @@ RSpec.describe API::Environments do
expect(json_response).to be_an Array
expect(json_response.size).to eq(1)
expect(json_response.first['name']).to eq(environment.name)
+ expect(json_response.first['tier']).to eq(environment.tier)
expect(json_response.first['external_url']).to eq(environment.external_url)
expect(json_response.first['project']).to match_schema('public_api/v4/project')
expect(json_response.first['enable_advanced_logs_querying']).to eq(false)
@@ -150,6 +151,13 @@ RSpec.describe API::Environments do
expect(json_response).to be_an Array
expect(json_response.size).to eq(0)
end
+
+ it 'returns a 400 status code with invalid states' do
+ get api("/projects/#{project.id}/environments?states=test", user)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to include('Requested states are invalid')
+ end
end
end
@@ -165,12 +173,13 @@ RSpec.describe API::Environments do
describe 'POST /projects/:id/environments' do
context 'as a member' do
it 'creates a environment with valid params' do
- post api("/projects/#{project.id}/environments", user), params: { name: "mepmep" }
+ post api("/projects/#{project.id}/environments", user), params: { name: "mepmep", tier: 'staging' }
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('public_api/v4/environment')
expect(json_response['name']).to eq('mepmep')
expect(json_response['slug']).to eq('mepmep')
+ expect(json_response['tier']).to eq('staging')
expect(json_response['external']).to be nil
end
@@ -219,6 +228,15 @@ RSpec.describe API::Environments do
expect(json_response['external_url']).to eq(url)
end
+ it 'returns a 200 if tier is changed' do
+ put api("/projects/#{project.id}/environments/#{environment.id}", user),
+ params: { tier: 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/environment')
+ expect(json_response['tier']).to eq('production')
+ end
+
it "won't allow slug to be changed" do
slug = environment.slug
api_url = api("/projects/#{project.id}/environments/#{environment.id}", user)
diff --git a/spec/requests/api/error_tracking/client_keys_spec.rb b/spec/requests/api/error_tracking/client_keys_spec.rb
index 00c1e8799e6..ba4d713dff2 100644
--- a/spec/requests/api/error_tracking/client_keys_spec.rb
+++ b/spec/requests/api/error_tracking/client_keys_spec.rb
@@ -81,6 +81,10 @@ RSpec.describe API::ErrorTracking::ClientKeys do
it 'returns a correct status' do
expect(response).to have_gitlab_http_status(:ok)
end
+
+ it 'returns specific fields using the entity' do
+ expect(json_response.keys).to match_array(%w[id active public_key sentry_dsn])
+ end
end
end
end
diff --git a/spec/requests/api/error_tracking/collector_spec.rb b/spec/requests/api/error_tracking/collector_spec.rb
index fa0b238dcad..c0d7eb5460f 100644
--- a/spec/requests/api/error_tracking/collector_spec.rb
+++ b/spec/requests/api/error_tracking/collector_spec.rb
@@ -152,7 +152,17 @@ RSpec.describe API::ErrorTracking::Collector do
context 'collector fails with validation error' do
before do
allow(::ErrorTracking::CollectErrorService)
- .to receive(:new).and_raise(ActiveRecord::RecordInvalid)
+ .to receive(:new).and_raise(Gitlab::ErrorTracking::ErrorRepository::DatabaseError)
+ end
+
+ it_behaves_like 'bad request'
+ end
+
+ context 'with platform field too long' do
+ let(:params) do
+ event = Gitlab::Json.parse(raw_event)
+ event['platform'] = 'a' * 256
+ Gitlab::Json.dump(event)
end
it_behaves_like 'bad request'
diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb
index a265f67115a..4e75b0510d0 100644
--- a/spec/requests/api/features_spec.rb
+++ b/spec/requests/api/features_spec.rb
@@ -26,6 +26,7 @@ RSpec.describe API::Features, stub_feature_flags: false do
end
skip_feature_flags_yaml_validation
+ skip_default_enabled_yaml_check
end
describe 'GET /features' do
@@ -309,6 +310,55 @@ RSpec.describe API::Features, stub_feature_flags: false do
'definition' => known_feature_flag_definition_hash
)
end
+
+ describe 'mutually exclusive parameters' do
+ shared_examples 'fails to set the feature flag' do
+ it 'returns an error' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to match(/key, \w+ are mutually exclusive/)
+ end
+ end
+
+ context 'when key and feature_group are provided' do
+ before do
+ post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', feature_group: 'some-value' }
+ end
+
+ it_behaves_like 'fails to set the feature flag'
+ end
+
+ context 'when key and user are provided' do
+ before do
+ post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', user: 'some-user' }
+ end
+
+ it_behaves_like 'fails to set the feature flag'
+ end
+
+ context 'when key and group are provided' do
+ before do
+ post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', group: 'somepath' }
+ end
+
+ it_behaves_like 'fails to set the feature flag'
+ end
+
+ context 'when key and namespace are provided' do
+ before do
+ post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', namespace: 'somepath' }
+ end
+
+ it_behaves_like 'fails to set the feature flag'
+ end
+
+ context 'when key and project are provided' do
+ before do
+ post api("/features/#{feature_name}", admin), params: { value: '0.01', key: 'percentage_of_actors', project: 'somepath' }
+ end
+
+ it_behaves_like 'fails to set the feature flag'
+ end
+ end
end
context 'when the feature exists' do
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index cb0b5f6bfc3..06d22e7e218 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -462,6 +462,66 @@ RSpec.describe API::Files do
expect(range['commit']['committer_email']).to eq('dmitriy.zaporozhets@gmail.com')
end
+ context 'with a range parameter' do
+ let(:params) { super().merge(range: { start: 2, end: 4 }) }
+
+ it 'returns file blame attributes as json for the range' do
+ get api(route(file_path) + '/blame', current_user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.size).to eq(2)
+
+ lines = json_response.map { |x| x['lines'] }
+
+ expect(lines.map(&:size)).to eq(expected_blame_range_sizes[1..2])
+ expect(lines.flatten).to eq(["require 'open3'", '', 'module Popen'])
+ end
+
+ context 'when start > end' do
+ let(:params) { super().merge(range: { start: 4, end: 2 }) }
+
+ it 'returns 400 error' do
+ get api(route(file_path) + '/blame', current_user), params: params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq('range[start] must be less than or equal to range[end]')
+ end
+ end
+
+ context 'when range is incomplete' do
+ let(:params) { super().merge(range: { start: 1 }) }
+
+ it 'returns 400 error' do
+ get api(route(file_path) + '/blame', current_user), params: params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('range[end] is missing, range[end] is empty')
+ end
+ end
+
+ context 'when range contains negative integers' do
+ let(:params) { super().merge(range: { start: -2, end: -5 }) }
+
+ it 'returns 400 error' do
+ get api(route(file_path) + '/blame', current_user), params: params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('range[start] does not have a valid value, range[end] does not have a valid value')
+ end
+ end
+
+ context 'when range is missing' do
+ let(:params) { super().merge(range: { start: '', end: '' }) }
+
+ it 'returns 400 error' do
+ get api(route(file_path) + '/blame', current_user), params: params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('range[start] is empty, range[end] is empty')
+ end
+ end
+ end
+
it 'returns blame file info for files with dots' do
url = route('.gitignore') + '/blame'
diff --git a/spec/requests/api/graphql/boards/board_lists_query_spec.rb b/spec/requests/api/graphql/boards/board_lists_query_spec.rb
index e8fb9daa43b..eb206465bce 100644
--- a/spec/requests/api/graphql/boards/board_lists_query_spec.rb
+++ b/spec/requests/api/graphql/boards/board_lists_query_spec.rb
@@ -69,6 +69,10 @@ RSpec.describe 'get board lists' do
let(:data_path) { [board_parent_type, :boards, :nodes, 0, :lists] }
+ def pagination_results_data(lists)
+ lists
+ end
+
def pagination_query(params)
graphql_query_for(
board_parent_type,
@@ -94,7 +98,7 @@ RSpec.describe 'get board lists' do
it_behaves_like 'sorted paginated query' do
let(:sort_param) { }
let(:first_param) { 2 }
- let(:all_records) { lists.map { |list| global_id_of(list) } }
+ let(:all_records) { lists.map { |list| a_graphql_entity_for(list) } }
end
end
end
diff --git a/spec/requests/api/graphql/ci/config_spec.rb b/spec/requests/api/graphql/ci/config_spec.rb
index 62b15a8396c..5f8a895b16e 100644
--- a/spec/requests/api/graphql/ci/config_spec.rb
+++ b/spec/requests/api/graphql/ci/config_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Query.ciConfig' do
include GraphqlHelpers
+ include StubRequests
subject(:post_graphql_query) { post_graphql(query, current_user: user) }
@@ -57,6 +58,16 @@ RSpec.describe 'Query.ciConfig' do
}
}
}
+ mergedYaml
+ includes {
+ type
+ location
+ blob
+ raw
+ extra
+ contextProject
+ contextSha
+ }
}
}
)
@@ -71,10 +82,12 @@ RSpec.describe 'Query.ciConfig' do
it 'returns the correct structure' do
post_graphql_query
- expect(graphql_data['ciConfig']).to eq(
+ expect(graphql_data['ciConfig']).to include(
"status" => "VALID",
"errors" => [],
"warnings" => [],
+ "includes" => [],
+ "mergedYaml" => a_kind_of(String),
"stages" =>
{
"nodes" =>
@@ -222,24 +235,6 @@ RSpec.describe 'Query.ciConfig' do
)
end
- context 'when using deprecated keywords' do
- let_it_be(:content) do
- YAML.dump(
- rspec: { script: 'ls', type: 'test' },
- types: ['test']
- )
- end
-
- it 'returns a warning' do
- post_graphql_query
-
- expect(graphql_data['ciConfig']['warnings']).to include(
- 'root `types` is deprecated in 9.0 and will be removed in 15.0.',
- 'jobs:rspec `type` is deprecated in 9.0 and will be removed in 15.0.'
- )
- end
- end
-
context 'when the config file includes other files' do
let_it_be(:content) do
YAML.dump(
@@ -271,6 +266,18 @@ RSpec.describe 'Query.ciConfig' do
"status" => "VALID",
"errors" => [],
"warnings" => [],
+ "includes" => [
+ {
+ "type" => "local",
+ "location" => "other_file.yml",
+ "blob" => "http://localhost/#{project.full_path}/-/blob/#{project.commit.sha}/other_file.yml",
+ "raw" => "http://localhost/#{project.full_path}/-/raw/#{project.commit.sha}/other_file.yml",
+ "extra" => {},
+ "contextProject" => project.full_path,
+ "contextSha" => project.commit.sha
+ }
+ ],
+ "mergedYaml" => "---\nbuild:\n script: build\nrspec:\n script: rspec\n",
"stages" =>
{
"nodes" =>
@@ -302,7 +309,7 @@ RSpec.describe 'Query.ciConfig' do
"when" => "on_success",
"tags" => [],
"needs" => { "nodes" => [] }
-}
+ }
]
}
},
@@ -337,4 +344,101 @@ RSpec.describe 'Query.ciConfig' do
)
end
end
+
+ context 'when the config file has multiple includes' do
+ let_it_be(:other_project) { create(:project, :repository, creator: user, namespace: user.namespace) }
+
+ let_it_be(:content) do
+ YAML.dump(
+ include: [
+ { local: 'other_file.yml' },
+ { remote: 'https://gitlab.com/gitlab-org/gitlab/raw/1234/.hello.yml' },
+ { file: 'other_project_file.yml', project: other_project.full_path },
+ { template: 'Jobs/Build.gitlab-ci.yml' }
+ ],
+ rspec: {
+ script: 'rspec'
+ }
+ )
+ end
+
+ let(:remote_file_content) do
+ YAML.dump(
+ remote_file_test: {
+ script: 'remote_file_test'
+ }
+ )
+ end
+
+ before do
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository).to receive(:blob_data_at).with(an_instance_of(String), 'other_file.yml') do
+ YAML.dump(
+ build: {
+ script: 'build'
+ }
+ )
+ end
+
+ allow(repository).to receive(:blob_data_at).with(an_instance_of(String), 'other_project_file.yml') do
+ YAML.dump(
+ other_project_test: {
+ script: 'other_project_test'
+ }
+ )
+ end
+ end
+
+ stub_full_request('https://gitlab.com/gitlab-org/gitlab/raw/1234/.hello.yml').to_return(body: remote_file_content)
+
+ post_graphql_query
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ # rubocop:disable Layout/LineLength
+ it 'returns correct includes' do
+ expect(graphql_data['ciConfig']["includes"]).to eq(
+ [
+ {
+ "type" => "local",
+ "location" => "other_file.yml",
+ "blob" => "http://localhost/#{project.full_path}/-/blob/#{project.commit.sha}/other_file.yml",
+ "raw" => "http://localhost/#{project.full_path}/-/raw/#{project.commit.sha}/other_file.yml",
+ "extra" => {},
+ "contextProject" => project.full_path,
+ "contextSha" => project.commit.sha
+ },
+ {
+ "type" => "remote",
+ "location" => "https://gitlab.com/gitlab-org/gitlab/raw/1234/.hello.yml",
+ "blob" => nil,
+ "raw" => "https://gitlab.com/gitlab-org/gitlab/raw/1234/.hello.yml",
+ "extra" => {},
+ "contextProject" => project.full_path,
+ "contextSha" => project.commit.sha
+ },
+ {
+ "type" => "file",
+ "location" => "other_project_file.yml",
+ "blob" => "http://localhost/#{other_project.full_path}/-/blob/#{other_project.commit.sha}/other_project_file.yml",
+ "raw" => "http://localhost/#{other_project.full_path}/-/raw/#{other_project.commit.sha}/other_project_file.yml",
+ "extra" => { "project" => other_project.full_path, "ref" => "HEAD" },
+ "contextProject" => project.full_path,
+ "contextSha" => project.commit.sha
+ },
+ {
+ "type" => "template",
+ "location" => "Jobs/Build.gitlab-ci.yml",
+ "blob" => nil,
+ "raw" => "https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/Jobs/Build.gitlab-ci.yml",
+ "extra" => {},
+ "contextProject" => project.full_path,
+ "contextSha" => project.commit.sha
+ }
+ ]
+ )
+ end
+ # rubocop:enable Layout/LineLength
+ end
end
diff --git a/spec/requests/api/graphql/ci/job_spec.rb b/spec/requests/api/graphql/ci/job_spec.rb
index ddb2664d353..2fb90dcd92b 100644
--- a/spec/requests/api/graphql/ci/job_spec.rb
+++ b/spec/requests/api/graphql/ci/job_spec.rb
@@ -47,10 +47,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.job(id)' do
)
post_graphql(query, current_user: user)
- expect(graphql_data_at(*path)).to match a_hash_including(
- 'id' => global_id_of(job_2),
- 'name' => job_2.name,
- 'allowFailure' => job_2.allow_failure,
+ expect(graphql_data_at(*path)).to match a_graphql_entity_for(
+ job_2, :name, :allow_failure,
'duration' => 25,
'kind' => 'BUILD',
'queuedDuration' => 2.0,
@@ -66,10 +64,7 @@ RSpec.describe 'Query.project(fullPath).pipelines.job(id)' do
it 'retrieves scalar fields' do
post_graphql(query, current_user: user)
- expect(graphql_data_at(*path)).to match a_hash_including(
- 'id' => global_id_of(job_2),
- 'name' => job_2.name
- )
+ expect(graphql_data_at(*path)).to match a_graphql_entity_for(job_2, :name)
end
end
end
@@ -102,8 +97,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.job(id)' do
'name' => test_stage.name,
'jobs' => a_hash_including(
'nodes' => contain_exactly(
- a_hash_including('id' => global_id_of(job_2)),
- a_hash_including('id' => global_id_of(job_3))
+ a_graphql_entity_for(job_2),
+ a_graphql_entity_for(job_3)
)
)
)
diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb
index 39f0f696b08..6fa455cbfca 100644
--- a/spec/requests/api/graphql/ci/runner_spec.rb
+++ b/spec/requests/api/graphql/ci/runner_spec.rb
@@ -11,7 +11,8 @@ RSpec.describe 'Query.runner(id)' do
let_it_be(:active_instance_runner) do
create(:ci_runner, :instance, description: 'Runner 1', contacted_at: 2.hours.ago,
active: true, version: 'adfe156', revision: 'a', locked: true, ip_address: '127.0.0.1', maximum_timeout: 600,
- access_level: 0, tag_list: %w[tag1 tag2], run_untagged: true, executor_type: :custom)
+ access_level: 0, tag_list: %w[tag1 tag2], run_untagged: true, executor_type: :custom,
+ maintenance_note: 'Test maintenance note')
end
let_it_be(:inactive_instance_runner) do
@@ -27,10 +28,6 @@ RSpec.describe 'Query.runner(id)' do
let_it_be(:active_project_runner) { create(:ci_runner, :project) }
- before do
- allow(Gitlab::Ci::RunnerUpgradeCheck.instance).to receive(:check_runner_upgrade_status)
- end
-
shared_examples 'runner details fetch' do
let(:query) do
wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
@@ -66,6 +63,9 @@ RSpec.describe 'Query.runner(id)' do
'ipAddress' => runner.ip_address,
'runnerType' => runner.instance_type? ? 'INSTANCE_TYPE' : 'PROJECT_TYPE',
'executorName' => runner.executor_type&.dasherize,
+ 'architectureName' => runner.architecture,
+ 'platformName' => runner.platform,
+ 'maintenanceNote' => runner.maintenance_note,
'jobCount' => 0,
'jobs' => a_hash_including("count" => 0, "nodes" => [], "pageInfo" => anything),
'projectCount' => nil,
@@ -239,8 +239,8 @@ RSpec.describe 'Query.runner(id)' do
stale_runner_data = graphql_data_at(:stale_runner)
expect(stale_runner_data).to match a_hash_including(
- 'status' => 'NOT_CONNECTED',
- 'legacyStatusWithExplicitVersion' => 'NOT_CONNECTED',
+ 'status' => 'STALE',
+ 'legacyStatusWithExplicitVersion' => 'STALE',
'newStatus' => 'STALE'
)
@@ -253,8 +253,8 @@ RSpec.describe 'Query.runner(id)' do
never_contacted_instance_runner_data = graphql_data_at(:never_contacted_instance_runner)
expect(never_contacted_instance_runner_data).to match a_hash_including(
- 'status' => 'NOT_CONNECTED',
- 'legacyStatusWithExplicitVersion' => 'NOT_CONNECTED',
+ 'status' => 'NEVER_CONTACTED',
+ 'legacyStatusWithExplicitVersion' => 'NEVER_CONTACTED',
'newStatus' => 'NEVER_CONTACTED'
)
end
diff --git a/spec/requests/api/graphql/ci/runners_spec.rb b/spec/requests/api/graphql/ci/runners_spec.rb
index 6b88c82b025..d3e94671724 100644
--- a/spec/requests/api/graphql/ci/runners_spec.rb
+++ b/spec/requests/api/graphql/ci/runners_spec.rb
@@ -56,9 +56,9 @@ RSpec.describe 'Query.runners' do
it_behaves_like 'a working graphql query returning expected runner'
end
- context 'runner_type is PROJECT_TYPE and status is NOT_CONNECTED' do
+ context 'runner_type is PROJECT_TYPE and status is NEVER_CONTACTED' do
let(:runner_type) { 'PROJECT_TYPE' }
- let(:status) { 'NOT_CONNECTED' }
+ let(:status) { 'NEVER_CONTACTED' }
let!(:expected_runner) { project_runner }
diff --git a/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
index 922a9ab277e..847fa72522e 100644
--- a/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
+++ b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
@@ -127,7 +127,7 @@ RSpec.describe 'container repository details' do
let(:query) do
<<~GQL
- query($id: ID!, $n: Int) {
+ query($id: ContainerRepositoryID!, $n: Int) {
containerRepository(id: $id) {
tags(first: $n) {
edges {
@@ -157,7 +157,7 @@ RSpec.describe 'container repository details' do
let(:query) do
<<~GQL
- query($id: ID!, $n: ContainerRepositoryTagSort) {
+ query($id: ContainerRepositoryID!, $n: ContainerRepositoryTagSort) {
containerRepository(id: $id) {
tags(sort: $n) {
edges {
@@ -194,7 +194,7 @@ RSpec.describe 'container repository details' do
let(:query) do
<<~GQL
- query($id: ID!, $n: String) {
+ query($id: ContainerRepositoryID!, $n: String) {
containerRepository(id: $id) {
tags(name: $n) {
edges {
@@ -232,7 +232,7 @@ RSpec.describe 'container repository details' do
let(:query) do
<<~GQL
- query($id: ID!) {
+ query($id: ContainerRepositoryID!) {
containerRepository(id: $id) {
size
}
diff --git a/spec/requests/api/graphql/current_user_todos_spec.rb b/spec/requests/api/graphql/current_user_todos_spec.rb
index 7f37abba74a..da1c893ec2b 100644
--- a/spec/requests/api/graphql/current_user_todos_spec.rb
+++ b/spec/requests/api/graphql/current_user_todos_spec.rb
@@ -37,8 +37,8 @@ RSpec.describe 'A Todoable that implements the CurrentUserTodos interface' do
post_graphql(query, current_user: current_user)
expect(todoable_response).to contain_exactly(
- a_hash_including('id' => global_id_of(done_todo)),
- a_hash_including('id' => global_id_of(pending_todo))
+ a_graphql_entity_for(done_todo),
+ a_graphql_entity_for(pending_todo)
)
end
@@ -63,7 +63,7 @@ RSpec.describe 'A Todoable that implements the CurrentUserTodos interface' do
post_graphql(query, current_user: current_user)
expect(todoable_response).to contain_exactly(
- a_hash_including('id' => global_id_of(pending_todo))
+ a_graphql_entity_for(pending_todo)
)
end
end
@@ -75,7 +75,7 @@ RSpec.describe 'A Todoable that implements the CurrentUserTodos interface' do
post_graphql(query, current_user: current_user)
expect(todoable_response).to contain_exactly(
- a_hash_including('id' => global_id_of(done_todo))
+ a_graphql_entity_for(done_todo)
)
end
end
diff --git a/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb
index de3dbc5c324..d21c3046c1a 100644
--- a/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb
+++ b/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb
@@ -47,14 +47,14 @@ RSpec.describe 'getting dependency proxy settings for a group' do
context 'with different permissions' do
where(:group_visibility, :role, :access_granted) do
:private | :maintainer | true
- :private | :developer | true
- :private | :reporter | true
- :private | :guest | true
+ :private | :developer | false
+ :private | :reporter | false
+ :private | :guest | false
:private | :anonymous | false
:public | :maintainer | true
- :public | :developer | true
- :public | :reporter | true
- :public | :guest | true
+ :public | :developer | false
+ :public | :reporter | false
+ :public | :guest | false
:public | :anonymous | false
end
diff --git a/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb
index c8797d84906..40f4b082072 100644
--- a/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb
+++ b/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb
@@ -46,14 +46,14 @@ RSpec.describe 'getting dependency proxy image ttl policy for a group' do
context 'with different permissions' do
where(:group_visibility, :role, :access_granted) do
:private | :maintainer | true
- :private | :developer | true
- :private | :reporter | true
- :private | :guest | true
+ :private | :developer | false
+ :private | :reporter | false
+ :private | :guest | false
:private | :anonymous | false
:public | :maintainer | true
- :public | :developer | true
- :public | :reporter | true
- :public | :guest | true
+ :public | :developer | false
+ :public | :reporter | false
+ :public | :guest | false
:public | :anonymous | false
end
diff --git a/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb
index 3527c8183f6..c7149c100b2 100644
--- a/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb
+++ b/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb
@@ -122,12 +122,12 @@ RSpec.describe 'getting dependency proxy manifests in a group' do
let(:current_user) { owner }
context 'with default sorting' do
- let_it_be(:descending_manifests) { manifests.reverse.map { |manifest| global_id_of(manifest)} }
+ let_it_be(:descending_manifests) { manifests.reverse.map { |manifest| global_id_of(manifest) } }
it_behaves_like 'sorted paginated query' do
let(:sort_param) { '' }
let(:first_param) { 2 }
- let(:all_records) { descending_manifests }
+ let(:all_records) { descending_manifests.map(&:to_s) }
end
end
diff --git a/spec/requests/api/graphql/group/group_members_spec.rb b/spec/requests/api/graphql/group/group_members_spec.rb
index 78852622835..fec866486ae 100644
--- a/spec/requests/api/graphql/group/group_members_spec.rb
+++ b/spec/requests/api/graphql/group/group_members_spec.rb
@@ -24,8 +24,8 @@ RSpec.describe 'getting group members information' do
expect(graphql_errors).to be_nil
expect(graphql_data_at(:group, :group_members, :edges, :node)).to contain_exactly(
- { 'user' => { 'id' => global_id_of(user_1) } },
- { 'user' => { 'id' => global_id_of(user_2) } },
+ { 'user' => a_graphql_entity_for(user_1) },
+ { 'user' => a_graphql_entity_for(user_2) },
'user' => nil
)
end
@@ -77,6 +77,48 @@ RSpec.describe 'getting group members information' do
end
end
+ context 'by access levels' do
+ before do
+ parent_group.add_owner(user_1)
+ parent_group.add_maintainer(user_2)
+ end
+
+ subject(:by_access_levels) { fetch_members(group: parent_group, args: { access_levels: access_levels }) }
+
+ context 'by owner' do
+ let(:access_levels) { :OWNER }
+
+ it 'returns owner' do
+ by_access_levels
+
+ expect(graphql_errors).to be_nil
+ expect_array_response(user_1)
+ end
+ end
+
+ context 'by maintainer' do
+ let(:access_levels) { :MAINTAINER }
+
+ it 'returns maintainer' do
+ by_access_levels
+
+ expect(graphql_errors).to be_nil
+ expect_array_response(user_2)
+ end
+ end
+
+ context 'by owner and maintainer' do
+ let(:access_levels) { [:OWNER, :MAINTAINER] }
+
+ it 'returns owner and maintainer' do
+ by_access_levels
+
+ expect(graphql_errors).to be_nil
+ expect_array_response(user_1, user_2)
+ end
+ end
+ end
+
context 'member relations' do
let_it_be(:child_group) { create(:group, :public, parent: parent_group) }
let_it_be(:grandchild_group) { create(:group, :public, parent: child_group) }
@@ -182,8 +224,8 @@ RSpec.describe 'getting group members information' do
def expect_array_response(*items)
expect(response).to have_gitlab_http_status(:success)
- member_gids = graphql_data_at(:group, :group_members, :edges, :node, :user, :id)
+ members = graphql_data_at(:group, :group_members, :edges, :node, :user)
- expect(member_gids).to match_array(items.map { |u| global_id_of(u) })
+ expect(members).to match_array(items.map { |u| a_graphql_entity_for(u) })
end
end
diff --git a/spec/requests/api/graphql/group/merge_requests_spec.rb b/spec/requests/api/graphql/group/merge_requests_spec.rb
index c0faff11c8d..434b0d16569 100644
--- a/spec/requests/api/graphql/group/merge_requests_spec.rb
+++ b/spec/requests/api/graphql/group/merge_requests_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe 'Query.group.mergeRequests' do
end
def expected_mrs(mrs)
- mrs.map { |mr| a_hash_including('id' => global_id_of(mr)) }
+ mrs.map { |mr| a_graphql_entity_for(mr) }
end
describe 'not passing any arguments' do
diff --git a/spec/requests/api/graphql/group/milestones_spec.rb b/spec/requests/api/graphql/group/milestones_spec.rb
index 2b80b5239c8..7c51409f907 100644
--- a/spec/requests/api/graphql/group/milestones_spec.rb
+++ b/spec/requests/api/graphql/group/milestones_spec.rb
@@ -170,10 +170,8 @@ RSpec.describe 'Milestones through GroupQuery' do
end
it 'returns correct values for scalar fields' do
- expect(post_query).to eq({
- 'id' => global_id_of(milestone),
- 'title' => milestone.title,
- 'description' => milestone.description,
+ expect(post_query).to match a_graphql_entity_for(
+ milestone, :title, :description,
'state' => 'active',
'webPath' => milestone_path(milestone),
'dueDate' => milestone.due_date.iso8601,
@@ -183,7 +181,7 @@ RSpec.describe 'Milestones through GroupQuery' do
'projectMilestone' => false,
'groupMilestone' => true,
'subgroupMilestone' => false
- })
+ )
end
context 'milestone statistics' do
diff --git a/spec/requests/api/graphql/issue/issue_spec.rb b/spec/requests/api/graphql/issue/issue_spec.rb
index 42ca3348384..05fd6bf3022 100644
--- a/spec/requests/api/graphql/issue/issue_spec.rb
+++ b/spec/requests/api/graphql/issue/issue_spec.rb
@@ -8,8 +8,8 @@ RSpec.describe 'Query.issue(id)' do
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } }
+ let(:issue_params) { { 'id' => global_id_of(issue) } }
let(:issue_data) { graphql_data['issue'] }
let(:issue_fields) { all_graphql_fields_for('Issue'.classify) }
@@ -100,7 +100,8 @@ RSpec.describe 'Query.issue(id)' do
let_it_be(:issue_fields) { ['moved', 'movedTo { title }'] }
let_it_be(:new_issue) { create(:issue) }
let_it_be(:issue) { create(:issue, project: project, moved_to: new_issue) }
- let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } }
+
+ let(:issue_params) { { 'id' => global_id_of(issue) } }
before_all do
new_issue.project.add_developer(current_user)
diff --git a/spec/requests/api/graphql/merge_request/merge_request_spec.rb b/spec/requests/api/graphql/merge_request/merge_request_spec.rb
index 75dd01a0763..d89f381753e 100644
--- a/spec/requests/api/graphql/merge_request/merge_request_spec.rb
+++ b/spec/requests/api/graphql/merge_request/merge_request_spec.rb
@@ -8,8 +8,8 @@ RSpec.describe 'Query.merge_request(id)' do
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:merge_request_params) { { 'id' => merge_request.to_global_id.to_s } }
+ let(:merge_request_params) { { 'id' => global_id_of(merge_request) } }
let(:merge_request_data) { graphql_data['mergeRequest'] }
let(:merge_request_fields) { all_graphql_fields_for('MergeRequest'.classify) }
diff --git a/spec/requests/api/graphql/mutations/ci/ci_cd_settings_update_spec.rb b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb
index 30e7f196542..394d9ff53d1 100644
--- a/spec/requests/api/graphql/mutations/ci/ci_cd_settings_update_spec.rb
+++ b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'CiCdSettingsUpdate' do
+RSpec.describe 'ProjectCiCdSettingsUpdate' do
include GraphqlHelpers
let_it_be(:project) do
diff --git a/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb b/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb
index 12368e7e9c5..6818ba33e74 100644
--- a/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb
+++ b/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb
@@ -64,11 +64,10 @@ RSpec.describe 'RunnersRegistrationTokenReset' do
context 'applied to project' do
let_it_be(:project) { create_default(:project) }
+ let(:target) { project }
let(:input) { { type: 'PROJECT_TYPE', id: project.to_global_id.to_s } }
- include_context 'when unauthorized', 'project' do
- let(:target) { project }
- end
+ include_context('when unauthorized', 'project')
include_context 'when authorized', 'project' do
let_it_be(:user) { project.first_owner }
@@ -82,11 +81,10 @@ RSpec.describe 'RunnersRegistrationTokenReset' do
context 'applied to group' do
let_it_be(:group) { create_default(:group) }
+ let(:target) { group }
let(:input) { { type: 'GROUP_TYPE', id: group.to_global_id.to_s } }
- include_context 'when unauthorized', 'group' do
- let(:target) { group }
- end
+ include_context('when unauthorized', 'group')
include_context 'when authorized', 'group' do
let_it_be(:user) { create_default(:group_member, :owner, user: create(:user), group: group ).user }
@@ -99,10 +97,12 @@ RSpec.describe 'RunnersRegistrationTokenReset' do
context 'applied to instance' do
before do
- ApplicationSetting.create_from_defaults
+ target
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
end
+ let_it_be(:target) { ApplicationSetting.create_from_defaults }
+
let(:input) { { type: 'INSTANCE_TYPE' } }
context 'when unauthorized' do
diff --git a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb
index 5f6822223ca..4891e64aab8 100644
--- a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb
+++ b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'Delete a cluster agent' do
'or you don\'t have permission to perform this action']
it 'does not delete cluster agent' do
- expect { cluster_agent.reload }.not_to raise_error(ActiveRecord::RecordNotFound)
+ expect { cluster_agent.reload }.not_to raise_error
end
end
diff --git a/spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb b/spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb
index 0156142dc6f..ca7c1b2ce5f 100644
--- a/spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb
@@ -135,7 +135,7 @@ RSpec.describe 'Updating the container expiration policy' do
context 'with existing container expiration policy' do
where(:user_role, :shared_examples_name) do
:maintainer | 'accepting the mutation request updating the container expiration policy'
- :developer | 'accepting the mutation request updating the container expiration policy'
+ :developer | 'denying the mutation request'
:reporter | 'denying the mutation request'
:guest | 'denying the mutation request'
:anonymous | 'denying the mutation request'
@@ -155,7 +155,7 @@ RSpec.describe 'Updating the container expiration policy' do
where(:user_role, :shared_examples_name) do
:maintainer | 'accepting the mutation request creating the container expiration policy'
- :developer | 'accepting the mutation request creating the container expiration policy'
+ :developer | 'denying the mutation request'
:reporter | 'denying the mutation request'
:guest | 'denying the mutation request'
:anonymous | 'denying the mutation request'
diff --git a/spec/requests/api/graphql/mutations/dependency_proxy/group_settings/update_spec.rb b/spec/requests/api/graphql/mutations/dependency_proxy/group_settings/update_spec.rb
index f05bf23ad27..9eb13e534ac 100644
--- a/spec/requests/api/graphql/mutations/dependency_proxy/group_settings/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/dependency_proxy/group_settings/update_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe 'Updating the dependency proxy group settings' do
context 'with permission' do
before do
- group.add_developer(user)
+ group.add_maintainer(user)
end
it 'returns the updated dependency proxy settings', :aggregate_failures do
diff --git a/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb b/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
index c9e9a22ee0b..31ba7ecdf0e 100644
--- a/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe 'Updating the dependency proxy image ttl policy' do
context 'with permission' do
before do
- group.add_developer(user)
+ group.add_maintainer(user)
end
it 'returns the updated dependency proxy image ttl policy', :aggregate_failures do
diff --git a/spec/requests/api/graphql/mutations/incident_management/timeline_event/create_spec.rb b/spec/requests/api/graphql/mutations/incident_management/timeline_event/create_spec.rb
new file mode 100644
index 00000000000..3ea8b38e20f
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/incident_management/timeline_event/create_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creating an incident timeline event' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:event_occurred_at) { Time.current }
+ let_it_be(:note) { 'demo note' }
+
+ let(:input) { { incident_id: incident.to_global_id.to_s, note: note, occurred_at: event_occurred_at } }
+ let(:mutation) do
+ graphql_mutation(:timeline_event_create, input) do
+ <<~QL
+ clientMutationId
+ errors
+ timelineEvent {
+ id
+ author { id username }
+ incident { id title }
+ note
+ editable
+ action
+ occurredAt
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:timeline_event_create) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'creates incident timeline event', :aggregate_failures do
+ post_graphql_mutation(mutation, current_user: user)
+
+ timeline_event_response = mutation_response['timelineEvent']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(timeline_event_response).to include(
+ 'author' => {
+ 'id' => user.to_global_id.to_s,
+ 'username' => user.username
+ },
+ 'incident' => {
+ 'id' => incident.to_global_id.to_s,
+ 'title' => incident.title
+ },
+ 'note' => note,
+ 'action' => 'comment',
+ 'editable' => false,
+ 'occurredAt' => event_occurred_at.iso8601
+ )
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/incident_management/timeline_event/destroy_spec.rb b/spec/requests/api/graphql/mutations/incident_management/timeline_event/destroy_spec.rb
new file mode 100644
index 00000000000..faff3bfe23a
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/incident_management/timeline_event/destroy_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Removing an incident timeline event' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:timeline_event) { create(:incident_management_timeline_event, incident: incident, project: project) }
+
+ let(:variables) { { id: timeline_event.to_global_id.to_s } }
+
+ let(:mutation) do
+ graphql_mutation(:timeline_event_destroy, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ timelineEvent {
+ id
+ author { id username }
+ incident { id title }
+ note
+ noteHtml
+ editable
+ action
+ occurredAt
+ createdAt
+ updatedAt
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:timeline_event_destroy) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'removes incident timeline event', :aggregate_failures do
+ post_graphql_mutation(mutation, current_user: user)
+
+ timeline_event_response = mutation_response['timelineEvent']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(timeline_event_response).to include(
+ 'author' => {
+ 'id' => timeline_event.author.to_global_id.to_s,
+ 'username' => timeline_event.author.username
+ },
+ 'incident' => {
+ 'id' => incident.to_global_id.to_s,
+ 'title' => incident.title
+ },
+ 'note' => timeline_event.note,
+ 'noteHtml' => timeline_event.note_html,
+ 'editable' => false,
+ 'action' => timeline_event.action,
+ 'occurredAt' => timeline_event.occurred_at.iso8601,
+ 'createdAt' => timeline_event.created_at.iso8601,
+ 'updatedAt' => timeline_event.updated_at.iso8601
+ )
+ expect { timeline_event.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/incident_management/timeline_event/promote_from_note_spec.rb b/spec/requests/api/graphql/mutations/incident_management/timeline_event/promote_from_note_spec.rb
new file mode 100644
index 00000000000..b92f6af1d3d
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/incident_management/timeline_event/promote_from_note_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Promote an incident timeline event from a comment' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:comment) { create(:note, project: project, noteable: incident) }
+
+ let(:input) { { note_id: comment.to_global_id.to_s } }
+ let(:mutation) do
+ graphql_mutation(:timeline_event_promote_from_note, input) do
+ <<~QL
+ clientMutationId
+ errors
+ timelineEvent {
+ author { id username }
+ incident { id title }
+ promotedFromNote { id }
+ note
+ action
+ editable
+ occurredAt
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:timeline_event_promote_from_note) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'creates incident timeline event from the note', :aggregate_failures do
+ post_graphql_mutation(mutation, current_user: user)
+
+ timeline_event_response = mutation_response['timelineEvent']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(timeline_event_response).to include(
+ 'author' => {
+ 'id' => user.to_global_id.to_s,
+ 'username' => user.username
+ },
+ 'incident' => {
+ 'id' => incident.to_global_id.to_s,
+ 'title' => incident.title
+ },
+ 'promotedFromNote' => {
+ 'id' => comment.to_global_id.to_s
+ },
+ 'note' => comment.note,
+ 'action' => 'comment',
+ 'editable' => false,
+ 'occurredAt' => comment.created_at.iso8601
+ )
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/incident_management/timeline_event/update_spec.rb b/spec/requests/api/graphql/mutations/incident_management/timeline_event/update_spec.rb
new file mode 100644
index 00000000000..1c4439cec6f
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/incident_management/timeline_event/update_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Updating an incident timeline event' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be_with_reload(:timeline_event) do
+ create(:incident_management_timeline_event, incident: incident, project: project)
+ end
+
+ let(:occurred_at) { 1.minute.ago.iso8601 }
+
+ let(:variables) do
+ {
+ id: timeline_event.to_global_id.to_s,
+ note: 'Updated note',
+ occurred_at: occurred_at
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:timeline_event_update, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ timelineEvent {
+ id
+ author { id username }
+ updatedByUser { id username }
+ incident { id title }
+ note
+ noteHtml
+ occurredAt
+ createdAt
+ updatedAt
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:timeline_event_update) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'updates the timeline event', :aggregate_failures do
+ post_graphql_mutation(mutation, current_user: user)
+
+ timeline_event_response = mutation_response['timelineEvent']
+
+ timeline_event.reload
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(timeline_event_response).to include(
+ 'id' => timeline_event.to_global_id.to_s,
+ 'author' => {
+ 'id' => timeline_event.author.to_global_id.to_s,
+ 'username' => timeline_event.author.username
+ },
+ 'updatedByUser' => {
+ 'id' => user.to_global_id.to_s,
+ 'username' => user.username
+ },
+ 'incident' => {
+ 'id' => incident.to_global_id.to_s,
+ 'title' => incident.title
+ },
+ 'note' => 'Updated note',
+ 'noteHtml' => timeline_event.note_html,
+ 'occurredAt' => occurred_at,
+ 'createdAt' => timeline_event.created_at.iso8601,
+ 'updatedAt' => timeline_event.updated_at.iso8601
+ )
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb
index 02b79dac489..715507c3cc5 100644
--- a/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb
+++ b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'Setting issues crm contacts' do
let(:operation_mode) { Types::MutationOperationModeEnum.default_mode }
let(:initial_contacts) { contacts[0..1] }
let(:mutation_contacts) { contacts[1..2] }
- let(:contact_ids) { contact_global_ids(mutation_contacts) }
+ let(:contact_ids) { mutation_contacts.map { global_id_of(_1) } }
let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" }
let(:mutation) do
@@ -45,8 +45,8 @@ RSpec.describe 'Setting issues crm contacts' do
graphql_mutation_response(:issue_set_crm_contacts)
end
- def contact_global_ids(contacts)
- contacts.map { |contact| global_id_of(contact) }
+ def expected_contacts(contacts)
+ contacts.map { |contact| a_graphql_entity_for(contact) }
end
before do
@@ -58,8 +58,8 @@ RSpec.describe 'Setting issues crm contacts' do
it 'updates the issue with correct contacts' do
post_graphql_mutation(mutation, current_user: user)
- expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id))
- .to match_array(contact_global_ids(mutation_contacts))
+ expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes))
+ .to match_array(expected_contacts(mutation_contacts))
end
end
@@ -70,8 +70,8 @@ RSpec.describe 'Setting issues crm contacts' do
it 'updates the issue with correct contacts' do
post_graphql_mutation(mutation, current_user: user)
- expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id))
- .to match_array(contact_global_ids(initial_contacts + mutation_contacts))
+ expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes))
+ .to match_array(expected_contacts(initial_contacts + mutation_contacts))
end
end
@@ -82,8 +82,8 @@ RSpec.describe 'Setting issues crm contacts' do
it 'updates the issue with correct contacts' do
post_graphql_mutation(mutation, current_user: user)
- expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id))
- .to match_array(contact_global_ids(initial_contacts - mutation_contacts))
+ expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes))
+ .to match_array(expected_contacts(initial_contacts - mutation_contacts))
end
end
end
@@ -117,7 +117,7 @@ RSpec.describe 'Setting issues crm contacts' do
it_behaves_like 'successful mutation'
context 'when the contact does not exist' do
- let(:contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] }
+ let(:contact_ids) { [global_id_of(model_name: 'CustomerRelations::Contact', id: non_existing_record_id)] }
it 'returns expected error' do
post_graphql_mutation(mutation, current_user: user)
@@ -159,7 +159,7 @@ RSpec.describe 'Setting issues crm contacts' do
context 'when trying to remove non-existent contact' do
let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] }
- let(:contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] }
+ let(:contact_ids) { [global_id_of(model_name: 'CustomerRelations::Contact', id: non_existing_record_id)] }
it 'raises expected error' do
post_graphql_mutation(mutation, current_user: user)
diff --git a/spec/requests/api/graphql/mutations/merge_requests/request_attention_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/request_attention_spec.rb
new file mode 100644
index 00000000000..9c751913827
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/merge_requests/request_attention_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Request attention' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request, reviewers: [user]) }
+ let_it_be(:project) { merge_request.project }
+
+ let(:input) { { user_id: global_id_of(user) } }
+
+ let(:mutation) do
+ variables = {
+ project_path: project.full_path,
+ iid: merge_request.iid.to_s
+ }
+ graphql_mutation(:merge_request_request_attention, variables.merge(input),
+ <<-QL.strip_heredoc
+ clientMutationId
+ errors
+ QL
+ )
+ end
+
+ def mutation_response
+ graphql_mutation_response(:merge_request_request_attention)
+ end
+
+ def mutation_errors
+ mutation_response['errors']
+ end
+
+ before_all do
+ project.add_developer(current_user)
+ project.add_developer(user)
+ end
+
+ it 'is successful' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_errors).to be_empty
+ end
+
+ context 'when current user is not allowed to update the merge request' do
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: create(:user))
+
+ expect(graphql_errors).not_to be_empty
+ end
+ end
+
+ context 'when user is not a reviewer' do
+ let(:input) { { user_id: global_id_of(create(:user)) } }
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_errors).not_to be_empty
+ end
+ end
+
+ context 'feature flag is disabled' do
+ before do
+ stub_feature_flags(mr_attention_requests: false)
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(graphql_errors[0]["message"]).to eq "Feature disabled"
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb b/spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb
index d335642d321..194e42bf59d 100644
--- a/spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb
@@ -109,7 +109,7 @@ RSpec.describe 'Updating the package settings' do
where(:user_role, :shared_examples_name) do
:maintainer | 'accepting the mutation request updating the package settings'
- :developer | 'accepting the mutation request updating the package settings'
+ :developer | 'denying the mutation request'
:reporter | 'denying the mutation request'
:guest | 'denying the mutation request'
:anonymous | 'denying the mutation request'
@@ -131,7 +131,7 @@ RSpec.describe 'Updating the package settings' do
where(:user_role, :shared_examples_name) do
:maintainer | 'accepting the mutation request creating the package settings'
- :developer | 'accepting the mutation request creating the package settings'
+ :developer | 'denying the mutation request'
:reporter | 'denying the mutation request'
:guest | 'denying the mutation request'
:anonymous | 'denying the mutation request'
diff --git a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
index 63b94dccca0..22b5f2d5112 100644
--- a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
@@ -64,7 +64,7 @@ RSpec.describe 'Adding a Note' do
it 'creates a Note in a discussion' do
post_graphql_mutation(mutation, current_user: current_user)
- expect(mutation_response['note']['discussion']['id']).to eq(discussion.to_global_id.to_s)
+ expect(mutation_response['note']['discussion']).to match a_graphql_entity_for(discussion)
end
context 'when the discussion_id is not for a Discussion' do
@@ -109,7 +109,7 @@ RSpec.describe 'Adding a Note' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response).to include(
- 'errors' => [/Merged this merge request/],
+ 'errors' => include(/Merged this merge request/),
'note' => nil
)
end
diff --git a/spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb b/spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb
index 89e3a71280f..0f7ccac3179 100644
--- a/spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe 'Repositioning an ImageDiffNote' do
post_graphql_mutation(mutation, current_user: current_user)
end.to change { note.reset.position.x }.to(10)
- expect(mutation_response['note']).to eq('id' => global_id_of(note))
+ expect(mutation_response['note']).to match a_graphql_entity_for(note)
expect(mutation_response['errors']).to be_empty
end
@@ -59,7 +59,7 @@ RSpec.describe 'Repositioning an ImageDiffNote' do
post_graphql_mutation(mutation, current_user: current_user)
end.not_to change { note.reset.position.x }
- expect(mutation_response['note']).to eq('id' => global_id_of(note))
+ expect(mutation_response['note']).to match a_graphql_entity_for(note)
expect(mutation_response['errors']).to be_empty
end
end
diff --git a/spec/requests/api/graphql/mutations/remove_attention_request_spec.rb b/spec/requests/api/graphql/mutations/remove_attention_request_spec.rb
new file mode 100644
index 00000000000..053559b039d
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/remove_attention_request_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Remove attention request' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request, reviewers: [user]) }
+ let_it_be(:project) { merge_request.project }
+
+ let(:input) { { user_id: global_id_of(user) } }
+
+ let(:mutation) do
+ variables = {
+ project_path: project.full_path,
+ iid: merge_request.iid.to_s
+ }
+ graphql_mutation(:merge_request_remove_attention_request, variables.merge(input),
+ <<-QL.strip_heredoc
+ clientMutationId
+ errors
+ QL
+ )
+ end
+
+ def mutation_response
+ graphql_mutation_response(:merge_request_remove_attention_request)
+ end
+
+ def mutation_errors
+ mutation_response['errors']
+ end
+
+ before_all do
+ project.add_developer(current_user)
+ project.add_developer(user)
+ end
+
+ it 'is successful' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_errors).to be_empty
+ end
+
+ context 'when current user is not allowed to update the merge request' do
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: create(:user))
+
+ expect(graphql_errors).not_to be_empty
+ end
+ end
+
+ context 'when user is not a reviewer' do
+ let(:input) { { user_id: global_id_of(create(:user)) } }
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_errors).not_to be_empty
+ end
+ end
+
+ context 'feature flag is disabled' do
+ before do
+ stub_feature_flags(mr_attention_requests: false)
+ end
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(graphql_errors[0]["message"]).to eq "Feature disabled"
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/timelogs/delete_spec.rb b/spec/requests/api/graphql/mutations/timelogs/delete_spec.rb
new file mode 100644
index 00000000000..b674e77f093
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/timelogs/delete_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Delete a timelog' do
+ include GraphqlHelpers
+ let_it_be(:author) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:timelog) { create(:timelog, user: author, issue: issue, time_spent: 1800)}
+
+ let(:current_user) { nil }
+ let(:mutation) { graphql_mutation(:timelogDelete, { 'id' => timelog.to_global_id.to_s }) }
+ let(:mutation_response) { graphql_mutation_response(:timelog_delete) }
+
+ context 'when the user is not allowed to delete a timelog' do
+ let(:current_user) { create(:user) }
+
+ before do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user has permissions to delete a timelog' do
+ let(:current_user) { author }
+
+ it 'deletes the timelog' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.to change(Timelog, :count).by(-1)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['timelog']).to include('id' => timelog.to_global_id.to_s)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
index c5c34e16717..dc20fde8e3c 100644
--- a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
+++ b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
@@ -46,8 +46,8 @@ RSpec.describe 'Marking all todos done' do
expect(todo3.reload.state).to eq('done')
expect(other_user_todo.reload.state).to eq('pending')
- updated_todo_ids = mutation_response['todos'].map { |todo| todo['id'] }
- expect(updated_todo_ids).to contain_exactly(global_id_of(todo1), global_id_of(todo3))
+ updated_todos = mutation_response['todos']
+ expect(updated_todos).to contain_exactly(a_graphql_entity_for(todo1), a_graphql_entity_for(todo3))
end
context 'when target_id is given', :aggregate_failures do
@@ -66,8 +66,8 @@ RSpec.describe 'Marking all todos done' do
expect(todo1.reload.state).to eq('pending')
expect(todo3.reload.state).to eq('pending')
- updated_todo_ids = mutation_response['todos'].map { |todo| todo['id'] }
- expect(updated_todo_ids).to contain_exactly(global_id_of(target_todo1), global_id_of(target_todo2))
+ updated_todos = mutation_response['todos']
+ expect(updated_todos).to contain_exactly(a_graphql_entity_for(target_todo1), a_graphql_entity_for(target_todo2))
end
context 'when target does not exist' do
diff --git a/spec/requests/api/graphql/mutations/todos/restore_many_spec.rb b/spec/requests/api/graphql/mutations/todos/restore_many_spec.rb
index 70e3cc7f5cd..4316bd060c1 100644
--- a/spec/requests/api/graphql/mutations/todos/restore_many_spec.rb
+++ b/spec/requests/api/graphql/mutations/todos/restore_many_spec.rb
@@ -11,8 +11,8 @@ RSpec.describe 'Restoring many Todos' do
let_it_be(:author) { create(:user) }
let_it_be(:other_user) { create(:user) }
- let_it_be(:todo1) { create(:todo, user: current_user, author: author, state: :done, target: issue) }
- let_it_be(:todo2) { create(:todo, user: current_user, author: author, state: :done, target: issue) }
+ let_it_be_with_reload(:todo1) { create(:todo, user: current_user, author: author, state: :done, target: issue) }
+ let_it_be_with_reload(:todo2) { create(:todo, user: current_user, author: author, state: :done, target: issue) }
let_it_be(:other_user_todo) { create(:todo, user: other_user, author: author, state: :done) }
@@ -50,8 +50,8 @@ RSpec.describe 'Restoring many Todos' do
expect(mutation_response).to include(
'errors' => be_empty,
'todos' => contain_exactly(
- { 'id' => global_id_of(todo1), 'state' => 'pending' },
- { 'id' => global_id_of(todo2), 'state' => 'pending' }
+ a_graphql_entity_for(todo1, 'state' => 'pending'),
+ a_graphql_entity_for(todo2, 'state' => 'pending')
)
)
end
diff --git a/spec/requests/api/graphql/mutations/work_items/delete_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/delete_task_spec.rb
new file mode 100644
index 00000000000..05d3587d342
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/work_items/delete_task_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "Delete a task in a work item's description" do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
+ let_it_be(:task) { create(:work_item, :task, project: project, author: developer) }
+ let_it_be(:work_item, refind: true) do
+ create(:work_item, project: project, description: "- [ ] #{task.to_reference}+", lock_version: 3)
+ end
+
+ before_all do
+ create(:issue_link, source_id: work_item.id, target_id: task.id)
+ end
+
+ let(:lock_version) { work_item.lock_version }
+ let(:input) do
+ {
+ 'id' => work_item.to_global_id.to_s,
+ 'lockVersion' => lock_version,
+ 'taskData' => {
+ 'id' => task.to_global_id.to_s,
+ 'lineNumberStart' => 1,
+ 'lineNumberEnd' => 1
+ }
+ }
+ end
+
+ let(:mutation) { graphql_mutation(:workItemDeleteTask, input) }
+ let(:mutation_response) { graphql_mutation_response(:work_item_delete_task) }
+
+ context 'the user is not allowed to update a work item' do
+ let(:current_user) { create(:user) }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user can update the description but not delete the task' do
+ let(:current_user) { create(:user).tap { |u| project.add_developer(u) } }
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user has permissions to remove a task' do
+ let(:current_user) { developer }
+
+ it 'removes the task from the work item' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change(WorkItem, :count).by(-1).and(
+ change(IssueLink, :count).by(-1)
+ ).and(
+ change(work_item, :description).from("- [ ] #{task.to_reference}+").to('')
+ )
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['workItem']).to include('id' => work_item.to_global_id.to_s)
+ end
+
+ context 'when removing the task fails' do
+ let(:lock_version) { 2 }
+
+ it 'makes no changes to the DB and returns an error message' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to not_change(WorkItem, :count).and(
+ not_change(work_item, :description)
+ )
+
+ expect(mutation_response['errors']).to contain_exactly('Stale work item. Check lock version')
+ end
+ end
+
+ context 'when the work_items feature flag is disabled' do
+ before do
+ stub_feature_flags(work_items: false)
+ end
+
+ it 'does nothing and returns and error' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.to not_change(WorkItem, :count)
+
+ expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/packages/conan_spec.rb b/spec/requests/api/graphql/packages/conan_spec.rb
index 84c5af33e5d..1f3732980d9 100644
--- a/spec/requests/api/graphql/packages/conan_spec.rb
+++ b/spec/requests/api/graphql/packages/conan_spec.rb
@@ -37,22 +37,19 @@ RSpec.describe 'conan package details' do
it_behaves_like 'a package with files'
it 'has the correct metadata' do
- expect(metadata_response).to include(
- 'id' => global_id_of(package.conan_metadatum),
- 'recipe' => package.conan_metadatum.recipe,
- 'packageChannel' => package.conan_metadatum.package_channel,
- 'packageUsername' => package.conan_metadatum.package_username,
- 'recipePath' => package.conan_metadatum.recipe_path
+ expect(metadata_response).to match(
+ a_graphql_entity_for(package.conan_metadatum,
+ :recipe, :package_channel, :package_username, :recipe_path)
)
end
it 'has the correct file metadata' do
- expect(first_file_response_metadata).to include(
- 'id' => global_id_of(first_file.conan_file_metadatum),
- 'packageRevision' => first_file.conan_file_metadatum.package_revision,
- 'conanPackageReference' => first_file.conan_file_metadatum.conan_package_reference,
- 'recipeRevision' => first_file.conan_file_metadatum.recipe_revision,
- 'conanFileType' => first_file.conan_file_metadatum.conan_file_type.upcase
+ expect(first_file_response_metadata).to match(
+ a_graphql_entity_for(
+ first_file.conan_file_metadatum,
+ :package_revision, :conan_package_reference, :recipe_revision,
+ conan_file_type: first_file.conan_file_metadatum.conan_file_type.upcase
+ )
)
end
end
diff --git a/spec/requests/api/graphql/packages/maven_spec.rb b/spec/requests/api/graphql/packages/maven_spec.rb
index d28d32b0df5..9d59a922660 100644
--- a/spec/requests/api/graphql/packages/maven_spec.rb
+++ b/spec/requests/api/graphql/packages/maven_spec.rb
@@ -11,12 +11,8 @@ RSpec.describe 'maven package details' do
shared_examples 'correct maven metadata' do
it 'has the correct metadata' do
- expect(metadata_response).to include(
- 'id' => global_id_of(package.maven_metadatum),
- 'path' => package.maven_metadatum.path,
- 'appGroup' => package.maven_metadatum.app_group,
- 'appVersion' => package.maven_metadatum.app_version,
- 'appName' => package.maven_metadatum.app_name
+ expect(metadata_response).to match a_graphql_entity_for(
+ package.maven_metadatum, :path, :app_group, :app_version, :app_name
)
end
end
diff --git a/spec/requests/api/graphql/packages/nuget_spec.rb b/spec/requests/api/graphql/packages/nuget_spec.rb
index ba8d2ca42d2..87cffc67ce5 100644
--- a/spec/requests/api/graphql/packages/nuget_spec.rb
+++ b/spec/requests/api/graphql/packages/nuget_spec.rb
@@ -22,24 +22,19 @@ RSpec.describe 'nuget package details' do
it_behaves_like 'a package with files'
it 'has the correct metadata' do
- expect(metadata_response).to include(
- 'id' => global_id_of(package.nuget_metadatum),
- 'licenseUrl' => package.nuget_metadatum.license_url,
- 'projectUrl' => package.nuget_metadatum.project_url,
- 'iconUrl' => package.nuget_metadatum.icon_url
+ expect(metadata_response).to match a_graphql_entity_for(
+ package.nuget_metadatum, :license_url, :project_url, :icon_url
)
end
it 'has dependency links' do
- expect(dependency_link_response).to include(
- 'id' => global_id_of(dependency_link),
+ expect(dependency_link_response).to match a_graphql_entity_for(
+ dependency_link,
'dependencyType' => dependency_link.dependency_type.upcase
)
- expect(dependency_response).to include(
- 'id' => global_id_of(dependency_link.dependency),
- 'name' => dependency_link.dependency.name,
- 'versionPattern' => dependency_link.dependency.version_pattern
+ expect(dependency_response).to match a_graphql_entity_for(
+ dependency_link.dependency, :name, :version_pattern
)
end
diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb
index 365efc514d4..0335c1085b4 100644
--- a/spec/requests/api/graphql/packages/package_spec.rb
+++ b/spec/requests/api/graphql/packages/package_spec.rb
@@ -65,32 +65,6 @@ RSpec.describe 'package details' do
end
end
- context 'there are other versions of this package' do
- let(:depth) { 3 }
- let(:excluded) { %w[metadata project tags pipelines] } # to limit the query complexity
-
- let_it_be(:siblings) { create_list(:composer_package, 2, project: project, name: composer_package.name) }
-
- it 'includes the sibling versions' do
- subject
-
- expect(graphql_data_at(:package, :versions, :nodes)).to match_array(
- siblings.map { |p| a_hash_including('id' => global_id_of(p)) }
- )
- end
-
- context 'going deeper' do
- let(:depth) { 6 }
-
- it 'does not create a cycle of versions' do
- subject
-
- expect(graphql_data_at(:package, :versions, :nodes, :version)).to be_present
- expect(graphql_data_at(:package, :versions, :nodes, :versions, :nodes)).to match_array [nil, nil]
- end
- end
- end
-
context 'with package files pending destruction' do
let_it_be(:package_file) { create(:package_file, package: composer_package) }
let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: composer_package) }
diff --git a/spec/requests/api/graphql/packages/pypi_spec.rb b/spec/requests/api/graphql/packages/pypi_spec.rb
index 64fe7d29a7a..0cc5bd2e3b2 100644
--- a/spec/requests/api/graphql/packages/pypi_spec.rb
+++ b/spec/requests/api/graphql/packages/pypi_spec.rb
@@ -19,9 +19,8 @@ RSpec.describe 'pypi package details' do
it_behaves_like 'a package with files'
it 'has the correct metadata' do
- expect(metadata_response).to include(
- 'id' => global_id_of(package.pypi_metadatum),
- 'requiredPython' => package.pypi_metadatum.required_python
+ expect(metadata_response).to match a_graphql_entity_for(
+ package.pypi_metadatum, :required_python
)
end
end
diff --git a/spec/requests/api/graphql/project/alert_management/integrations_spec.rb b/spec/requests/api/graphql/project/alert_management/integrations_spec.rb
index 1793d4961eb..773922c1864 100644
--- a/spec/requests/api/graphql/project/alert_management/integrations_spec.rb
+++ b/spec/requests/api/graphql/project/alert_management/integrations_spec.rb
@@ -53,33 +53,24 @@ RSpec.describe 'getting Alert Management Integrations' do
end
context 'when no extra params given' do
- let(:http_integration_response) { integrations.first }
- let(:prometheus_integration_response) { integrations.second }
-
it_behaves_like 'a working graphql query'
- it { expect(integrations.size).to eq(2) }
-
it 'returns the correct properties of the integrations' do
- expect(http_integration_response).to include(
- 'id' => global_id_of(active_http_integration),
- 'type' => 'HTTP',
- 'name' => active_http_integration.name,
- 'active' => active_http_integration.active,
- 'token' => active_http_integration.token,
- 'url' => active_http_integration.url,
- 'apiUrl' => nil
- )
-
- expect(prometheus_integration_response).to include(
- 'id' => global_id_of(prometheus_integration),
- 'type' => 'PROMETHEUS',
- 'name' => 'Prometheus',
- 'active' => prometheus_integration.manual_configuration?,
- 'token' => project_alerting_setting.token,
- 'url' => "http://localhost/#{project.full_path}/prometheus/alerts/notify.json",
- 'apiUrl' => prometheus_integration.api_url
- )
+ expect(integrations).to match [
+ a_graphql_entity_for(
+ active_http_integration,
+ :name, :active, :token, :url, type: 'HTTP', api_url: nil
+ ),
+ a_graphql_entity_for(
+ prometheus_integration,
+ 'type' => 'PROMETHEUS',
+ 'name' => 'Prometheus',
+ 'active' => prometheus_integration.manual_configuration?,
+ 'token' => project_alerting_setting.token,
+ 'url' => "http://localhost/#{project.full_path}/prometheus/alerts/notify.json",
+ 'apiUrl' => prometheus_integration.api_url
+ )
+ ]
end
end
@@ -88,17 +79,9 @@ RSpec.describe 'getting Alert Management Integrations' do
it_behaves_like 'a working graphql query'
- it { expect(integrations).to be_one }
-
it 'returns the correct properties of the HTTP integration' do
- expect(integrations.first).to include(
- 'id' => global_id_of(active_http_integration),
- 'type' => 'HTTP',
- 'name' => active_http_integration.name,
- 'active' => active_http_integration.active,
- 'token' => active_http_integration.token,
- 'url' => active_http_integration.url,
- 'apiUrl' => nil
+ expect(integrations).to contain_exactly a_graphql_entity_for(
+ active_http_integration, :name, :active, :token, :url, type: 'HTTP', api_url: nil
)
end
end
@@ -108,11 +91,9 @@ RSpec.describe 'getting Alert Management Integrations' do
it_behaves_like 'a working graphql query'
- it { expect(integrations).to be_one }
-
it 'returns the correct properties of the Prometheus Integration' do
- expect(integrations.first).to include(
- 'id' => global_id_of(prometheus_integration),
+ expect(integrations).to contain_exactly a_graphql_entity_for(
+ prometheus_integration,
'type' => 'PROMETHEUS',
'name' => 'Prometheus',
'active' => prometheus_integration.manual_configuration?,
diff --git a/spec/requests/api/graphql/project/cluster_agents_spec.rb b/spec/requests/api/graphql/project/cluster_agents_spec.rb
index c9900fea277..a34df0ee6f4 100644
--- a/spec/requests/api/graphql/project/cluster_agents_spec.rb
+++ b/spec/requests/api/graphql/project/cluster_agents_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe 'Project.cluster_agents' do
post_graphql(query, current_user: current_user)
expect(graphql_data_at(:project, :cluster_agents, :nodes)).to match_array(
- agents.map { |agent| a_hash_including('id' => global_id_of(agent)) }
+ agents.map { |agent| a_graphql_entity_for(agent) }
)
end
@@ -62,9 +62,9 @@ RSpec.describe 'Project.cluster_agents' do
tokens = graphql_data_at(:project, :cluster_agents, :nodes, :tokens, :nodes)
expect(tokens).to match([
- a_hash_including('id' => global_id_of(token_3)),
- a_hash_including('id' => global_id_of(token_2)),
- a_hash_including('id' => global_id_of(token_1))
+ a_graphql_entity_for(token_3),
+ a_graphql_entity_for(token_2),
+ a_graphql_entity_for(token_1)
])
end
diff --git a/spec/requests/api/graphql/project/incident_management/timeline_events_spec.rb b/spec/requests/api/graphql/project/incident_management/timeline_events_spec.rb
new file mode 100644
index 00000000000..708fa96986c
--- /dev/null
+++ b/spec/requests/api/graphql/project/incident_management/timeline_events_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting incident timeline events' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:updated_by_user) { create(:user) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:another_incident) { create(:incident, project: project) }
+ let_it_be(:promoted_from_note) { create(:note, project: project, noteable: incident) }
+
+ let_it_be(:timeline_event) do
+ create(
+ :incident_management_timeline_event,
+ incident: incident,
+ project: project,
+ updated_by_user: updated_by_user,
+ promoted_from_note: promoted_from_note
+ )
+ end
+
+ let_it_be(:second_timeline_event) do
+ create(:incident_management_timeline_event, incident: incident, project: project)
+ end
+
+ let_it_be(:another_timeline_event) do
+ create(:incident_management_timeline_event, incident: another_incident, project: project)
+ end
+
+ let(:params) { { incident_id: incident.to_global_id.to_s } }
+
+ let(:timeline_event_fields) do
+ <<~QUERY
+ nodes {
+ id
+ author { id username }
+ updatedByUser { id username }
+ incident { id title }
+ note
+ noteHtml
+ promotedFromNote { id body }
+ editable
+ action
+ occurredAt
+ createdAt
+ updatedAt
+ }
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('incidentManagementTimelineEvents', params, timeline_event_fields)
+ )
+ end
+
+ let(:timeline_events) do
+ graphql_data.dig('project', 'incidentManagementTimelineEvents', 'nodes')
+ end
+
+ before do
+ project.add_guest(current_user)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns the correct number of timeline events' do
+ expect(timeline_events.count).to eq(2)
+ end
+
+ it 'returns the correct properties of the incident timeline events' do
+ expect(timeline_events.first).to include(
+ 'author' => {
+ 'id' => timeline_event.author.to_global_id.to_s,
+ 'username' => timeline_event.author.username
+ },
+ 'updatedByUser' => {
+ 'id' => updated_by_user.to_global_id.to_s,
+ 'username' => updated_by_user.username
+ },
+ 'incident' => {
+ 'id' => incident.to_global_id.to_s,
+ 'title' => incident.title
+ },
+ 'note' => timeline_event.note,
+ 'noteHtml' => timeline_event.note_html,
+ 'promotedFromNote' => {
+ 'id' => promoted_from_note.to_global_id.to_s,
+ 'body' => promoted_from_note.note
+ },
+ 'editable' => false,
+ 'action' => timeline_event.action,
+ 'occurredAt' => timeline_event.occurred_at.iso8601,
+ 'createdAt' => timeline_event.created_at.iso8601,
+ 'updatedAt' => timeline_event.updated_at.iso8601
+ )
+ end
+
+ context 'when filtering by id' do
+ let(:params) { { incident_id: incident.to_global_id.to_s, id: timeline_event.to_global_id.to_s } }
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('incidentManagementTimelineEvent', params, 'id occurredAt')
+ )
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns a single timeline event', :aggregate_failures do
+ single_timeline_event = graphql_data.dig('project', 'incidentManagementTimelineEvent')
+
+ expect(single_timeline_event).to include(
+ 'id' => timeline_event.to_global_id.to_s,
+ 'occurredAt' => timeline_event.occurred_at.iso8601
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb b/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb
index f544d78ecbb..8cda61f0628 100644
--- a/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb
+++ b/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb
@@ -71,11 +71,7 @@ RSpec.describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)
it 'finds all the designs as of the given version' do
post_query
- expect(data).to match(
- a_hash_including(
- 'id' => global_id_of(design_at_version),
- 'filename' => design.filename
- ))
+ expect(data).to match a_graphql_entity_for(design_at_version, filename: design.filename)
end
context 'when the current_user is not authorized' do
@@ -119,7 +115,8 @@ RSpec.describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)
let(:results) do
issue.designs.visible_at_version(version).map do |d|
dav = build(:design_at_version, design: d, version: version)
- { 'id' => global_id_of(dav), 'filename' => d.filename }
+
+ a_graphql_entity_for(dav, filename: d.filename)
end
end
@@ -132,8 +129,8 @@ RSpec.describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)
describe 'filtering' do
let(:designs) { issue.designs.sample(3) }
let(:filenames) { designs.map(&:filename) }
- let(:ids) do
- designs.map { |d| global_id_of(build(:design_at_version, design: d, version: version)) }
+ let(:expected_designs) do
+ designs.map { |d| a_graphql_entity_for(build(:design_at_version, design: d, version: version)) }
end
before do
@@ -144,7 +141,7 @@ RSpec.describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)
let(:dav_params) { { filenames: filenames } }
it 'finds the designs by filename' do
- expect(data.map { |e| e.dig('node', 'id') }).to match_array(ids)
+ expect(data.map { |e| e['node'] }).to match_array expected_designs
end
end
@@ -160,9 +157,9 @@ RSpec.describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)
describe 'pagination' do
let(:end_cursor) { graphql_data_at(*path_prefix, :designs_at_version, :page_info, :end_cursor) }
- let(:ids) do
+ let(:entities) do
::DesignManagement::Design.visible_at_version(version).order(:id).map do |d|
- global_id_of(build(:design_at_version, design: d, version: version))
+ a_graphql_entity_for(build(:design_at_version, design: d, version: version))
end
end
@@ -178,19 +175,19 @@ RSpec.describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)
let(:fields) { ['pageInfo { endCursor }', 'edges { node { id } }'] }
def response_values(data = graphql_data)
- data.dig(*path).map { |e| e.dig('node', 'id') }
+ data.dig(*path).map { |e| e['node'] }
end
it 'sorts designs for reliable pagination' do
post_graphql(query, current_user: current_user)
- expect(response_values).to match_array(ids.take(2))
+ expect(response_values).to match_array(entities.take(2))
post_graphql(cursored_query, current_user: current_user)
new_data = Gitlab::Json.parse(response.body).fetch('data')
- expect(response_values(new_data)).to match_array(ids.drop(2))
+ expect(response_values(new_data)).to match_array(entities.drop(2))
end
end
end
@@ -202,9 +199,7 @@ RSpec.describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)
end
let(:results) do
- version.designs.map do |design|
- { 'id' => global_id_of(design), 'filename' => design.filename }
- end
+ version.designs.map { |design| a_graphql_entity_for(design, :filename) }
end
it 'finds all the designs as of the given version' do
diff --git a/spec/requests/api/graphql/project/issue/designs/designs_spec.rb b/spec/requests/api/graphql/project/issue/designs/designs_spec.rb
index f0205319983..02bc9457c07 100644
--- a/spec/requests/api/graphql/project/issue/designs/designs_spec.rb
+++ b/spec/requests/api/graphql/project/issue/designs/designs_spec.rb
@@ -58,8 +58,8 @@ RSpec.describe 'Getting designs related to an issue' do
post_graphql(query, current_user: current_user)
- expect(design_response).to eq(
- 'id' => design.to_global_id.to_s,
+ expect(design_response).to match a_graphql_entity_for(
+ design,
'event' => 'CREATION',
'fullPath' => design.full_path,
'filename' => design.filename,
@@ -93,7 +93,7 @@ RSpec.describe 'Getting designs related to an issue' do
let(:end_cursor) { design_collection.dig('designs', 'pageInfo', 'endCursor') }
- let(:ids) { issue.designs.order(:id).map { |d| global_id_of(d) } }
+ let(:expected_designs) { issue.designs.order(:id).map { |d| a_graphql_entity_for(d) } }
let(:query) { make_query(designs_fragment(first: 2)) }
@@ -107,19 +107,19 @@ RSpec.describe 'Getting designs related to an issue' do
query_graphql_field(:designs, params, design_query_fields)
end
- def response_ids(data = graphql_data)
+ def response_designs(data = graphql_data)
path = %w[project issue designCollection designs edges]
- data.dig(*path).map { |e| e.dig('node', 'id') }
+ data.dig(*path).map { |e| e['node'] }
end
it 'sorts designs for reliable pagination' do
- expect(response_ids).to match_array(ids.take(2))
+ expect(response_designs).to match_array(expected_designs.take(2))
post_graphql(cursored_query, current_user: current_user)
new_data = Gitlab::Json.parse(response.body).fetch('data')
- expect(response_ids(new_data)).to match_array(ids.drop(2))
+ expect(response_designs(new_data)).to match_array(expected_designs.drop(2))
end
end
@@ -273,8 +273,10 @@ RSpec.describe 'Getting designs related to an issue' do
end
it 'returns the correct v432x230-sized design images' do
+ v0 = design.actions.most_recent.first.version
+
expect(design_nodes).to contain_exactly(
- a_hash_including('imageV432x230' => design_image_url(design, ref: version.sha, size: :v432x230)),
+ a_hash_including('imageV432x230' => design_image_url(design, ref: v0.sha, size: :v432x230)),
a_hash_including('imageV432x230' => design_image_url(second_design, ref: version.sha, size: :v432x230))
)
end
@@ -323,8 +325,10 @@ RSpec.describe 'Getting designs related to an issue' do
end
it 'returns the correct v432x230-sized design images' do
+ v0 = design.actions.most_recent.first.version
+
expect(design_nodes).to contain_exactly(
- a_hash_including('imageV432x230' => design_image_url(design, ref: version.sha, size: :v432x230)),
+ a_hash_including('imageV432x230' => design_image_url(design, ref: v0.sha, size: :v432x230)),
a_hash_including('imageV432x230' => design_image_url(second_design, ref: version.sha, size: :v432x230))
)
end
diff --git a/spec/requests/api/graphql/project/issue/designs/notes_spec.rb b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
index de2ace95757..3b1eb0b4b02 100644
--- a/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
+++ b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe 'Getting designs related to an issue' do
design_data = designs_data['nodes'].first
note_data = design_data['notes']['nodes'].first
- expect(note_data['id']).to eq(note.to_global_id.to_s)
+ expect(note_data).to match(a_graphql_entity_for(note))
end
def query(note_fields = all_graphql_fields_for(Note, max_depth: 1))
diff --git a/spec/requests/api/graphql/project/issue_spec.rb b/spec/requests/api/graphql/project/issue_spec.rb
index ddf63a8f2c9..2415e9ef60f 100644
--- a/spec/requests/api/graphql/project/issue_spec.rb
+++ b/spec/requests/api/graphql/project/issue_spec.rb
@@ -144,10 +144,7 @@ RSpec.describe 'Query.project(fullPath).issue(iid)' do
data = graphql_data.dig(*path)
- expect(data).to match(
- a_hash_including('id' => global_id_of(version),
- 'sha' => version.sha)
- )
+ expect(data).to match a_graphql_entity_for(version, :sha)
end
end
@@ -184,6 +181,6 @@ RSpec.describe 'Query.project(fullPath).issue(iid)' do
end
def id_hash(object)
- a_hash_including('id' => global_id_of(object))
+ a_graphql_entity_for(object)
end
end
diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb
index cefe88aafc8..d2f34080be3 100644
--- a/spec/requests/api/graphql/project/merge_request_spec.rb
+++ b/spec/requests/api/graphql/project/merge_request_spec.rb
@@ -66,7 +66,7 @@ RSpec.describe 'getting merge request information nested in a project' do
it 'includes reviewers' do
expected = merge_request.reviewers.map do |r|
- a_hash_including('id' => global_id_of(r), 'username' => r.username)
+ a_graphql_entity_for(r, :username)
end
post_graphql(query, current_user: current_user)
@@ -425,7 +425,7 @@ RSpec.describe 'getting merge request information nested in a project' do
other_users.each do |user|
assign_user(user)
- merge_request.merge_request_reviewers.find_or_create_by!(reviewer: user)
+ merge_request.merge_request_reviewers.find_or_create_by!(reviewer: user, state: :attention_requested)
end
expect { post_graphql(query) }.not_to exceed_query_limit(baseline)
@@ -466,7 +466,7 @@ RSpec.describe 'getting merge request information nested in a project' do
let(:can_update) { false }
def assign_user(user)
- merge_request.merge_request_reviewers.create!(reviewer: user)
+ merge_request.merge_request_reviewers.create!(reviewer: user, state: :attention_requested)
end
end
diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb
index 303748bc70e..5daec5543c0 100644
--- a/spec/requests/api/graphql/project/merge_requests_spec.rb
+++ b/spec/requests/api/graphql/project/merge_requests_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'getting merge request listings nested in a project' do
let_it_be(:current_user) { create(:user) }
let_it_be(:label) { create(:label, project: project) }
- let_it_be(:merge_request_a) do
+ let_it_be_with_reload(:merge_request_a) do
create(:labeled_merge_request, :unique_branches, source_project: project, labels: [label])
end
@@ -96,7 +96,7 @@ RSpec.describe 'getting merge request listings nested in a project' do
where(:field, :subfield, :is_connection) do
nested_fields_of('MergeRequest').flat_map do |name, field|
type = field_type(field)
- is_connection = type.name.ends_with?('Connection')
+ is_connection = type.graphql_name.ends_with?('Connection')
type = field_type(type.fields['nodes']) if is_connection
type.fields
@@ -412,6 +412,10 @@ RSpec.describe 'getting merge request listings nested in a project' do
describe 'sorting and pagination' do
let(:data_path) { [:project, :mergeRequests] }
+ def pagination_results_data(nodes)
+ nodes
+ end
+
def pagination_query(params)
graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY)
mergeRequests(#{params}) {
@@ -429,7 +433,7 @@ RSpec.describe 'getting merge request listings nested in a project' do
merge_request_c,
merge_request_e,
merge_request_a
- ].map { |mr| global_id_of(mr) }
+ ].map { |mr| a_graphql_entity_for(mr) }
end
before do
@@ -455,7 +459,7 @@ RSpec.describe 'getting merge request listings nested in a project' do
query = pagination_query(params)
post_graphql(query, current_user: current_user)
- expect(results.map { |item| item["id"] }).to eq(all_records.last(2))
+ expect(results).to match(all_records.last(2))
end
end
end
@@ -469,7 +473,7 @@ RSpec.describe 'getting merge request listings nested in a project' do
merge_request_c,
merge_request_e,
merge_request_a
- ].map { |mr| global_id_of(mr) }
+ ].map { |mr| a_graphql_entity_for(mr) }
end
before do
@@ -495,17 +499,19 @@ RSpec.describe 'getting merge request listings nested in a project' do
query = pagination_query(params)
post_graphql(query, current_user: current_user)
- expect(results.map { |item| item["id"] }).to eq(all_records.last(2))
+ expect(results).to match(all_records.last(2))
end
end
end
end
context 'when only the count is requested' do
+ let_it_be(:merged_at) { Time.new(2020, 1, 3) }
+
context 'when merged at filter is present' do
let_it_be(:merge_request) do
create(:merge_request, :unique_branches, source_project: project).tap do |mr|
- mr.metrics.update!(merged_at: Time.new(2020, 1, 3))
+ mr.metrics.update!(merged_at: merged_at, created_at: merged_at - 2.days)
end
end
@@ -522,12 +528,18 @@ RSpec.describe 'getting merge request listings nested in a project' do
it 'does not query the merge requests table for the count' do
query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
- queries = query_recorder.data.each_value.first[:occurrences]
+ queries = query_recorder.log
expect(queries).not_to include(match(/SELECT COUNT\(\*\) FROM "merge_requests"/))
expect(queries).to include(match(/SELECT COUNT\(\*\) FROM "merge_request_metrics"/))
end
context 'when total_time_to_merge and count is queried' do
+ let_it_be(:merge_request_2) do
+ create(:merge_request, :unique_branches, source_project: project).tap do |mr|
+ mr.metrics.update!(merged_at: merged_at, created_at: merged_at - 1.day)
+ end
+ end
+
let(:query) do
graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY)
mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0) {
@@ -537,11 +549,18 @@ RSpec.describe 'getting merge request listings nested in a project' do
QUERY
end
- it 'does not query the merge requests table for the total_time_to_merge' do
+ it 'uses the merge_request_metrics table for total_time_to_merge' do
query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
- queries = query_recorder.data.each_value.first[:occurrences]
- expect(queries).to include(match(/SELECT.+SUM.+FROM "merge_request_metrics" WHERE/))
+ expect(query_recorder.log).to include(match(/SELECT.+SUM.+FROM "merge_request_metrics" WHERE/))
+ end
+
+ it 'returns the correct total time to merge' do
+ post_graphql(query, current_user: current_user)
+
+ sum = graphql_data_at(:project, :merge_requests, :total_time_to_merge)
+
+ expect(sum).to eq(3.days.to_f)
end
end
diff --git a/spec/requests/api/graphql/project/milestones_spec.rb b/spec/requests/api/graphql/project/milestones_spec.rb
index 2fede4c7285..3e8948d83b1 100644
--- a/spec/requests/api/graphql/project/milestones_spec.rb
+++ b/spec/requests/api/graphql/project/milestones_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe 'getting milestone listings nested in a project' do
def result_list(expected)
expected.map do |milestone|
- a_hash_including('id' => global_id_of(milestone))
+ a_graphql_entity_for(milestone)
end
end
diff --git a/spec/requests/api/graphql/project/pipeline_spec.rb b/spec/requests/api/graphql/project/pipeline_spec.rb
index 73e02e2a4b1..ccf97918021 100644
--- a/spec/requests/api/graphql/project/pipeline_spec.rb
+++ b/spec/requests/api/graphql/project/pipeline_spec.rb
@@ -89,17 +89,16 @@ RSpec.describe 'getting pipeline information nested in a project' do
post_graphql(query, current_user: current_user)
expect(graphql_data_at(*path, :jobs, :nodes)).to contain_exactly(
- a_hash_including(
- 'name' => build_job.name,
- 'status' => build_job.status.upcase,
- 'duration' => build_job.duration
+ a_graphql_entity_for(
+ build_job, :name, :duration,
+ 'status' => build_job.status.upcase
),
- a_hash_including(
- 'id' => global_id_of(failed_build),
+ a_graphql_entity_for(
+ failed_build,
'status' => failed_build.status.upcase
),
- a_hash_including(
- 'id' => global_id_of(bridge),
+ a_graphql_entity_for(
+ bridge,
'status' => bridge.status.upcase
)
)
@@ -135,7 +134,7 @@ RSpec.describe 'getting pipeline information nested in a project' do
post_graphql(query, current_user: current_user, variables: variables)
expect(graphql_data_at(*path, :jobs, :nodes))
- .to contain_exactly(a_hash_including('id' => global_id_of(failed_build)))
+ .to contain_exactly(a_graphql_entity_for(failed_build))
end
end
@@ -166,7 +165,7 @@ RSpec.describe 'getting pipeline information nested in a project' do
end
let(:the_job) do
- a_hash_including('name' => build_job.name, 'id' => global_id_of(build_job))
+ a_graphql_entity_for(build_job, :name)
end
it 'can request a build by name' do
diff --git a/spec/requests/api/graphql/project/project_members_spec.rb b/spec/requests/api/graphql/project/project_members_spec.rb
index 315d44884ff..c3281b44954 100644
--- a/spec/requests/api/graphql/project/project_members_spec.rb
+++ b/spec/requests/api/graphql/project/project_members_spec.rb
@@ -60,7 +60,10 @@ RSpec.describe 'getting project members information' do
fetch_members(project: parent_project, args: { relations: [:DIRECT] })
expect(graphql_errors).to be_nil
- expect(graphql_data_at(:project, :project_members, :edges, :node)).to contain_exactly({ 'user' => { 'id' => global_id_of(user) } }, 'user' => nil)
+ expect(graphql_data_at(:project, :project_members, :edges, :node)).to contain_exactly(
+ a_graphql_entity_for(user: a_graphql_entity_for(user)),
+ { 'user' => nil }
+ )
end
end
@@ -238,7 +241,7 @@ RSpec.describe 'getting project members information' do
def expect_array_response(*items)
expect(response).to have_gitlab_http_status(:success)
- member_gids = graphql_data_at(:project, :project_members, :edges, :node, :user, :id)
- expect(member_gids).to match_array(items.map { |u| global_id_of(u) })
+ members = graphql_data_at(:project, :project_members, :edges, :node, :user)
+ expect(members).to match_array(items.map { |u| a_graphql_entity_for(u) })
end
end
diff --git a/spec/requests/api/graphql/project/release_spec.rb b/spec/requests/api/graphql/project/release_spec.rb
index 77abac4ef04..c4899dbb71e 100644
--- a/spec/requests/api/graphql/project/release_spec.rb
+++ b/spec/requests/api/graphql/project/release_spec.rb
@@ -77,10 +77,10 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
post_query
expected = release.milestones.order_by_dates_and_title.map do |milestone|
- { 'id' => global_id_of(milestone), 'title' => milestone.title }
+ a_graphql_entity_for(milestone, :title)
end
- expect(data).to eq(expected)
+ expect(data).to match(expected)
end
end
@@ -94,10 +94,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
it 'finds the author of the release' do
post_query
- expect(data).to eq(
- 'id' => global_id_of(release.author),
- 'username' => release.author.username
- )
+ expect(data).to match a_graphql_entity_for(release.author, :username)
end
end
@@ -142,13 +139,11 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
post_query
expected = release.links.map do |link|
- {
- 'id' => global_id_of(link),
- 'name' => link.name,
- 'url' => link.url,
+ a_graphql_entity_for(
+ link, :name, :url,
'external' => link.external?,
'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << "/downloads#{link.filepath}" : link.url
- }
+ )
end
expect(data).to match_array(expected)
@@ -218,10 +213,8 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
evidence = release.evidences.first.present
- expect(data["nodes"].first).to eq(
- 'id' => global_id_of(evidence),
- 'sha' => evidence.sha,
- 'filepath' => evidence.filepath,
+ expect(data["nodes"].first).to match a_graphql_entity_for(
+ evidence, :sha, :filepath,
'collectedAt' => evidence.collected_at.utc.iso8601
)
end
@@ -274,10 +267,10 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
post_query
expected = release.milestones.order_by_dates_and_title.map do |milestone|
- { 'id' => global_id_of(milestone), 'title' => milestone.title }
+ a_graphql_entity_for(milestone, :title)
end
- expect(data).to eq(expected)
+ expect(data).to match(expected)
end
end
@@ -291,10 +284,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
it 'finds the author of the release' do
post_query
- expect(data).to eq(
- 'id' => global_id_of(release.author),
- 'username' => release.author.username
- )
+ expect(data).to match a_graphql_entity_for(release.author, :username)
end
end
@@ -339,13 +329,11 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
post_query
expected = release.links.map do |link|
- {
- 'id' => global_id_of(link),
- 'name' => link.name,
- 'url' => link.url,
+ a_graphql_entity_for(
+ link, :name, :url,
'external' => true,
'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << "/downloads#{link.filepath}" : link.url
- }
+ )
end
expect(data).to match_array(expected)
diff --git a/spec/requests/api/graphql/project/terraform/state_spec.rb b/spec/requests/api/graphql/project/terraform/state_spec.rb
index 9f1d9ab204a..8f2d2cffef2 100644
--- a/spec/requests/api/graphql/project/terraform/state_spec.rb
+++ b/spec/requests/api/graphql/project/terraform/state_spec.rb
@@ -57,22 +57,22 @@ RSpec.describe 'query a single terraform state' do
it_behaves_like 'a working graphql query'
it 'returns terraform state data' do
- expect(data).to match(a_hash_including({
- 'id' => global_id_of(terraform_state),
- 'name' => terraform_state.name,
+ expect(data).to match a_graphql_entity_for(
+ terraform_state,
+ :name,
'lockedAt' => terraform_state.locked_at.iso8601,
'createdAt' => terraform_state.created_at.iso8601,
'updatedAt' => terraform_state.updated_at.iso8601,
- 'lockedByUser' => { 'id' => global_id_of(terraform_state.locked_by_user) },
- 'latestVersion' => {
- 'id' => eq(global_id_of(latest_version)),
+ 'lockedByUser' => a_graphql_entity_for(terraform_state.locked_by_user),
+ 'latestVersion' => a_graphql_entity_for(
+ latest_version,
'serial' => eq(latest_version.version),
'createdAt' => eq(latest_version.created_at.iso8601),
'updatedAt' => eq(latest_version.updated_at.iso8601),
- 'createdByUser' => { 'id' => eq(global_id_of(latest_version.created_by_user)) },
+ 'createdByUser' => a_graphql_entity_for(latest_version.created_by_user),
'job' => { 'name' => eq(latest_version.build.name) }
- }
- }))
+ )
+ )
end
context 'unauthorized users' do
diff --git a/spec/requests/api/graphql/project/terraform/states_spec.rb b/spec/requests/api/graphql/project/terraform/states_spec.rb
index 2879530acc5..a7ec6f69776 100644
--- a/spec/requests/api/graphql/project/terraform/states_spec.rb
+++ b/spec/requests/api/graphql/project/terraform/states_spec.rb
@@ -62,23 +62,22 @@ RSpec.describe 'query terraform states' do
)
)
- expect(data['nodes']).to contain_exactly({
- 'id' => global_id_of(terraform_state),
- 'name' => terraform_state.name,
+ expect(data['nodes']).to contain_exactly a_graphql_entity_for(
+ terraform_state, :name,
'lockedAt' => terraform_state.locked_at.iso8601,
'createdAt' => terraform_state.created_at.iso8601,
'updatedAt' => terraform_state.updated_at.iso8601,
- 'lockedByUser' => { 'id' => global_id_of(terraform_state.locked_by_user) },
- 'latestVersion' => {
- 'id' => eq(global_id_of(latest_version)),
+ 'lockedByUser' => a_graphql_entity_for(terraform_state.locked_by_user),
+ 'latestVersion' => a_graphql_entity_for(
+ latest_version,
'serial' => eq(latest_version.version),
'downloadPath' => eq(download_path),
'createdAt' => eq(latest_version.created_at.iso8601),
'updatedAt' => eq(latest_version.updated_at.iso8601),
- 'createdByUser' => { 'id' => eq(global_id_of(latest_version.created_by_user)) },
+ 'createdByUser' => a_graphql_entity_for(latest_version.created_by_user),
'job' => { 'name' => eq(latest_version.build.name) }
- }
- })
+ )
+ )
end
it 'returns count of terraform states' do
diff --git a/spec/requests/api/graphql/query_spec.rb b/spec/requests/api/graphql/query_spec.rb
index d650acc8354..4aa9c4b8254 100644
--- a/spec/requests/api/graphql/query_spec.rb
+++ b/spec/requests/api/graphql/query_spec.rb
@@ -76,10 +76,8 @@ RSpec.describe 'Query' do
it_behaves_like 'a working graphql query'
it_behaves_like 'a query that needs authorization'
- context 'the current user is able to read designs' do
- it 'fetches the expected data' do
- expect(query_result).to eq('id' => global_id_of(version), 'sha' => version.sha)
- end
+ it 'fetches the expected data' do
+ expect(query_result).to match a_graphql_entity_for(version, :sha)
end
end
@@ -106,13 +104,13 @@ RSpec.describe 'Query' do
context 'the current user is able to read designs' do
it 'fetches the expected data, including the correct associations' do
- expect(query_result).to eq(
- 'id' => global_id_of(design_at_version),
+ expect(query_result).to match a_graphql_entity_for(
+ design_at_version,
'filename' => design_at_version.design.filename,
- 'version' => { 'id' => global_id_of(version), 'sha' => version.sha },
- 'design' => { 'id' => global_id_of(design) },
+ 'version' => a_graphql_entity_for(version, :sha),
+ 'design' => a_graphql_entity_for(design),
'issue' => { 'title' => issue.title, 'iid' => issue.iid.to_s },
- 'project' => { 'id' => global_id_of(project), 'fullPath' => project.full_path }
+ 'project' => a_graphql_entity_for(project, :full_path)
)
end
end
diff --git a/spec/requests/api/graphql/user/starred_projects_query_spec.rb b/spec/requests/api/graphql/user/starred_projects_query_spec.rb
index a8c087d1fbf..37a85b98e5f 100644
--- a/spec/requests/api/graphql/user/starred_projects_query_spec.rb
+++ b/spec/requests/api/graphql/user/starred_projects_query_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe 'Getting starredProjects of the user' do
it 'found only public project' do
expect(starred_projects).to contain_exactly(
- a_hash_including('id' => global_id_of(project_a))
+ a_graphql_entity_for(project_a)
)
end
@@ -51,9 +51,9 @@ RSpec.describe 'Getting starredProjects of the user' do
it 'found all projects' do
expect(starred_projects).to contain_exactly(
- a_hash_including('id' => global_id_of(project_a)),
- a_hash_including('id' => global_id_of(project_b)),
- a_hash_including('id' => global_id_of(project_c))
+ a_graphql_entity_for(project_a),
+ a_graphql_entity_for(project_b),
+ a_graphql_entity_for(project_c)
)
end
end
@@ -69,8 +69,8 @@ RSpec.describe 'Getting starredProjects of the user' do
it 'finds public and member projects' do
expect(starred_projects).to contain_exactly(
- a_hash_including('id' => global_id_of(project_a)),
- a_hash_including('id' => global_id_of(project_b))
+ a_graphql_entity_for(project_a),
+ a_graphql_entity_for(project_b)
)
end
end
@@ -93,9 +93,9 @@ RSpec.describe 'Getting starredProjects of the user' do
it 'finds all projects starred by the user, which the current user has access to' do
expect(starred_projects).to contain_exactly(
- a_hash_including('id' => global_id_of(project_a)),
- a_hash_including('id' => global_id_of(project_b)),
- a_hash_including('id' => global_id_of(project_c))
+ a_graphql_entity_for(project_a),
+ a_graphql_entity_for(project_b),
+ a_graphql_entity_for(project_c)
)
end
end
diff --git a/spec/requests/api/graphql/user_query_spec.rb b/spec/requests/api/graphql/user_query_spec.rb
index 1cba3674d25..8f286180617 100644
--- a/spec/requests/api/graphql/user_query_spec.rb
+++ b/spec/requests/api/graphql/user_query_spec.rb
@@ -91,11 +91,11 @@ RSpec.describe 'getting user information' do
presenter = UserPresenter.new(user)
expect(graphql_data['user']).to match(
- a_hash_including(
- 'id' => global_id_of(user),
+ a_graphql_entity_for(
+ user,
+ :username,
'state' => presenter.state,
'name' => presenter.name,
- 'username' => presenter.username,
'webUrl' => presenter.web_url,
'avatarUrl' => presenter.avatar_url,
'email' => presenter.public_email,
@@ -121,9 +121,9 @@ RSpec.describe 'getting user information' do
it 'can be found' do
expect(assigned_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(assigned_mr)),
- a_hash_including('id' => global_id_of(assigned_mr_b)),
- a_hash_including('id' => global_id_of(assigned_mr_c))
+ a_graphql_entity_for(assigned_mr),
+ a_graphql_entity_for(assigned_mr_b),
+ a_graphql_entity_for(assigned_mr_c)
)
end
@@ -145,7 +145,7 @@ RSpec.describe 'getting user information' do
it 'selects the correct MRs' do
expect(assigned_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(assigned_mr_b))
+ a_graphql_entity_for(assigned_mr_b)
)
end
end
@@ -157,8 +157,8 @@ RSpec.describe 'getting user information' do
it 'selects the correct MRs' do
expect(assigned_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(assigned_mr_b)),
- a_hash_including('id' => global_id_of(assigned_mr_c))
+ a_graphql_entity_for(assigned_mr_b),
+ a_graphql_entity_for(assigned_mr_c)
)
end
end
@@ -169,7 +169,7 @@ RSpec.describe 'getting user information' do
it 'finds the authored mrs' do
expect(assigned_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(assigned_mr_b))
+ a_graphql_entity_for(assigned_mr_b)
)
end
end
@@ -185,8 +185,8 @@ RSpec.describe 'getting user information' do
post_graphql(query, current_user: current_user)
expect(assigned_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(assigned_mr_b)),
- a_hash_including('id' => global_id_of(assigned_mr_c))
+ a_graphql_entity_for(assigned_mr_b),
+ a_graphql_entity_for(assigned_mr_c)
)
end
end
@@ -212,9 +212,9 @@ RSpec.describe 'getting user information' do
it 'can be found' do
expect(reviewed_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(reviewed_mr)),
- a_hash_including('id' => global_id_of(reviewed_mr_b)),
- a_hash_including('id' => global_id_of(reviewed_mr_c))
+ a_graphql_entity_for(reviewed_mr),
+ a_graphql_entity_for(reviewed_mr_b),
+ a_graphql_entity_for(reviewed_mr_c)
)
end
@@ -236,7 +236,7 @@ RSpec.describe 'getting user information' do
it 'selects the correct MRs' do
expect(reviewed_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(reviewed_mr_b))
+ a_graphql_entity_for(reviewed_mr_b)
)
end
end
@@ -248,8 +248,8 @@ RSpec.describe 'getting user information' do
it 'selects the correct MRs' do
expect(reviewed_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(reviewed_mr_b)),
- a_hash_including('id' => global_id_of(reviewed_mr_c))
+ a_graphql_entity_for(reviewed_mr_b),
+ a_graphql_entity_for(reviewed_mr_c)
)
end
end
@@ -260,7 +260,7 @@ RSpec.describe 'getting user information' do
it 'finds the authored mrs' do
expect(reviewed_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(reviewed_mr_b))
+ a_graphql_entity_for(reviewed_mr_b)
)
end
end
@@ -275,7 +275,7 @@ RSpec.describe 'getting user information' do
post_graphql(query, current_user: current_user)
expect(reviewed_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(reviewed_mr_c))
+ a_graphql_entity_for(reviewed_mr_c)
)
end
end
@@ -301,9 +301,9 @@ RSpec.describe 'getting user information' do
it 'can be found' do
expect(authored_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(authored_mr)),
- a_hash_including('id' => global_id_of(authored_mr_b)),
- a_hash_including('id' => global_id_of(authored_mr_c))
+ a_graphql_entity_for(authored_mr),
+ a_graphql_entity_for(authored_mr_b),
+ a_graphql_entity_for(authored_mr_c)
)
end
@@ -329,8 +329,8 @@ RSpec.describe 'getting user information' do
post_graphql(query, current_user: current_user)
expect(authored_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(authored_mr)),
- a_hash_including('id' => global_id_of(authored_mr_c))
+ a_graphql_entity_for(authored_mr),
+ a_graphql_entity_for(authored_mr_c)
)
end
end
@@ -346,8 +346,8 @@ RSpec.describe 'getting user information' do
post_graphql(query, current_user: current_user)
expect(authored_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(authored_mr_b)),
- a_hash_including('id' => global_id_of(authored_mr_c))
+ a_graphql_entity_for(authored_mr_b),
+ a_graphql_entity_for(authored_mr_c)
)
end
end
@@ -359,7 +359,7 @@ RSpec.describe 'getting user information' do
it 'selects the correct MRs' do
expect(authored_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(authored_mr_b))
+ a_graphql_entity_for(authored_mr_b)
)
end
end
@@ -371,8 +371,8 @@ RSpec.describe 'getting user information' do
it 'selects the correct MRs' do
expect(authored_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(authored_mr_b)),
- a_hash_including('id' => global_id_of(authored_mr_c))
+ a_graphql_entity_for(authored_mr_b),
+ a_graphql_entity_for(authored_mr_c)
)
end
end
@@ -417,7 +417,7 @@ RSpec.describe 'getting user information' do
it 'can be found' do
expect(group_memberships).to include(
- a_hash_including('id' => global_id_of(membership_a))
+ a_graphql_entity_for(membership_a)
)
end
end
@@ -440,7 +440,7 @@ RSpec.describe 'getting user information' do
it 'can be found' do
expect(project_memberships).to include(
- a_hash_including('id' => global_id_of(membership_a))
+ a_graphql_entity_for(membership_a)
)
end
end
@@ -460,7 +460,7 @@ RSpec.describe 'getting user information' do
it 'can be found' do
expect(authored_mrs).to include(
- a_hash_including('id' => global_id_of(authored_mr))
+ a_graphql_entity_for(authored_mr)
)
end
end
@@ -480,9 +480,9 @@ RSpec.describe 'getting user information' do
it 'can be found' do
expect(assigned_mrs).to contain_exactly(
- a_hash_including('id' => global_id_of(assigned_mr)),
- a_hash_including('id' => global_id_of(assigned_mr_b)),
- a_hash_including('id' => global_id_of(assigned_mr_c))
+ a_graphql_entity_for(assigned_mr),
+ a_graphql_entity_for(assigned_mr_b),
+ a_graphql_entity_for(assigned_mr_c)
)
end
end
diff --git a/spec/requests/api/graphql/users_spec.rb b/spec/requests/api/graphql/users_spec.rb
index fe824834a2c..79ee3c2cb57 100644
--- a/spec/requests/api/graphql/users_spec.rb
+++ b/spec/requests/api/graphql/users_spec.rb
@@ -72,12 +72,12 @@ RSpec.describe 'Users' do
post_query
expect(graphql_data.dig('users', 'nodes')).to include(
- { "id" => user0.to_global_id.to_s },
- { "id" => user1.to_global_id.to_s },
- { "id" => user2.to_global_id.to_s },
- { "id" => user3.to_global_id.to_s },
- { "id" => admin.to_global_id.to_s },
- { "id" => another_admin.to_global_id.to_s }
+ a_graphql_entity_for(user0),
+ a_graphql_entity_for(user1),
+ a_graphql_entity_for(user2),
+ a_graphql_entity_for(user3),
+ a_graphql_entity_for(admin),
+ a_graphql_entity_for(another_admin)
)
end
end
@@ -91,15 +91,15 @@ RSpec.describe 'Users' do
post_graphql(query, current_user: current_user)
expect(graphql_data.dig('users', 'nodes')).to include(
- { "id" => another_admin.to_global_id.to_s },
- { "id" => admin.to_global_id.to_s }
+ a_graphql_entity_for(another_admin),
+ a_graphql_entity_for(admin)
)
expect(graphql_data.dig('users', 'nodes')).not_to include(
- { "id" => user0.to_global_id.to_s },
- { "id" => user1.to_global_id.to_s },
- { "id" => user2.to_global_id.to_s },
- { "id" => user3.to_global_id.to_s }
+ a_graphql_entity_for(user0),
+ a_graphql_entity_for(user1),
+ a_graphql_entity_for(user2),
+ a_graphql_entity_for(user3)
)
end
end
@@ -114,7 +114,7 @@ RSpec.describe 'Users' do
end
context 'when sorting by created_at' do
- let_it_be(:ascending_users) { [user3, user2, user1, user0].map { |u| global_id_of(u) } }
+ let_it_be(:ascending_users) { [user3, user2, user1, user0].map { |u| global_id_of(u).to_s } }
context 'when ascending' do
it_behaves_like 'sorted paginated query' do
diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb
index bc5a8b3e006..5b34c21989a 100644
--- a/spec/requests/api/graphql/work_item_spec.rb
+++ b/spec/requests/api/graphql/work_item_spec.rb
@@ -33,7 +33,8 @@ RSpec.describe 'Query.work_item(id)' do
'lockVersion' => work_item.lock_version,
'state' => "OPEN",
'title' => work_item.title,
- 'workItemType' => hash_including('id' => work_item.work_item_type.to_gid.to_s)
+ 'workItemType' => hash_including('id' => work_item.work_item_type.to_gid.to_s),
+ 'userPermissions' => { 'readWorkItem' => true, 'updateWorkItem' => true, 'deleteWorkItem' => false }
)
end
diff --git a/spec/requests/api/group_container_repositories_spec.rb b/spec/requests/api/group_container_repositories_spec.rb
index bf29bd91414..413c37eaed9 100644
--- a/spec/requests/api/group_container_repositories_spec.rb
+++ b/spec/requests/api/group_container_repositories_spec.rb
@@ -37,13 +37,10 @@ RSpec.describe API::GroupContainerRepositories do
let(:url) { "/groups/#{group.id}/registry/repositories" }
let(:snowplow_gitlab_standard_context) { { user: api_user, namespace: group } }
- subject { get api(url, api_user), params: params }
+ subject { get api(url, api_user) }
it_behaves_like 'rejected container repository access', :guest, :forbidden
it_behaves_like 'rejected container repository access', :anonymous, :not_found
- it_behaves_like 'handling network errors with the container registry' do
- let(:params) { { tags: true } }
- end
it_behaves_like 'returns repositories for allowed users', :reporter, 'group' do
let(:object) { group }
diff --git a/spec/requests/api/group_milestones_spec.rb b/spec/requests/api/group_milestones_spec.rb
index 2312d35c815..da84e98b905 100644
--- a/spec/requests/api/group_milestones_spec.rb
+++ b/spec/requests/api/group_milestones_spec.rb
@@ -85,7 +85,7 @@ RSpec.describe API::GroupMilestones do
def setup_for_group
context_group.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- context_group.add_developer(user)
+ context_group.add_reporter(user)
public_project.update!(namespace: context_group)
context_group.reload
end
diff --git a/spec/requests/api/import_bitbucket_server_spec.rb b/spec/requests/api/import_bitbucket_server_spec.rb
index 970416c7444..8ab41f49549 100644
--- a/spec/requests/api/import_bitbucket_server_spec.rb
+++ b/spec/requests/api/import_bitbucket_server_spec.rb
@@ -9,7 +9,15 @@ RSpec.describe API::ImportBitbucketServer do
let(:secret) { "sekrettt" }
let(:project_key) { 'TES' }
let(:repo_slug) { 'vim' }
- let(:repo) { { name: 'vim' } }
+ let(:repo) do
+ double('repo',
+ name: repo_slug,
+ browse_url: "#{base_uri}/projects/#{project_key}/repos/#{repo_slug}/browse",
+ clone_url: "#{base_uri}/scm/#{project_key}/#{repo_slug}.git",
+ description: 'provider',
+ visibility_level: Gitlab::VisibilityLevel::PUBLIC
+ )
+ end
describe "POST /import/bitbucket_server" do
context 'with no optional parameters' do
@@ -20,7 +28,7 @@ RSpec.describe API::ImportBitbucketServer do
before do
Grape::Endpoint.before_each do |endpoint|
allow(endpoint).to receive(:client).and_return(client.as_null_object)
- allow(client).to receive(:repo).with(project_key, repo_slug).and_return(double(name: repo_slug))
+ allow(client).to receive(:repo).with(project_key, repo_slug).and_return(repo)
end
end
diff --git a/spec/requests/api/import_github_spec.rb b/spec/requests/api/import_github_spec.rb
index f0c4fcc4f29..7de72de3940 100644
--- a/spec/requests/api/import_github_spec.rb
+++ b/spec/requests/api/import_github_spec.rb
@@ -16,7 +16,11 @@ RSpec.describe API::ImportGithub do
double('provider',
name: 'vim',
full_name: "#{provider_username}/vim",
- owner: double('provider', login: provider_username)
+ owner: double('provider', login: provider_username),
+ description: 'provider',
+ private: false,
+ clone_url: 'https://fake.url/vim.git',
+ has_wiki?: true
)
end
diff --git a/spec/requests/api/integrations/jira_connect/subscriptions_spec.rb b/spec/requests/api/integrations/jira_connect/subscriptions_spec.rb
new file mode 100644
index 00000000000..86f8992a624
--- /dev/null
+++ b/spec/requests/api/integrations/jira_connect/subscriptions_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Integrations::JiraConnect::Subscriptions do
+ describe 'POST /integrations/jira_connect/subscriptions' do
+ subject(:post_subscriptions) { post api('/integrations/jira_connect/subscriptions') }
+
+ it 'returns 401' do
+ post_subscriptions
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ context 'with user token' do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+
+ subject(:post_subscriptions) do
+ post api('/integrations/jira_connect/subscriptions', user), params: { jwt: jwt, namespace_path: group.path }
+ end
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(jira_connect_oauth: false)
+ end
+
+ let(:jwt) { '123' }
+
+ it 'returns 404' do
+ post_subscriptions
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'with invalid JWT' do
+ let(:jwt) { '123' }
+
+ it 'returns 401' do
+ post_subscriptions
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'with valid JWT' do
+ let_it_be(:installation) { create(:jira_connect_installation) }
+ let_it_be(:user) { create(:user) }
+
+ let(:claims) { { iss: installation.client_key, qsh: 'context-qsh', sub: 1234 } }
+ let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) }
+ let(:jira_user) { { 'groups' => { 'items' => [{ 'name' => jira_group_name }] } } }
+ let(:jira_group_name) { 'site-admins' }
+
+ before do
+ WebMock
+ .stub_request(:get, "#{installation.base_url}/rest/api/3/user?accountId=1234&expand=groups")
+ .to_return(body: jira_user.to_json, status: 200, headers: { 'Content-Type' => 'application/json' })
+ end
+
+ it 'returns 401 if the user does not have access to the group' do
+ post_subscriptions
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ context 'user has access to the group' do
+ before do
+ group.add_maintainer(user)
+ end
+
+ it 'creates a subscription' do
+ expect { post_subscriptions }.to change { installation.subscriptions.count }.from(0).to(1)
+ end
+
+ it 'returns 201' do
+ post_subscriptions
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb
index 2b7963eadab..acfe476a864 100644
--- a/spec/requests/api/internal/base_spec.rb
+++ b/spec/requests/api/internal/base_spec.rb
@@ -612,30 +612,6 @@ RSpec.describe API::Internal::Base do
expect(json_response["gitaly"]["features"]).to eq('gitaly-feature-mep-mep' => 'false')
end
end
-
- context "with a sidechannels enabled for a project" do
- before do
- stub_feature_flags(gitlab_shell_upload_pack_sidechannel: project)
- end
-
- it "has the use_sidechannel field set to true for that project" do
- pull(key, project)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response["gl_repository"]).to eq("project-#{project.id}")
- expect(json_response["gitaly"]["use_sidechannel"]).to eq(true)
- end
-
- it "has the use_sidechannel field set to false for other projects" do
- other_project = create(:project, :public, :repository)
-
- pull(key, other_project)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response["gl_repository"]).to eq("project-#{other_project.id}")
- expect(json_response["gitaly"]["use_sidechannel"]).to eq(false)
- end
- end
end
context "git push" do
@@ -826,13 +802,13 @@ RSpec.describe API::Internal::Base do
context 'git pull' do
context 'with a key that has expired' do
- let(:key) { create(:key, user: user, expires_at: 2.days.ago) }
+ let(:key) { create(:key, :expired, user: user) }
- it 'includes the `key expired` message in the response' do
+ it 'includes the `key expired` message in the response and fails' do
pull(key, project)
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['gl_console_messages']).to eq(['INFO: Your SSH key has expired. Please generate a new key.'])
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(json_response['message']).to eq('Your SSH key has expired.')
end
end
@@ -1490,6 +1466,89 @@ RSpec.describe API::Internal::Base do
subject
expect(json_response['success']).to be_falsey
+ expect(json_response['message']).to eq 'Feature is not available'
+ end
+ end
+
+ describe 'POST /internal/two_factor_manual_otp_check' do
+ let(:key_id) { key.id }
+ let(:otp) { '123456'}
+
+ subject do
+ post api('/internal/two_factor_manual_otp_check'),
+ params: {
+ secret_token: secret_token,
+ key_id: key_id,
+ otp_attempt: otp
+ }
+ end
+
+ it 'is not available' do
+ subject
+
+ expect(json_response['success']).to be_falsey
+ expect(json_response['message']).to eq 'Feature is not available'
+ end
+ end
+
+ describe 'POST /internal/two_factor_push_otp_check' do
+ let(:key_id) { key.id }
+ let(:otp) { '123456'}
+
+ subject do
+ post api('/internal/two_factor_push_otp_check'),
+ params: {
+ secret_token: secret_token,
+ key_id: key_id,
+ otp_attempt: otp
+ }
+ end
+
+ it 'is not available' do
+ subject
+
+ expect(json_response['success']).to be_falsey
+ expect(json_response['message']).to eq 'Feature is not available'
+ end
+ end
+
+ describe 'POST /internal/two_factor_manual_otp_check' do
+ let(:key_id) { key.id }
+ let(:otp) { '123456'}
+
+ subject do
+ post api('/internal/two_factor_manual_otp_check'),
+ params: {
+ secret_token: secret_token,
+ key_id: key_id,
+ otp_attempt: otp
+ }
+ end
+
+ it 'is not available' do
+ subject
+
+ expect(json_response['success']).to be_falsey
+ end
+ end
+
+ describe 'POST /internal/two_factor_push_otp_check' do
+ let(:key_id) { key.id }
+ let(:otp) { '123456'}
+
+ subject do
+ post api('/internal/two_factor_push_otp_check'),
+ params: {
+ secret_token: secret_token,
+ key_id: key_id,
+ otp_attempt: otp
+ }
+ end
+
+ it 'is not available' do
+ subject
+
+ expect(json_response['success']).to be_falsey
end
end
diff --git a/spec/requests/api/internal/container_registry/migration_spec.rb b/spec/requests/api/internal/container_registry/migration_spec.rb
index 35113c66f11..db2918e65f1 100644
--- a/spec/requests/api/internal/container_registry/migration_spec.rb
+++ b/spec/requests/api/internal/container_registry/migration_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Internal::ContainerRegistry::Migration do
+RSpec.describe API::Internal::ContainerRegistry::Migration, :aggregate_failures do
let_it_be_with_reload(:repository) { create(:container_repository) }
let(:secret_token) { 'secret_token' }
@@ -127,6 +127,12 @@ RSpec.describe API::Internal::ContainerRegistry::Migration do
it_behaves_like 'updating the repository migration status', from: 'pre_importing', to: 'import_aborted'
end
+
+ context 'with repository in unabortable migration state' do
+ let(:repository) { create(:container_repository, :import_skipped) }
+
+ it_behaves_like 'returning an error', with_message: 'Wrong migration state (import_skipped)'
+ end
end
end
@@ -147,6 +153,17 @@ RSpec.describe API::Internal::ContainerRegistry::Migration do
it_behaves_like 'returning an error', returning_status: :not_found
end
+
+ context 'query read location' do
+ it 'reads from the primary' do
+ expect(ContainerRepository).to receive(:find_by_path!).and_wrap_original do |m, *args|
+ expect(::Gitlab::Database::LoadBalancing::Session.current.use_primary?).to eq(true)
+ m.call(*args)
+ end
+
+ subject
+ end
+ end
end
context 'with an invalid sent token' do
diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb
index ef7f5ee87dc..5d8ed3dd0f5 100644
--- a/spec/requests/api/lint_spec.rb
+++ b/spec/requests/api/lint_spec.rb
@@ -123,6 +123,7 @@ RSpec.describe API::Lint do
expect(json_response['status']).to eq('valid')
expect(json_response['warnings']).to match_array([])
expect(json_response['errors']).to match_array([])
+ expect(json_response['includes']).to eq([])
end
it 'outputs expanded yaml content' do
@@ -153,19 +154,6 @@ RSpec.describe API::Lint do
end
end
- context 'with valid .gitlab-ci.yml using deprecated keywords' do
- let(:yaml_content) { { job: { script: 'ls', type: 'test' }, types: ['test'] }.to_yaml }
-
- it 'passes validation but returns warnings' do
- post api('/ci/lint', api_user), params: { content: yaml_content }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['status']).to eq('valid')
- expect(json_response['warnings']).not_to be_empty
- expect(json_response['errors']).to match_array([])
- end
- end
-
context 'with an invalid .gitlab-ci.yml' do
context 'with invalid syntax' do
let(:yaml_content) { 'invalid content' }
@@ -177,6 +165,7 @@ RSpec.describe API::Lint do
expect(json_response['status']).to eq('invalid')
expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq(['Invalid configuration format'])
+ expect(json_response['includes']).to eq(nil)
end
it 'outputs expanded yaml content' do
@@ -204,6 +193,7 @@ RSpec.describe API::Lint do
expect(json_response['status']).to eq('invalid')
expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
+ expect(json_response['includes']).to eq([])
end
it 'outputs expanded yaml content' do
@@ -262,6 +252,17 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
expect(json_response['merged_yaml']).to eq(expected_yaml)
+ expect(json_response['includes']).to contain_exactly(
+ {
+ 'type' => 'local',
+ 'location' => 'another-gitlab-ci.yml',
+ 'blob' => "http://localhost/#{project.full_path}/-/blob/#{project.commit.sha}/another-gitlab-ci.yml",
+ 'raw' => "http://localhost/#{project.full_path}/-/raw/#{project.commit.sha}/another-gitlab-ci.yml",
+ 'extra' => {},
+ 'context_project' => project.full_path,
+ 'context_sha' => project.commit.sha
+ }
+ )
expect(json_response['valid']).to eq(true)
expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq([])
@@ -274,6 +275,7 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['merged_yaml']).to eq(yaml_content)
+ expect(json_response['includes']).to eq([])
expect(json_response['valid']).to eq(false)
expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
@@ -327,6 +329,7 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['merged_yaml']).to eq(nil)
+ expect(json_response['includes']).to eq(nil)
expect(json_response['valid']).to eq(false)
expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq(['Insufficient permissions to create a new pipeline'])
@@ -539,6 +542,17 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
expect(json_response['merged_yaml']).to eq(expected_yaml)
+ expect(json_response['includes']).to contain_exactly(
+ {
+ 'type' => 'local',
+ 'location' => 'another-gitlab-ci.yml',
+ 'blob' => "http://localhost/#{project.full_path}/-/blob/#{project.commit.sha}/another-gitlab-ci.yml",
+ 'raw' => "http://localhost/#{project.full_path}/-/raw/#{project.commit.sha}/another-gitlab-ci.yml",
+ 'extra' => {},
+ 'context_project' => project.full_path,
+ 'context_sha' => project.commit.sha
+ }
+ )
expect(json_response['valid']).to eq(true)
expect(json_response['errors']).to eq([])
end
@@ -550,6 +564,7 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['merged_yaml']).to eq(yaml_content)
+ expect(json_response['includes']).to eq([])
expect(json_response['valid']).to eq(false)
expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
end
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 6bacb3a59b2..0db42e7439c 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -543,7 +543,7 @@ RSpec.describe API::Members do
end
it 'returns 409 if member does not exist' do
- put api("/#{source_type.pluralize}/#{source.id}/members/123", maintainer),
+ put api("/#{source_type.pluralize}/#{source.id}/members/#{non_existing_record_id}", maintainer),
params: { access_level: Member::MAINTAINER }
expect(response).to have_gitlab_http_status(:not_found)
@@ -618,7 +618,7 @@ RSpec.describe API::Members do
end
it 'returns 404 if member does not exist' do
- delete api("/#{source_type.pluralize}/#{source.id}/members/123", maintainer)
+ delete api("/#{source_type.pluralize}/#{source.id}/members/#{non_existing_record_id}", maintainer)
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index b1183bb10fa..a7ede7f4150 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -27,10 +27,10 @@ RSpec.describe API::MergeRequests do
shared_context 'with merge requests' do
let_it_be(:milestone1) { create(:milestone, title: '0.9', project: project) }
- let_it_be(:merge_request) { create(:merge_request, :simple, milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
- let_it_be(:merge_request_closed) { create(:merge_request, state: "closed", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) }
- let_it_be(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignees: [user], source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') }
- let_it_be(:merge_request_locked) { create(:merge_request, state: "locked", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Locked test", created_at: base_time + 1.second) }
+ let_it_be(:merge_request) { create(:merge_request, :simple, milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time, updated_at: base_time + 3.hours) }
+ let_it_be(:merge_request_closed) { create(:merge_request, state: "closed", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second, updated_at: base_time) }
+ let_it_be(:merge_request_locked) { create(:merge_request, state: "locked", milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, title: "Locked test", created_at: base_time + 1.second, updated_at: base_time + 2.hours) }
+ let_it_be(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignees: [user], source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, updated_at: base_time + 1.hour, merge_commit_sha: '9999999999999999999999999999999999999999') }
let_it_be(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") }
let_it_be(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") }
end
@@ -348,19 +348,14 @@ RSpec.describe API::MergeRequests do
end
context 'with ordering' do
- before do
- @mr_later = mr_with_later_created_and_updated_at_time
- @mr_earlier = mr_with_earlier_created_and_updated_at_time
- end
-
it 'returns an array of merge_requests in ascending order' do
path = endpoint_path + '?sort=asc'
get api(path, user)
expect_paginated_array_response([
- merge_request_closed.id, merge_request_locked.id,
- merge_request_merged.id, merge_request.id
+ merge_request.id, merge_request_closed.id,
+ merge_request_locked.id, merge_request_merged.id
])
response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort)
@@ -372,42 +367,28 @@ RSpec.describe API::MergeRequests do
get api(path, user)
expect_paginated_array_response([
- merge_request.id, merge_request_merged.id,
- merge_request_locked.id, merge_request_closed.id
+ merge_request_merged.id, merge_request_locked.id,
+ merge_request_closed.id, merge_request.id
])
response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort.reverse)
end
context '2 merge requests with equal created_at' do
- let!(:closed_mr2) do
- create :merge_request,
- state: 'closed',
- milestone: milestone1,
- author: user,
- assignees: [user],
- source_project: project,
- target_project: project,
- title: "Test",
- created_at: @mr_earlier.created_at
- end
-
it 'page breaks first page correctly' do
- get api("#{endpoint_path}?sort=desc&per_page=4", user)
+ get api("#{endpoint_path}?sort=desc&per_page=2", user)
response_ids = json_response.map { |merge_request| merge_request['id'] }
- expect(response_ids).to include(closed_mr2.id)
- expect(response_ids).not_to include(@mr_earlier.id)
+ expect(response_ids).to contain_exactly(merge_request_merged.id, merge_request_locked.id)
end
it 'page breaks second page correctly' do
- get api("#{endpoint_path}?sort=desc&per_page=4&page=2", user)
+ get api("#{endpoint_path}?sort=desc&per_page=2&page=2", user)
response_ids = json_response.map { |merge_request| merge_request['id'] }
- expect(response_ids).not_to include(closed_mr2.id)
- expect(response_ids).to include(@mr_earlier.id)
+ expect(response_ids).to contain_exactly(merge_request_closed.id, merge_request.id)
end
end
@@ -430,8 +411,8 @@ RSpec.describe API::MergeRequests do
get api(path, user)
expect_paginated_array_response([
- merge_request_closed.id, merge_request_locked.id,
- merge_request_merged.id, merge_request.id
+ merge_request.id, merge_request_closed.id,
+ merge_request_locked.id, merge_request_merged.id
])
response_dates = json_response.map { |merge_request| merge_request['created_at'] }
expect(response_dates).to eq(response_dates.sort)
@@ -3379,20 +3360,4 @@ RSpec.describe API::MergeRequests do
include_examples 'time tracking endpoints', 'merge_request'
end
-
- def mr_with_later_created_and_updated_at_time
- merge_request
- merge_request.created_at += 1.hour
- merge_request.updated_at += 30.minutes
- merge_request.save!
- merge_request
- end
-
- def mr_with_earlier_created_and_updated_at_time
- merge_request_closed
- merge_request_closed.created_at -= 1.hour
- merge_request_closed.updated_at -= 30.minutes
- merge_request_closed.save!
- merge_request_closed
- end
end
diff --git a/spec/requests/api/personal_access_tokens_spec.rb b/spec/requests/api/personal_access_tokens_spec.rb
index 0ff2c46e693..01f69f0aae2 100644
--- a/spec/requests/api/personal_access_tokens_spec.rb
+++ b/spec/requests/api/personal_access_tokens_spec.rb
@@ -73,6 +73,54 @@ RSpec.describe API::PersonalAccessTokens do
end
end
+ describe 'DELETE /personal_access_tokens/self' do
+ let(:path) { '/personal_access_tokens/self' }
+ let(:token) { create(:personal_access_token, user: current_user) }
+
+ subject { delete api(path, current_user, personal_access_token: token) }
+
+ shared_examples 'revoking token succeeds' do
+ it 'revokes token' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(token.reload).to be_revoked
+ end
+ end
+
+ shared_examples 'revoking token denied' do |status|
+ it 'cannot revoke token' do
+ subject
+
+ expect(response).to have_gitlab_http_status(status)
+ end
+ end
+
+ context 'when current_user is an administrator', :enable_admin_mode do
+ let(:current_user) { create(:admin) }
+
+ it_behaves_like 'revoking token succeeds'
+ end
+
+ context 'when current_user is not an administrator' do
+ let(:current_user) { create(:user) }
+
+ it_behaves_like 'revoking token succeeds'
+
+ context 'with impersonated token' do
+ let(:token) { create(:personal_access_token, :impersonation, user: current_user) }
+
+ it_behaves_like 'revoking token denied', :bad_request
+ end
+
+ context 'with already revoked token' do
+ let(:token) { create(:personal_access_token, :revoked, user: current_user) }
+
+ it_behaves_like 'revoking token denied', :unauthorized
+ end
+ end
+ end
+
describe 'DELETE /personal_access_tokens/:id' do
let(:path) { "/personal_access_tokens/#{token1.id}" }
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index fbcaa404edb..eb6f81c2810 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -99,6 +99,7 @@ ci_cd_settings:
default_git_depth: ci_default_git_depth
forward_deployment_enabled: ci_forward_deployment_enabled
job_token_scope_enabled: ci_job_token_scope_enabled
+ separated_caches: ci_separated_caches
build_import_state: # import_state
unexposed_attributes:
diff --git a/spec/requests/api/project_container_repositories_spec.rb b/spec/requests/api/project_container_repositories_spec.rb
index 196b0395ec0..506e60d19a6 100644
--- a/spec/requests/api/project_container_repositories_spec.rb
+++ b/spec/requests/api/project_container_repositories_spec.rb
@@ -113,6 +113,10 @@ RSpec.describe API::ProjectContainerRepositories do
it_behaves_like 'returns repositories for allowed users', :reporter, 'project' do
let(:object) { project }
end
+
+ it_behaves_like 'returns tags for allowed users', :reporter, 'project' do
+ let(:object) { project }
+ end
end
end
@@ -246,8 +250,7 @@ RSpec.describe API::ProjectContainerRepositories do
name_regex_delete: 'v10.*',
name_regex_keep: 'v10.1.*',
keep_n: 100,
- older_than: '1 day',
- container_expiration_policy: false }
+ older_than: '1 day' }
end
let(:lease_key) { "container_repository:cleanup_tags:#{root_repository.id}" }
@@ -293,8 +296,7 @@ RSpec.describe API::ProjectContainerRepositories do
name_regex_delete: nil,
name_regex_keep: 'v10.1.*',
keep_n: 100,
- older_than: '1 day',
- container_expiration_policy: false }
+ older_than: '1 day' }
end
it 'schedules cleanup of tags repository' do
diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb
index 07efd56fef4..8a8cd8512f8 100644
--- a/spec/requests/api/project_export_spec.rb
+++ b/spec/requests/api/project_export_spec.rb
@@ -410,6 +410,27 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache do
it_behaves_like 'post project export start'
+ context 'with project export size limit' do
+ before do
+ stub_application_setting(max_export_size: 1)
+ end
+
+ it 'starts if limit not exceeded' do
+ post api(path, user)
+
+ expect(response).to have_gitlab_http_status(:accepted)
+ end
+
+ it '400 response if limit exceeded' do
+ project.statistics.update!(lfs_objects_size: 2.megabytes, repository_size: 2.megabytes)
+
+ post api(path, user)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response["message"]).to include('The project size exceeds the export limit.')
+ end
+ end
+
context 'when rate limit is exceeded across projects' do
before do
allow(Gitlab::ApplicationRateLimiter)
diff --git a/spec/requests/api/project_milestones_spec.rb b/spec/requests/api/project_milestones_spec.rb
index 8c9a93cf9fa..b23fb86a9de 100644
--- a/spec/requests/api/project_milestones_spec.rb
+++ b/spec/requests/api/project_milestones_spec.rb
@@ -9,8 +9,8 @@ RSpec.describe API::ProjectMilestones do
let_it_be(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') }
let_it_be(:route) { "/projects/#{project.id}/milestones" }
- before do
- project.add_developer(user)
+ before_all do
+ project.add_reporter(user)
end
it_behaves_like 'group and project milestones', "/projects/:id/milestones"
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 011300a038f..d2189ab02ea 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -739,6 +739,32 @@ RSpec.describe API::Projects do
end
end
+ context 'with default created_at desc order' do
+ let_it_be(:group_with_projects) { create(:group) }
+ let_it_be(:project_1) { create(:project, name: 'Project 1', created_at: 3.days.ago, path: 'project1', group: group_with_projects) }
+ let_it_be(:project_2) { create(:project, name: 'Project 2', created_at: 2.days.ago, path: 'project2', group: group_with_projects) }
+ let_it_be(:project_3) { create(:project, name: 'Project 3', created_at: 1.day.ago, path: 'project3', group: group_with_projects) }
+
+ let(:current_user) { user }
+ let(:params) { {} }
+
+ subject { get api('/projects', current_user), params: params }
+
+ before do
+ group_with_projects.add_owner(current_user) if current_user
+ end
+
+ it 'orders by id desc instead' do
+ projects_ordered_by_id_desc = /SELECT "projects".+ORDER BY "projects"."id" DESC/i
+ expect { subject }.to make_queries_matching projects_ordered_by_id_desc
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first['id']).to eq(project_3.id)
+ end
+ end
+
context 'sorting' do
context 'by project statistics' do
%w(repository_size storage_size wiki_size packages_size).each do |order_by|
@@ -1180,9 +1206,15 @@ RSpec.describe API::Projects do
end
it 'disallows creating a project with an import_url when git import source is disabled' do
+ url = 'http://example.com'
stub_application_setting(import_sources: nil)
- project_params = { import_url: 'http://example.com', path: 'path-project-Foo', name: 'Foo Project' }
+ endpoint_url = "#{url}/info/refs?service=git-upload-pack"
+ stub_full_request(endpoint_url, method: :get).to_return({ status: 200,
+ body: '001e# service=git-upload-pack',
+ headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' } })
+
+ project_params = { import_url: url, path: 'path-project-Foo', name: 'Foo Project' }
expect { post api('/projects', user), params: project_params }
.not_to change { Project.count }
@@ -2522,6 +2554,7 @@ RSpec.describe API::Projects do
expect(links['labels']).to end_with("/api/v4/projects/#{project.id}/labels")
expect(links['events']).to end_with("/api/v4/projects/#{project.id}/events")
expect(links['members']).to end_with("/api/v4/projects/#{project.id}/members")
+ expect(links['cluster_agents']).to end_with("/api/v4/projects/#{project.id}/cluster_agents")
end
it 'filters related URIs when their feature is not enabled' do
@@ -3556,6 +3589,20 @@ RSpec.describe API::Projects do
expect(json_response['topics']).to eq(%w[topic2])
end
+ it 'updates enforce_auth_checks_on_uploads' do
+ project3.update!(enforce_auth_checks_on_uploads: false)
+
+ project_param = { enforce_auth_checks_on_uploads: true }
+
+ expect { put api("/projects/#{project3.id}", user), params: project_param }
+ .to change { project3.reload.enforce_auth_checks_on_uploads }
+ .from(false)
+ .to(true)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['enforce_auth_checks_on_uploads']).to eq(true)
+ end
+
it 'updates squash_option' do
project3.update!(squash_option: 'always')
@@ -4463,6 +4510,43 @@ RSpec.describe API::Projects do
end
end
+ describe 'POST /projects/:id/repository_size' do
+ let(:update_statistics_service) { Projects::UpdateStatisticsService.new(project, nil, statistics: [:repository_size, :lfs_objects_size]) }
+
+ before do
+ allow(Projects::UpdateStatisticsService).to receive(:new).with(project, nil, statistics: [:repository_size, :lfs_objects_size]).and_return(update_statistics_service)
+ end
+
+ context 'when authenticated as owner' do
+ it 'starts the housekeeping process' do
+ expect(update_statistics_service).to receive(:execute).once
+
+ post api("/projects/#{project.id}/repository_size", user)
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'when authenticated as developer' do
+ before do
+ project_member
+ end
+
+ it 'returns forbidden error' do
+ post api("/projects/#{project.id}/repository_size", user3)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when unauthenticated' do
+ it 'returns authentication error' do
+ post api("/projects/#{project.id}/repository_size")
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
describe 'PUT /projects/:id/transfer' do
context 'when authenticated as owner' do
let(:group) { create :group }
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index c6bf72176a8..3c0f3a75f10 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -1399,14 +1399,6 @@ RSpec.describe API::Releases do
expect(response).to have_gitlab_http_status(:not_found)
end
-
- it 'returns not found unless :group_releases_finder_inoperator feature flag enabled' do
- stub_feature_flags(group_releases_finder_inoperator: false)
-
- get api("/groups/#{group1.id}/releases", admin)
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
end
context 'when authenticated as guest' do
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index c724c69045e..cfda06da8f3 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -54,6 +54,8 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
expect(json_response['runner_token_expiration_interval']).to be_nil
expect(json_response['group_runner_token_expiration_interval']).to be_nil
expect(json_response['project_runner_token_expiration_interval']).to be_nil
+ expect(json_response['max_export_size']).to eq(0)
+ expect(json_response['pipeline_limit_per_project_user_sha']).to eq(0)
end
end
@@ -138,6 +140,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
spam_check_api_key: 'SPAM_CHECK_API_KEY',
mailgun_events_enabled: true,
mailgun_signing_key: 'MAILGUN_SIGNING_KEY',
+ max_export_size: 6,
disabled_oauth_sign_in_sources: 'unknown',
import_sources: 'github,bitbucket',
wiki_page_max_content_bytes: 12345,
@@ -193,6 +196,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
expect(json_response['spam_check_api_key']).to eq('SPAM_CHECK_API_KEY')
expect(json_response['mailgun_events_enabled']).to be(true)
expect(json_response['mailgun_signing_key']).to eq('MAILGUN_SIGNING_KEY')
+ expect(json_response['max_export_size']).to eq(6)
expect(json_response['disabled_oauth_sign_in_sources']).to eq([])
expect(json_response['import_sources']).to match_array(%w(github bitbucket))
expect(json_response['wiki_page_max_content_bytes']).to eq(12345)
@@ -736,5 +740,39 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
)
end
end
+
+ context 'with pipeline_limit_per_project_user_sha' do
+ it 'updates the settings' do
+ put api("/application/settings", admin), params: {
+ pipeline_limit_per_project_user_sha: 25
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ 'pipeline_limit_per_project_user_sha' => 25
+ )
+ end
+
+ it 'updates the settings with zero value' do
+ put api("/application/settings", admin), params: {
+ pipeline_limit_per_project_user_sha: 0
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ 'pipeline_limit_per_project_user_sha' => 0
+ )
+ end
+
+ it 'does not allow null values' do
+ put api("/application/settings", admin), params: {
+ pipeline_limit_per_project_user_sha: nil
+ }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']['pipeline_limit_per_project_user_sha'])
+ .to include(a_string_matching('is not a number'))
+ end
+ end
end
end
diff --git a/spec/requests/api/sidekiq_metrics_spec.rb b/spec/requests/api/sidekiq_metrics_spec.rb
index 23ac2ea5c0b..302d824e650 100644
--- a/spec/requests/api/sidekiq_metrics_spec.rb
+++ b/spec/requests/api/sidekiq_metrics_spec.rb
@@ -10,7 +10,18 @@ RSpec.describe API::SidekiqMetrics do
get api('/sidekiq/queue_metrics', admin)
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_a Hash
+ expect(json_response).to match a_hash_including(
+ 'queues' => a_hash_including(
+ 'default' => {
+ 'backlog' => be_a(Integer),
+ 'latency' => be_a(Integer)
+ },
+ 'mailers' => {
+ 'backlog' => be_a(Integer),
+ 'latency' => be_a(Integer)
+ }
+ )
+ )
end
it 'defines the `process_metrics` endpoint' do
diff --git a/spec/requests/api/topics_spec.rb b/spec/requests/api/topics_spec.rb
index 5c17ca9581e..e711414a895 100644
--- a/spec/requests/api/topics_spec.rb
+++ b/spec/requests/api/topics_spec.rb
@@ -117,7 +117,7 @@ RSpec.describe API::Topics do
describe 'POST /topics', :aggregate_failures do
context 'as administrator' do
it 'creates a topic' do
- post api('/topics/', admin), params: { name: 'my-topic' }
+ post api('/topics/', admin), params: { name: 'my-topic', title: 'My Topic' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq('my-topic')
@@ -128,7 +128,7 @@ RSpec.describe API::Topics do
workhorse_form_with_file(
api('/topics/', admin),
file_key: :avatar,
- params: { name: 'my-topic', description: 'my description...', avatar: file }
+ params: { name: 'my-topic', title: 'My Topic', description: 'my description...', avatar: file }
)
expect(response).to have_gitlab_http_status(:created)
@@ -137,23 +137,30 @@ RSpec.describe API::Topics do
end
it 'returns 400 if name is missing' do
- post api('/topics/', admin)
+ post api('/topics/', admin), params: { title: 'My Topic' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eql('name is missing')
end
it 'returns 400 if name is not unique (case insensitive)' do
- post api('/topics/', admin), params: { name: topic_1.name.downcase }
+ post api('/topics/', admin), params: { name: topic_1.name.downcase, title: 'My Topic' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['name']).to eq(['has already been taken'])
end
+
+ it 'returns 400 if title is missing' do
+ post api('/topics/', admin), params: { name: 'my-topic' }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eql('title is missing')
+ end
end
context 'as normal user' do
it 'returns 403 Forbidden' do
- post api('/topics/', user), params: { name: 'my-topic' }
+ post api('/topics/', user), params: { name: 'my-topic', title: 'My Topic' }
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -161,7 +168,7 @@ RSpec.describe API::Topics do
context 'as anonymous' do
it 'returns 401 Unauthorized' do
- post api('/topics/'), params: { name: 'my-topic' }
+ post api('/topics/'), params: { name: 'my-topic', title: 'My Topic' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
diff --git a/spec/requests/api/usage_data_spec.rb b/spec/requests/api/usage_data_spec.rb
index aefccc4fbf7..ea50c404d92 100644
--- a/spec/requests/api/usage_data_spec.rb
+++ b/spec/requests/api/usage_data_spec.rb
@@ -7,9 +7,7 @@ RSpec.describe API::UsageData do
describe 'POST /usage_data/increment_counter' do
let(:endpoint) { '/usage_data/increment_counter' }
- let(:known_event) { "#{known_event_prefix}_#{known_event_postfix}" }
- let(:known_event_prefix) { "static_site_editor" }
- let(:known_event_postfix) { 'commits' }
+ let(:known_event) { "diff_searches" }
let(:unknown_event) { 'unknown' }
context 'without CSRF token' do
@@ -44,7 +42,6 @@ RSpec.describe API::UsageData do
context 'with authentication' do
before do
stub_feature_flags(usage_data_api: true)
- stub_feature_flags("usage_data_#{known_event}" => true)
stub_application_setting(usage_ping_enabled: true)
allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(true)
end
@@ -58,28 +55,18 @@ RSpec.describe API::UsageData do
end
context 'with correct params' do
- using RSpec::Parameterized::TableSyntax
-
- where(:prefix, :event) do
- 'static_site_editor' | 'merge_requests'
- 'static_site_editor' | 'commits'
- end
-
before do
stub_application_setting(usage_ping_enabled: true)
stub_feature_flags(usage_data_api: true)
allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(true)
- stub_feature_flags("usage_data_#{prefix}_#{event}" => true)
end
- with_them do
- it 'returns status :ok' do
- expect(Gitlab::UsageDataCounters::BaseCounter).to receive(:count).with(event)
+ it 'returns status :ok' do
+ expect(Gitlab::UsageDataCounters::BaseCounter).to receive(:count).with("searches")
- post api(endpoint, user), params: { event: "#{prefix}_#{event}" }
+ post api(endpoint, user), params: { event: known_event }
- expect(response).to have_gitlab_http_status(:ok)
- end
+ expect(response).to have_gitlab_http_status(:ok)
end
end
@@ -137,7 +124,6 @@ RSpec.describe API::UsageData do
context 'with authentication' do
before do
stub_feature_flags(usage_data_api: true)
- stub_feature_flags("usage_data_#{known_event}" => true)
stub_application_setting(usage_ping_enabled: true)
allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(true)
end
diff --git a/spec/requests/api/user_counts_spec.rb b/spec/requests/api/user_counts_spec.rb
index 27ebf02dd81..2d4705920cf 100644
--- a/spec/requests/api/user_counts_spec.rb
+++ b/spec/requests/api/user_counts_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe API::UserCounts do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_a Hash
expect(json_response['merge_requests']).to eq(2)
- expect(json_response['attention_requests']).to eq(2)
+ expect(json_response['attention_requests']).to eq(0)
end
describe 'mr_attention_requests is disabled' do
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index c554463df76..040ac4f74a7 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -1679,13 +1679,13 @@ RSpec.describe API::Users do
end
it 'creates SSH key with `expires_at` attribute' do
- optional_attributes = { expires_at: '2016-01-21T00:00:00.000Z' }
+ optional_attributes = { expires_at: 3.weeks.from_now }
attributes = attributes_for(:key).merge(optional_attributes)
post api("/users/#{user.id}/keys", admin), params: attributes
expect(response).to have_gitlab_http_status(:created)
- expect(json_response['expires_at']).to eq(optional_attributes[:expires_at])
+ expect(json_response['expires_at'].to_date).to eq(optional_attributes[:expires_at].to_date)
end
it "returns 400 for invalid ID" do
@@ -2373,13 +2373,13 @@ RSpec.describe API::Users do
end
it 'creates SSH key with `expires_at` attribute' do
- optional_attributes = { expires_at: '2016-01-21T00:00:00.000Z' }
+ optional_attributes = { expires_at: 3.weeks.from_now }
attributes = attributes_for(:key).merge(optional_attributes)
post api("/user/keys", user), params: attributes
expect(response).to have_gitlab_http_status(:created)
- expect(json_response['expires_at']).to eq(optional_attributes[:expires_at])
+ expect(json_response['expires_at'].to_date).to eq(optional_attributes[:expires_at].to_date)
end
it "returns a 401 error if unauthorized" do
diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb
index 4b2f11da77e..acf83916f82 100644
--- a/spec/requests/lfs_http_spec.rb
+++ b/spec/requests/lfs_http_spec.rb
@@ -2,9 +2,12 @@
require 'spec_helper'
RSpec.describe 'Git LFS API and storage' do
+ using RSpec::Parameterized::TableSyntax
+
include LfsHttpHelpers
include ProjectForksHelper
include WorkhorseHelpers
+ include WorkhorseLfsHelpers
let_it_be(:project, reload: true) { create(:project, :empty_repo) }
let_it_be(:user) { create(:user) }
@@ -814,7 +817,23 @@ RSpec.describe 'Git LFS API and storage' do
context 'and request to finalize the upload is not sent by gitlab-workhorse' do
it 'fails with a JWT decode error' do
- expect { put_finalize(lfs_tmp_file, verified: false) }.to raise_error(JWT::DecodeError)
+ expect { put_finalize(verified: false) }.to raise_error(JWT::DecodeError)
+ end
+ end
+
+ context 'and the uploaded file is invalid' do
+ where(:size, :sha256, :status) do
+ nil | nil | :ok # Test setup sanity check
+ 0 | nil | :bad_request
+ nil | 'a' * 64 | :bad_request
+ end
+
+ with_them do
+ it 'validates the upload size and SHA256' do
+ put_finalize(size: size, sha256: sha256)
+
+ expect(response).to have_gitlab_http_status(status)
+ end
end
end
@@ -840,7 +859,7 @@ RSpec.describe 'Git LFS API and storage' do
let(:tmp_object) do
fog_connection.directories.new(key: 'lfs-objects').files.create( # rubocop: disable Rails/SaveBang
key: 'tmp/uploads/12312300',
- body: 'content'
+ body: 'x' * sample_size
)
end
@@ -1106,13 +1125,7 @@ RSpec.describe 'Git LFS API and storage' do
context 'when pushing the same LFS object to the second project' do
before do
- finalize_headers = headers
- .merge('X-Gitlab-Lfs-Tmp' => lfs_tmp_file)
- .merge(workhorse_internal_api_request_header)
-
- put objects_url(second_project, sample_oid, sample_size),
- params: {},
- headers: finalize_headers
+ put_finalize(with_tempfile: true, to_project: second_project)
end
it_behaves_like 'LFS http 200 response'
@@ -1130,38 +1143,6 @@ RSpec.describe 'Git LFS API and storage' do
put authorize_url(project, sample_oid, sample_size), params: {}, headers: authorize_headers
end
-
- def put_finalize(lfs_tmp = lfs_tmp_file, with_tempfile: false, verified: true, remote_object: nil, args: {})
- uploaded_file = nil
-
- if with_tempfile
- upload_path = LfsObjectUploader.workhorse_local_upload_path
- file_path = upload_path + '/' + lfs_tmp if lfs_tmp
-
- FileUtils.mkdir_p(upload_path)
- FileUtils.touch(file_path)
-
- uploaded_file = UploadedFile.new(file_path, filename: File.basename(file_path))
- elsif remote_object
- uploaded_file = fog_to_uploaded_file(remote_object)
- end
-
- finalize_headers = headers
- finalize_headers.merge!(workhorse_internal_api_request_header) if verified
-
- workhorse_finalize(
- objects_url(project, sample_oid, sample_size),
- method: :put,
- file_key: :file,
- params: args.merge(file: uploaded_file),
- headers: finalize_headers,
- send_rewritten_field: include_workhorse_jwt_header
- )
- end
-
- def lfs_tmp_file
- "#{sample_oid}012345678"
- end
end
end
end
diff --git a/spec/requests/oauth_tokens_spec.rb b/spec/requests/oauth_tokens_spec.rb
index fdcc76f42cc..30659a5b896 100644
--- a/spec/requests/oauth_tokens_spec.rb
+++ b/spec/requests/oauth_tokens_spec.rb
@@ -54,30 +54,7 @@ RSpec.describe 'OAuth Tokens requests' do
end.to change { Doorkeeper::AccessToken.count }.by(1)
expect(json_response['access_token']).not_to be_nil
- end
-
- context 'when the application is configured to use expiring tokens' do
- before do
- application.update!(expire_access_tokens: true)
- end
-
- it 'generates an access token with an expiration' do
- request_access_token(user)
-
- expect(json_response['expires_in']).not_to be_nil
- end
- end
-
- context 'when the application is configured not to use expiring tokens' do
- before do
- application.update!(expire_access_tokens: false)
- end
-
- it 'generates an access token without an expiration' do
- request_access_token(user)
-
- expect(json_response.key?('expires_in')).to eq(false)
- end
+ expect(json_response['expires_in']).not_to be_nil
end
end
end
diff --git a/spec/requests/projects/issue_links_controller_spec.rb b/spec/requests/projects/issue_links_controller_spec.rb
index d22955718f8..3447ff83ed8 100644
--- a/spec/requests/projects/issue_links_controller_spec.rb
+++ b/spec/requests/projects/issue_links_controller_spec.rb
@@ -24,6 +24,17 @@ RSpec.describe Projects::IssueLinksController do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq(list_service_response.as_json)
end
+
+ context 'when linked issue is a task' do
+ let(:issue_b) { create :issue, :task, project: project }
+
+ it 'returns a work item path for the linked task' do
+ get namespace_project_issue_links_path(issue_links_params)
+
+ expect(json_response.count).to eq(1)
+ expect(json_response.first).to include('path' => project_work_items_path(issue_b.project, issue_b.id))
+ end
+ end
end
describe 'POST /*namespace_id/:project_id/issues/:issue_id/links' do
diff --git a/spec/requests/pwa_controller_spec.rb b/spec/requests/pwa_controller_spec.rb
new file mode 100644
index 00000000000..f74f37ea9d0
--- /dev/null
+++ b/spec/requests/pwa_controller_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PwaController do
+ describe 'GET #offline' do
+ it 'responds with static HTML page' do
+ get offline_path
+
+ expect(response.body).to include('You are currently offline')
+ expect(response).to have_gitlab_http_status(:success)
+ end
+ end
+end
diff --git a/spec/requests/request_profiler_spec.rb b/spec/requests/request_profiler_spec.rb
deleted file mode 100644
index 72689595480..00000000000
--- a/spec/requests/request_profiler_spec.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Request Profiler' do
- let(:user) { create(:user) }
-
- shared_examples 'profiling a request' do |profile_type, extension|
- before do
- allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
- allow(RubyProf::Profile).to receive(:profile) do |&blk|
- blk.call
- RubyProf::Profile.new
- end
- allow(MemoryProfiler).to receive(:report) do |&blk|
- blk.call
- MemoryProfiler.start
- MemoryProfiler.stop
- end
- end
-
- it 'creates a profile of the request' do
- project = create(:project, namespace: user.namespace)
- time = Time.now
- path = "/#{project.full_path}"
-
- travel_to(time) do
- get path, params: {}, headers: { 'X-Profile-Token' => Gitlab::RequestProfiler.profile_token, 'X-Profile-Mode' => profile_type }
- end
-
- profile_type = 'execution' if profile_type.nil?
- profile_path = "#{Gitlab.config.shared.path}/tmp/requests_profiles/#{path.tr('/', '|')}_#{time.to_i}_#{profile_type}.#{extension}"
- expect(File.exist?(profile_path)).to be true
- end
-
- after do
- Gitlab::RequestProfiler.remove_all_profiles
- end
- end
-
- context "when user is logged-in" do
- before do
- login_as(user)
- end
-
- include_examples 'profiling a request', 'execution', 'html'
- include_examples 'profiling a request', nil, 'html'
- include_examples 'profiling a request', 'memory', 'txt'
- end
-
- context "when user is not logged-in" do
- include_examples 'profiling a request', 'execution', 'html'
- include_examples 'profiling a request', nil, 'html'
- include_examples 'profiling a request', 'memory', 'txt'
- end
-end
diff --git a/spec/rubocop/cop/database/multiple_databases_spec.rb b/spec/rubocop/cop/database/multiple_databases_spec.rb
index 8bcd4710305..6ee1e7b13ca 100644
--- a/spec/rubocop/cop/database/multiple_databases_spec.rb
+++ b/spec/rubocop/cop/database/multiple_databases_spec.rb
@@ -13,6 +13,13 @@ RSpec.describe RuboCop::Cop::Database::MultipleDatabases do
SOURCE
end
+ it 'flags the use of ::ActiveRecord::Base.connection' do
+ expect_offense(<<~SOURCE)
+ ::ActiveRecord::Base.connection.inspect
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use methods from ActiveRecord::Base, [...]
+ SOURCE
+ end
+
described_class::ALLOWED_METHODS.each do |method_name|
it "does not flag use of ActiveRecord::Base.#{method_name}" do
expect_no_offenses(<<~SOURCE)
diff --git a/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb b/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb
index 6b5b07fb357..2ec3ae7aada 100644
--- a/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb
+++ b/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb
@@ -13,7 +13,6 @@ RSpec.describe RuboCop::Cop::Gitlab::MarkUsedFeatureFlags do
subject(:cop) { described_class.new }
before do
- stub_const("#{described_class}::DYNAMIC_FEATURE_FLAGS", [])
allow(cop).to receive(:defined_feature_flags).and_return(defined_feature_flags)
allow(cop).to receive(:usage_data_counters_known_event_feature_flags).and_return([])
described_class.feature_flags_already_tracked = false
diff --git a/spec/rubocop/cop/gitlab/namespaced_class_spec.rb b/spec/rubocop/cop/gitlab/namespaced_class_spec.rb
index 824a1b8cef5..d9209a8672c 100644
--- a/spec/rubocop/cop/gitlab/namespaced_class_spec.rb
+++ b/spec/rubocop/cop/gitlab/namespaced_class_spec.rb
@@ -1,72 +1,125 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop/rspec/support'
require_relative '../../../../rubocop/cop/gitlab/namespaced_class'
RSpec.describe RuboCop::Cop::Gitlab::NamespacedClass do
subject(:cop) { described_class.new }
- it 'flags a class definition without namespace' do
- expect_offense(<<~SOURCE)
- class MyClass
- ^^^^^^^^^^^^^ #{described_class::MSG}
- end
- SOURCE
- end
+ shared_examples 'enforces namespaced classes' do
+ def namespaced(code)
+ return code unless namespace
- it 'flags a class definition with inheritance without namespace' do
- expect_offense(<<~SOURCE)
- class MyClass < ApplicationRecord
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
- def some_method
- true
+ <<~SOURCE
+ module #{namespace}
+ #{code}
end
- end
- SOURCE
- end
+ SOURCE
+ end
+
+ it 'flags a class definition without additional namespace' do
+ expect_offense(namespaced(<<~SOURCE))
+ class MyClass
+ ^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+ SOURCE
+ end
- it 'does not flag the class definition with namespace in separate lines' do
- expect_no_offenses(<<~SOURCE)
- module MyModule
+ it 'flags a compact class definition without additional namespace' do
+ expect_offense(<<~SOURCE, namespace: namespace)
+ class %{namespace}::MyClass
+ ^{namespace}^^^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+ SOURCE
+ end
+
+ it 'flags a class definition with inheritance without additional namespace' do
+ expect_offense(namespaced(<<~SOURCE))
class MyClass < ApplicationRecord
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ def some_method
+ true
+ end
end
+ SOURCE
+ end
- class MyOtherClass
- def other_method
- 1 + 1
+ it 'does not flag the class definition with namespace in separate lines' do
+ expect_no_offenses(namespaced(<<~SOURCE))
+ module MyModule
+ class MyClass < ApplicationRecord
+ end
+
+ class MyOtherClass
+ def other_method
+ 1 + 1
+ end
end
end
- end
- SOURCE
- end
+ SOURCE
+ end
- it 'does not flag the class definition with nested namespace in separate lines' do
- expect_no_offenses(<<~SOURCE)
- module TopLevelModule
- module NestedModule
- class MyClass
+ it 'does not flag the class definition with nested namespace in separate lines' do
+ expect_no_offenses(namespaced(<<~SOURCE))
+ module TopLevelModule
+ module NestedModule
+ class MyClass
+ end
end
end
- end
- SOURCE
- end
+ SOURCE
+ end
+
+ it 'does not flag the class definition nested inside namespaced class' do
+ expect_no_offenses(namespaced(<<~SOURCE))
+ module TopLevelModule
+ class TopLevelClass
+ class MyClass
+ end
+ end
+ end
+ SOURCE
+ end
- it 'does not flag the class definition nested inside namespaced class' do
- expect_no_offenses(<<~SOURCE)
- module TopLevelModule
- class TopLevelClass
+ it 'does not flag the class definition nested inside compact namespace' do
+ expect_no_offenses(<<~SOURCE)
+ module #{namespace}::TopLevelModule
class MyClass
end
end
- end
- SOURCE
+ SOURCE
+ end
+
+ it 'does not flag a compact namespaced class definition' do
+ expect_no_offenses(namespaced(<<~SOURCE))
+ class MyModule::MyClass < ApplicationRecord
+ end
+ SOURCE
+ end
+
+ it 'does not flag a truly compact namespaced class definition' do
+ expect_no_offenses(<<~SOURCE, namespace: namespace)
+ class %{namespace}::MyModule::MyClass < ApplicationRecord
+ end
+ SOURCE
+ end
end
- it 'does not flag a compact namespaced class definition' do
- expect_no_offenses(<<~SOURCE)
- class MyModule::MyClass < ApplicationRecord
- end
- SOURCE
+ context 'without top-level namespace' do
+ let(:namespace) { nil }
+
+ it_behaves_like 'enforces namespaced classes'
+ end
+
+ context 'with Gitlab namespace' do
+ let(:namespace) { 'Gitlab' }
+
+ it_behaves_like 'enforces namespaced classes'
+ end
+
+ context 'with ::Gitlab namespace' do
+ let(:namespace) { '::Gitlab' }
+
+ it_behaves_like 'enforces namespaced classes'
end
end
diff --git a/spec/rubocop/cop/migration/background_migration_base_class_spec.rb b/spec/rubocop/cop/migration/background_migration_base_class_spec.rb
new file mode 100644
index 00000000000..0a110418139
--- /dev/null
+++ b/spec/rubocop/cop/migration/background_migration_base_class_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../../rubocop/cop/migration/background_migration_base_class'
+
+RSpec.describe RuboCop::Cop::Migration::BackgroundMigrationBaseClass do
+ subject(:cop) { described_class.new }
+
+ context 'when the migration class inherits from BatchedMigrationJob' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ module Gitlab
+ module BackgroundMigration
+ class MyJob < BatchedMigrationJob
+ def perform
+ connection.execute("select 1")
+ end
+ end
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'when the migration class inherits from the namespaced BatchedMigrationJob' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ module Gitlab
+ module BackgroundMigration
+ class MyJob < Gitlab::BackgroundMigration::BatchedMigrationJob
+ def perform
+ connection.execute("select 1")
+ end
+ end
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'when the migration class inherits from the top-level namespaced BatchedMigrationJob' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ module Gitlab
+ module BackgroundMigration
+ class MyJob < ::Gitlab::BackgroundMigration::BatchedMigrationJob
+ def perform
+ connection.execute("select 1")
+ end
+ end
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'when a nested class is used inside the job class' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~RUBY)
+ module Gitlab
+ module BackgroundMigration
+ class MyJob < BatchedMigrationJob
+ class Project < ApplicationRecord
+ self.table_name = 'projects'
+ end
+
+ def perform
+ Project.update!(name: 'hi')
+ end
+ end
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'when the migration class inherits from another class' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ module Gitlab
+ module BackgroundMigration
+ class MyJob < SomeOtherClass
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ end
+ end
+ end
+ RUBY
+ end
+ end
+
+ context 'when the migration class does not inherit from anything' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ module Gitlab
+ module BackgroundMigration
+ class MyJob
+ ^^^^^^^^^^^ #{described_class::MSG}
+ end
+ end
+ end
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/background_migration_record_spec.rb b/spec/rubocop/cop/migration/background_migration_record_spec.rb
new file mode 100644
index 00000000000..b5724ef1efd
--- /dev/null
+++ b/spec/rubocop/cop/migration/background_migration_record_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../../rubocop/cop/migration/background_migration_record'
+
+RSpec.describe RuboCop::Cop::Migration::BackgroundMigrationRecord do
+ subject(:cop) { described_class.new }
+
+ context 'outside of a migration' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~SOURCE)
+ class MigrateProjectRecords
+ class Project < ActiveRecord::Base
+ end
+ end
+ SOURCE
+ end
+ end
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_background_migration?).and_return(true)
+ end
+
+ it 'adds an offense if inheriting from ActiveRecord::Base' do
+ expect_offense(<<~RUBY)
+ class MigrateProjectRecords
+ class Project < ActiveRecord::Base
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use or inherit from ActiveRecord::Base.[...]
+ end
+ end
+ RUBY
+ end
+
+ it 'adds an offense if create dynamic model from ActiveRecord::Base' do
+ expect_offense(<<~RUBY)
+ class MigrateProjectRecords
+ def define_model(table_name)
+ Class.new(ActiveRecord::Base) do
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use or inherit from ActiveRecord::Base.[...]
+ self.table_name = table_name
+ self.inheritance_column = :_type_disabled
+ end
+ end
+ end
+ RUBY
+ end
+
+ it 'adds an offense if inheriting from ::ActiveRecord::Base' do
+ expect_offense(<<~RUBY)
+ class MigrateProjectRecords
+ class Project < ::ActiveRecord::Base
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use or inherit from ActiveRecord::Base.[...]
+ end
+ end
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/migration/hash_index_spec.rb b/spec/rubocop/cop/migration/hash_index_spec.rb
deleted file mode 100644
index 6da27af39b6..00000000000
--- a/spec/rubocop/cop/migration/hash_index_spec.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-require_relative '../../../../rubocop/cop/migration/hash_index'
-
-RSpec.describe RuboCop::Cop::Migration::HashIndex do
- subject(:cop) { described_class.new }
-
- context 'when in migration' do
- before do
- allow(cop).to receive(:in_migration?).and_return(true)
- end
-
- it 'registers an offense when creating a hash index' do
- expect_offense(<<~RUBY)
- def change
- add_index :table, :column, using: :hash
- ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...]
- end
- RUBY
- end
-
- it 'registers an offense when creating a concurrent hash index' do
- expect_offense(<<~RUBY)
- def change
- add_concurrent_index :table, :column, using: :hash
- ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...]
- end
- RUBY
- end
-
- it 'registers an offense when creating a hash index using t.index' do
- expect_offense(<<~RUBY)
- def change
- t.index :table, :column, using: :hash
- ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...]
- end
- RUBY
- end
- end
-
- context 'when outside of migration' do
- it 'registers no offense' do
- expect_no_offenses('def change; index :table, :column, using: :hash; end')
- end
- end
-end
diff --git a/spec/rubocop/cop/migration/migration_record_spec.rb b/spec/rubocop/cop/migration/migration_record_spec.rb
new file mode 100644
index 00000000000..bab0ca469df
--- /dev/null
+++ b/spec/rubocop/cop/migration/migration_record_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../../rubocop/cop/migration/migration_record'
+
+RSpec.describe RuboCop::Cop::Migration::MigrationRecord do
+ subject(:cop) { described_class.new }
+
+ shared_examples 'a disabled cop' do
+ it 'does not register any offenses' do
+ expect_no_offenses(<<~SOURCE)
+ class MyMigration < Gitlab::Database::Migration[2.0]
+ class Project < ActiveRecord::Base
+ end
+ end
+ SOURCE
+ end
+ end
+
+ context 'outside of a migration' do
+ it_behaves_like 'a disabled cop'
+ end
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ context 'in an old migration' do
+ before do
+ allow(cop).to receive(:version).and_return(described_class::ENFORCED_SINCE - 5)
+ end
+
+ it_behaves_like 'a disabled cop'
+ end
+
+ context 'that is recent' do
+ before do
+ allow(cop).to receive(:version).and_return(described_class::ENFORCED_SINCE)
+ end
+
+ it 'adds an offense if inheriting from ActiveRecord::Base' do
+ expect_offense(<<~RUBY)
+ class Project < ActiveRecord::Base
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't inherit from ActiveRecord::Base but use MigrationRecord instead.[...]
+ end
+ RUBY
+ end
+
+ it 'adds an offense if inheriting from ::ActiveRecord::Base' do
+ expect_offense(<<~RUBY)
+ class Project < ::ActiveRecord::Base
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't inherit from ActiveRecord::Base but use MigrationRecord instead.[...]
+ end
+ RUBY
+ end
+ end
+ end
+end
diff --git a/spec/scripts/changed-feature-flags_spec.rb b/spec/scripts/changed-feature-flags_spec.rb
index 5c858588c0c..bbae49a90e4 100644
--- a/spec/scripts/changed-feature-flags_spec.rb
+++ b/spec/scripts/changed-feature-flags_spec.rb
@@ -6,8 +6,8 @@ load File.expand_path('../../scripts/changed-feature-flags', __dir__)
RSpec.describe 'scripts/changed-feature-flags' do
describe GetFeatureFlagsFromFiles do
- let(:feature_flag_definition1) do
- file = Tempfile.new('foo.yml', ff_dir)
+ let!(:feature_flag_definition1) do
+ file = File.open(File.join(ff_dir, "#{file_name1}.yml"), 'w+')
file.write(<<~YAML)
---
name: foo_flag
@@ -17,8 +17,8 @@ RSpec.describe 'scripts/changed-feature-flags' do
file
end
- let(:feature_flag_definition2) do
- file = Tempfile.new('bar.yml', ff_dir)
+ let!(:feature_flag_definition2) do
+ file = File.open(File.join(ff_dir, "#{file_name2}.yml"), 'w+')
file.write(<<~YAML)
---
name: bar_flag
@@ -28,48 +28,136 @@ RSpec.describe 'scripts/changed-feature-flags' do
file
end
+ let!(:feature_flag_diff1) do
+ FileUtils.mkdir_p(File.join(diffs_dir, ff_sub_dir))
+ file = File.open(File.join(diffs_dir, ff_sub_dir, "#{file_name1}.yml.diff"), 'w+')
+ file.write(<<~YAML)
+ @@ -5,4 +5,4 @@
+ name: foo_flag
+ -default_enabled: false
+ +default_enabled: true
+ YAML
+ file.rewind
+ file
+ end
+
+ let!(:feature_flag_diff2) do
+ FileUtils.mkdir_p(File.join(diffs_dir, ff_sub_dir))
+ file = File.open(File.join(diffs_dir, ff_sub_dir, "#{file_name2}.yml.diff"), 'w+')
+ file.write(<<~YAML)
+ @@ -0,0 +0,0 @@
+ name: bar_flag
+ -default_enabled: true
+ +default_enabled: false
+ YAML
+ file.rewind
+ file
+ end
+
+ let!(:deleted_feature_flag_diff) do
+ FileUtils.mkdir_p(File.join(diffs_dir, ff_sub_dir))
+ file = File.open(File.join(diffs_dir, ff_sub_dir, "foobar_ff_#{SecureRandom.hex(8)}.yml.deleted.diff"), 'w+')
+ file.write(<<~YAML)
+ @@ -0,0 +0,0 @@
+ -name: foobar_flag
+ -default_enabled: true
+ YAML
+ file.rewind
+ file
+ end
+
+ before do
+ allow(Dir).to receive(:pwd).and_return(Dir.tmpdir)
+ end
+
after do
- FileUtils.remove_entry(ff_dir, true)
+ feature_flag_definition1.close
+ feature_flag_definition2.close
+ feature_flag_diff1.close
+ feature_flag_diff2.close
+ deleted_feature_flag_diff.close
+ FileUtils.rm_r(ff_dir)
+ FileUtils.rm_r(diffs_dir)
end
describe '.extracted_flags' do
+ let(:file_name1) { "foo_ff_#{SecureRandom.hex(8)}"}
+ let(:file_name2) { "bar_ff_#{SecureRandom.hex(8)}"}
+ let(:ff_dir) { FileUtils.mkdir_p(File.join(Dir.tmpdir, ff_sub_dir)) }
+ let(:diffs_dir) { FileUtils.mkdir_p(File.join(Dir.tmpdir, 'diffs')).first }
+
shared_examples 'extract feature flags' do
it 'returns feature flags on their own' do
- subject = described_class.new({ files: [feature_flag_definition1.path, feature_flag_definition2.path] })
+ subject = described_class.new({ files: diffs_dir })
- expect(subject.extracted_flags).to eq('foo_flag,bar_flag')
+ expect(subject.extracted_flags.split(',')).to include('foo_flag', 'bar_flag')
end
it 'returns feature flags and their state as enabled' do
- subject = described_class.new({ files: [feature_flag_definition1.path, feature_flag_definition2.path], state: 'enabled' })
+ subject = described_class.new({ files: diffs_dir, state: 'enabled' })
- expect(subject.extracted_flags).to eq('foo_flag=enabled,bar_flag=enabled')
+ expect(subject.extracted_flags.split(',')).to include('foo_flag=enabled', 'bar_flag=enabled')
end
it 'returns feature flags and their state as disabled' do
- subject = described_class.new({ files: [feature_flag_definition1.path, feature_flag_definition2.path], state: 'disabled' })
+ subject = described_class.new({ files: diffs_dir, state: 'disabled' })
+
+ expect(subject.extracted_flags.split(',')).to include('foo_flag=disabled', 'bar_flag=disabled')
+ end
+
+ it 'does not return feature flags when there are mixed deleted and non-deleted definition files' do
+ subject = described_class.new({ files: diffs_dir, state: 'deleted' })
- expect(subject.extracted_flags).to eq('foo_flag=disabled,bar_flag=disabled')
+ expect(subject.extracted_flags).to eq('')
end
end
context 'with definition files in the development directory' do
- let(:ff_dir) { FileUtils.mkdir_p(File.join(Dir.tmpdir, 'feature_flags', 'development')) }
+ let(:ff_sub_dir) { %w[feature_flags development] }
it_behaves_like 'extract feature flags'
end
context 'with definition files in the ops directory' do
- let(:ff_dir) { FileUtils.mkdir_p(File.join(Dir.tmpdir, 'feature_flags', 'ops')) }
+ let(:ff_sub_dir) { %w[feature_flags ops] }
it_behaves_like 'extract feature flags'
end
context 'with definition files in the experiment directory' do
- let(:ff_dir) { FileUtils.mkdir_p(File.join(Dir.tmpdir, 'feature_flags', 'experiment')) }
+ let(:ff_sub_dir) { %w[feature_flags experiment] }
it 'ignores the files' do
- subject = described_class.new({ files: [feature_flag_definition1.path, feature_flag_definition2.path] })
+ subject = described_class.new({ files: diffs_dir })
+
+ expect(subject.extracted_flags).to eq('')
+ end
+ end
+
+ context 'with only deleted definition files' do
+ let(:ff_sub_dir) { %w[feature_flags development] }
+
+ before do
+ feature_flag_diff1.close
+ feature_flag_diff2.close
+ FileUtils.rm_r(feature_flag_diff1)
+ FileUtils.rm_r(feature_flag_diff2)
+ end
+
+ it 'returns feature flags and their state as deleted' do
+ subject = described_class.new({ files: diffs_dir, state: 'deleted' })
+
+ expect(subject.extracted_flags).to eq('foobar_flag=deleted')
+ end
+
+ it 'does not return feature flags when the desired state is enabled' do
+ subject = described_class.new({ files: diffs_dir, state: 'enabled' })
+
+ expect(subject.extracted_flags).to eq('')
+ end
+
+ it 'does not return feature flags when the desired state is disabled' do
+ subject = described_class.new({ files: diffs_dir, state: 'disabled' })
expect(subject.extracted_flags).to eq('')
end
diff --git a/spec/scripts/lib/glfm/shared_spec.rb b/spec/scripts/lib/glfm/shared_spec.rb
new file mode 100644
index 00000000000..f6792b93718
--- /dev/null
+++ b/spec/scripts/lib/glfm/shared_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+require 'fast_spec_helper'
+require_relative '../../../../scripts/lib/glfm/shared'
+
+RSpec.describe Glfm::Shared do
+ let(:instance) do
+ Class.new do
+ include Glfm::Shared
+ end.new
+ end
+
+ describe '#run_external_cmd' do
+ it 'works' do
+ expect(instance.run_external_cmd('echo "hello"')).to eq("hello\n")
+ end
+
+ context 'when command fails' do
+ it 'raises error' do
+ invalid_cmd = 'ls nonexistent_file'
+ expect(instance).to receive(:warn).with(/Error running command `#{invalid_cmd}`/)
+ expect(instance).to receive(:warn).with(/nonexistent_file.*no such file/i)
+ expect { instance.run_external_cmd(invalid_cmd) }.to raise_error(RuntimeError)
+ end
+ end
+ end
+
+ describe '#output' do
+ # NOTE: The #output method is normally always mocked, to prevent output while the specs are
+ # running. However, in order to provide code coverage for the method, we have to invoke
+ # it at least once.
+ it 'has code coverage' do
+ allow(instance).to receive(:puts)
+ instance.output('')
+ end
+ end
+end
diff --git a/spec/scripts/lib/glfm/update_example_snapshots_spec.rb b/spec/scripts/lib/glfm/update_example_snapshots_spec.rb
new file mode 100644
index 00000000000..169f5d1c5a6
--- /dev/null
+++ b/spec/scripts/lib/glfm/update_example_snapshots_spec.rb
@@ -0,0 +1,316 @@
+# frozen_string_literal: true
+require 'fast_spec_helper'
+require_relative '../../../../scripts/lib/glfm/update_example_snapshots'
+
+RSpec.describe Glfm::UpdateExampleSnapshots, '#process' do
+ subject { described_class.new }
+
+ # GLFM input files
+ let(:glfm_spec_txt_path) { described_class::GLFM_SPEC_TXT_PATH }
+ let(:glfm_spec_txt_local_io) { StringIO.new(glfm_spec_txt_contents) }
+ let(:glfm_example_status_yml_path) { described_class::GLFM_EXAMPLE_STATUS_YML_PATH }
+ let(:glfm_example_status_yml_io) { StringIO.new(glfm_example_status_yml_contents) }
+
+ # Example Snapshot (ES) output files
+ let(:es_examples_index_yml_path) { described_class::ES_EXAMPLES_INDEX_YML_PATH }
+ let(:es_examples_index_yml_io) { StringIO.new }
+ let(:es_markdown_yml_path) { described_class::ES_MARKDOWN_YML_PATH }
+ let(:es_markdown_yml_io) { StringIO.new }
+ let(:es_html_yml_path) { described_class::ES_HTML_YML_PATH }
+ let(:es_html_yml_io) { StringIO.new }
+ let(:es_prosemirror_json_yml_path) { described_class::ES_PROSEMIRROR_JSON_YML_PATH }
+ let(:es_prosemirror_json_yml_io) { StringIO.new }
+
+ # Internal tempfiles
+ let(:static_html_tempfile_path) { Tempfile.new.path }
+
+ let(:glfm_spec_txt_contents) do
+ <<~GLFM_SPEC_TXT_CONTENTS
+ ---
+ title: GitLab Flavored Markdown Spec
+ ...
+
+ # Introduction
+
+ GLFM intro text...
+
+ # Inlines
+
+ ## Strong
+
+ ```````````````````````````````` example
+ __bold__
+ .
+ <p><strong>bold</strong></p>
+ ````````````````````````````````
+
+ ```````````````````````````````` example strikethrough
+ __bold with more text__
+ .
+ <p><strong>bold with more text</strong></p>
+ ````````````````````````````````
+
+ <div class="extension">
+
+ ## Strikethrough (extension)
+
+ GFM enables the `strikethrough` extension.
+
+ ```````````````````````````````` example strikethrough
+ ~~Hi~~ Hello, world!
+ .
+ <p><del>Hi</del> Hello, world!</p>
+ ````````````````````````````````
+
+ </div>
+
+ End of last GitHub examples section.
+
+ # First GitLab-Specific Section with Examples
+
+ ## Strong but with two asterisks
+
+ ```````````````````````````````` example gitlab strong
+ **bold**
+ .
+ <p><strong>bold</strong></p>
+ ````````````````````````````````
+
+ # Second GitLab-Specific Section with Examples
+
+ ## Strong but with HTML
+
+ ```````````````````````````````` example gitlab strong
+ <strong>
+ bold
+ </strong>
+ .
+ <p><strong>
+ bold
+ </strong></p>
+ ````````````````````````````````
+
+ <!-- END TESTS -->
+
+ # Appendix
+
+ Appendix text.
+ GLFM_SPEC_TXT_CONTENTS
+ end
+
+ let(:glfm_example_status_yml_contents) do
+ <<~GLFM_EXAMPLE_STATUS_YML_CONTENTS
+ ---
+ - 07_01_first_gitlab_specific_section_with_examples_strong_but_with_two_asterisks:
+ skip_update_example_snapshots: false
+ skip_running_snapshot_static_html_tests: false
+ skip_running_snapshot_wysiwyg_html_tests: false
+ skip_running_snapshot_prosemirror_json_tests: false
+ skip_running_conformance_static_tests: false
+ skip_running_conformance_wysiwyg_tests: false
+ - 07_02_first_gitlab_specific_section_with_examples_strong_but_with_html:
+ skip_update_example_snapshots: false
+ skip_running_snapshot_static_html_tests: false
+ skip_running_snapshot_wysiwyg_html_tests: false
+ skip_running_snapshot_prosemirror_json_tests: false
+ skip_running_conformance_static_tests: false
+ skip_running_conformance_wysiwyg_tests: false
+ GLFM_EXAMPLE_STATUS_YML_CONTENTS
+ end
+
+ before do
+ # We mock out the URI and local file IO objects with real StringIO, instead of just mock
+ # objects. This gives better and more realistic coverage, while still avoiding
+ # actual network and filesystem I/O during the spec run.
+
+ # input files
+ allow(File).to receive(:open).with(glfm_spec_txt_path) { glfm_spec_txt_local_io }
+ allow(File).to receive(:open).with(glfm_example_status_yml_path) { glfm_example_status_yml_io }
+
+ # output files
+ allow(File).to receive(:open).with(es_examples_index_yml_path, 'w') { es_examples_index_yml_io }
+ allow(File).to receive(:open).with(es_html_yml_path, 'w') { es_html_yml_io }
+ allow(File).to receive(:open).with(es_prosemirror_json_yml_path, 'w') { es_prosemirror_json_yml_io }
+
+ # output files which are also input files
+ allow(File).to receive(:open).with(es_markdown_yml_path, 'w') { es_markdown_yml_io }
+ allow(File).to receive(:open).with(es_markdown_yml_path) { es_markdown_yml_io }
+
+ # Allow normal opening of Tempfile files created during script execution.
+ tempfile_basenames = [
+ described_class::MARKDOWN_TEMPFILE_BASENAME[0],
+ described_class::STATIC_HTML_TEMPFILE_BASENAME[0],
+ described_class::WYSIWYG_HTML_AND_JSON_TEMPFILE_BASENAME[0]
+ ].join('|')
+ # NOTE: This approach with a single regex seems to be the only way this can work. If you
+ # attempt to have multiple `allow...and_call_original` with `any_args`, the mocked
+ # parameter matching will fail to match the second one.
+ tempfiles_regex = /(#{tempfile_basenames})/
+ allow(File).to receive(:open).with(tempfiles_regex, any_args).and_call_original
+
+ # Prevent console output when running tests
+ allow(subject).to receive(:output)
+ end
+
+ describe 'writing examples_index.yml' do
+ let(:es_examples_index_yml_contents) { reread_io(es_examples_index_yml_io) }
+
+ it 'writes the correct content' do
+ subject.process(skip_static_and_wysiwyg: true)
+
+ expected =
+ <<~ES_EXAMPLES_INDEX_YML_CONTENTS
+ ---
+ 02_01__inlines__strong__01:
+ spec_txt_example_position: 1
+ source_specification: commonmark
+ 02_01__inlines__strong__02:
+ spec_txt_example_position: 2
+ source_specification: github
+ 02_02__inlines__strikethrough_extension__01:
+ spec_txt_example_position: 3
+ source_specification: github
+ 03_01__first_gitlab_specific_section_with_examples__strong_but_with_two_asterisks__01:
+ spec_txt_example_position: 4
+ source_specification: gitlab
+ 04_01__second_gitlab_specific_section_with_examples__strong_but_with_html__01:
+ spec_txt_example_position: 5
+ source_specification: gitlab
+ ES_EXAMPLES_INDEX_YML_CONTENTS
+ expect(es_examples_index_yml_contents).to eq(expected)
+ end
+ end
+
+ describe 'writing markdown.yml' do
+ let(:es_markdown_yml_contents) { reread_io(es_markdown_yml_io) }
+
+ it 'writes the correct content' do
+ subject.process(skip_static_and_wysiwyg: true)
+
+ expected =
+ <<~ES_MARKDOWN_YML_CONTENTS
+ ---
+ 02_01__inlines__strong__01: |
+ __bold__
+ 02_01__inlines__strong__02: |
+ __bold with more text__
+ 02_02__inlines__strikethrough_extension__01: |
+ ~~Hi~~ Hello, world!
+ 03_01__first_gitlab_specific_section_with_examples__strong_but_with_two_asterisks__01: |
+ **bold**
+ 04_01__second_gitlab_specific_section_with_examples__strong_but_with_html__01: |
+ <strong>
+ bold
+ </strong>
+ ES_MARKDOWN_YML_CONTENTS
+
+ expect(es_markdown_yml_contents).to eq(expected)
+ end
+ end
+
+ describe 'writing html.yml and prosemirror_json.yml' do
+ let(:es_html_yml_contents) { reread_io(es_html_yml_io) }
+ let(:es_prosemirror_json_yml_contents) { reread_io(es_prosemirror_json_yml_io) }
+
+ let(:glfm_example_status_yml_contents) do
+ <<~GLFM_EXAMPLE_STATUS_YML_CONTENTS
+ ---
+ - 02_01_gitlab_specific_section_with_examples_strong_but_with_two_asterisks:
+ skip_update_example_snapshots: false
+ skip_running_snapshot_static_html_tests: false
+ skip_running_snapshot_wysiwyg_html_tests: false
+ skip_running_snapshot_prosemirror_json_tests: false
+ skip_running_conformance_static_tests: false
+ skip_running_conformance_wysiwyg_tests: false
+ GLFM_EXAMPLE_STATUS_YML_CONTENTS
+ end
+
+ let(:glfm_spec_txt_contents) do
+ <<~GLFM_SPEC_TXT_CONTENTS
+ ---
+ title: GitLab Flavored Markdown Spec
+ ...
+
+ # Introduction
+
+ # GitLab-Specific Section with Examples
+
+ ## Strong but with two asterisks
+
+ ```````````````````````````````` example gitlab strong
+ **bold**
+ .
+ <p><strong>bold</strong></p>
+ ````````````````````````````````
+ <!-- END TESTS -->
+
+ # Appendix
+
+ Appendix text.
+ GLFM_SPEC_TXT_CONTENTS
+ end
+
+ before do
+ # NOTE: This is a necessary to avoid an `error Couldn't find an integrity file` error
+ # when invoking `yarn jest ...` on CI from within an RSpec job. It could be solved by
+ # adding `.yarn-install` to be included in the RSpec CI job, but that would be a performance
+ # hit to all RSpec jobs. We could also make a dedicate job just for this spec. However,
+ # since this is just a single script, those options may not be justified.
+ described_class.new.run_external_cmd('yarn install') if ENV['CI']
+ end
+
+ # NOTE: Both `html.yml` and `prosemirror_json.yml` generation are tested in a single example, to
+ # avoid slower tests, because generating the static HTML is slow due to the need to invoke
+ # the rails environment. We could have separate sections, but this would require an extra flag
+ # to the `process` method to independently skip static vs. WYSIWYG, which is not worth the effort.
+ it 'writes the correct content' do
+ subject.process
+
+ expected_html =
+ <<~ES_HTML_YML_CONTENTS
+ ---
+ 02_01__gitlab_specific_section_with_examples__strong_but_with_two_asterisks__01:
+ canonical: |
+ <p><strong>bold</strong></p>
+ static: |-
+ <p data-sourcepos="1:1-1:8" dir="auto"><strong>bold</strong></p>
+ wysiwyg: |-
+ <p><strong>bold</strong></p>
+ ES_HTML_YML_CONTENTS
+
+ expected_prosemirror_json =
+ <<~ES_PROSEMIRROR_JSON_YML_CONTENTS
+ ---
+ 02_01__gitlab_specific_section_with_examples__strong_but_with_two_asterisks__01: |-
+ {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "bold"
+ }
+ ]
+ }
+ ]
+ }
+ ES_PROSEMIRROR_JSON_YML_CONTENTS
+
+ expect(es_html_yml_contents).to eq(expected_html)
+ expect(es_prosemirror_json_yml_contents).to eq(expected_prosemirror_json)
+ end
+ end
+
+ def reread_io(io)
+ # Reset the io StringIO to the beginning position of the buffer
+ io.seek(0)
+ io.read
+ end
+end
diff --git a/spec/scripts/lib/glfm/update_specification_spec.rb b/spec/scripts/lib/glfm/update_specification_spec.rb
new file mode 100644
index 00000000000..e8d34b13efa
--- /dev/null
+++ b/spec/scripts/lib/glfm/update_specification_spec.rb
@@ -0,0 +1,196 @@
+# frozen_string_literal: true
+require 'fast_spec_helper'
+require_relative '../../../../scripts/lib/glfm/update_specification'
+
+RSpec.describe Glfm::UpdateSpecification, '#process' do
+ subject { described_class.new }
+
+ let(:ghfm_spec_txt_uri) { described_class::GHFM_SPEC_TXT_URI }
+ let(:ghfm_spec_txt_uri_io) { StringIO.new(ghfm_spec_txt_contents) }
+ let(:ghfm_spec_txt_path) { described_class::GHFM_SPEC_TXT_PATH }
+ let(:ghfm_spec_txt_local_io) { StringIO.new(ghfm_spec_txt_contents) }
+
+ let(:glfm_intro_txt_path) { described_class::GLFM_INTRO_TXT_PATH }
+ let(:glfm_intro_txt_io) { StringIO.new(glfm_intro_txt_contents) }
+ let(:glfm_examples_txt_path) { described_class::GLFM_EXAMPLES_TXT_PATH }
+ let(:glfm_examples_txt_io) { StringIO.new(glfm_examples_txt_contents) }
+ let(:glfm_spec_txt_path) { described_class::GLFM_SPEC_TXT_PATH }
+ let(:glfm_spec_txt_io) { StringIO.new }
+
+ let(:ghfm_spec_txt_contents) do
+ <<~GHFM_SPEC_TXT_CONTENTS
+ ---
+ title: GitHub Flavored Markdown Spec
+ version: 0.29
+ date: '2019-04-06'
+ license: '[CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)'
+ ...
+
+ # Introduction
+
+ ## What is GitHub Flavored Markdown?
+
+ It's like GLFM, but with an H.
+
+ # Section with Examples
+
+ ## Strong
+
+ ```````````````````````````````` example
+ __bold__
+ .
+ <p><strong>bold</strong></p>
+ ````````````````````````````````
+
+ End of last GitHub examples section.
+
+ <!-- END TESTS -->
+
+ # Appendix
+
+ Appendix text.
+ GHFM_SPEC_TXT_CONTENTS
+ end
+
+ let(:glfm_intro_txt_contents) do
+ <<~GLFM_INTRO_TXT_CONTENTS
+ # Introduction
+
+ ## What is GitLab Flavored Markdown?
+
+ Intro text about GitLab Flavored Markdown.
+ GLFM_INTRO_TXT_CONTENTS
+ end
+
+ let(:glfm_examples_txt_contents) do
+ <<~GLFM_EXAMPLES_TXT_CONTENTS
+ # GitLab-Specific Section with Examples
+
+ Some examples.
+ GLFM_EXAMPLES_TXT_CONTENTS
+ end
+
+ before do
+ # Mock default ENV var values
+ allow(ENV).to receive(:[]).with('UPDATE_GHFM_SPEC_TXT').and_return(nil)
+ allow(ENV).to receive(:[]).and_call_original
+
+ # We mock out the URI and local file IO objects with real StringIO, instead of just mock
+ # objects. This gives better and more realistic coverage, while still avoiding
+ # actual network and filesystem I/O during the spec run.
+ allow(URI).to receive(:open).with(ghfm_spec_txt_uri) { ghfm_spec_txt_uri_io }
+ allow(File).to receive(:open).with(ghfm_spec_txt_path) { ghfm_spec_txt_local_io }
+ allow(File).to receive(:open).with(glfm_intro_txt_path) { glfm_intro_txt_io }
+ allow(File).to receive(:open).with(glfm_examples_txt_path) { glfm_examples_txt_io }
+ allow(File).to receive(:open).with(glfm_spec_txt_path, 'w') { glfm_spec_txt_io }
+
+ # Prevent console output when running tests
+ allow(subject).to receive(:output)
+ end
+
+ describe 'retrieving latest GHFM spec.txt' do
+ context 'when UPDATE_GHFM_SPEC_TXT is not true (default)' do
+ it 'does not download' do
+ expect(URI).not_to receive(:open).with(ghfm_spec_txt_uri)
+
+ subject.process
+
+ expect(reread_io(ghfm_spec_txt_local_io)).to eq(ghfm_spec_txt_contents)
+ end
+ end
+
+ context 'when UPDATE_GHFM_SPEC_TXT is true' do
+ let(:ghfm_spec_txt_local_io) { StringIO.new }
+
+ before do
+ allow(ENV).to receive(:[]).with('UPDATE_GHFM_SPEC_TXT').and_return('true')
+ allow(File).to receive(:open).with(ghfm_spec_txt_path, 'w') { ghfm_spec_txt_local_io }
+ end
+
+ context 'with success' do
+ it 'downloads and saves' do
+ subject.process
+
+ expect(reread_io(ghfm_spec_txt_local_io)).to eq(ghfm_spec_txt_contents)
+ end
+ end
+
+ context 'with error handling' do
+ context 'with a version mismatch' do
+ let(:ghfm_spec_txt_contents) do
+ <<~GHFM_SPEC_TXT_CONTENTS
+ ---
+ title: GitHub Flavored Markdown Spec
+ version: 0.30
+ ...
+ GHFM_SPEC_TXT_CONTENTS
+ end
+
+ it 'raises an error' do
+ expect { subject.process }.to raise_error /version mismatch.*expected.*29.*got.*30/i
+ end
+ end
+
+ context 'with a failed read of file lines' do
+ let(:ghfm_spec_txt_contents) { '' }
+
+ it 'raises an error if lines cannot be read' do
+ expect { subject.process }.to raise_error /unable to read lines/i
+ end
+ end
+
+ context 'with a failed re-read of file string' do
+ before do
+ allow(ghfm_spec_txt_uri_io).to receive(:read).and_return(nil)
+ end
+
+ it 'raises an error if file is blank' do
+ expect { subject.process }.to raise_error /unable to read string/i
+ end
+ end
+ end
+ end
+ end
+
+ describe 'writing GLFM spec.txt' do
+ let(:glfm_contents) { reread_io(glfm_spec_txt_io) }
+
+ before do
+ subject.process
+ end
+
+ it 'replaces the header text with the GitLab version' do
+ expect(glfm_contents).not_to match(/GitHub Flavored Markdown Spec/m)
+ expect(glfm_contents).not_to match(/^version: \d\.\d/m)
+ expect(glfm_contents).not_to match(/^date: /m)
+ expect(glfm_contents).not_to match(/^license: /m)
+ expect(glfm_contents).to match(/#{Regexp.escape(described_class::GLFM_SPEC_TXT_HEADER)}\n/mo)
+ end
+
+ it 'replaces the intro section with the GitLab version' do
+ expect(glfm_contents).not_to match(/What is GitHub Flavored Markdown/m)
+ expect(glfm_contents).to match(/#{Regexp.escape(glfm_intro_txt_contents)}/m)
+ end
+
+ it 'inserts the GitLab examples sections before the appendix section' do
+ expected = <<~GHFM_SPEC_TXT_CONTENTS
+ End of last GitHub examples section.
+
+ # GitLab-Specific Section with Examples
+
+ Some examples.
+
+ <!-- END TESTS -->
+
+ # Appendix
+ GHFM_SPEC_TXT_CONTENTS
+ expect(glfm_contents).to match(/#{Regexp.escape(expected)}/m)
+ end
+ end
+
+ def reread_io(io)
+ # Reset the io StringIO to the beginning position of the buffer
+ io.seek(0)
+ io.read
+ end
+end
diff --git a/spec/scripts/trigger-build_spec.rb b/spec/scripts/trigger-build_spec.rb
new file mode 100644
index 00000000000..76a3cdbeaa2
--- /dev/null
+++ b/spec/scripts/trigger-build_spec.rb
@@ -0,0 +1,970 @@
+# frozen_string_literal: true
+# rubocop:disable RSpec/VerifiedDoubles
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+
+require_relative '../../scripts/trigger-build'
+
+RSpec.describe Trigger do
+ let(:env) do
+ {
+ 'CI_JOB_URL' => 'ci_job_url',
+ 'CI_PROJECT_PATH' => 'ci_project_path',
+ 'CI_COMMIT_REF_NAME' => 'ci_commit_ref_name',
+ 'CI_COMMIT_REF_SLUG' => 'ci_commit_ref_slug',
+ 'CI_COMMIT_SHA' => 'ci_commit_sha',
+ 'CI_MERGE_REQUEST_PROJECT_ID' => 'ci_merge_request_project_id',
+ 'CI_MERGE_REQUEST_IID' => 'ci_merge_request_iid',
+ 'GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN' => 'bot-token',
+ 'CI_JOB_TOKEN' => 'job-token',
+ 'GITLAB_USER_NAME' => 'gitlab_user_name',
+ 'GITLAB_USER_LOGIN' => 'gitlab_user_login',
+ 'QA_IMAGE' => 'qa_image',
+ 'OMNIBUS_GITLAB_CACHE_UPDATE' => 'omnibus_gitlab_cache_update',
+ 'OMNIBUS_GITLAB_PROJECT_ACCESS_TOKEN' => nil,
+ 'DOCS_PROJECT_API_TOKEN' => nil
+ }
+ end
+
+ let(:com_api_endpoint) { 'https://gitlab.com/api/v4' }
+ let(:com_api_token) { env['GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN'] }
+ let(:com_gitlab_client) { double('com_gitlab_client') }
+
+ let(:downstream_gitlab_client_endpoint) { com_api_endpoint }
+ let(:downstream_gitlab_client_token) { com_api_token }
+ let(:downstream_gitlab_client) { com_gitlab_client }
+
+ let(:stubbed_pipeline) { Struct.new(:id, :web_url).new(42, 'pipeline_url') }
+ let(:trigger_token) { env['CI_JOB_TOKEN'] }
+
+ before do
+ stub_env(env)
+ allow(subject).to receive(:puts)
+ allow(Gitlab).to receive(:client)
+ .with(
+ endpoint: downstream_gitlab_client_endpoint,
+ private_token: downstream_gitlab_client_token
+ )
+ .and_return(downstream_gitlab_client)
+ end
+
+ def expect_run_trigger_with_params(variables = {})
+ expect(downstream_gitlab_client).to receive(:run_trigger)
+ .with(
+ downstream_project_path,
+ trigger_token,
+ ref,
+ hash_including(variables)
+ )
+ .and_return(stubbed_pipeline)
+ end
+
+ describe Trigger::Base do
+ let(:ref) { 'main' }
+
+ describe '#invoke!' do
+ context "when required methods aren't defined" do
+ it 'raises a NotImplementedError' do
+ expect { described_class.new.invoke! }.to raise_error(NotImplementedError)
+ end
+ end
+
+ context "when required methods are defined" do
+ let(:downstream_project_path) { 'foo/bar' }
+ let(:subclass) do
+ Class.new(Trigger::Base) do
+ def downstream_project_path
+ 'foo/bar'
+ end
+
+ # Must be overridden
+ def ref_param_name
+ 'FOO_BAR_BRANCH'
+ end
+ end
+ end
+
+ subject { subclass.new }
+
+ context 'when env variable `FOO_BAR_BRANCH` does not exist' do
+ it 'triggers the pipeline on the correct project and branch' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+
+ context 'when env variable `FOO_BAR_BRANCH` exists' do
+ let(:ref) { 'foo_bar_branch' }
+
+ before do
+ stub_env('FOO_BAR_BRANCH', ref)
+ end
+
+ it 'triggers the pipeline on the correct project and branch' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+
+ it 'waits for downstream pipeline' do
+ expect_run_trigger_with_params
+ expect(Trigger::Pipeline).to receive(:new)
+ .with(downstream_project_path, stubbed_pipeline.id, downstream_gitlab_client)
+
+ subject.invoke!
+ end
+
+ context 'with downstream_job_name: "foo"' do
+ let(:downstream_job) { Struct.new(:id, :name).new(42, 'foo') }
+ let(:paginated_resources) { Struct.new(:auto_paginate).new([downstream_job]) }
+
+ before do
+ stub_env('CI_COMMIT_REF_NAME', "#{ref}-ee")
+ end
+
+ it 'fetches the downstream job' do
+ expect_run_trigger_with_params
+ expect(downstream_gitlab_client).to receive(:pipeline_jobs)
+ .with(downstream_project_path, stubbed_pipeline.id).and_return(paginated_resources)
+ expect(Trigger::Job).to receive(:new)
+ .with(downstream_project_path, downstream_job.id, downstream_gitlab_client)
+
+ subject.invoke!(downstream_job_name: 'foo')
+ end
+ end
+ end
+ end
+
+ describe '#variables' do
+ let(:simple_forwarded_variables) do
+ {
+ 'TRIGGER_SOURCE' => env['CI_JOB_URL'],
+ 'TOP_UPSTREAM_SOURCE_PROJECT' => env['CI_PROJECT_PATH'],
+ 'TOP_UPSTREAM_SOURCE_REF' => env['CI_COMMIT_REF_NAME'],
+ 'TOP_UPSTREAM_SOURCE_JOB' => env['CI_JOB_URL'],
+ 'TOP_UPSTREAM_MERGE_REQUEST_PROJECT_ID' => env['CI_MERGE_REQUEST_PROJECT_ID'],
+ 'TOP_UPSTREAM_MERGE_REQUEST_IID' => env['CI_MERGE_REQUEST_IID']
+ }
+ end
+
+ it 'includes simple forwarded variables' do
+ expect(subject.variables).to include(simple_forwarded_variables)
+ end
+
+ describe "#base_variables" do
+ context 'when CI_COMMIT_TAG is set' do
+ before do
+ stub_env('CI_COMMIT_TAG', 'v1.0')
+ end
+
+ it 'sets GITLAB_REF_SLUG to CI_COMMIT_REF_NAME' do
+ expect(subject.variables['GITLAB_REF_SLUG']).to eq(env['CI_COMMIT_REF_NAME'])
+ end
+ end
+
+ context 'when CI_COMMIT_TAG is nil' do
+ before do
+ stub_env('CI_COMMIT_TAG', nil)
+ end
+
+ it 'sets GITLAB_REF_SLUG to CI_COMMIT_REF_SLUG' do
+ expect(subject.variables['GITLAB_REF_SLUG']).to eq(env['CI_COMMIT_REF_SLUG'])
+ end
+ end
+
+ context 'when TRIGGERED_USER is set' do
+ before do
+ stub_env('TRIGGERED_USER', 'triggered_user')
+ end
+
+ it 'sets TRIGGERED_USER to triggered_user' do
+ expect(subject.variables['TRIGGERED_USER']).to eq('triggered_user')
+ end
+ end
+
+ context 'when TRIGGERED_USER is not set' do
+ before do
+ stub_env('TRIGGERED_USER', nil)
+ end
+
+ it 'sets TRIGGERED_USER to GITLAB_USER_NAME' do
+ expect(subject.variables['TRIGGERED_USER']).to eq(env['GITLAB_USER_NAME'])
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', 'ci_merge_request_source_branch_sha')
+ end
+
+ it 'sets TOP_UPSTREAM_SOURCE_SHA to ci_merge_request_source_branch_sha' do
+ expect(subject.variables['TOP_UPSTREAM_SOURCE_SHA']).to eq('ci_merge_request_source_branch_sha')
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is set as empty' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', '')
+ end
+
+ it 'sets TOP_UPSTREAM_SOURCE_SHA to CI_COMMIT_SHA' do
+ expect(subject.variables['TOP_UPSTREAM_SOURCE_SHA']).to eq(env['CI_COMMIT_SHA'])
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is not set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', nil)
+ end
+
+ it 'sets TOP_UPSTREAM_SOURCE_SHA to CI_COMMIT_SHA' do
+ expect(subject.variables['TOP_UPSTREAM_SOURCE_SHA']).to eq(env['CI_COMMIT_SHA'])
+ end
+ end
+ end
+
+ describe "#version_file_variables" do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:version_file, :version) do
+ 'GITALY_SERVER_VERSION' | "1"
+ 'GITLAB_ELASTICSEARCH_INDEXER_VERSION' | "2"
+ 'GITLAB_KAS_VERSION' | "3"
+ 'GITLAB_PAGES_VERSION' | "4"
+ 'GITLAB_SHELL_VERSION' | "5"
+ 'GITLAB_WORKHORSE_VERSION' | "6"
+ end
+
+ with_them do
+ context "when set in ENV" do
+ before do
+ stub_env(version_file, version)
+ end
+
+ it 'includes the version from ENV' do
+ expect(subject.variables[version_file]).to eq(version)
+ end
+ end
+
+ context "when set in a file" do
+ before do
+ allow(File).to receive(:read).and_call_original
+ end
+
+ it 'includes the version from the file' do
+ expect(File).to receive(:read).with(version_file).and_return(version)
+ expect(subject.variables[version_file]).to eq(version)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe Trigger::Omnibus do
+ describe '#variables' do
+ it 'invokes the trigger with expected variables' do
+ expect(subject.variables).to include(
+ 'QA_IMAGE' => env['QA_IMAGE'],
+ 'SKIP_QA_DOCKER' => 'true',
+ 'ALTERNATIVE_SOURCES' => 'true',
+ 'CACHE_UPDATE' => env['OMNIBUS_GITLAB_CACHE_UPDATE'],
+ 'GITLAB_QA_OPTIONS' => env['GITLAB_QA_OPTIONS'],
+ 'QA_TESTS' => env['QA_TESTS'],
+ 'ALLURE_JOB_NAME' => env['ALLURE_JOB_NAME']
+ )
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', 'ci_merge_request_source_branch_sha')
+ end
+
+ it 'sets GITLAB_VERSION & IMAGE_TAG to ci_merge_request_source_branch_sha' do
+ expect(subject.variables).to include(
+ 'GITLAB_VERSION' => 'ci_merge_request_source_branch_sha',
+ 'IMAGE_TAG' => 'ci_merge_request_source_branch_sha'
+ )
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is set as empty' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', '')
+ end
+
+ it 'sets GITLAB_VERSION & IMAGE_TAG to CI_COMMIT_SHA' do
+ expect(subject.variables).to include(
+ 'GITLAB_VERSION' => env['CI_COMMIT_SHA'],
+ 'IMAGE_TAG' => env['CI_COMMIT_SHA']
+ )
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is not set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', nil)
+ end
+
+ it 'sets GITLAB_VERSION & IMAGE_TAG to CI_COMMIT_SHA' do
+ expect(subject.variables).to include(
+ 'GITLAB_VERSION' => env['CI_COMMIT_SHA'],
+ 'IMAGE_TAG' => env['CI_COMMIT_SHA']
+ )
+ end
+ end
+
+ context 'when Trigger.security? is true' do
+ before do
+ allow(Trigger).to receive(:security?).and_return(true)
+ end
+
+ it 'sets SECURITY_SOURCES to true' do
+ expect(subject.variables['SECURITY_SOURCES']).to eq('true')
+ end
+ end
+
+ context 'when Trigger.security? is false' do
+ before do
+ allow(Trigger).to receive(:security?).and_return(false)
+ end
+
+ it 'sets SECURITY_SOURCES to false' do
+ expect(subject.variables['SECURITY_SOURCES']).to eq('false')
+ end
+ end
+
+ context 'when Trigger.ee? is true' do
+ before do
+ allow(Trigger).to receive(:ee?).and_return(true)
+ end
+
+ it 'sets ee to true' do
+ expect(subject.variables['ee']).to eq('true')
+ end
+ end
+
+ context 'when Trigger.ee? is false' do
+ before do
+ allow(Trigger).to receive(:ee?).and_return(false)
+ end
+
+ it 'sets ee to false' do
+ expect(subject.variables['ee']).to eq('false')
+ end
+ end
+
+ context 'when QA_BRANCH is set' do
+ before do
+ stub_env('QA_BRANCH', 'qa_branch')
+ end
+
+ it 'sets QA_BRANCH to qa_branch' do
+ expect(subject.variables['QA_BRANCH']).to eq('qa_branch')
+ end
+ end
+ end
+
+ describe '.access_token' do
+ context 'when OMNIBUS_GITLAB_PROJECT_ACCESS_TOKEN is set' do
+ let(:omnibus_gitlab_project_access_token) { 'omnibus_gitlab_project_access_token' }
+
+ before do
+ stub_env('OMNIBUS_GITLAB_PROJECT_ACCESS_TOKEN', omnibus_gitlab_project_access_token)
+ end
+
+ it 'returns the omnibus-specific access token' do
+ expect(described_class.access_token).to eq(omnibus_gitlab_project_access_token)
+ end
+ end
+
+ context 'when OMNIBUS_GITLAB_PROJECT_ACCESS_TOKEN is not set' do
+ before do
+ stub_env('OMNIBUS_GITLAB_PROJECT_ACCESS_TOKEN', nil)
+ end
+
+ it 'returns the default access token' do
+ expect(described_class.access_token).to eq(Trigger::Base.access_token)
+ end
+ end
+ end
+
+ describe '#invoke!' do
+ let(:downstream_project_path) { 'gitlab-org/build/omnibus-gitlab-mirror' }
+ let(:ref) { 'master' }
+
+ let(:env) do
+ super().merge(
+ 'QA_IMAGE' => 'qa_image',
+ 'GITLAB_QA_OPTIONS' => 'gitlab_qa_options',
+ 'QA_TESTS' => 'qa_tests',
+ 'ALLURE_JOB_NAME' => 'allure_job_name'
+ )
+ end
+
+ describe '#downstream_project_path' do
+ context 'when OMNIBUS_PROJECT_PATH is set' do
+ let(:downstream_project_path) { 'omnibus_project_path' }
+
+ before do
+ stub_env('OMNIBUS_PROJECT_PATH', downstream_project_path)
+ end
+
+ it 'triggers the pipeline on the correct project' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+ end
+
+ describe '#ref' do
+ context 'when OMNIBUS_BRANCH is set' do
+ let(:ref) { 'omnibus_branch' }
+
+ before do
+ stub_env('OMNIBUS_BRANCH', ref)
+ end
+
+ it 'triggers the pipeline on the correct ref' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+ end
+
+ context 'when CI_COMMIT_REF_NAME is a stable branch' do
+ let(:ref) { '14-10-stable' }
+
+ before do
+ stub_env('CI_COMMIT_REF_NAME', "#{ref}-ee")
+ end
+
+ it 'triggers the pipeline on the correct ref' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+ end
+ end
+
+ describe Trigger::CNG do
+ describe '#variables' do
+ it 'does not include redundant variables' do
+ expect(subject.variables).not_to include('TRIGGER_SOURCE', 'TRIGGERED_USER')
+ end
+
+ it 'invokes the trigger with expected variables' do
+ expect(subject.variables).to include('FORCE_RAILS_IMAGE_BUILDS' => 'true')
+ end
+
+ describe "TRIGGER_BRANCH" do
+ context 'when CNG_BRANCH is not set' do
+ it 'sets TRIGGER_BRANCH to master' do
+ expect(subject.variables['TRIGGER_BRANCH']).to eq('master')
+ end
+ end
+
+ context 'when CNG_BRANCH is set' do
+ let(:ref) { 'cng_branch' }
+
+ before do
+ stub_env('CNG_BRANCH', ref)
+ end
+
+ it 'sets TRIGGER_BRANCH to cng_branch' do
+ expect(subject.variables['TRIGGER_BRANCH']).to eq(ref)
+ end
+ end
+
+ context 'when CI_COMMIT_REF_NAME is a stable branch' do
+ let(:ref) { '14-10-stable' }
+
+ before do
+ stub_env('CI_COMMIT_REF_NAME', "#{ref}-ee")
+ end
+
+ it 'sets TRIGGER_BRANCH to the corresponding stable branch' do
+ expect(subject.variables['TRIGGER_BRANCH']).to eq(ref)
+ end
+ end
+ end
+
+ describe "GITLAB_VERSION" do
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', 'ci_merge_request_source_branch_sha')
+ end
+
+ it 'sets GITLAB_VERSION to CI_MERGE_REQUEST_SOURCE_BRANCH_SHA' do
+ expect(subject.variables['GITLAB_VERSION']).to eq('ci_merge_request_source_branch_sha')
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is set as empty' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', '')
+ end
+
+ it 'sets GITLAB_VERSION to CI_COMMIT_SHA' do
+ expect(subject.variables['GITLAB_VERSION']).to eq(env['CI_COMMIT_SHA'])
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is not set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', nil)
+ end
+
+ it 'sets GITLAB_VERSION to CI_COMMIT_SHA' do
+ expect(subject.variables['GITLAB_VERSION']).to eq(env['CI_COMMIT_SHA'])
+ end
+ end
+ end
+
+ describe "GITLAB_TAG" do
+ context 'when CI_COMMIT_TAG is set' do
+ before do
+ stub_env('CI_COMMIT_TAG', 'v1.0')
+ end
+
+ it 'sets GITLAB_TAG to true' do
+ expect(subject.variables['GITLAB_TAG']).to eq('v1.0')
+ end
+ end
+
+ context 'when CI_COMMIT_TAG is nil' do
+ before do
+ stub_env('CI_COMMIT_TAG', nil)
+ end
+
+ it 'sets GITLAB_TAG to nil' do
+ expect(subject.variables['GITLAB_TAG']).to eq(nil)
+ end
+ end
+ end
+
+ describe "GITLAB_ASSETS_TAG" do
+ context 'when CI_COMMIT_TAG is set' do
+ before do
+ stub_env('CI_COMMIT_TAG', 'v1.0')
+ end
+
+ it 'sets GITLAB_ASSETS_TAG to CI_COMMIT_REF_NAME' do
+ expect(subject.variables['GITLAB_ASSETS_TAG']).to eq(env['CI_COMMIT_REF_NAME'])
+ end
+ end
+
+ context 'when CI_COMMIT_TAG and CI_MERGE_REQUEST_SOURCE_BRANCH_SHA are nil' do
+ before do
+ stub_env('CI_COMMIT_TAG', nil)
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', nil)
+ end
+
+ it 'sets GITLAB_ASSETS_TAG to CI_COMMIT_SHA' do
+ expect(subject.variables['GITLAB_ASSETS_TAG']).to eq(env['CI_COMMIT_SHA'])
+ end
+ end
+ end
+
+ describe "CE_PIPELINE" do
+ context 'when Trigger.ee? is true' do
+ before do
+ allow(Trigger).to receive(:ee?).and_return(true)
+ end
+
+ it 'sets CE_PIPELINE to nil' do
+ expect(subject.variables['CE_PIPELINE']).to eq(nil)
+ end
+ end
+
+ context 'when Trigger.ee? is false' do
+ before do
+ allow(Trigger).to receive(:ee?).and_return(false)
+ end
+
+ it 'sets CE_PIPELINE to true' do
+ expect(subject.variables['CE_PIPELINE']).to eq('true')
+ end
+ end
+ end
+
+ describe "EE_PIPELINE" do
+ context 'when Trigger.ee? is true' do
+ before do
+ allow(Trigger).to receive(:ee?).and_return(true)
+ end
+
+ it 'sets EE_PIPELINE to true' do
+ expect(subject.variables['EE_PIPELINE']).to eq('true')
+ end
+ end
+
+ context 'when Trigger.ee? is false' do
+ before do
+ allow(Trigger).to receive(:ee?).and_return(false)
+ end
+
+ it 'sets EE_PIPELINE to nil' do
+ expect(subject.variables['EE_PIPELINE']).to eq(nil)
+ end
+ end
+ end
+
+ describe "#version_param_value" do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:version_file) { 'GITALY_SERVER_VERSION' }
+
+ where(:raw_version, :expected_version) do
+ "1.2.3" | "v1.2.3"
+ "1.2.3-rc1" | "v1.2.3-rc1"
+ "1.2.3-ee" | "v1.2.3-ee"
+ "1.2.3-rc1-ee" | "v1.2.3-rc1-ee"
+ end
+
+ with_them do
+ context "when set in ENV" do
+ before do
+ stub_env(version_file, raw_version)
+ end
+
+ it 'includes the version from ENV' do
+ expect(subject.variables[version_file]).to eq(expected_version)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe Trigger::Docs do
+ let(:downstream_project_path) { 'gitlab-org/gitlab-docs' }
+
+ describe '#variables' do
+ describe "BRANCH_CE" do
+ before do
+ stub_env('CI_PROJECT_PATH', 'gitlab-org/gitlab-foss')
+ end
+
+ context 'when CI_PROJECT_PATH is gitlab-org/gitlab-foss' do
+ it 'sets BRANCH_CE to CI_COMMIT_REF_NAME' do
+ expect(subject.variables['BRANCH_CE']).to eq(env['CI_COMMIT_REF_NAME'])
+ end
+ end
+ end
+
+ describe "BRANCH_EE" do
+ before do
+ stub_env('CI_PROJECT_PATH', 'gitlab-org/gitlab')
+ end
+
+ context 'when CI_PROJECT_PATH is gitlab-org/gitlab' do
+ it 'sets BRANCH_EE to CI_COMMIT_REF_NAME' do
+ expect(subject.variables['BRANCH_EE']).to eq(env['CI_COMMIT_REF_NAME'])
+ end
+ end
+ end
+
+ describe "BRANCH_RUNNER" do
+ before do
+ stub_env('CI_PROJECT_PATH', 'gitlab-org/gitlab-runner')
+ end
+
+ context 'when CI_PROJECT_PATH is gitlab-org/gitlab-runner' do
+ it 'sets BRANCH_RUNNER to CI_COMMIT_REF_NAME' do
+ expect(subject.variables['BRANCH_RUNNER']).to eq(env['CI_COMMIT_REF_NAME'])
+ end
+ end
+ end
+
+ describe "BRANCH_OMNIBUS" do
+ before do
+ stub_env('CI_PROJECT_PATH', 'gitlab-org/omnibus-gitlab')
+ end
+
+ context 'when CI_PROJECT_PATH is gitlab-org/omnibus-gitlab' do
+ it 'sets BRANCH_OMNIBUS to CI_COMMIT_REF_NAME' do
+ expect(subject.variables['BRANCH_OMNIBUS']).to eq(env['CI_COMMIT_REF_NAME'])
+ end
+ end
+ end
+
+ describe "BRANCH_CHARTS" do
+ before do
+ stub_env('CI_PROJECT_PATH', 'gitlab-org/charts/gitlab')
+ end
+
+ context 'when CI_PROJECT_PATH is gitlab-org/charts/gitlab' do
+ it 'sets BRANCH_CHARTS to CI_COMMIT_REF_NAME' do
+ expect(subject.variables['BRANCH_CHARTS']).to eq(env['CI_COMMIT_REF_NAME'])
+ end
+ end
+ end
+
+ describe "REVIEW_SLUG" do
+ before do
+ stub_env('CI_PROJECT_PATH', 'gitlab-org/gitlab-foss')
+ end
+
+ context 'when CI_MERGE_REQUEST_IID is set' do
+ it 'sets REVIEW_SLUG' do
+ expect(subject.variables['REVIEW_SLUG']).to eq("-ce-#{env['CI_MERGE_REQUEST_IID']}")
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_IID is not set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_IID', nil)
+ end
+
+ it 'sets REVIEW_SLUG' do
+ expect(subject.variables['REVIEW_SLUG']).to eq("-ce-#{env['CI_COMMIT_REF_SLUG']}")
+ end
+ end
+ end
+ end
+
+ describe '.access_token' do
+ context 'when DOCS_PROJECT_API_TOKEN is set' do
+ let(:docs_project_api_token) { 'docs_project_api_token' }
+
+ before do
+ stub_env('DOCS_PROJECT_API_TOKEN', docs_project_api_token)
+ end
+
+ it 'returns the docs-specific access token' do
+ expect(described_class.access_token).to eq(docs_project_api_token)
+ end
+ end
+
+ context 'when DOCS_PROJECT_API_TOKEN is not set' do
+ before do
+ stub_env('DOCS_PROJECT_API_TOKEN', nil)
+ end
+
+ it 'returns the default access token' do
+ expect(described_class.access_token).to eq(Trigger::Base.access_token)
+ end
+ end
+ end
+
+ describe '#invoke!' do
+ let(:trigger_token) { 'docs_trigger_token' }
+ let(:ref) { 'main' }
+
+ let(:env) do
+ super().merge(
+ 'CI_PROJECT_PATH' => 'gitlab-org/gitlab-foss',
+ 'DOCS_TRIGGER_TOKEN' => trigger_token
+ )
+ end
+
+ describe '#downstream_project_path' do
+ context 'when DOCS_PROJECT_PATH is set' do
+ let(:downstream_project_path) { 'docs_project_path' }
+
+ before do
+ stub_env('DOCS_PROJECT_PATH', downstream_project_path)
+ end
+
+ it 'triggers the pipeline on the correct project' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+ end
+
+ describe '#ref' do
+ context 'when DOCS_BRANCH is set' do
+ let(:ref) { 'docs_branch' }
+
+ before do
+ stub_env('DOCS_BRANCH', ref)
+ end
+
+ it 'triggers the pipeline on the correct ref' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+ end
+ end
+
+ describe '#cleanup!' do
+ let(:downstream_environment_response) { double('downstream_environment', id: 42) }
+ let(:downstream_environments_response) { [downstream_environment_response] }
+
+ before do
+ expect(com_gitlab_client).to receive(:environments)
+ .with(downstream_project_path, name: subject.__send__(:downstream_environment))
+ .and_return(downstream_environments_response)
+ expect(com_gitlab_client).to receive(:stop_environment)
+ .with(downstream_project_path, downstream_environment_response.id)
+ .and_return(downstream_environment_stopping_response)
+ end
+
+ context "when stopping the environment succeeds" do
+ let(:downstream_environment_stopping_response) { double('downstream_environment', state: 'stopped') }
+
+ it 'displays a success message' do
+ expect(subject).to receive(:puts)
+ .with("=> Downstream environment '#{subject.__send__(:downstream_environment)}' stopped.")
+
+ subject.cleanup!
+ end
+ end
+
+ context "when stopping the environment fails" do
+ let(:downstream_environment_stopping_response) { double('downstream_environment', state: 'running') }
+
+ it 'displays a failure message' do
+ expect(subject).to receive(:puts)
+ .with("=> Downstream environment '#{subject.__send__(:downstream_environment)}' failed to stop.")
+
+ subject.cleanup!
+ end
+ end
+ end
+ end
+
+ describe Trigger::DatabaseTesting do
+ describe '#variables' do
+ it 'invokes the trigger with expected variables' do
+ expect(subject.variables).to include('TRIGGERED_USER_LOGIN' => env['GITLAB_USER_LOGIN'])
+ end
+
+ describe "GITLAB_COMMIT_SHA" do
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', 'ci_merge_request_source_branch_sha')
+ end
+
+ it 'sets GITLAB_COMMIT_SHA to ci_merge_request_source_branch_sha' do
+ expect(subject.variables['GITLAB_COMMIT_SHA']).to eq('ci_merge_request_source_branch_sha')
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is set as empty' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', '')
+ end
+
+ it 'sets GITLAB_COMMIT_SHA to CI_COMMIT_SHA' do
+ expect(subject.variables['GITLAB_COMMIT_SHA']).to eq(env['CI_COMMIT_SHA'])
+ end
+ end
+
+ context 'when CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is not set' do
+ before do
+ stub_env('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', nil)
+ end
+
+ it 'sets GITLAB_COMMIT_SHA to CI_COMMIT_SHA' do
+ expect(subject.variables['GITLAB_COMMIT_SHA']).to eq(env['CI_COMMIT_SHA'])
+ end
+ end
+ end
+ end
+
+ describe '#invoke!' do
+ let(:downstream_project_path) { 'gitlab-com/database-team/gitlab-com-database-testing' }
+ let(:trigger_token) { 'gitlabcom_database_testing_access_token' }
+ let(:ops_api_endpoint) { 'https://ops.gitlab.net/api/v4' }
+ let(:ops_api_token) { 'gitlabcom_database_testing_access_token' }
+ let(:ops_gitlab_client) { double('ops_gitlab_client') }
+
+ let(:downstream_gitlab_client_endpoint) { ops_api_endpoint }
+ let(:downstream_gitlab_client_token) { ops_api_token }
+ let(:downstream_gitlab_client) { ops_gitlab_client }
+
+ let(:ref) { 'master' }
+ let(:mr_notes) { [double(body: described_class::IDENTIFIABLE_NOTE_TAG)] }
+
+ let(:env) do
+ super().merge(
+ 'GITLABCOM_DATABASE_TESTING_ACCESS_TOKEN' => ops_api_token,
+ 'GITLABCOM_DATABASE_TESTING_TRIGGER_TOKEN' => trigger_token
+ )
+ end
+
+ before do
+ allow(Gitlab).to receive(:client)
+ .with(
+ endpoint: com_api_endpoint,
+ private_token: com_api_token
+ )
+ .and_return(com_gitlab_client)
+ allow(com_gitlab_client).to receive(:merge_request_notes)
+ .with(
+ env['CI_PROJECT_PATH'],
+ env['CI_MERGE_REQUEST_IID']
+ )
+ .and_return(double(auto_paginate: mr_notes))
+ end
+
+ it 'invokes the trigger with expected variables' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+
+ describe '#downstream_project_path' do
+ context 'when GITLABCOM_DATABASE_TESTING_PROJECT_PATH is set' do
+ let(:downstream_project_path) { 'gitlabcom_database_testing_project_path' }
+
+ before do
+ stub_env('GITLABCOM_DATABASE_TESTING_PROJECT_PATH', downstream_project_path)
+ end
+
+ it 'triggers the pipeline on the correct project' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+ end
+
+ describe '#ref' do
+ context 'when GITLABCOM_DATABASE_TESTING_TRIGGER_REF is set' do
+ let(:ref) { 'gitlabcom_database_testing_trigger_ref' }
+
+ before do
+ stub_env('GITLABCOM_DATABASE_TESTING_TRIGGER_REF', ref)
+ end
+
+ it 'triggers the pipeline on the correct ref' do
+ expect_run_trigger_with_params
+
+ subject.invoke!
+ end
+ end
+ end
+
+ context 'when no MR notes with the identifier exist yet' do
+ let(:mr_notes) { [double(body: 'hello world')] }
+
+ it 'posts a new note' do
+ expect_run_trigger_with_params
+ expect(com_gitlab_client).to receive(:create_merge_request_note)
+ .with(
+ env['CI_PROJECT_PATH'],
+ env['CI_MERGE_REQUEST_IID'],
+ instance_of(String)
+ )
+ .and_return(double(id: 42))
+
+ subject.invoke!
+ end
+ end
+ end
+ end
+end
+# rubocop:enable RSpec/VerifiedDoubles
diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb
index da2734feb51..dd8238456aa 100644
--- a/spec/serializers/build_details_entity_spec.rb
+++ b/spec/serializers/build_details_entity_spec.rb
@@ -127,21 +127,48 @@ RSpec.describe BuildDetailsEntity do
end
context 'when the build has failed due to a missing dependency' do
- let!(:test1) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test1', stage_idx: 0) }
- let!(:test2) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test2', stage_idx: 1) }
- let!(:build) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 2, options: { dependencies: %w(test1 test2) }) }
let(:message) { subject[:callout_message] }
- before do
- build.pipeline.unlocked!
- build.drop!(:missing_dependency_failure)
+ context 'when the dependency is in the same pipeline' do
+ let!(:test1) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test1', stage_idx: 0) }
+ let!(:test2) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test2', stage_idx: 1) }
+ let!(:build) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 2, options: { dependencies: %w(test1 test2) }) }
+
+ before do
+ build.pipeline.unlocked!
+ build.drop!(:missing_dependency_failure)
+ end
+
+ it { is_expected.to include(failure_reason: 'missing_dependency_failure') }
+
+ it 'includes the failing dependencies in the callout message' do
+ expect(message).to include('test1')
+ expect(message).to include('test2')
+ end
+
+ it 'includes message for list of invalid dependencies' do
+ expect(message).to include('could not retrieve the needed artifacts:')
+ end
end
- it { is_expected.to include(failure_reason: 'missing_dependency_failure') }
+ context 'when dependency is not found' do
+ let!(:build) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 2, options: { dependencies: %w(test1 test2) }) }
+
+ before do
+ build.pipeline.unlocked!
+ build.drop!(:missing_dependency_failure)
+ end
- it 'includes the failing dependencies in the callout message' do
- expect(message).to include('test1')
- expect(message).to include('test2')
+ it { is_expected.to include(failure_reason: 'missing_dependency_failure') }
+
+ it 'excludes the failing dependencies in the callout message' do
+ expect(message).not_to include('test1')
+ expect(message).not_to include('test2')
+ end
+
+ it 'includes the correct punctuation in the message' do
+ expect(message).to include('could not retrieve the needed artifacts.')
+ end
end
end
diff --git a/spec/serializers/ci/job_entity_spec.rb b/spec/serializers/ci/job_entity_spec.rb
index ba68b9a6c16..05b9e38444c 100644
--- a/spec/serializers/ci/job_entity_spec.rb
+++ b/spec/serializers/ci/job_entity_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Ci::JobEntity do
let(:user) { create(:user) }
- let(:job) { create(:ci_build) }
+ let(:job) { create(:ci_build, :running) }
let(:project) { job.project }
let(:request) { double('request') }
@@ -21,6 +21,11 @@ RSpec.describe Ci::JobEntity do
subject { entity.as_json }
+ it 'contains started' do
+ expect(subject).to include(:started)
+ expect(subject[:started]).to eq(true)
+ end
+
it 'contains complete to indicate if a pipeline is completed' do
expect(subject).to include(:complete)
end
@@ -128,6 +133,15 @@ RSpec.describe Ci::JobEntity do
end
end
+ context 'when job is running' do
+ let_it_be(:job) { create(:ci_build, :running) }
+
+ it 'contains started_at' do
+ expect(subject[:started]).to be_truthy
+ expect(subject[:started_at]).to eq(job.started_at)
+ end
+ end
+
context 'when job is generic commit status' do
let(:job) { create(:generic_commit_status, target_url: 'http://google.com') }
diff --git a/spec/serializers/cluster_entity_spec.rb b/spec/serializers/cluster_entity_spec.rb
index ee1388024ea..514828e3c69 100644
--- a/spec/serializers/cluster_entity_spec.rb
+++ b/spec/serializers/cluster_entity_spec.rb
@@ -77,6 +77,14 @@ RSpec.describe ClusterEntity do
expect(subject[:gitlab_managed_apps_logs_path]).to eq(log_explorer_path)
end
+
+ context 'when feature is disabled' do
+ before do
+ stub_feature_flags(monitor_logging: false)
+ end
+
+ specify { is_expected.not_to include(:gitlab_managed_apps_logs_path) }
+ end
end
context 'enable_advanced_logs_querying' do
@@ -98,6 +106,14 @@ RSpec.describe ClusterEntity do
expect(subject[:enable_advanced_logs_querying]).to be true
end
end
+
+ context 'when feature is disabled' do
+ before do
+ stub_feature_flags(monitor_logging: false)
+ end
+
+ specify { is_expected.not_to include(:enable_advanced_logs_querying) }
+ end
end
end
end
diff --git a/spec/serializers/discussion_entity_spec.rb b/spec/serializers/discussion_entity_spec.rb
index 0645d19da5b..0fe10ed2c6d 100644
--- a/spec/serializers/discussion_entity_spec.rb
+++ b/spec/serializers/discussion_entity_spec.rb
@@ -5,8 +5,11 @@ require 'spec_helper'
RSpec.describe DiscussionEntity do
include RepoHelpers
- let(:user) { create(:user) }
- let(:note) { create(:discussion_note_on_merge_request) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: group) }
+
+ let(:note) { create(:discussion_note_on_merge_request, project: project) }
let(:discussion) { note.discussion }
let(:request) { double('request', note_entity: ProjectNoteEntity) }
let(:controller) { double('controller') }
@@ -50,10 +53,15 @@ RSpec.describe DiscussionEntity do
.to match_schema('entities/note_user_entity')
end
+ it 'exposes the url for custom award emoji' do
+ custom_emoji = create(:custom_emoji, group: group)
+ create(:award_emoji, awardable: note, name: custom_emoji.name)
+
+ expect(subject[:notes].last[:award_emoji].first.keys).to include(:url)
+ end
+
context 'when is LegacyDiffDiscussion' do
- let(:project) { create(:project) }
- let(:merge_request) { create(:merge_request, source_project: project) }
- let(:discussion) { create(:legacy_diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
+ let(:discussion) { create(:legacy_diff_note_on_merge_request, noteable: note.noteable, project: project).to_discussion }
it 'exposes correct attributes' do
expect(subject.keys.sort).to include(
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index a59107ad309..9b6a293da16 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -166,6 +166,18 @@ RSpec.describe EnvironmentEntity do
expect(subject[:logs_api_path]).to eq(elasticsearch_project_logs_path(project, environment_name: environment.name, format: :json))
end
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(monitor_logging: false)
+ end
+
+ it 'does not expose logs keys' do
+ expect(subject).not_to include(:logs_path)
+ expect(subject).not_to include(:logs_api_path)
+ expect(subject).not_to include(:enable_advanced_logs_querying)
+ end
+ end
end
end
diff --git a/spec/serializers/issue_board_entity_spec.rb b/spec/serializers/issue_board_entity_spec.rb
index 30423ceba6d..b8e2bfeaa3d 100644
--- a/spec/serializers/issue_board_entity_spec.rb
+++ b/spec/serializers/issue_board_entity_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe IssueBoardEntity do
+ include Gitlab::Routing.url_helpers
+
let_it_be(:project) { create(:project) }
let_it_be(:resource) { create(:issue, project: project) }
let_it_be(:user) { create(:user) }
@@ -40,4 +42,18 @@ RSpec.describe IssueBoardEntity do
expect(subject).to include(labels: array_including(hash_including(:id, :title, :color, :description, :text_color, :priority)))
end
+
+ describe 'real_path' do
+ it 'has an issue path' do
+ expect(subject[:real_path]).to eq(project_issue_path(project, resource.iid))
+ end
+
+ context 'when issue is of type task' do
+ let(:resource) { create(:issue, :task, project: project) }
+
+ it 'has a work item path' do
+ expect(subject[:real_path]).to eq(project_work_items_path(project, resource.id))
+ end
+ end
+ end
end
diff --git a/spec/serializers/issue_entity_spec.rb b/spec/serializers/issue_entity_spec.rb
index 76f8cf644c6..6ccb3dbc657 100644
--- a/spec/serializers/issue_entity_spec.rb
+++ b/spec/serializers/issue_entity_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe IssueEntity do
+ include Gitlab::Routing.url_helpers
+
let(:project) { create(:project) }
let(:resource) { create(:issue, project: project) }
let(:user) { create(:user) }
@@ -11,6 +13,17 @@ RSpec.describe IssueEntity do
subject { described_class.new(resource, request: request).as_json }
+ describe 'web_url' do
+ context 'when issue is of type task' do
+ let(:resource) { create(:issue, :task, project: project) }
+
+ # This was already a path and not a url when the work items change was introduced
+ it 'has a work item path' do
+ expect(subject[:web_url]).to eq(project_work_items_path(project, resource.id))
+ end
+ end
+ end
+
it 'has Issuable attributes' do
expect(subject).to include(:id, :iid, :author_id, :description, :lock_version, :milestone_id,
:title, :updated_by_id, :created_at, :updated_at, :milestone, :labels)
diff --git a/spec/serializers/issue_sidebar_basic_entity_spec.rb b/spec/serializers/issue_sidebar_basic_entity_spec.rb
index 716c97f72af..564ffb1aea9 100644
--- a/spec/serializers/issue_sidebar_basic_entity_spec.rb
+++ b/spec/serializers/issue_sidebar_basic_entity_spec.rb
@@ -94,5 +94,37 @@ RSpec.describe IssueSidebarBasicEntity do
expect(entity[:show_crm_contacts]).to be(expected)
end
end
+
+ context 'in subgroup' do
+ let(:subgroup_project) { create(:project, :repository, group: subgroup) }
+ let(:subgroup_issue) { create(:issue, project: subgroup_project) }
+ let(:serializer) { IssueSerializer.new(current_user: user, project: subgroup_project) }
+
+ subject(:entity) { serializer.represent(subgroup_issue, serializer: 'sidebar') }
+
+ before do
+ subgroup_project.root_ancestor.add_reporter(user)
+ end
+
+ context 'with crm enabled' do
+ let(:subgroup) { create(:group, :crm_enabled, parent: group) }
+
+ it 'is true' do
+ allow(CustomerRelations::Contact).to receive(:exists_for_group?).with(group).and_return(true)
+
+ expect(entity[:show_crm_contacts]).to be_truthy
+ end
+ end
+
+ context 'with crm disabled' do
+ let(:subgroup) { create(:group, parent: group) }
+
+ it 'is false' do
+ allow(CustomerRelations::Contact).to receive(:exists_for_group?).with(group).and_return(true)
+
+ expect(entity[:show_crm_contacts]).to be_falsy
+ end
+ end
+ end
end
end
diff --git a/spec/serializers/linked_project_issue_entity_spec.rb b/spec/serializers/linked_project_issue_entity_spec.rb
index 864b5c45599..b28b00bd8e1 100644
--- a/spec/serializers/linked_project_issue_entity_spec.rb
+++ b/spec/serializers/linked_project_issue_entity_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe LinkedProjectIssueEntity do
+ include Gitlab::Routing.url_helpers
+
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:issue_link) { create(:issue_link) }
@@ -17,7 +19,25 @@ RSpec.describe LinkedProjectIssueEntity do
issue_link.target.project.add_developer(user)
end
+ subject(:serialized_entity) { entity.as_json }
+
describe 'issue_link_type' do
- it { expect(entity.as_json).to include(link_type: 'relates_to') }
+ it { is_expected.to include(link_type: 'relates_to') }
+ end
+
+ describe 'path' do
+ it 'returns an issue path' do
+ expect(serialized_entity).to include(path: project_issue_path(related_issue.project, related_issue.iid))
+ end
+
+ context 'when related issue is a task' do
+ before do
+ related_issue.update!(issue_type: :task, work_item_type: WorkItems::Type.default_by_type(:task))
+ end
+
+ it 'returns a work items path' do
+ expect(serialized_entity).to include(path: project_work_items_path(related_issue.project, related_issue.id))
+ end
+ end
end
end
diff --git a/spec/serializers/merge_request_user_entity_spec.rb b/spec/serializers/merge_request_user_entity_spec.rb
index 72d1b0c0dd2..7877356ff0f 100644
--- a/spec/serializers/merge_request_user_entity_spec.rb
+++ b/spec/serializers/merge_request_user_entity_spec.rb
@@ -58,6 +58,10 @@ RSpec.describe MergeRequestUserEntity do
end
context 'attention_requested' do
+ before do
+ merge_request.find_assignee(user).update!(state: :attention_requested)
+ end
+
it { is_expected.to include(attention_requested: true ) }
end
diff --git a/spec/serializers/release_serializer_spec.rb b/spec/serializers/release_serializer_spec.rb
index 518d281f370..b31172c3a50 100644
--- a/spec/serializers/release_serializer_spec.rb
+++ b/spec/serializers/release_serializer_spec.rb
@@ -19,6 +19,10 @@ RSpec.describe ReleaseSerializer do
it 'serializes the label object' do
expect(subject[:tag]).to eq resource.tag
end
+
+ it 'does not expose git-sha as sensitive information' do
+ expect(subject[:sha]).to be_nil
+ end
end
context 'when multiple objects are being serialized' do
diff --git a/spec/services/alert_management/alerts/update_service_spec.rb b/spec/services/alert_management/alerts/update_service_spec.rb
index 882543fd701..f02607b8174 100644
--- a/spec/services/alert_management/alerts/update_service_spec.rb
+++ b/spec/services/alert_management/alerts/update_service_spec.rb
@@ -88,7 +88,7 @@ RSpec.describe AlertManagement::Alerts::UpdateService do
it_behaves_like 'title update'
end
- context 'when alert is resolved and another existing open alert' do
+ context 'when alert is resolved and another existing unresolved alert' do
let!(:alert) { create(:alert_management_alert, :resolved, project: project) }
let!(:existing_alert) { create(:alert_management_alert, :triggered, project: project) }
@@ -193,27 +193,38 @@ RSpec.describe AlertManagement::Alerts::UpdateService do
end
end
- context 'with an opening status and existing open alert' do
- let_it_be(:alert) { create(:alert_management_alert, :resolved, :with_fingerprint, project: project) }
- let_it_be(:existing_alert) { create(:alert_management_alert, :triggered, fingerprint: alert.fingerprint, project: project) }
- let_it_be(:url) { Gitlab::Routing.url_helpers.details_project_alert_management_path(project, existing_alert) }
- let_it_be(:link) { ActionController::Base.helpers.link_to(_('alert'), url) }
+ context 'with existing unresolved alert' do
+ context 'with fingerprints' do
+ let_it_be(:existing_alert) { create(:alert_management_alert, :triggered, fingerprint: alert.fingerprint, project: project) }
- let(:message) do
- "An #{link} with the same fingerprint is already open. " \
- 'To change the status of this alert, resolve the linked alert.'
- end
+ it 'does not query for existing alerts' do
+ expect(::AlertManagement::Alert).not_to receive(:find_unresolved_alert)
- it_behaves_like 'does not add a todo'
- it_behaves_like 'does not add a system note'
+ response
+ end
- it 'has an informative message' do
- expect(response).to be_error
- expect(response.message).to eq(message)
+ context 'when status was resolved' do
+ let_it_be(:alert) { create(:alert_management_alert, :resolved, :with_fingerprint, project: project) }
+ let_it_be(:existing_alert) { create(:alert_management_alert, :triggered, fingerprint: alert.fingerprint, project: project) }
+
+ let(:url) { Gitlab::Routing.url_helpers.details_project_alert_management_path(project, existing_alert) }
+ let(:link) { ActionController::Base.helpers.link_to(_('alert'), url) }
+ let(:message) do
+ "An #{link} with the same fingerprint is already open. " \
+ 'To change the status of this alert, resolve the linked alert.'
+ end
+
+ it_behaves_like 'does not add a todo'
+ it_behaves_like 'does not add a system note'
+
+ it 'has an informative message' do
+ expect(response).to be_error
+ expect(response.message).to eq(message)
+ end
+ end
end
- context 'fingerprints are blank' do
- let_it_be(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: nil) }
+ context 'without fingerprints' do
let_it_be(:existing_alert) { create(:alert_management_alert, :triggered, fingerprint: alert.fingerprint, project: project) }
it 'successfully changes the status' do
diff --git a/spec/services/audit_event_service_spec.rb b/spec/services/audit_event_service_spec.rb
index 6963515ba5c..063d250f22b 100644
--- a/spec/services/audit_event_service_spec.rb
+++ b/spec/services/audit_event_service_spec.rb
@@ -76,11 +76,13 @@ RSpec.describe AuditEventService do
it 'creates an authentication event' do
expect(AuthenticationEvent).to receive(:new).with(
- user: user,
- user_name: user.name,
- ip_address: user.current_sign_in_ip,
- result: AuthenticationEvent.results[:success],
- provider: 'standard'
+ {
+ user: user,
+ user_name: user.name,
+ ip_address: user.current_sign_in_ip,
+ result: AuthenticationEvent.results[:success],
+ provider: 'standard'
+ }
).and_call_original
audit_service.for_authentication.security_event
diff --git a/spec/services/authorized_project_update/project_create_service_spec.rb b/spec/services/authorized_project_update/project_create_service_spec.rb
deleted file mode 100644
index a9d0b82acfb..00000000000
--- a/spec/services/authorized_project_update/project_create_service_spec.rb
+++ /dev/null
@@ -1,185 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe AuthorizedProjectUpdate::ProjectCreateService do
- let_it_be(:group_parent) { create(:group, :private) }
- let_it_be(:group) { create(:group, :private, parent: group_parent) }
- let_it_be(:group_child) { create(:group, :private, parent: group) }
-
- let_it_be(:group_project) { create(:project, group: group) }
-
- let_it_be(:parent_group_user) { create(:user) }
- let_it_be(:group_user) { create(:user) }
- let_it_be(:child_group_user) { create(:user) }
-
- let(:access_level) { Gitlab::Access::MAINTAINER }
-
- subject(:service) { described_class.new(group_project) }
-
- describe '#perform' do
- context 'direct group members' do
- before do
- create(:group_member, access_level: access_level, group: group, user: group_user)
- ProjectAuthorization.delete_all
- end
-
- it 'creates project authorization' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
-
- project_authorization = ProjectAuthorization.where(
- project_id: group_project.id,
- user_id: group_user.id,
- access_level: access_level)
-
- expect(project_authorization).to exist
- end
- end
-
- context 'inherited group members' do
- before do
- create(:group_member, access_level: access_level, group: group_parent, user: parent_group_user)
- ProjectAuthorization.delete_all
- end
-
- it 'creates project authorization' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
-
- project_authorization = ProjectAuthorization.where(
- project_id: group_project.id,
- user_id: parent_group_user.id,
- access_level: access_level)
- expect(project_authorization).to exist
- end
- end
-
- context 'membership overrides' do
- context 'group hierarchy' do
- before do
- create(:group_member, access_level: Gitlab::Access::REPORTER, group: group_parent, user: group_user)
- create(:group_member, access_level: Gitlab::Access::DEVELOPER, group: group, user: group_user)
- ProjectAuthorization.delete_all
- end
-
- it 'creates project authorization' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
-
- project_authorization = ProjectAuthorization.where(
- project_id: group_project.id,
- user_id: group_user.id,
- access_level: Gitlab::Access::DEVELOPER)
- expect(project_authorization).to exist
- end
- end
-
- context 'group sharing' do
- let!(:shared_with_group) { create(:group) }
-
- before do
- create(:group_member, access_level: Gitlab::Access::REPORTER, group: group, user: group_user)
- create(:group_member, access_level: Gitlab::Access::MAINTAINER, group: shared_with_group, user: group_user)
- create(:group_member, :minimal_access, source: shared_with_group, user: create(:user))
-
- create(:group_group_link, shared_group: group, shared_with_group: shared_with_group, group_access: Gitlab::Access::DEVELOPER)
-
- ProjectAuthorization.delete_all
- end
-
- it 'creates project authorization' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
-
- project_authorization = ProjectAuthorization.where(
- project_id: group_project.id,
- user_id: group_user.id,
- access_level: Gitlab::Access::DEVELOPER)
- expect(project_authorization).to exist
- end
-
- it 'does not create project authorization for user with minimal access' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
- end
- end
- end
-
- context 'no group member' do
- it 'does not create project authorization' do
- expect { service.execute }.not_to(
- change { ProjectAuthorization.count }.from(0))
- end
- end
-
- context 'unapproved access requests' do
- before do
- create(:group_member, :guest, :access_request, user: group_user, group: group)
- end
-
- it 'does not create project authorization' do
- expect { service.execute }.not_to(
- change { ProjectAuthorization.count }.from(0))
- end
- end
-
- context 'member with minimal access' do
- before do
- create(:group_member, :minimal_access, user: group_user, source: group)
- end
-
- it 'does not create project authorization' do
- expect { service.execute }.not_to(
- change { ProjectAuthorization.count }.from(0))
- end
- end
-
- context 'project has more user than BATCH_SIZE' do
- let(:batch_size) { 2 }
- let(:users) { create_list(:user, batch_size + 1 ) }
-
- before do
- stub_const("#{described_class.name}::BATCH_SIZE", batch_size)
-
- users.each do |user|
- create(:group_member, access_level: access_level, group: group_parent, user: user)
- end
-
- ProjectAuthorization.delete_all
- end
-
- it 'bulk creates project authorizations in batches' do
- users.each_slice(batch_size) do |batch|
- attributes = batch.map do |user|
- { user_id: user.id, project_id: group_project.id, access_level: access_level }
- end
-
- expect(ProjectAuthorization).to(
- receive(:insert_all).with(array_including(attributes)).and_call_original)
- end
-
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(batch_size + 1))
- end
- end
-
- context 'ignores existing project authorizations' do
- before do
- # ProjectAuthorizations is also created because of an after_commit
- # callback on Member model
- create(:group_member, access_level: access_level, group: group, user: group_user)
- end
-
- it 'does not create project authorization' do
- project_authorization = ProjectAuthorization.where(
- project_id: group_project.id,
- user_id: group_user.id,
- access_level: access_level)
-
- expect { service.execute }.not_to(
- change { project_authorization.reload.exists? }.from(true))
- end
- end
- end
-end
diff --git a/spec/services/authorized_project_update/project_group_link_create_service_spec.rb b/spec/services/authorized_project_update/project_group_link_create_service_spec.rb
deleted file mode 100644
index 1fd47f78c24..00000000000
--- a/spec/services/authorized_project_update/project_group_link_create_service_spec.rb
+++ /dev/null
@@ -1,222 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe AuthorizedProjectUpdate::ProjectGroupLinkCreateService do
- let_it_be(:group_parent) { create(:group, :private) }
- let_it_be(:group) { create(:group, :private, parent: group_parent) }
- let_it_be(:group_child) { create(:group, :private, parent: group) }
-
- let_it_be(:parent_group_user) { create(:user) }
- let_it_be(:group_user) { create(:user) }
-
- let_it_be(:project) { create(:project, :private, group: create(:group, :private)) }
-
- let(:access_level) { Gitlab::Access::MAINTAINER }
- let(:group_access) { nil }
-
- subject(:service) { described_class.new(project, group, group_access) }
-
- describe '#perform' do
- context 'direct group members' do
- before do
- create(:group_member, access_level: access_level, group: group, user: group_user)
- ProjectAuthorization.delete_all
- end
-
- it 'creates project authorization' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
-
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: group_user.id,
- access_level: access_level)
-
- expect(project_authorization).to exist
- end
- end
-
- context 'inherited group members' do
- before do
- create(:group_member, access_level: access_level, group: group_parent, user: parent_group_user)
- ProjectAuthorization.delete_all
- end
-
- it 'creates project authorization' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
-
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: parent_group_user.id,
- access_level: access_level)
- expect(project_authorization).to exist
- end
- end
-
- context 'with group_access' do
- let(:group_access) { Gitlab::Access::REPORTER }
-
- before do
- create(:group_member, access_level: access_level, group: group_parent, user: parent_group_user)
- ProjectAuthorization.delete_all
- end
-
- it 'creates project authorization' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
-
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: parent_group_user.id,
- access_level: group_access)
- expect(project_authorization).to exist
- end
- end
-
- context 'membership overrides' do
- before do
- create(:group_member, access_level: Gitlab::Access::REPORTER, group: group_parent, user: group_user)
- create(:group_member, access_level: Gitlab::Access::DEVELOPER, group: group, user: group_user)
- ProjectAuthorization.delete_all
- end
-
- it 'creates project authorization' do
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(1))
-
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: group_user.id,
- access_level: Gitlab::Access::DEVELOPER)
- expect(project_authorization).to exist
- end
- end
-
- context 'no group member' do
- it 'does not create project authorization' do
- expect { service.execute }.not_to(
- change { ProjectAuthorization.count }.from(0))
- end
- end
-
- context 'unapproved access requests' do
- before do
- create(:group_member, :guest, :access_request, user: group_user, group: group)
- end
-
- it 'does not create project authorization' do
- expect { service.execute }.not_to(
- change { ProjectAuthorization.count }.from(0))
- end
- end
-
- context 'minimal access member' do
- before do
- create(:group_member, :minimal_access, user: group_user, source: group)
- end
-
- it 'does not create project authorization' do
- expect { service.execute }.not_to(
- change { ProjectAuthorization.count }.from(0))
- end
- end
-
- context 'project has more users than BATCH_SIZE' do
- let(:batch_size) { 2 }
- let(:users) { create_list(:user, batch_size + 1 ) }
-
- before do
- stub_const("#{described_class.name}::BATCH_SIZE", batch_size)
-
- users.each do |user|
- create(:group_member, access_level: access_level, group: group_parent, user: user)
- end
-
- ProjectAuthorization.delete_all
- end
-
- it 'bulk creates project authorizations in batches' do
- users.each_slice(batch_size) do |batch|
- attributes = batch.map do |user|
- { user_id: user.id, project_id: project.id, access_level: access_level }
- end
-
- expect(ProjectAuthorization).to(
- receive(:insert_all).with(array_including(attributes)).and_call_original)
- end
-
- expect { service.execute }.to(
- change { ProjectAuthorization.count }.from(0).to(batch_size + 1))
- end
- end
-
- context 'users have existing project authorizations' do
- before do
- create(:group_member, access_level: access_level, group: group, user: group_user)
- ProjectAuthorization.delete_all
-
- create(:project_authorization, user_id: group_user.id,
- project_id: project.id,
- access_level: existing_access_level)
- end
-
- context 'when access level is the same' do
- let(:existing_access_level) { access_level }
-
- it 'does not create project authorization' do
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: group_user.id,
- access_level: existing_access_level)
-
- expect(ProjectAuthorization).not_to receive(:insert_all)
-
- expect { service.execute }.not_to(
- change { project_authorization.reload.exists? }.from(true))
- end
- end
-
- context 'when existing access level is lower' do
- let(:existing_access_level) { Gitlab::Access::DEVELOPER }
-
- it 'creates new project authorization' do
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: group_user.id,
- access_level: access_level)
-
- expect { service.execute }.to(
- change { project_authorization.reload.exists? }.from(false).to(true))
- end
-
- it 'deletes previous project authorization' do
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: group_user.id,
- access_level: existing_access_level)
-
- expect { service.execute }.to(
- change { project_authorization.reload.exists? }.from(true).to(false))
- end
- end
-
- context 'when existing access level is higher' do
- let(:existing_access_level) { Gitlab::Access::OWNER }
-
- it 'does not create project authorization' do
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: group_user.id,
- access_level: existing_access_level)
-
- expect(ProjectAuthorization).not_to receive(:insert_all)
-
- expect { service.execute }.not_to(
- change { project_authorization.reload.exists? }.from(true))
- end
- end
- end
- end
-end
diff --git a/spec/services/ci/create_downstream_pipeline_service_spec.rb b/spec/services/ci/create_downstream_pipeline_service_spec.rb
index 6142704b00e..11fb564b843 100644
--- a/spec/services/ci/create_downstream_pipeline_service_spec.rb
+++ b/spec/services/ci/create_downstream_pipeline_service_spec.rb
@@ -35,18 +35,20 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
upstream_project.add_developer(user)
end
+ subject { service.execute(bridge) }
+
context 'when downstream project has not been found' do
let(:trigger) do
{ trigger: { project: 'unknown/project' } }
end
it 'does not create a pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.not_to change { Ci::Pipeline.count }
end
it 'changes pipeline bridge job status to failed' do
- service.execute(bridge)
+ subject
expect(bridge.reload).to be_failed
expect(bridge.failure_reason)
@@ -56,12 +58,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
context 'when user can not access downstream project' do
it 'does not create a new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.not_to change { Ci::Pipeline.count }
end
it 'changes status of the bridge build' do
- service.execute(bridge)
+ subject
expect(bridge.reload).to be_failed
expect(bridge.failure_reason)
@@ -75,12 +77,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'does not create a new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.not_to change { Ci::Pipeline.count }
end
it 'changes status of the bridge build' do
- service.execute(bridge)
+ subject
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq 'insufficient_bridge_permissions'
@@ -96,12 +98,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'creates only one new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.to change { Ci::Pipeline.count }.by(1)
end
it 'creates a new pipeline in a downstream project' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.user).to eq bridge.user
expect(pipeline.project).to eq downstream_project
@@ -111,8 +113,14 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
expect(pipeline.source_bridge).to be_a ::Ci::Bridge
end
+ it_behaves_like 'logs downstream pipeline creation' do
+ let(:expected_root_pipeline) { upstream_pipeline }
+ let(:expected_hierarchy_size) { 2 }
+ let(:expected_downstream_relationship) { :multi_project }
+ end
+
it 'updates bridge status when downstream pipeline gets processed' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.reload).to be_created
expect(bridge.reload).to be_success
@@ -136,7 +144,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
bridge_id: bridge.id, project_id: bridge.project.id)
.and_call_original
expect(Ci::CreatePipelineService).not_to receive(:new)
- expect(service.execute(bridge)).to eq({ message: "Already has a downstream pipeline", status: :error })
+ expect(subject).to eq({ message: "Already has a downstream pipeline", status: :error })
end
end
@@ -146,7 +154,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'is using default branch name' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.ref).to eq 'master'
end
@@ -158,12 +166,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'creates only one new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.to change { Ci::Pipeline.count }.by(1)
end
it 'creates a new pipeline in a downstream project' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.user).to eq bridge.user
expect(pipeline.project).to eq downstream_project
@@ -174,7 +182,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'updates the bridge status when downstream pipeline gets processed' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.reload).to be_failed
expect(bridge.reload).to be_failed
@@ -188,12 +196,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
context 'detects a circular dependency' do
it 'does not create a new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.not_to change { Ci::Pipeline.count }
end
it 'changes status of the bridge build' do
- service.execute(bridge)
+ subject
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq 'invalid_bridge_trigger'
@@ -209,12 +217,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
shared_examples 'creates a child pipeline' do
it 'creates only one new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.to change { Ci::Pipeline.count }.by(1)
end
it 'creates a child pipeline in the same project' do
- pipeline = service.execute(bridge)
+ pipeline = subject
pipeline.reload
expect(pipeline.builds.map(&:name)).to match_array(%w[rspec echo])
@@ -227,14 +235,14 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'updates bridge status when downstream pipeline gets processed' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.reload).to be_created
expect(bridge.reload).to be_success
end
it 'propagates parent pipeline settings to the child pipeline' do
- pipeline = service.execute(bridge)
+ pipeline = subject
pipeline.reload
expect(pipeline.ref).to eq(upstream_pipeline.ref)
@@ -264,8 +272,14 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
it_behaves_like 'creates a child pipeline'
+ it_behaves_like 'logs downstream pipeline creation' do
+ let(:expected_root_pipeline) { upstream_pipeline }
+ let(:expected_hierarchy_size) { 2 }
+ let(:expected_downstream_relationship) { :parent_child }
+ end
+
it 'updates the bridge job to success' do
- expect { service.execute(bridge) }.to change { bridge.status }.to 'success'
+ expect { subject }.to change { bridge.status }.to 'success'
end
context 'when bridge uses "depend" strategy' do
@@ -276,7 +290,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'does not update the bridge job status' do
- expect { service.execute(bridge) }.not_to change { bridge.status }
+ expect { subject }.not_to change { bridge.status }
end
end
@@ -306,7 +320,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
it_behaves_like 'creates a child pipeline'
it 'propagates the merge request to the child pipeline' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.merge_request).to eq(merge_request)
expect(pipeline).to be_merge_request
@@ -322,11 +336,17 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'creates the pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.to change { Ci::Pipeline.count }.by(1)
expect(bridge.reload).to be_success
end
+
+ it_behaves_like 'logs downstream pipeline creation' do
+ let(:expected_root_pipeline) { upstream_pipeline.parent_pipeline }
+ let(:expected_hierarchy_size) { 3 }
+ let(:expected_downstream_relationship) { :parent_child }
+ end
end
context 'when upstream pipeline has a parent pipeline, which has a parent pipeline' do
@@ -345,7 +365,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'does not create a second descendant pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.not_to change { Ci::Pipeline.count }
expect(bridge.reload).to be_failed
@@ -370,7 +390,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'create the pipeline' do
- expect { service.execute(bridge) }.to change { Ci::Pipeline.count }.by(1)
+ expect { subject }.to change { Ci::Pipeline.count }.by(1)
end
end
@@ -382,11 +402,11 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'creates a new pipeline allowing variables to be passed downstream' do
- expect { service.execute(bridge) }.to change { Ci::Pipeline.count }.by(1)
+ expect { subject }.to change { Ci::Pipeline.count }.by(1)
end
it 'passes variables downstream from the bridge' do
- pipeline = service.execute(bridge)
+ pipeline = subject
pipeline.variables.map(&:key).tap do |variables|
expect(variables).to include 'BRIDGE'
@@ -444,12 +464,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
describe 'cyclical dependency detection' do
shared_examples 'detects cyclical pipelines' do
it 'does not create a new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.not_to change { Ci::Pipeline.count }
end
it 'changes status of the bridge build' do
- service.execute(bridge)
+ subject
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq 'pipeline_loop_detected'
@@ -458,12 +478,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
shared_examples 'passes cyclical pipeline precondition' do
it 'creates a new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.to change { Ci::Pipeline.count }
end
it 'expect bridge build not to be failed' do
- service.execute(bridge)
+ subject
expect(bridge.reload).not_to be_failed
end
@@ -537,19 +557,19 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'creates only one new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.to change { Ci::Pipeline.count }.by(1)
end
it 'creates a new pipeline in the downstream project' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.user).to eq bridge.user
expect(pipeline.project).to eq downstream_project
end
it 'drops the bridge' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.reload).to be_failed
expect(bridge.reload).to be_failed
@@ -573,7 +593,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
bridge_id: bridge.id,
downstream_pipeline_id: kind_of(Numeric))
- service.execute(bridge)
+ subject
end
end
@@ -583,7 +603,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'passes bridge variables to downstream pipeline' do
- pipeline = service.execute(bridge)
+ pipeline = subject
expect(pipeline.variables.first)
.to have_attributes(key: 'BRIDGE', value: 'var')
@@ -596,7 +616,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'does not pass pipeline variables directly downstream' do
- pipeline = service.execute(bridge)
+ pipeline = subject
pipeline.variables.map(&:key).tap do |variables|
expect(variables).not_to include 'PIPELINE_VARIABLE'
@@ -609,7 +629,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'makes it possible to pass pipeline variable downstream' do
- pipeline = service.execute(bridge)
+ pipeline = subject
pipeline.variables.find_by(key: 'BRIDGE').tap do |variable|
expect(variable.value).to eq 'my-value-var'
@@ -622,12 +642,12 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'does not create a new pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.not_to change { Ci::Pipeline.count }
end
it 'ignores variables passed downstream from the bridge' do
- pipeline = service.execute(bridge)
+ pipeline = subject
pipeline.variables.map(&:key).tap do |variables|
expect(variables).not_to include 'BRIDGE'
@@ -635,7 +655,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'sets errors', :aggregate_failures do
- service.execute(bridge)
+ subject
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq('downstream_pipeline_creation_failed')
@@ -679,7 +699,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
context 'that include the bridge job' do
it 'creates the downstream pipeline' do
- expect { service.execute(bridge) }
+ expect { subject }
.to change(downstream_project.ci_pipelines, :count).by(1)
end
end
@@ -692,7 +712,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'changes status of the bridge build' do
- service.execute(bridge)
+ subject
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq 'insufficient_bridge_permissions'
@@ -710,7 +730,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'does not create a pipeline and drops the bridge' do
- expect { service.execute(bridge) }.not_to change(downstream_project.ci_pipelines, :count)
+ expect { subject }.not_to change(downstream_project.ci_pipelines, :count)
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq('downstream_pipeline_creation_failed')
@@ -733,7 +753,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'does not create a pipeline and drops the bridge' do
- expect { service.execute(bridge) }.not_to change(downstream_project.ci_pipelines, :count)
+ expect { subject }.not_to change(downstream_project.ci_pipelines, :count)
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq('downstream_pipeline_creation_failed')
@@ -755,7 +775,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'creates the pipeline but drops the bridge' do
- expect { service.execute(bridge) }.to change(downstream_project.ci_pipelines, :count).by(1)
+ expect { subject }.to change(downstream_project.ci_pipelines, :count).by(1)
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq('downstream_pipeline_creation_failed')
@@ -787,7 +807,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
it 'creates the pipeline' do
- expect { service.execute(bridge) }.to change(downstream_project.ci_pipelines, :count).by(1)
+ expect { subject }.to change(downstream_project.ci_pipelines, :count).by(1)
expect(bridge.reload).to be_success
end
@@ -795,7 +815,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
context 'when not passing the required variable' do
it 'does not create the pipeline' do
- expect { service.execute(bridge) }.not_to change(downstream_project.ci_pipelines, :count)
+ expect { subject }.not_to change(downstream_project.ci_pipelines, :count)
end
end
end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index 943d70ba142..c39a76ad2fc 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -466,7 +466,7 @@ RSpec.describe Ci::CreatePipelineService do
it 'pull it from Auto-DevOps' do
pipeline = execute_service.payload
expect(pipeline).to be_auto_devops_source
- expect(pipeline.builds.map(&:name)).to match_array(%w[brakeman-sast build code_quality eslint-sast secret_detection semgrep-sast test])
+ expect(pipeline.builds.map(&:name)).to match_array(%w[brakeman-sast build code_quality container_scanning eslint-sast secret_detection semgrep-sast test])
end
end
diff --git a/spec/services/ci/generate_kubeconfig_service_spec.rb b/spec/services/ci/generate_kubeconfig_service_spec.rb
index b0673d16158..e3088ca6ea7 100644
--- a/spec/services/ci/generate_kubeconfig_service_spec.rb
+++ b/spec/services/ci/generate_kubeconfig_service_spec.rb
@@ -6,16 +6,17 @@ RSpec.describe Ci::GenerateKubeconfigService do
describe '#execute' do
let(:project) { create(:project) }
let(:build) { create(:ci_build, project: project) }
+ let(:pipeline) { build.pipeline }
let(:agent1) { create(:cluster_agent, project: project) }
let(:agent2) { create(:cluster_agent) }
let(:template) { instance_double(Gitlab::Kubernetes::Kubeconfig::Template) }
- subject { described_class.new(build).execute }
+ subject { described_class.new(pipeline, token: build.token).execute }
before do
expect(Gitlab::Kubernetes::Kubeconfig::Template).to receive(:new).and_return(template)
- expect(build.pipeline).to receive(:authorized_cluster_agents).and_return([agent1, agent2])
+ expect(pipeline).to receive(:authorized_cluster_agents).and_return([agent1, agent2])
end
it 'adds a cluster, and a user and context for each available agent' do
diff --git a/spec/services/ci/job_artifacts/create_service_spec.rb b/spec/services/ci/job_artifacts/create_service_spec.rb
index b8487e438a9..01f240805f5 100644
--- a/spec/services/ci/job_artifacts/create_service_spec.rb
+++ b/spec/services/ci/job_artifacts/create_service_spec.rb
@@ -42,6 +42,13 @@ RSpec.describe Ci::JobArtifacts::CreateService do
subject { service.execute(artifacts_file, params, metadata_file: metadata_file) }
context 'when artifacts file is uploaded' do
+ it 'returns artifact in the response' do
+ response = subject
+ new_artifact = job.job_artifacts.last
+
+ expect(response[:artifact]).to eq(new_artifact)
+ end
+
it 'saves artifact for the given type' do
expect { subject }.to change { Ci::JobArtifact.count }.by(1)
@@ -84,7 +91,7 @@ RSpec.describe Ci::JobArtifacts::CreateService do
it 'sets expiration date according to application settings' do
expected_expire_at = 1.day.from_now
- expect(subject).to match(a_hash_including(status: :success))
+ expect(subject).to match(a_hash_including(status: :success, artifact: anything))
archive_artifact, metadata_artifact = job.job_artifacts.last(2)
expect(job.artifacts_expire_at).to be_within(1.minute).of(expected_expire_at)
@@ -100,7 +107,7 @@ RSpec.describe Ci::JobArtifacts::CreateService do
it 'sets expiration date according to the parameter' do
expected_expire_at = 2.hours.from_now
- expect(subject).to match(a_hash_including(status: :success))
+ expect(subject).to match(a_hash_including(status: :success, artifact: anything))
archive_artifact, metadata_artifact = job.job_artifacts.last(2)
expect(job.artifacts_expire_at).to be_within(1.minute).of(expected_expire_at)
diff --git a/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb b/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
index b48ea70aa4c..98b01e2b303 100644
--- a/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
+++ b/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe ::Ci::PipelineArtifacts::CoverageReportService do
context 'when pipeline artifact has already been created' do
it 'do not raise an error and do not persist the same artifact twice' do
- expect { 2.times { described_class.new.execute(pipeline) } }.not_to raise_error(ActiveRecord::RecordNotUnique)
+ expect { 2.times { described_class.new.execute(pipeline) } }.not_to raise_error
expect(Ci::PipelineArtifact.count).to eq(1)
end
diff --git a/spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb b/spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb
index 2aa810e8ea1..ab4ba20e716 100644
--- a/spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb
+++ b/spec/services/ci/pipeline_creation/start_pipeline_service_spec.rb
@@ -16,5 +16,11 @@ RSpec.describe Ci::PipelineCreation::StartPipelineService do
service.execute
end
+
+ it 'creates pipeline ref' do
+ expect(pipeline.persistent_ref).to receive(:create).once
+
+ service.execute
+ end
end
end
diff --git a/spec/services/ci/pipeline_trigger_service_spec.rb b/spec/services/ci/pipeline_trigger_service_spec.rb
index 29d12b0dd0e..a794dedc658 100644
--- a/spec/services/ci/pipeline_trigger_service_spec.rb
+++ b/spec/services/ci/pipeline_trigger_service_spec.rb
@@ -187,6 +187,14 @@ RSpec.describe Ci::PipelineTriggerService do
expect(result[:status]).to eq(:success)
end
+ it_behaves_like 'logs downstream pipeline creation' do
+ subject { result[:pipeline] }
+
+ let(:expected_root_pipeline) { pipeline }
+ let(:expected_hierarchy_size) { 2 }
+ let(:expected_downstream_relationship) { :multi_project }
+ end
+
context 'when commit message has [ci skip]' do
before do
allow_next_instance_of(Ci::Pipeline) do |instance|
diff --git a/spec/services/ci/retry_job_service_spec.rb b/spec/services/ci/retry_job_service_spec.rb
index 25aab73ab01..acc7a99637b 100644
--- a/spec/services/ci/retry_job_service_spec.rb
+++ b/spec/services/ci/retry_job_service_spec.rb
@@ -17,396 +17,276 @@ RSpec.describe Ci::RetryJobService do
name: 'test')
end
- let_it_be_with_refind(:build) { create(:ci_build, :success, pipeline: pipeline, stage_id: stage.id) }
-
let(:user) { developer }
- let(:service) do
- described_class.new(project, user)
- end
+ let(:service) { described_class.new(project, user) }
before_all do
project.add_developer(developer)
project.add_reporter(reporter)
end
- clone_accessors = ::Ci::Build.clone_accessors.without(::Ci::Build.extra_accessors)
-
- reject_accessors =
- %i[id status user token token_encrypted coverage trace runner
- artifacts_expire_at
- created_at updated_at started_at finished_at queued_at erased_by
- erased_at auto_canceled_by job_artifacts job_artifacts_archive
- job_artifacts_metadata job_artifacts_trace job_artifacts_junit
- job_artifacts_sast job_artifacts_secret_detection job_artifacts_dependency_scanning
- job_artifacts_container_scanning job_artifacts_cluster_image_scanning job_artifacts_dast
- job_artifacts_license_scanning
- job_artifacts_performance job_artifacts_browser_performance job_artifacts_load_performance
- job_artifacts_lsif job_artifacts_terraform job_artifacts_cluster_applications
- job_artifacts_codequality job_artifacts_metrics scheduled_at
- job_variables waiting_for_resource_at job_artifacts_metrics_referee
- job_artifacts_network_referee job_artifacts_dotenv
- job_artifacts_cobertura needs job_artifacts_accessibility
- job_artifacts_requirements job_artifacts_coverage_fuzzing
- job_artifacts_api_fuzzing terraform_state_versions].freeze
-
- ignore_accessors =
- %i[type lock_version target_url base_tags trace_sections
- commit_id deployment erased_by_id project_id
- runner_id tag_taggings taggings tags trigger_request_id
- user_id auto_canceled_by_id retried failure_reason
- sourced_pipelines artifacts_file_store artifacts_metadata_store
- metadata runner_session trace_chunks upstream_pipeline_id
- artifacts_file artifacts_metadata artifacts_size commands
- resource resource_group_id processed security_scans author
- pipeline_id report_results pending_state pages_deployments
- queuing_entry runtime_metadata trace_metadata
- dast_site_profile dast_scanner_profile].freeze
-
- shared_examples 'build duplication' do
- let_it_be(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
+ shared_context 'retryable bridge' do
+ let_it_be(:downstream_project) { create(:project, :repository) }
- let_it_be(:build) do
- create(:ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags,
- :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group,
- description: 'my-job', stage: 'test', stage_id: stage.id,
- pipeline: pipeline, auto_canceled_by: another_pipeline,
- scheduled_at: 10.seconds.since)
+ let_it_be_with_refind(:job) do
+ create(
+ :ci_bridge, :success, pipeline: pipeline, downstream: downstream_project,
+ description: 'a trigger job', stage_id: stage.id
+ )
end
- let_it_be(:internal_job_variable) { create(:ci_job_variable, job: build) }
+ let_it_be(:job_to_clone) { job }
- before_all do
- # Make sure that build has both `stage_id` and `stage` because FactoryBot
- # can reset one of the fields when assigning another. We plan to deprecate
- # and remove legacy `stage` column in the future.
- build.update!(stage: 'test', stage_id: stage.id)
-
- # Make sure we have one instance for every possible job_artifact_X
- # associations to check they are correctly rejected on build duplication.
- Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.each do |file_type, file_format|
- create(:ci_job_artifact, file_format,
- file_type: file_type, job: build, expire_at: build.artifacts_expire_at)
- end
+ before do
+ job.update!(retried: false)
+ end
+ end
+
+ shared_context 'retryable build' do
+ let_it_be_with_refind(:job) { create(:ci_build, :success, pipeline: pipeline, stage_id: stage.id) }
+ let_it_be(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
- create(:ci_job_variable, :dotenv_source, job: build)
- create(:ci_build_need, build: build)
- create(:terraform_state_version, build: build)
+ let_it_be(:job_to_clone) do
+ create(:ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags,
+ :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group,
+ description: 'my-job', stage: 'test', stage_id: stage.id,
+ pipeline: pipeline, auto_canceled_by: another_pipeline,
+ scheduled_at: 10.seconds.since)
end
before do
- build.update!(retried: false, status: :success)
+ job.update!(retried: false, status: :success)
+ job_to_clone.update!(retried: false, status: :success)
end
+ end
- describe 'clone accessors' do
- let(:forbidden_associations) do
- Ci::Build.reflect_on_all_associations.each_with_object(Set.new) do |assoc, memo|
- memo << assoc.name unless assoc.macro == :belongs_to
- end
- end
+ shared_examples_for 'clones the job' do
+ let(:job) { job_to_clone }
- clone_accessors.each do |attribute|
- it "clones #{attribute} build attribute", :aggregate_failures do
- expect(attribute).not_to be_in(forbidden_associations), "association #{attribute} must be `belongs_to`"
- expect(build.send(attribute)).not_to be_nil
- expect(new_build.send(attribute)).not_to be_nil
- expect(new_build.send(attribute)).to eq build.send(attribute)
- end
+ before_all do
+ # Make sure that job has both `stage_id` and `stage`
+ job_to_clone.update!(stage: 'test', stage_id: stage.id)
+
+ create(:ci_build_need, build: job_to_clone)
+ end
+
+ context 'when the user has ability to execute job' do
+ before do
+ stub_not_protect_default_branch
end
- context 'when job has nullified protected' do
- before do
- build.update_attribute(:protected, nil)
- end
+ context 'when there is a failed job ToDo for the MR' do
+ let!(:merge_request) { create(:merge_request, source_project: project, author: user, head_pipeline: pipeline) }
+ let!(:todo) { create(:todo, :build_failed, user: user, project: project, author: user, target: merge_request) }
- it "clones protected build attribute" do
- expect(new_build.protected).to be_nil
- expect(new_build.protected).to eq build.protected
+ it 'resolves the ToDo for the failed job' do
+ expect do
+ service.execute(job)
+ end.to change { todo.reload.state }.from('pending').to('done')
end
end
- it 'clones only the needs attributes' do
- expect(new_build.needs.exists?).to be_truthy
- expect(build.needs.exists?).to be_truthy
+ context 'when the job has needs' do
+ before do
+ create(:ci_build_need, build: job, name: 'build1')
+ create(:ci_build_need, build: job, name: 'build2')
+ end
- expect(new_build.needs_attributes).to match(build.needs_attributes)
- expect(new_build.needs).not_to match(build.needs)
- end
+ it 'bulk inserts all the needs' do
+ expect(Ci::BuildNeed).to receive(:bulk_insert!).and_call_original
- it 'clones only internal job variables' do
- expect(new_build.job_variables.count).to eq(1)
- expect(new_build.job_variables).to contain_exactly(having_attributes(key: internal_job_variable.key, value: internal_job_variable.value))
+ new_job
+ end
end
- end
- describe 'reject accessors' do
- reject_accessors.each do |attribute|
- it "does not clone #{attribute} build attribute" do
- expect(new_build.send(attribute)).not_to eq build.send(attribute)
- end
+ it 'marks the old job as retried' do
+ expect(new_job).to be_latest
+ expect(job).to be_retried
+ expect(job).to be_processed
end
end
- it 'has correct number of known attributes', :aggregate_failures do
- processed_accessors = clone_accessors + reject_accessors
- known_accessors = processed_accessors + ignore_accessors
-
- # :tag_list is a special case, this accessor does not exist
- # in reflected associations, comes from `act_as_taggable` and
- # we use it to copy tags, instead of reusing tags.
- #
- current_accessors =
- Ci::Build.attribute_names.map(&:to_sym) +
- Ci::Build.attribute_aliases.keys.map(&:to_sym) +
- Ci::Build.reflect_on_all_associations.map(&:name) +
- [:tag_list, :needs_attributes, :job_variables_attributes] -
- # ee-specific accessors should be tested in ee/spec/services/ci/retry_job_service_spec.rb instead
- Ci::Build.extra_accessors -
- [:dast_site_profiles_build, :dast_scanner_profiles_build] # join tables
-
- current_accessors.uniq!
-
- expect(current_accessors).to include(*processed_accessors)
- expect(known_accessors).to include(*current_accessors)
- end
- end
+ context 'when the user does not have permission to execute the job' do
+ let(:user) { reporter }
- describe '#execute' do
- let(:new_build) do
- travel_to(1.second.from_now) do
- service.execute(build)[:job]
+ it 'raises an error' do
+ expect { service.execute(job) }
+ .to raise_error Gitlab::Access::AccessDeniedError
end
end
+ end
- context 'when user has ability to execute build' do
- before do
- stub_not_protect_default_branch
- end
-
- it_behaves_like 'build duplication'
+ shared_examples_for 'retries the job' do
+ it_behaves_like 'clones the job'
- it 'creates a new build that represents the old one' do
- expect(new_build.name).to eq build.name
- end
+ it 'enqueues the new job' do
+ expect(new_job).to be_pending
+ end
- it 'enqueues the new build' do
- expect(new_build).to be_pending
+ context 'when there are subsequent processables that are skipped' do
+ let!(:subsequent_build) do
+ create(:ci_build, :skipped, stage_idx: 2,
+ pipeline: pipeline,
+ stage: 'deploy')
end
- context 'when there are subsequent processables that are skipped' do
- let!(:subsequent_build) do
- create(:ci_build, :skipped, stage_idx: 2,
+ let!(:subsequent_bridge) do
+ create(:ci_bridge, :skipped, stage_idx: 2,
pipeline: pipeline,
stage: 'deploy')
- end
-
- let!(:subsequent_bridge) do
- create(:ci_bridge, :skipped, stage_idx: 2,
- pipeline: pipeline,
- stage: 'deploy')
- end
-
- it 'resumes pipeline processing in the subsequent stage' do
- service.execute(build)
-
- expect(subsequent_build.reload).to be_created
- expect(subsequent_bridge.reload).to be_created
- end
-
- it 'updates ownership for subsequent builds' do
- expect { service.execute(build) }.to change { subsequent_build.reload.user }.to(user)
- end
-
- it 'updates ownership for subsequent bridges' do
- expect { service.execute(build) }.to change { subsequent_bridge.reload.user }.to(user)
- end
-
- it 'does not cause n+1 when updaing build ownership' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { service.execute(build) }.count
+ end
- create_list(:ci_build, 2, :skipped, stage_idx: build.stage_idx + 1, pipeline: pipeline, stage: 'deploy')
+ it 'resumes pipeline processing in the subsequent stage' do
+ service.execute(job)
- expect { service.execute(build) }.not_to exceed_all_query_limit(control_count)
- end
+ expect(subsequent_build.reload).to be_created
+ expect(subsequent_bridge.reload).to be_created
end
- context 'when pipeline has other builds' do
- let!(:stage2) { create(:ci_stage_entity, project: project, pipeline: pipeline, name: 'deploy') }
- let!(:build2) { create(:ci_build, pipeline: pipeline, stage_id: stage.id ) }
- let!(:deploy) { create(:ci_build, pipeline: pipeline, stage_id: stage2.id) }
- let!(:deploy_needs_build2) { create(:ci_build_need, build: deploy, name: build2.name) }
-
- context 'when build has nil scheduling_type' do
- before do
- build.pipeline.processables.update_all(scheduling_type: nil)
- build.reload
- end
-
- it 'populates scheduling_type of processables' do
- expect(new_build.scheduling_type).to eq('stage')
- expect(build.reload.scheduling_type).to eq('stage')
- expect(build2.reload.scheduling_type).to eq('stage')
- expect(deploy.reload.scheduling_type).to eq('dag')
- end
- end
-
- context 'when build has scheduling_type' do
- it 'does not call populate_scheduling_type!' do
- expect_any_instance_of(Ci::Pipeline).not_to receive(:ensure_scheduling_type!) # rubocop: disable RSpec/AnyInstanceOf
+ it 'updates ownership for subsequent builds' do
+ expect { service.execute(job) }.to change { subsequent_build.reload.user }.to(user)
+ end
- expect(new_build.scheduling_type).to eq('stage')
- end
- end
+ it 'updates ownership for subsequent bridges' do
+ expect { service.execute(job) }.to change { subsequent_bridge.reload.user }.to(user)
end
+ end
- context 'when the pipeline is a child pipeline and the bridge is depended' do
- let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
- let!(:bridge) { create(:ci_bridge, :strategy_depend, pipeline: parent_pipeline, status: 'success') }
- let!(:source_pipeline) { create(:ci_sources_pipeline, pipeline: pipeline, source_job: bridge) }
+ context 'when the pipeline has other jobs' do
+ let!(:stage2) { create(:ci_stage_entity, project: project, pipeline: pipeline, name: 'deploy') }
+ let!(:build2) { create(:ci_build, pipeline: pipeline, stage_id: stage.id ) }
+ let!(:deploy) { create(:ci_build, pipeline: pipeline, stage_id: stage2.id) }
+ let!(:deploy_needs_build2) { create(:ci_build_need, build: deploy, name: build2.name) }
- it 'marks source bridge as pending' do
- service.execute(build)
+ context 'when job has a nil scheduling_type' do
+ before do
+ job.pipeline.processables.update_all(scheduling_type: nil)
+ job.reload
+ end
- expect(bridge.reload).to be_pending
+ it 'populates scheduling_type of processables' do
+ expect(new_job.scheduling_type).to eq('stage')
+ expect(job.reload.scheduling_type).to eq('stage')
+ expect(build2.reload.scheduling_type).to eq('stage')
+ expect(deploy.reload.scheduling_type).to eq('dag')
end
end
- context 'when there is a failed job todo for the MR' do
- let!(:merge_request) { create(:merge_request, source_project: project, author: user, head_pipeline: pipeline) }
- let!(:todo) { create(:todo, :build_failed, user: user, project: project, author: user, target: merge_request) }
+ context 'when job has scheduling_type' do
+ it 'does not call populate_scheduling_type!' do
+ expect(job.pipeline).not_to receive(:ensure_scheduling_type!)
- it 'resolves the todo for the old failed build' do
- expect do
- service.execute(build)
- end.to change { todo.reload.state }.from('pending').to('done')
+ expect(new_job.scheduling_type).to eq('stage')
end
end
end
- context 'when user does not have ability to execute build' do
- let(:user) { reporter }
+ context 'when the pipeline is a child pipeline and the bridge uses strategy:depend' do
+ let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:bridge) { create(:ci_bridge, :strategy_depend, pipeline: parent_pipeline, status: 'success') }
+ let!(:source_pipeline) { create(:ci_sources_pipeline, pipeline: pipeline, source_job: bridge) }
- it 'raises an error' do
- expect { service.execute(build) }
- .to raise_error Gitlab::Access::AccessDeniedError
- end
-
- context 'when the job is not retryable' do
- let(:build) { create(:ci_build, :created, pipeline: pipeline) }
+ it 'marks the source bridge as pending' do
+ service.execute(job)
- it 'returns a ServiceResponse error' do
- response = service.execute(build)
-
- expect(response).to be_a(ServiceResponse)
- expect(response).to be_error
- expect(response.message).to eq("Job cannot be retried")
- end
+ expect(bridge.reload).to be_pending
end
end
end
describe '#clone!' do
- let(:new_build) do
- travel_to(1.second.from_now) do
- service.clone!(build)
- end
- end
+ let(:new_job) { service.clone!(job) }
it 'raises an error when an unexpected class is passed' do
expect { service.clone!(create(:ci_build).present) }.to raise_error(TypeError)
end
- context 'when user has ability to execute build' do
- before do
- stub_not_protect_default_branch
- end
+ context 'when the job to be cloned is a bridge' do
+ include_context 'retryable bridge'
- it_behaves_like 'build duplication'
+ it_behaves_like 'clones the job'
+ end
- it 'creates a new build that represents the old one' do
- expect(new_build.name).to eq build.name
- end
+ context 'when the job to be cloned is a build' do
+ include_context 'retryable build'
- it 'does not enqueue the new build' do
- expect(new_build).to be_created
- expect(new_build).not_to be_processed
- end
+ let(:job) { job_to_clone }
- it 'does mark old build as retried' do
- expect(new_build).to be_latest
- expect(build).to be_retried
- expect(build).to be_processed
- end
+ it_behaves_like 'clones the job'
- shared_examples_for 'when build with deployment is retried' do
- let!(:build) do
+ context 'when a build with a deployment is retried' do
+ let!(:job) do
create(:ci_build, :with_deployment, :deploy_to_production,
- pipeline: pipeline, stage_id: stage.id, project: project)
+ pipeline: pipeline, stage_id: stage.id, project: project)
end
it 'creates a new deployment' do
- expect { new_build }.to change { Deployment.count }.by(1)
- end
-
- it 'persists expanded environment name' do
- expect(new_build.metadata.expanded_environment_name).to eq('production')
+ expect { new_job }.to change { Deployment.count }.by(1)
end
it 'does not create a new environment' do
- expect { new_build }.not_to change { Environment.count }
+ expect { new_job }.not_to change { Environment.count }
end
end
- shared_examples_for 'when build with dynamic environment is retried' do
+ context 'when a build with a dynamic environment is retried' do
let_it_be(:other_developer) { create(:user).tap { |u| project.add_developer(u) } }
let(:environment_name) { 'review/$CI_COMMIT_REF_SLUG-$GITLAB_USER_ID' }
- let!(:build) do
+ let!(:job) do
create(:ci_build, :with_deployment, environment: environment_name,
options: { environment: { name: environment_name } },
pipeline: pipeline, stage_id: stage.id, project: project,
user: other_developer)
end
- it 're-uses the previous persisted environment' do
- expect(build.persisted_environment.name).to eq("review/#{build.ref}-#{other_developer.id}")
-
- expect(new_build.persisted_environment.name).to eq("review/#{build.ref}-#{other_developer.id}")
- end
-
it 'creates a new deployment' do
- expect { new_build }.to change { Deployment.count }.by(1)
+ expect { new_job }.to change { Deployment.count }.by(1)
end
it 'does not create a new environment' do
- expect { new_build }.not_to change { Environment.count }
+ expect { new_job }.not_to change { Environment.count }
end
end
+ end
+ end
+
+ describe '#execute' do
+ let(:new_job) { service.execute(job)[:job] }
- it_behaves_like 'when build with deployment is retried'
- it_behaves_like 'when build with dynamic environment is retried'
+ context 'when the job to be retried is a bridge' do
+ include_context 'retryable bridge'
- context 'when build has needs' do
- before do
- create(:ci_build_need, build: build, name: 'build1')
- create(:ci_build_need, build: build, name: 'build2')
- end
+ it_behaves_like 'retries the job'
+ end
- it 'bulk inserts all needs' do
- expect(Ci::BuildNeed).to receive(:bulk_insert!).and_call_original
+ context 'when the job to be retried is a build' do
+ include_context 'retryable build'
+
+ it_behaves_like 'retries the job'
- new_build
+ context 'when there are subsequent jobs that are skipped' do
+ let!(:subsequent_build) do
+ create(:ci_build, :skipped, stage_idx: 2,
+ pipeline: pipeline,
+ stage: 'deploy')
end
- end
- end
- context 'when user does not have ability to execute build' do
- let(:user) { reporter }
+ let!(:subsequent_bridge) do
+ create(:ci_bridge, :skipped, stage_idx: 2,
+ pipeline: pipeline,
+ stage: 'deploy')
+ end
- it 'raises an error' do
- expect { service.clone!(build) }
- .to raise_error Gitlab::Access::AccessDeniedError
+ it 'does not cause an N+1 when updating the job ownership' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { service.execute(job) }.count
+
+ create_list(:ci_build, 2, :skipped, stage_idx: job.stage_idx + 1, pipeline: pipeline, stage: 'deploy')
+
+ expect { service.execute(job) }.not_to exceed_all_query_limit(control_count)
+ end
end
end
end
diff --git a/spec/services/clusters/agents/delete_service_spec.rb b/spec/services/clusters/agents/delete_service_spec.rb
index 1d6bc9618dd..abe1bdaab27 100644
--- a/spec/services/clusters/agents/delete_service_spec.rb
+++ b/spec/services/clusters/agents/delete_service_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Clusters::Agents::DeleteService do
expect(response.status).to eq(:error)
expect(response.message).to eq('You have insufficient permissions to delete this cluster agent')
- expect { cluster_agent.reload }.not_to raise_error(ActiveRecord::RecordNotFound)
+ expect { cluster_agent.reload }.not_to raise_error
end
end
diff --git a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
index 98963f57341..90956e7b4ea 100644
--- a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
@@ -39,8 +39,6 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute'
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace)
stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace)
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace)
- stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME, namespace: namespace)
- stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME, namespace: namespace)
stub_kubeclient_get_secret(
api_url,
diff --git a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
index 11045dfe950..a4f018aec0c 100644
--- a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
@@ -147,8 +147,6 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace)
stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace)
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace)
- stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME, namespace: namespace)
- stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME, namespace: namespace)
end
it 'creates a namespace object' do
@@ -245,47 +243,6 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
)
)
end
-
- it 'creates a role granting cilium permissions to the service account' do
- subject
-
- expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/roles/#{Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME}").with(
- body: hash_including(
- metadata: {
- name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME,
- namespace: namespace
- },
- rules: [{
- apiGroups: %w(cilium.io),
- resources: %w(ciliumnetworkpolicies),
- verbs: %w(get list create update patch)
- }]
- )
- )
- end
-
- it 'creates a role binding granting cilium permissions to the service account' do
- subject
-
- expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME}").with(
- body: hash_including(
- metadata: {
- name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME,
- namespace: namespace
- },
- roleRef: {
- apiGroup: 'rbac.authorization.k8s.io',
- kind: 'Role',
- name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME
- },
- subjects: [{
- kind: 'ServiceAccount',
- name: service_account_name,
- namespace: namespace
- }]
- )
- )
- end
end
end
end
diff --git a/spec/services/container_expiration_policies/cleanup_service_spec.rb b/spec/services/container_expiration_policies/cleanup_service_spec.rb
index a1f76e5e5dd..c265ce74d14 100644
--- a/spec/services/container_expiration_policies/cleanup_service_spec.rb
+++ b/spec/services/container_expiration_policies/cleanup_service_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do
it 'completely clean up the repository' do
expect(Projects::ContainerRepository::CleanupTagsService)
.to receive(:new).with(repository, nil, cleanup_tags_service_params).and_return(cleanup_tags_service)
- expect(cleanup_tags_service).to receive(:execute).and_return(status: :success)
+ expect(cleanup_tags_service).to receive(:execute).and_return(status: :success, deleted_size: 1)
response = subject
@@ -36,6 +36,7 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do
expect(repository.reload.cleanup_unscheduled?).to be_truthy
expect(repository.expiration_policy_completed_at).not_to eq(nil)
expect(repository.expiration_policy_started_at).not_to eq(nil)
+ expect(repository.last_cleanup_deleted_tags_count).to eq(1)
end
end
end
@@ -58,6 +59,7 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do
expect(repository.reload.cleanup_unfinished?).to be_truthy
expect(repository.expiration_policy_started_at).not_to eq(nil)
expect(repository.expiration_policy_completed_at).to eq(nil)
+ expect(repository.last_cleanup_deleted_tags_count).to eq(nil)
end
end
@@ -94,6 +96,7 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do
expect(repository.reload.cleanup_unfinished?).to be_truthy
expect(repository.expiration_policy_started_at).not_to eq(nil)
expect(repository.expiration_policy_completed_at).to eq(nil)
+ expect(repository.last_cleanup_deleted_tags_count).to eq(nil)
end
end
end
@@ -138,6 +141,7 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do
expect(repository.reload.cleanup_unfinished?).to be_truthy
expect(repository.expiration_policy_started_at).not_to eq(nil)
expect(repository.expiration_policy_completed_at).to eq(nil)
+ expect(repository.last_cleanup_deleted_tags_count).to eq(nil)
end
end
diff --git a/spec/services/container_expiration_policies/update_service_spec.rb b/spec/services/container_expiration_policies/update_service_spec.rb
index d4b6715ae86..7d949b77de7 100644
--- a/spec/services/container_expiration_policies/update_service_spec.rb
+++ b/spec/services/container_expiration_policies/update_service_spec.rb
@@ -63,7 +63,7 @@ RSpec.describe ContainerExpirationPolicies::UpdateService do
context 'with existing container expiration policy' do
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the container expiration policy'
- :developer | 'updating the container expiration policy'
+ :developer | 'denying access to container expiration policy'
:reporter | 'denying access to container expiration policy'
:guest | 'denying access to container expiration policy'
:anonymous | 'denying access to container expiration policy'
@@ -83,7 +83,7 @@ RSpec.describe ContainerExpirationPolicies::UpdateService do
where(:user_role, :shared_examples_name) do
:maintainer | 'creating the container expiration policy'
- :developer | 'creating the container expiration policy'
+ :developer | 'denying access to container expiration policy'
:reporter | 'denying access to container expiration policy'
:guest | 'denying access to container expiration policy'
:anonymous | 'denying access to container expiration policy'
diff --git a/spec/services/container_expiration_policy_service_spec.rb b/spec/services/container_expiration_policy_service_spec.rb
deleted file mode 100644
index 41dd890dd35..00000000000
--- a/spec/services/container_expiration_policy_service_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ContainerExpirationPolicyService do
- let_it_be(:user) { create(:user) }
- let_it_be(:container_expiration_policy) { create(:container_expiration_policy, :runnable) }
-
- let(:project) { container_expiration_policy.project }
- let(:container_repository) { create(:container_repository, project: project) }
-
- before do
- project.add_maintainer(user)
- end
-
- describe '#execute' do
- subject { described_class.new(project, user).execute(container_expiration_policy) }
-
- it 'kicks off a cleanup worker for the container repository' do
- expect(CleanupContainerRepositoryWorker).to receive(:perform_async)
- .with(nil, container_repository.id, hash_including(container_expiration_policy: true))
-
- subject
- end
-
- it 'sets next_run_at on the container_expiration_policy' do
- subject
-
- expect(container_expiration_policy.next_run_at).to be > Time.zone.now
- end
- end
-end
diff --git a/spec/services/customer_relations/contacts/create_service_spec.rb b/spec/services/customer_relations/contacts/create_service_spec.rb
index 567e1c91e78..db6cce799fe 100644
--- a/spec/services/customer_relations/contacts/create_service_spec.rb
+++ b/spec/services/customer_relations/contacts/create_service_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe CustomerRelations::Contacts::CreateService do
it 'returns an error' do
expect(response).to be_error
- expect(response.message).to match_array(['You have insufficient permissions to create a contact for this group'])
+ expect(response.message).to match_array(['You have insufficient permissions to manage contacts for this group'])
end
end
diff --git a/spec/services/customer_relations/contacts/update_service_spec.rb b/spec/services/customer_relations/contacts/update_service_spec.rb
index 253bbc23226..729fdc2058b 100644
--- a/spec/services/customer_relations/contacts/update_service_spec.rb
+++ b/spec/services/customer_relations/contacts/update_service_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe CustomerRelations::Contacts::UpdateService do
let_it_be(:user) { create(:user) }
- let(:contact) { create(:contact, first_name: 'Mark', group: group) }
+ let(:contact) { create(:contact, first_name: 'Mark', group: group, state: 'active') }
subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(contact) }
@@ -19,7 +19,7 @@ RSpec.describe CustomerRelations::Contacts::UpdateService do
response = update
expect(response).to be_error
- expect(response.message).to match_array(['You have insufficient permissions to update a contact for this group'])
+ expect(response.message).to match_array(['You have insufficient permissions to manage contacts for this group'])
end
end
@@ -41,6 +41,29 @@ RSpec.describe CustomerRelations::Contacts::UpdateService do
end
end
+ context 'when activating' do
+ let(:contact) { create(:contact, state: 'inactive') }
+ let(:params) { { active: true } }
+
+ it 'updates the contact' do
+ response = update
+
+ expect(response).to be_success
+ expect(response.payload.active?).to be_truthy
+ end
+ end
+
+ context 'when deactivating' do
+ let(:params) { { active: false } }
+
+ it 'updates the contact' do
+ response = update
+
+ expect(response).to be_success
+ expect(response.payload.active?).to be_falsy
+ end
+ end
+
context 'when the contact is invalid' do
let(:params) { { first_name: nil } }
diff --git a/spec/services/customer_relations/organizations/update_service_spec.rb b/spec/services/customer_relations/organizations/update_service_spec.rb
index 8461c98ef0e..4764ba85551 100644
--- a/spec/services/customer_relations/organizations/update_service_spec.rb
+++ b/spec/services/customer_relations/organizations/update_service_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe CustomerRelations::Organizations::UpdateService do
let_it_be(:user) { create(:user) }
- let(:organization) { create(:organization, name: 'Test', group: group) }
+ let(:organization) { create(:organization, name: 'Test', group: group, state: 'active') }
subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(organization) }
@@ -41,6 +41,29 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do
end
end
+ context 'when activating' do
+ let(:organization) { create(:organization, state: 'inactive') }
+ let(:params) { { active: true } }
+
+ it 'updates the contact' do
+ response = update
+
+ expect(response).to be_success
+ expect(response.payload.active?).to be_truthy
+ end
+ end
+
+ context 'when deactivating' do
+ let(:params) { { active: false } }
+
+ it 'updates the organization' do
+ response = update
+
+ expect(response).to be_success
+ expect(response.payload.active?).to be_falsy
+ end
+ end
+
context 'when the organization is invalid' do
let(:params) { { name: nil } }
diff --git a/spec/services/database/consistency_fix_service_spec.rb b/spec/services/database/consistency_fix_service_spec.rb
new file mode 100644
index 00000000000..9a0fac2191c
--- /dev/null
+++ b/spec/services/database/consistency_fix_service_spec.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Database::ConsistencyFixService do
+ describe '#execute' do
+ context 'fixing namespaces inconsistencies' do
+ subject(:consistency_fix_service) do
+ described_class.new(
+ source_model: Namespace,
+ target_model: Ci::NamespaceMirror,
+ sync_event_class: Namespaces::SyncEvent,
+ source_sort_key: :id,
+ target_sort_key: :namespace_id
+ )
+ end
+
+ let(:table) { 'public.namespaces' }
+ let!(:namespace) { create(:namespace) }
+ let!(:namespace_mirror) { Ci::NamespaceMirror.find_by(namespace_id: namespace.id) }
+
+ context 'when both objects exist' do
+ it 'creates a Namespaces::SyncEvent to modify the target object' do
+ expect do
+ consistency_fix_service.execute(ids: [namespace.id])
+ end.to change {
+ Namespaces::SyncEvent.where(namespace_id: namespace.id).count
+ }.by(1)
+ end
+
+ it 'enqueues the worker to process the Namespaces::SyncEvents' do
+ expect(::Namespaces::ProcessSyncEventsWorker).to receive(:perform_async)
+ consistency_fix_service.execute(ids: [namespace.id])
+ end
+ end
+
+ context 'when the source object has been deleted, but not the target' do
+ before do
+ namespace.delete
+ end
+
+ it 'deletes the target object' do
+ expect do
+ consistency_fix_service.execute(ids: [namespace.id])
+ end.to change { Ci::NamespaceMirror.where(namespace_id: namespace.id).count }.by(-1)
+ end
+ end
+ end
+
+ context 'fixing projects inconsistencies' do
+ subject(:consistency_fix_service) do
+ described_class.new(
+ source_model: Project,
+ target_model: Ci::ProjectMirror,
+ sync_event_class: Projects::SyncEvent,
+ source_sort_key: :id,
+ target_sort_key: :project_id
+ )
+ end
+
+ let(:table) { 'public.projects' }
+ let!(:project) { create(:project) }
+ let!(:project_mirror) { Ci::ProjectMirror.find_by(project_id: project.id) }
+
+ context 'when both objects exist' do
+ it 'creates a Projects::SyncEvent to modify the target object' do
+ expect do
+ consistency_fix_service.execute(ids: [project.id])
+ end.to change {
+ Projects::SyncEvent.where(project_id: project.id).count
+ }.by(1)
+ end
+
+ it 'enqueues the worker to process the Projects::SyncEvents' do
+ expect(::Projects::ProcessSyncEventsWorker).to receive(:perform_async)
+ consistency_fix_service.execute(ids: [project.id])
+ end
+ end
+
+ context 'when the source object has been deleted, but not the target' do
+ before do
+ project.delete
+ end
+
+ it 'deletes the target object' do
+ expect do
+ consistency_fix_service.execute(ids: [project.id])
+ end.to change { Ci::ProjectMirror.where(project_id: project.id).count }.by(-1)
+ end
+ end
+ end
+ end
+
+ describe '#create_sync_event_for' do
+ context 'when the source model is Namespace' do
+ let(:namespace) { create(:namespace) }
+
+ let(:service) do
+ described_class.new(
+ source_model: Namespace,
+ target_model: Ci::NamespaceMirror,
+ sync_event_class: Namespaces::SyncEvent,
+ source_sort_key: :id,
+ target_sort_key: :namespace_id
+ )
+ end
+
+ it 'creates a Namespaces::SyncEvent object' do
+ expect do
+ service.send(:create_sync_event_for, namespace.id)
+ end.to change { Namespaces::SyncEvent.where(namespace_id: namespace.id).count }.by(1)
+ end
+ end
+
+ context 'when the source model is Project' do
+ let(:project) { create(:project) }
+
+ let(:service) do
+ described_class.new(
+ source_model: Project,
+ target_model: Ci::ProjectMirror,
+ sync_event_class: Projects::SyncEvent,
+ source_sort_key: :id,
+ target_sort_key: :project_id
+ )
+ end
+
+ it 'creates a Projects::SyncEvent object' do
+ expect do
+ service.send(:create_sync_event_for, project.id)
+ end.to change { Projects::SyncEvent.where(project_id: project.id).count }.by(1)
+ end
+ end
+ end
+
+ context 'when the source model is User' do
+ let(:service) do
+ described_class.new(
+ source_model: User,
+ target_model: Ci::ProjectMirror,
+ sync_event_class: Projects::SyncEvent,
+ source_sort_key: :id,
+ target_sort_key: :project_id
+ )
+ end
+
+ it 'raises an error' do
+ expect do
+ service.send(:create_sync_event_for, 1)
+ end.to raise_error("Unknown Source Model User")
+ end
+ end
+end
diff --git a/spec/services/dependency_proxy/group_settings/update_service_spec.rb b/spec/services/dependency_proxy/group_settings/update_service_spec.rb
index 6f8c55daa8d..4954d9ec267 100644
--- a/spec/services/dependency_proxy/group_settings/update_service_spec.rb
+++ b/spec/services/dependency_proxy/group_settings/update_service_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe ::DependencyProxy::GroupSettings::UpdateService do
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the dependency proxy group settings'
- :developer | 'updating the dependency proxy group settings'
+ :developer | 'denying access to dependency proxy group settings'
:reporter | 'denying access to dependency proxy group settings'
:guest | 'denying access to dependency proxy group settings'
:anonymous | 'denying access to dependency proxy group settings'
diff --git a/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb b/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb
index ceac8985c8e..3a6ba2cca71 100644
--- a/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb
+++ b/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe ::DependencyProxy::ImageTtlGroupPolicies::UpdateService do
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the dependency proxy image ttl policy'
- :developer | 'updating the dependency proxy image ttl policy'
+ :developer | 'denying access to dependency proxy image ttl policy'
:reporter | 'denying access to dependency proxy image ttl policy'
:guest | 'denying access to dependency proxy image ttl policy'
:anonymous | 'denying access to dependency proxy image ttl policy'
@@ -92,7 +92,7 @@ RSpec.describe ::DependencyProxy::ImageTtlGroupPolicies::UpdateService do
where(:user_role, :shared_examples_name) do
:maintainer | 'creating the dependency proxy image ttl policy'
- :developer | 'creating the dependency proxy image ttl policy'
+ :developer | 'denying access to dependency proxy image ttl policy'
:reporter | 'denying access to dependency proxy image ttl policy'
:guest | 'denying access to dependency proxy image ttl policy'
:anonymous | 'denying access to dependency proxy image ttl policy'
@@ -108,7 +108,7 @@ RSpec.describe ::DependencyProxy::ImageTtlGroupPolicies::UpdateService do
context 'when the policy is not found' do
before do
- group.add_developer(user)
+ group.add_maintainer(user)
expect(group).to receive(:dependency_proxy_image_ttl_policy).and_return nil
end
diff --git a/spec/services/environments/stop_service_spec.rb b/spec/services/environments/stop_service_spec.rb
index 9e9ef127c67..afbc0ba70f9 100644
--- a/spec/services/environments/stop_service_spec.rb
+++ b/spec/services/environments/stop_service_spec.rb
@@ -161,8 +161,8 @@ RSpec.describe Environments::StopService do
end
end
- describe '#execute_for_merge_request' do
- subject { service.execute_for_merge_request(merge_request) }
+ describe '#execute_for_merge_request_pipeline' do
+ subject { service.execute_for_merge_request_pipeline(merge_request) }
let(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master') }
let(:project) { merge_request.project }
@@ -199,6 +199,19 @@ RSpec.describe Environments::StopService do
expect(pipeline.environments_in_self_and_descendants.first).to be_stopped
end
+ context 'when pipeline is a branch pipeline for merge request' do
+ let(:pipeline) do
+ create(:ci_pipeline, source: :push, project: project, sha: merge_request.diff_head_sha,
+ merge_requests_as_head_pipeline: [merge_request])
+ end
+
+ it 'does not stop the active environment' do
+ subject
+
+ expect(pipeline.environments_in_self_and_descendants.first).to be_available
+ end
+ end
+
context 'with environment related jobs ' do
let!(:environment) { create(:environment, :available, name: 'staging', project: project) }
let!(:prepare_staging_job) { create(:ci_build, :prepare_staging, pipeline: pipeline, project: project) }
@@ -210,18 +223,6 @@ RSpec.describe Environments::StopService do
expect(prepare_staging_job.persisted_environment.state).to eq('available')
end
-
- context 'when fix_related_environments_for_merge_requests feature flag is disabled' do
- before do
- stub_feature_flags(fix_related_environments_for_merge_requests: false)
- end
-
- it 'stops unrelated environments too' do
- subject
-
- expect(prepare_staging_job.persisted_environment.state).to eq('stopped')
- end
- end
end
end
diff --git a/spec/services/error_tracking/base_service_spec.rb b/spec/services/error_tracking/base_service_spec.rb
index 2f2052f0189..de3523cb847 100644
--- a/spec/services/error_tracking/base_service_spec.rb
+++ b/spec/services/error_tracking/base_service_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
RSpec.describe ErrorTracking::BaseService do
describe '#compose_response' do
- let(:project) { double('project') }
- let(:user) { double('user', id: non_existing_record_id) }
+ let(:project) { build_stubbed(:project) }
+ let(:user) { build_stubbed(:user, id: non_existing_record_id) }
let(:service) { described_class.new(project, user) }
it 'returns bad_request error when response has an error key' do
@@ -19,7 +19,10 @@ RSpec.describe ErrorTracking::BaseService do
end
it 'returns server error when response has missing key error_type' do
- data = { error: 'Unexpected Error', error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS }
+ data = {
+ error: 'Unexpected Error',
+ error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS
+ }
result = service.send(:compose_response, data)
@@ -48,7 +51,7 @@ RSpec.describe ErrorTracking::BaseService do
context 'when parse_response is implemented' do
before do
- expect(service).to receive(:parse_response) do |response|
+ allow(service).to receive(:parse_response) do |response|
{ animal: response[:thing] }
end
end
diff --git a/spec/services/error_tracking/collect_error_service_spec.rb b/spec/services/error_tracking/collect_error_service_spec.rb
index faca3c12a48..159c070c683 100644
--- a/spec/services/error_tracking/collect_error_service_spec.rb
+++ b/spec/services/error_tracking/collect_error_service_spec.rb
@@ -52,12 +52,13 @@ RSpec.describe ErrorTracking::CollectErrorService do
end
context 'with unusual payload' do
- let(:modified_event) { parsed_event }
- let(:event) { described_class.new(project, nil, event: modified_event).execute }
+ let(:event) { ErrorTracking::ErrorEvent.last! }
context 'when transaction is missing' do
it 'builds actor from stacktrace' do
- modified_event.delete('transaction')
+ parsed_event.delete('transaction')
+
+ subject.execute
expect(event.error.actor).to eq 'find()'
end
@@ -65,7 +66,9 @@ RSpec.describe ErrorTracking::CollectErrorService do
context 'when transaction is an empty string' do \
it 'builds actor from stacktrace' do
- modified_event['transaction'] = ''
+ parsed_event['transaction'] = ''
+
+ subject.execute
expect(event.error.actor).to eq 'find()'
end
@@ -73,7 +76,9 @@ RSpec.describe ErrorTracking::CollectErrorService do
context 'when timestamp is numeric' do
it 'parses timestamp' do
- modified_event['timestamp'] = '1631015580.50'
+ parsed_event['timestamp'] = '1631015580.50'
+
+ subject.execute
expect(event.occurred_at).to eq '2021-09-07T11:53:00.5'
end
diff --git a/spec/services/error_tracking/issue_details_service_spec.rb b/spec/services/error_tracking/issue_details_service_spec.rb
index 8cc2688d198..29f8154a27c 100644
--- a/spec/services/error_tracking/issue_details_service_spec.rb
+++ b/spec/services/error_tracking/issue_details_service_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe ErrorTracking::IssueDetailsService do
let(:params) { { issue_id: detailed_error.id } }
before do
- expect(error_tracking_setting)
+ allow(error_tracking_setting)
.to receive(:issue_details).and_return(issue: detailed_error)
end
@@ -40,7 +40,7 @@ RSpec.describe ErrorTracking::IssueDetailsService do
include_examples 'error tracking service sentry error handling', :issue_details
include_examples 'error tracking service http status handling', :issue_details
- context 'integrated error tracking' do
+ context 'with integrated error tracking' do
let_it_be(:error) { create(:error_tracking_error, project: project) }
let(:params) { { issue_id: error.id } }
@@ -53,6 +53,18 @@ RSpec.describe ErrorTracking::IssueDetailsService do
expect(result[:status]).to eq(:success)
expect(result[:issue].to_json).to eq(error.to_sentry_detailed_error.to_json)
end
+
+ context 'when error does not exist' do
+ let(:params) { { issue_id: non_existing_record_id } }
+
+ it 'returns the error in detailed format' do
+ expect(result).to match(
+ status: :error,
+ message: /Couldn't find ErrorTracking::Error/,
+ http_status: :bad_request
+ )
+ end
+ end
end
end
diff --git a/spec/services/error_tracking/issue_latest_event_service_spec.rb b/spec/services/error_tracking/issue_latest_event_service_spec.rb
index e914cb1241e..aa2430ddffb 100644
--- a/spec/services/error_tracking/issue_latest_event_service_spec.rb
+++ b/spec/services/error_tracking/issue_latest_event_service_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe ErrorTracking::IssueLatestEventService do
let(:error_event) { build(:error_tracking_sentry_error_event) }
before do
- expect(error_tracking_setting)
+ allow(error_tracking_setting)
.to receive(:issue_latest_event).and_return(latest_event: error_event)
end
@@ -28,7 +28,7 @@ RSpec.describe ErrorTracking::IssueLatestEventService do
include_examples 'error tracking service sentry error handling', :issue_latest_event
include_examples 'error tracking service http status handling', :issue_latest_event
- context 'integrated error tracking' do
+ context 'with integrated error tracking' do
let_it_be(:error) { create(:error_tracking_error, project: project) }
let_it_be(:event) { create(:error_tracking_error_event, error: error) }
@@ -42,6 +42,18 @@ RSpec.describe ErrorTracking::IssueLatestEventService do
expect(result[:status]).to eq(:success)
expect(result[:latest_event].to_json).to eq(event.to_sentry_error_event.to_json)
end
+
+ context 'when error does not exist' do
+ let(:params) { { issue_id: non_existing_record_id } }
+
+ it 'returns the error in detailed format' do
+ expect(result).to match(
+ status: :error,
+ message: /Couldn't find ErrorTracking::Error/,
+ http_status: :bad_request
+ )
+ end
+ end
end
end
diff --git a/spec/services/error_tracking/issue_update_service_spec.rb b/spec/services/error_tracking/issue_update_service_spec.rb
index 31a66654100..a06c3588264 100644
--- a/spec/services/error_tracking/issue_update_service_spec.rb
+++ b/spec/services/error_tracking/issue_update_service_spec.rb
@@ -13,8 +13,7 @@ RSpec.describe ErrorTracking::IssueUpdateService do
it 'does not call the close issue service' do
update_service.execute
- expect(issue_close_service)
- .not_to have_received(:execute)
+ expect(issue_close_service).not_to have_received(:execute)
end
it 'does not create system note' do
@@ -29,8 +28,7 @@ RSpec.describe ErrorTracking::IssueUpdateService do
let(:update_issue_response) { { updated: true } }
before do
- expect(error_tracking_setting)
- .to receive(:update_issue).and_return(update_issue_response)
+ allow(error_tracking_setting).to receive(:update_issue).and_return(update_issue_response)
end
it 'returns the response' do
@@ -49,12 +47,11 @@ RSpec.describe ErrorTracking::IssueUpdateService do
result
end
- context 'related issue and resolving' do
+ context 'with related issue and resolving' do
let(:issue) { create(:issue, project: project) }
let(:sentry_issue) { create(:sentry_issue, issue: issue) }
let(:arguments) { { issue_id: sentry_issue.sentry_issue_identifier, status: 'resolved' } }
-
- let(:issue_close_service) { spy(:issue_close_service) }
+ let(:issue_close_service) { instance_double('Issues::CloseService') }
before do
allow_next_instance_of(SentryIssueFinder) do |finder|
@@ -78,11 +75,11 @@ RSpec.describe ErrorTracking::IssueUpdateService do
.with(issue, system_note: false)
end
- context 'issues gets closed' do
+ context 'when issue gets closed' do
let(:closed_issue) { create(:issue, :closed, project: project) }
before do
- expect(issue_close_service)
+ allow(issue_close_service)
.to receive(:execute)
.with(issue, system_note: false)
.and_return(closed_issue)
@@ -99,13 +96,13 @@ RSpec.describe ErrorTracking::IssueUpdateService do
end
end
- context 'issue is already closed' do
+ context 'when issue is already closed' do
let(:issue) { create(:issue, :closed, project: project) }
include_examples 'does not perform close issue flow'
end
- context 'status is not resolving' do
+ context 'when status is not resolving' do
let(:arguments) { { issue_id: sentry_issue.sentry_issue_identifier, status: 'ignored' } }
include_examples 'does not perform close issue flow'
@@ -115,7 +112,7 @@ RSpec.describe ErrorTracking::IssueUpdateService do
include_examples 'error tracking service sentry error handling', :update_issue
- context 'integrated error tracking' do
+ context 'with integrated error tracking' do
let(:error) { create(:error_tracking_error, project: project) }
let(:arguments) { { issue_id: error.id, status: 'resolved' } }
let(:update_issue_response) { { updated: true, status: :success, closed_issue_iid: nil } }
diff --git a/spec/services/groups/group_links/create_service_spec.rb b/spec/services/groups/group_links/create_service_spec.rb
index 03dac14be54..bfbaedbd06f 100644
--- a/spec/services/groups/group_links/create_service_spec.rb
+++ b/spec/services/groups/group_links/create_service_spec.rb
@@ -3,23 +3,13 @@
require 'spec_helper'
RSpec.describe Groups::GroupLinks::CreateService, '#execute' do
- let(:parent_group_user) { create(:user) }
- let(:group_user) { create(:user) }
- let(:child_group_user) { create(:user) }
- let(:prevent_sharing) { false }
+ let_it_be(:shared_with_group_parent) { create(:group, :private) }
+ let_it_be(:shared_with_group) { create(:group, :private, parent: shared_with_group_parent) }
+ let_it_be(:shared_with_group_child) { create(:group, :private, parent: shared_with_group) }
let_it_be(:group_parent) { create(:group, :private) }
- let_it_be(:group) { create(:group, :private, parent: group_parent) }
- let_it_be(:group_child) { create(:group, :private, parent: group) }
- let(:ns_for_parent) { create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: prevent_sharing) }
- let(:shared_group_parent) { create(:group, :private, namespace_settings: ns_for_parent) }
- let(:shared_group) { create(:group, :private, parent: shared_group_parent) }
- let(:shared_group_child) { create(:group, :private, parent: shared_group) }
-
- let(:project_parent) { create(:project, group: shared_group_parent) }
- let(:project) { create(:project, group: shared_group) }
- let(:project_child) { create(:project, group: shared_group_child) }
+ let(:group) { create(:group, :private, parent: group_parent) }
let(:opts) do
{
@@ -28,127 +18,161 @@ RSpec.describe Groups::GroupLinks::CreateService, '#execute' do
}
end
- let(:user) { group_user }
+ subject { described_class.new(group, shared_with_group, user, opts) }
- subject { described_class.new(shared_group, group, user, opts) }
+ shared_examples_for 'not shareable' do
+ it 'does not share and returns an error' do
+ expect do
+ result = subject.execute
- before do
- group.add_guest(group_user)
- shared_group.add_owner(group_user)
+ expect(result[:status]).to eq(:error)
+ expect(result[:http_status]).to eq(404)
+ end.not_to change { group.shared_with_group_links.count }
+ end
end
- it 'adds group to another group' do
- expect { subject.execute }.to change { group.shared_group_links.count }.from(0).to(1)
- end
+ shared_examples_for 'shareable' do
+ it 'adds group to another group' do
+ expect do
+ result = subject.execute
- it 'returns false if shared group is blank' do
- expect { described_class.new(nil, group, user, opts) }.not_to change { group.shared_group_links.count }
+ expect(result[:status]).to eq(:success)
+ end.to change { group.shared_with_group_links.count }.from(0).to(1)
+ end
end
- context 'user does not have access to group' do
- let(:user) { create(:user) }
-
- before do
- shared_group.add_owner(user)
- end
+ context 'when user has proper membership to share a group' do
+ let_it_be(:group_user) { create(:user) }
- it 'returns error' do
- result = subject.execute
+ let(:user) { group_user }
- expect(result[:status]).to eq(:error)
- expect(result[:http_status]).to eq(404)
+ before do
+ shared_with_group.add_guest(group_user)
+ group.add_owner(group_user)
end
- end
- context 'user does not have admin access to shared group' do
- let(:user) { create(:user) }
+ it_behaves_like 'shareable'
- before do
- group.add_guest(user)
- shared_group.add_developer(user)
- end
+ context 'when sharing outside the hierarchy is disabled' do
+ let_it_be(:group_parent) do
+ create(:group,
+ namespace_settings: create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: true))
+ end
- it 'returns error' do
- result = subject.execute
+ it_behaves_like 'not shareable'
- expect(result[:status]).to eq(:error)
- expect(result[:http_status]).to eq(404)
- end
- end
+ context 'when group is inside hierarchy' do
+ let(:shared_with_group) { create(:group, :private, parent: group_parent) }
- context 'project authorizations based on group hierarchies' do
- before do
- group_parent.add_owner(parent_group_user)
- group.add_owner(group_user)
- group_child.add_owner(child_group_user)
+ it_behaves_like 'shareable'
+ end
end
- context 'project authorizations refresh' do
- it 'is executed only for the direct members of the group' do
- expect(UserProjectAccessChangedService).to receive(:new).with(contain_exactly(group_user.id)).and_call_original
+ context 'project authorizations based on group hierarchies' do
+ let_it_be(:child_group_user) { create(:user) }
+ let_it_be(:parent_group_user) { create(:user) }
- subject.execute
+ before do
+ shared_with_group_parent.add_owner(parent_group_user)
+ shared_with_group.add_owner(group_user)
+ shared_with_group_child.add_owner(child_group_user)
end
- end
- context 'project authorizations' do
- context 'group user' do
- let(:user) { group_user }
+ context 'project authorizations refresh' do
+ it 'is executed only for the direct members of the group' do
+ expect(UserProjectAccessChangedService).to receive(:new).with(contain_exactly(group_user.id))
+ .and_call_original
- it 'create proper authorizations' do
subject.execute
-
- expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project)).to be_truthy
- expect(Ability.allowed?(user, :read_project, project_child)).to be_truthy
end
end
- context 'parent group user' do
- let(:user) { parent_group_user }
+ context 'project authorizations' do
+ let(:group_child) { create(:group, :private, parent: group) }
+ let(:project_parent) { create(:project, group: group_parent) }
+ let(:project) { create(:project, group: group) }
+ let(:project_child) { create(:project, group: group_child) }
- it 'create proper authorizations' do
- subject.execute
+ context 'group user' do
+ let(:user) { group_user }
+
+ it 'create proper authorizations' do
+ subject.execute
- expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project)).to be_truthy
+ expect(Ability.allowed?(user, :read_project, project_child)).to be_truthy
+ end
end
- end
- context 'child group user' do
- let(:user) { child_group_user }
+ context 'parent group user' do
+ let(:user) { parent_group_user }
- it 'create proper authorizations' do
- subject.execute
+ it 'create proper authorizations' do
+ subject.execute
+
+ expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey
+ end
+ end
- expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey
+ context 'child group user' do
+ let(:user) { child_group_user }
+
+ it 'create proper authorizations' do
+ subject.execute
+
+ expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey
+ end
end
end
end
end
- context 'sharing outside the hierarchy is disabled' do
- let(:prevent_sharing) { true }
+ context 'user does not have access to group' do
+ let(:user) { create(:user) }
- it 'prevents sharing with a group outside the hierarchy' do
- result = subject.execute
+ before do
+ group.add_owner(user)
+ end
- expect(group.reload.shared_group_links.count).to eq(0)
- expect(result[:status]).to eq(:error)
- expect(result[:http_status]).to eq(404)
+ it_behaves_like 'not shareable'
+ end
+
+ context 'user does not have admin access to shared group' do
+ let(:user) { create(:user) }
+
+ before do
+ shared_with_group.add_guest(user)
+ group.add_developer(user)
end
- it 'allows sharing with a group within the hierarchy' do
- sibling_group = create(:group, :private, parent: shared_group_parent)
- sibling_group.add_guest(group_user)
+ it_behaves_like 'not shareable'
+ end
+
+ context 'when group is blank' do
+ let(:group_user) { create(:user) }
+ let(:user) { group_user }
+ let(:group) { nil }
- result = described_class.new(shared_group, sibling_group, user, opts).execute
+ it 'does not share and returns an error' do
+ expect do
+ result = subject.execute
- expect(sibling_group.reload.shared_group_links.count).to eq(1)
- expect(result[:status]).to eq(:success)
+ expect(result[:status]).to eq(:error)
+ expect(result[:http_status]).to eq(404)
+ end.not_to change { shared_with_group.shared_group_links.count }
end
end
+
+ context 'when shared_with_group is blank' do
+ let(:group_user) { create(:user) }
+ let(:user) { group_user }
+ let(:shared_with_group) { nil }
+
+ it_behaves_like 'not shareable'
+ end
end
diff --git a/spec/services/groups/group_links/destroy_service_spec.rb b/spec/services/groups/group_links/destroy_service_spec.rb
index e63adc07313..6aaf5f45069 100644
--- a/spec/services/groups/group_links/destroy_service_spec.rb
+++ b/spec/services/groups/group_links/destroy_service_spec.rb
@@ -3,54 +3,77 @@
require 'spec_helper'
RSpec.describe Groups::GroupLinks::DestroyService, '#execute' do
- let(:user) { create(:user) }
-
+ let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:shared_group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: shared_group) }
let_it_be(:owner) { create(:user) }
- before do
- group.add_developer(owner)
- shared_group.add_owner(owner)
- end
-
subject { described_class.new(shared_group, owner) }
- context 'single link' do
- let!(:link) { create(:group_group_link, shared_group: shared_group, shared_with_group: group) }
+ context 'when authorizing by user' do
+ before do
+ group.add_developer(owner)
+ shared_group.add_owner(owner)
+ end
+
+ context 'single link' do
+ let!(:link) { create(:group_group_link, shared_group: shared_group, shared_with_group: group) }
- it 'destroys link' do
- expect { subject.execute(link) }.to change { shared_group.shared_with_group_links.count }.from(1).to(0)
+ it 'destroys the link' do
+ expect { subject.execute(link) }.to change { shared_group.shared_with_group_links.count }.from(1).to(0)
+ end
+
+ it 'revokes project authorization', :sidekiq_inline do
+ group.add_developer(user)
+
+ expect { subject.execute(link) }.to(
+ change { Ability.allowed?(user, :read_project, project) }.from(true).to(false))
+ end
end
- it 'revokes project authorization', :sidekiq_inline do
- group.add_developer(user)
+ context 'multiple links' do
+ let_it_be(:another_group) { create(:group, :private) }
+ let_it_be(:another_shared_group) { create(:group, :private) }
+
+ let!(:links) do
+ [
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group),
+ create(:group_group_link, shared_group: shared_group, shared_with_group: another_group),
+ create(:group_group_link, shared_group: another_shared_group, shared_with_group: group),
+ create(:group_group_link, shared_group: another_shared_group, shared_with_group: another_group)
+ ]
+ end
- expect { subject.execute(link) }.to(
- change { Ability.allowed?(user, :read_project, project) }.from(true).to(false))
+ it 'updates project authorization once per group' do
+ expect(GroupGroupLink).to receive(:delete).and_call_original
+ expect(group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true, blocking: false).once
+ expect(another_group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true, blocking: false).once
+
+ subject.execute(links)
+ end
end
end
- context 'multiple links' do
- let_it_be(:another_group) { create(:group, :private) }
- let_it_be(:another_shared_group) { create(:group, :private) }
-
- let!(:links) do
- [
- create(:group_group_link, shared_group: shared_group, shared_with_group: group),
- create(:group_group_link, shared_group: shared_group, shared_with_group: another_group),
- create(:group_group_link, shared_group: another_shared_group, shared_with_group: group),
- create(:group_group_link, shared_group: another_shared_group, shared_with_group: another_group)
- ]
+ context 'when skipping authorization' do
+ let!(:link) { create(:group_group_link, shared_group: shared_group, shared_with_group: group) }
+
+ context 'with provided group and owner' do
+ it 'destroys the link' do
+ expect do
+ subject.execute(link, skip_authorization: true)
+ end.to change { shared_group.shared_with_group_links.count }.from(1).to(0)
+ end
end
- it 'updates project authorization once per group' do
- expect(GroupGroupLink).to receive(:delete).and_call_original
- expect(group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true, blocking: false).once
- expect(another_group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true, blocking: false).once
+ context 'without providing group or owner' do
+ subject { described_class.new(nil, nil) }
- subject.execute(links)
+ it 'destroys the link' do
+ expect do
+ subject.execute(link, skip_authorization: true)
+ end.to change { shared_group.shared_with_group_links.count }.from(1).to(0)
+ end
end
end
end
diff --git a/spec/services/groups/open_issues_count_service_spec.rb b/spec/services/groups/open_issues_count_service_spec.rb
index 7dd8c2a59a0..fca09bfdebe 100644
--- a/spec/services/groups/open_issues_count_service_spec.rb
+++ b/spec/services/groups/open_issues_count_service_spec.rb
@@ -3,18 +3,12 @@
require 'spec_helper'
RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_caching do
- let_it_be(:group) { create(:group, :public) }
+ let_it_be(:group) { create(:group, :public)}
let_it_be(:project) { create(:project, :public, namespace: group) }
- let_it_be(:admin) { create(:user, :admin) }
let_it_be(:user) { create(:user) }
- let_it_be(:banned_user) { create(:user, :banned) }
-
- before do
- create(:issue, :opened, project: project)
- create(:issue, :opened, confidential: true, project: project)
- create(:issue, :opened, author: banned_user, project: project)
- create(:issue, :closed, project: project)
- end
+ let_it_be(:issue) { create(:issue, :opened, project: project) }
+ let_it_be(:confidential) { create(:issue, :opened, confidential: true, project: project) }
+ let_it_be(:closed) { create(:issue, :closed, project: project) }
subject { described_class.new(group, user) }
@@ -26,27 +20,17 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac
it 'uses the IssuesFinder to scope issues' do
expect(IssuesFinder)
.to receive(:new)
- .with(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: true, include_hidden: false)
+ .with(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: true)
subject.count
end
end
describe '#count' do
- shared_examples 'counts public issues, does not count hidden or confidential' do
- it 'counts only public issues' do
- expect(subject.count).to eq(1)
- end
-
- it 'uses PUBLIC_COUNT_WITHOUT_HIDDEN_KEY cache key' do
- expect(subject.cache_key).to include('group_open_public_issues_without_hidden_count')
- end
- end
-
context 'when user is nil' do
- let(:user) { nil }
-
- it_behaves_like 'counts public issues, does not count hidden or confidential'
+ it 'does not include confidential issues in the issue count' do
+ expect(described_class.new(group).count).to eq(1)
+ end
end
context 'when user is provided' do
@@ -55,13 +39,9 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac
group.add_reporter(user)
end
- it 'includes confidential issues and does not include hidden issues in count' do
+ it 'returns the right count with confidential issues' do
expect(subject.count).to eq(2)
end
-
- it 'uses TOTAL_COUNT_WITHOUT_HIDDEN_KEY cache key' do
- expect(subject.cache_key).to include('group_open_issues_without_hidden_count')
- end
end
context 'when user cannot read confidential issues' do
@@ -69,24 +49,8 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac
group.add_guest(user)
end
- it_behaves_like 'counts public issues, does not count hidden or confidential'
- end
-
- context 'when user is an admin' do
- let(:user) { admin }
-
- context 'when admin mode is enabled', :enable_admin_mode do
- it 'includes confidential and hidden issues in count' do
- expect(subject.count).to eq(3)
- end
-
- it 'uses TOTAL_COUNT_KEY cache key' do
- expect(subject.cache_key).to include('group_open_issues_including_hidden_count')
- end
- end
-
- context 'when admin mode is disabled' do
- it_behaves_like 'counts public issues, does not count hidden or confidential'
+ it 'does not include confidential issues' do
+ expect(subject.count).to eq(1)
end
end
@@ -97,13 +61,11 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac
describe '#clear_all_cache_keys' do
it 'calls `Rails.cache.delete` with the correct keys' do
expect(Rails.cache).to receive(:delete)
- .with(['groups', 'open_issues_count_service', 1, group.id, described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY])
+ .with(['groups', 'open_issues_count_service', 1, group.id, described_class::PUBLIC_COUNT_KEY])
expect(Rails.cache).to receive(:delete)
.with(['groups', 'open_issues_count_service', 1, group.id, described_class::TOTAL_COUNT_KEY])
- expect(Rails.cache).to receive(:delete)
- .with(['groups', 'open_issues_count_service', 1, group.id, described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY])
- described_class.new(group).clear_all_cache_keys
+ subject.clear_all_cache_keys
end
end
end
diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb
index 1c4b7aac87e..20ea8b2bf1b 100644
--- a/spec/services/groups/transfer_service_spec.rb
+++ b/spec/services/groups/transfer_service_spec.rb
@@ -574,7 +574,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do
context 'resets project authorizations' do
let_it_be(:old_parent_group) { create(:group) }
- let_it_be_with_reload(:group) { create(:group, :private, parent: old_parent_group) }
+ let_it_be_with_refind(:group) { create(:group, :private, parent: old_parent_group) }
let_it_be(:new_group_member) { create(:user) }
let_it_be(:old_group_member) { create(:user) }
let_it_be(:unique_subgroup_member) { create(:user) }
diff --git a/spec/services/import/bitbucket_server_service_spec.rb b/spec/services/import/bitbucket_server_service_spec.rb
index 56d93625b91..0b9fe10e95a 100644
--- a/spec/services/import/bitbucket_server_service_spec.rb
+++ b/spec/services/import/bitbucket_server_service_spec.rb
@@ -48,6 +48,23 @@ RSpec.describe Import::BitbucketServerService do
end
end
+ context 'when import source is disabled' do
+ before do
+ stub_application_setting(import_sources: nil)
+ allow(subject).to receive(:authorized?).and_return(true)
+ allow(client).to receive(:repo).with(project_key, repo_slug).and_return(double(repo))
+ end
+
+ it 'returns forbidden' do
+ result = subject.execute(credentials)
+
+ expect(result).to include(
+ status: :error,
+ http_status: :forbidden
+ )
+ end
+ end
+
context 'when user is unauthorized' do
before do
allow(subject).to receive(:authorized?).and_return(false)
diff --git a/spec/services/import/github_service_spec.rb b/spec/services/import/github_service_spec.rb
index 58afae1e647..1c26677cfa5 100644
--- a/spec/services/import/github_service_spec.rb
+++ b/spec/services/import/github_service_spec.rb
@@ -111,6 +111,33 @@ RSpec.describe Import::GithubService do
end
end
+ context 'when import source is disabled' do
+ let(:repository_double) do
+ double({
+ name: 'vim',
+ description: 'test',
+ full_name: 'test/vim',
+ clone_url: 'http://repo.com/repo/repo.git',
+ private: false,
+ has_wiki?: false
+ })
+ end
+
+ before do
+ stub_application_setting(import_sources: nil)
+ allow(client).to receive(:repository).and_return(repository_double)
+ end
+
+ it 'returns forbidden' do
+ result = subject.execute(access_params, :github)
+
+ expect(result).to include(
+ status: :error,
+ http_status: :forbidden
+ )
+ end
+ end
+
context 'when a blocked/local URL is used as github_hostname' do
let(:message) { 'Error while attempting to import from GitHub' }
let(:error) { "Invalid URL: #{url}" }
diff --git a/spec/services/incident_management/timeline_events/create_service_spec.rb b/spec/services/incident_management/timeline_events/create_service_spec.rb
new file mode 100644
index 00000000000..38ce15e74f1
--- /dev/null
+++ b/spec/services/incident_management/timeline_events/create_service_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::TimelineEvents::CreateService do
+ let_it_be(:user_with_permissions) { create(:user) }
+ let_it_be(:user_without_permissions) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be_with_refind(:incident) { create(:incident, project: project) }
+ let_it_be(:comment) { create(:note, project: project, noteable: incident) }
+
+ let(:args) do
+ {
+ note: 'note',
+ occurred_at: Time.current,
+ action: 'new comment',
+ promoted_from_note: comment
+ }
+ end
+
+ let(:current_user) { user_with_permissions }
+ let(:service) { described_class.new(incident, current_user, args) }
+
+ before_all do
+ project.add_developer(user_with_permissions)
+ project.add_reporter(user_without_permissions)
+ end
+
+ describe '#execute' do
+ shared_examples 'error response' do |message|
+ it 'has an informative message' do
+ expect(execute).to be_error
+ expect(execute.message).to eq(message)
+ end
+ end
+
+ shared_examples 'success response' do
+ it 'has timeline event', :aggregate_failures do
+ expect(execute).to be_success
+
+ result = execute.payload[:timeline_event]
+ expect(result).to be_a(::IncidentManagement::TimelineEvent)
+ expect(result.author).to eq(current_user)
+ expect(result.incident).to eq(incident)
+ expect(result.project).to eq(project)
+ expect(result.note).to eq(args[:note])
+ expect(result.promoted_from_note).to eq(comment)
+ end
+ end
+
+ subject(:execute) { service.execute }
+
+ context 'when current user is blank' do
+ let(:current_user) { nil }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
+ end
+
+ context 'when user does not have permissions to create timeline events' do
+ let(:current_user) { user_without_permissions }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
+ end
+
+ context 'when error occurs during creation' do
+ let(:args) { {} }
+
+ it_behaves_like 'error response', "Occurred at can't be blank, Note can't be blank, and Note html can't be blank"
+ end
+
+ context 'with default action' do
+ let(:args) { { note: 'note', occurred_at: Time.current, promoted_from_note: comment } }
+
+ it_behaves_like 'success response'
+
+ it 'matches the default action', :aggregate_failures do
+ result = execute.payload[:timeline_event]
+
+ expect(result.action).to eq(IncidentManagement::TimelineEvents::DEFAULT_ACTION)
+ end
+ end
+
+ context 'with non_default action' do
+ it_behaves_like 'success response'
+
+ it 'matches the action from arguments', :aggregate_failures do
+ result = execute.payload[:timeline_event]
+
+ expect(result.action).to eq(args[:action])
+ end
+ end
+
+ it 'successfully creates a database record', :aggregate_failures do
+ expect { execute }.to change { ::IncidentManagement::TimelineEvent.count }.by(1)
+ end
+
+ context 'when incident_timeline feature flag is enabled' do
+ before do
+ stub_feature_flags(incident_timeline: project)
+ end
+
+ it 'creates a system note' do
+ expect { execute }.to change { incident.notes.reload.count }.by(1)
+ end
+ end
+
+ context 'when incident_timeline feature flag is disabled' do
+ before do
+ stub_feature_flags(incident_timeline: false)
+ end
+
+ it 'does not create a system note' do
+ expect { execute }.not_to change { incident.notes.reload.count }
+ end
+ end
+ end
+end
diff --git a/spec/services/incident_management/timeline_events/destroy_service_spec.rb b/spec/services/incident_management/timeline_events/destroy_service_spec.rb
new file mode 100644
index 00000000000..01daee2b749
--- /dev/null
+++ b/spec/services/incident_management/timeline_events/destroy_service_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::TimelineEvents::DestroyService do
+ let_it_be(:user_with_permissions) { create(:user) }
+ let_it_be(:user_without_permissions) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be_with_refind(:incident) { create(:incident, project: project) }
+
+ let!(:timeline_event) { create(:incident_management_timeline_event, incident: incident, project: project) }
+ let(:current_user) { user_with_permissions }
+ let(:params) { {} }
+ let(:service) { described_class.new(timeline_event, current_user) }
+
+ before_all do
+ project.add_developer(user_with_permissions)
+ project.add_reporter(user_without_permissions)
+ end
+
+ describe '#execute' do
+ shared_examples 'error response' do |message|
+ it 'has an informative message' do
+ expect(execute).to be_error
+ expect(execute.message).to eq(message)
+ end
+ end
+
+ subject(:execute) { service.execute }
+
+ context 'when current user is anonymous' do
+ let(:current_user) { nil }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
+ end
+
+ context 'when user does not have permissions to remove timeline events' do
+ let(:current_user) { user_without_permissions }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
+ end
+
+ context 'when an error occurs during removal' do
+ before do
+ allow(timeline_event).to receive(:destroy).and_return(false)
+ timeline_event.errors.add(:note, 'cannot be removed')
+ end
+
+ it_behaves_like 'error response', 'Note cannot be removed'
+ end
+
+ it 'successfully returns the timeline event', :aggregate_failures do
+ expect(execute).to be_success
+
+ result = execute.payload[:timeline_event]
+ expect(result).to be_a(::IncidentManagement::TimelineEvent)
+ expect(result.id).to eq(timeline_event.id)
+ end
+
+ context 'when incident_timeline feature flag is enabled' do
+ before do
+ stub_feature_flags(incident_timeline: project)
+ end
+
+ it 'creates a system note' do
+ expect { execute }.to change { incident.notes.reload.count }.by(1)
+ end
+ end
+
+ context 'when incident_timeline feature flag is disabled' do
+ before do
+ stub_feature_flags(incident_timeline: false)
+ end
+
+ it 'does not create a system note' do
+ expect { execute }.not_to change { incident.notes.reload.count }
+ end
+ end
+ end
+end
diff --git a/spec/services/incident_management/timeline_events/update_service_spec.rb b/spec/services/incident_management/timeline_events/update_service_spec.rb
new file mode 100644
index 00000000000..8bc0e5ce0ed
--- /dev/null
+++ b/spec/services/incident_management/timeline_events/update_service_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::TimelineEvents::UpdateService do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident) { create(:incident, project: project) }
+
+ let!(:timeline_event) { create(:incident_management_timeline_event, project: project, incident: incident) }
+ let(:occurred_at) { 1.minute.ago }
+ let(:params) { { note: 'Updated note', occurred_at: occurred_at } }
+
+ before do
+ stub_feature_flags(incident_timeline: project)
+ end
+
+ describe '#execute' do
+ shared_examples 'successful response' do
+ it 'responds with success', :aggregate_failures do
+ expect(execute).to be_success
+ expect(execute.payload).to eq(timeline_event: timeline_event.reload)
+ end
+ end
+
+ shared_examples 'error response' do |message|
+ it 'has an informative message' do
+ expect(execute).to be_error
+ expect(execute.message).to eq(message)
+ end
+ end
+
+ shared_examples 'passing the correct was_changed value' do |was_changed|
+ it 'passes the correct was_changed value into SysteNoteService.edit_timeline_event' do
+ expect(SystemNoteService)
+ .to receive(:edit_timeline_event)
+ .with(timeline_event, user, was_changed: was_changed)
+ .and_call_original
+
+ execute
+ end
+ end
+
+ subject(:execute) { described_class.new(timeline_event, user, params).execute }
+
+ context 'when user has permissions' do
+ before do
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'successful response'
+
+ it 'updates attributes' do
+ expect { execute }.to change { timeline_event.note }.to(params[:note])
+ .and change { timeline_event.occurred_at }.to(params[:occurred_at])
+ end
+
+ it 'creates a system note' do
+ expect { execute }.to change { incident.notes.reload.count }.by(1)
+ end
+
+ it_behaves_like 'passing the correct was_changed value', :occurred_at_and_note
+
+ context 'when incident_timeline feature flag is disabled' do
+ before do
+ stub_feature_flags(incident_timeline: false)
+ end
+
+ it 'does not add a system note' do
+ expect { execute }.not_to change { incident.notes }
+ end
+ end
+
+ context 'when note is nil' do
+ let(:params) { { occurred_at: occurred_at } }
+
+ it_behaves_like 'successful response'
+ it_behaves_like 'passing the correct was_changed value', :occurred_at
+
+ it 'does not update the note' do
+ expect { execute }.not_to change { timeline_event.reload.note }
+ end
+
+ it 'updates occurred_at' do
+ expect { execute }.to change { timeline_event.occurred_at }.to(params[:occurred_at])
+ end
+ end
+
+ context 'when note is blank' do
+ let(:params) { { note: '', occurred_at: occurred_at } }
+
+ it_behaves_like 'successful response'
+ it_behaves_like 'passing the correct was_changed value', :occurred_at
+
+ it 'does not update the note' do
+ expect { execute }.not_to change { timeline_event.reload.note }
+ end
+
+ it 'updates occurred_at' do
+ expect { execute }.to change { timeline_event.occurred_at }.to(params[:occurred_at])
+ end
+ end
+
+ context 'when occurred_at is nil' do
+ let(:params) { { note: 'Updated note' } }
+
+ it_behaves_like 'successful response'
+ it_behaves_like 'passing the correct was_changed value', :note
+
+ it 'updates the note' do
+ expect { execute }.to change { timeline_event.note }.to(params[:note])
+ end
+
+ it 'does not update occurred_at' do
+ expect { execute }.not_to change { timeline_event.reload.occurred_at }
+ end
+ end
+
+ context 'when both occurred_at and note is nil' do
+ let(:params) { {} }
+
+ it_behaves_like 'successful response'
+
+ it 'does not update the note' do
+ expect { execute }.not_to change { timeline_event.note }
+ end
+
+ it 'does not update occurred_at' do
+ expect { execute }.not_to change { timeline_event.reload.occurred_at }
+ end
+
+ it 'does not call SysteNoteService.edit_timeline_event' do
+ expect(SystemNoteService).not_to receive(:edit_timeline_event)
+
+ execute
+ end
+ end
+ end
+
+ context 'when user does not have permissions' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
+ end
+ end
+end
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index 1f6118e9fcc..344da5a6582 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -279,7 +279,7 @@ RSpec.describe Issues::CloseService do
it 'verifies the number of queries' do
recorded = ActiveRecord::QueryRecorder.new { close_issue }
- expected_queries = 32
+ expected_queries = 30
expect(recorded.count).to be <= expected_queries
expect(recorded.cached_count).to eq(0)
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 6b7b72d83fc..3934ca04a00 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -47,6 +47,14 @@ RSpec.describe Issues::CreateService do
due_date: Date.tomorrow }
end
+ it 'works if base work item types were not created yet' do
+ WorkItems::Type.delete_all
+
+ expect do
+ issue
+ end.to change(Issue, :count).by(1)
+ end
+
it 'creates the issue with the given params' do
expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute)
diff --git a/spec/services/issues/set_crm_contacts_service_spec.rb b/spec/services/issues/set_crm_contacts_service_spec.rb
index b0befb9f77c..5613cc49cc5 100644
--- a/spec/services/issues/set_crm_contacts_service_spec.rb
+++ b/spec/services/issues/set_crm_contacts_service_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Issues::SetCrmContactsService do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :crm_enabled) }
- let_it_be(:project) { create(:project, group: create(:group, parent: group)) }
+ let_it_be(:project) { create(:project, group: create(:group, :crm_enabled, parent: group)) }
let_it_be(:contacts) { create_list(:contact, 4, group: group) }
let_it_be(:issue, reload: true) { create(:issue, project: project) }
let_it_be(:issue_contact_1) do
@@ -58,6 +58,20 @@ RSpec.describe Issues::SetCrmContactsService do
group.add_reporter(user)
end
+ context 'but the crm setting is disabled' do
+ let(:params) { { replace_ids: [contacts[1].id, contacts[2].id] } }
+ let(:subgroup_with_crm_disabled) { create(:group, parent: group) }
+ let(:project_with_crm_disabled) { create(:project, group: subgroup_with_crm_disabled) }
+ let(:issue_with_crm_disabled) { create(:issue, project: project_with_crm_disabled) }
+
+ it 'returns expected error response' do
+ response = described_class.new(project: project_with_crm_disabled, current_user: user, params: params).execute(issue_with_crm_disabled)
+
+ expect(response).to be_error
+ expect(response.message).to eq('You have insufficient permissions to set customer relations contacts for this issue')
+ end
+ end
+
context 'when the contact does not exist' do
let(:params) { { replace_ids: [non_existing_record_id] } }
diff --git a/spec/services/jira_connect/sync_service_spec.rb b/spec/services/jira_connect/sync_service_spec.rb
index c20aecaaef0..7242b1f41f9 100644
--- a/spec/services/jira_connect/sync_service_spec.rb
+++ b/spec/services/jira_connect/sync_service_spec.rb
@@ -24,13 +24,15 @@ RSpec.describe JiraConnect::SyncService do
end
def expect_log(type, message)
- expect(Gitlab::ProjectServiceLogger)
+ expect(Gitlab::IntegrationsLogger)
.to receive(type).with(
- message: 'response from jira dev_info api',
- integration: 'JiraConnect',
- project_id: project.id,
- project_path: project.full_path,
- jira_response: message&.to_json
+ {
+ message: 'response from jira dev_info api',
+ integration: 'JiraConnect',
+ project_id: project.id,
+ project_path: project.full_path,
+ jira_response: message&.to_json
+ }
)
end
diff --git a/spec/services/keys/expiry_notification_service_spec.rb b/spec/services/keys/expiry_notification_service_spec.rb
index 1d1da179cf7..7cb6cbce311 100644
--- a/spec/services/keys/expiry_notification_service_spec.rb
+++ b/spec/services/keys/expiry_notification_service_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe Keys::ExpiryNotificationService do
end
context 'with key expiring today', :mailer do
- let_it_be_with_reload(:key) { create(:key, expires_at: Time.current, user: user) }
+ let_it_be_with_reload(:key) { create(:key, expires_at: 10.minutes.from_now, user: user) }
let(:expiring_soon) { false }
diff --git a/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb b/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb
index 538d9638879..735f090d926 100644
--- a/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb
+++ b/spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb
@@ -181,16 +181,6 @@ RSpec.describe LooseForeignKeys::BatchCleanerService do
end
end
end
-
- context 'when the lfk_fair_queueing FF is off' do
- before do
- stub_feature_flags(lfk_fair_queueing: false)
- end
-
- it 'does nothing' do
- expect { cleaner.execute }.not_to change { deleted_record.reload.cleanup_attempts }
- end
- end
end
end
end
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 25437be1e78..730175af0bb 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -7,11 +7,11 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
let_it_be(:user) { create(:user) }
let_it_be(:member) { create(:user) }
let_it_be(:user_invited_by_id) { create(:user) }
- let_it_be(:user_ids) { member.id.to_s }
+ let_it_be(:user_id) { member.id.to_s }
let_it_be(:access_level) { Gitlab::Access::GUEST }
let(:additional_params) { { invite_source: '_invite_source_' } }
- let(:params) { { user_ids: user_ids, access_level: access_level }.merge(additional_params) }
+ let(:params) { { user_id: user_id, access_level: access_level }.merge(additional_params) }
let(:current_user) { user }
subject(:execute_service) { described_class.new(current_user, params.merge({ source: source })).execute }
@@ -51,7 +51,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when user_id is passed as an integer' do
- let(:user_ids) { member.id }
+ let(:user_id) { member.id }
it 'successfully creates member' do
expect(execute_service[:status]).to eq(:success)
@@ -60,8 +60,8 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'with user_ids as an array of integers' do
- let(:user_ids) { [member.id, user_invited_by_id.id] }
+ context 'with user_id as an array of integers' do
+ let(:user_id) { [member.id, user_invited_by_id.id] }
it 'successfully creates members' do
expect(execute_service[:status]).to eq(:success)
@@ -70,8 +70,8 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'with user_ids as an array of strings' do
- let(:user_ids) { [member.id.to_s, user_invited_by_id.id.to_s] }
+ context 'with user_id as an array of strings' do
+ let(:user_id) { [member.id.to_s, user_invited_by_id.id.to_s] }
it 'successfully creates members' do
expect(execute_service[:status]).to eq(:success)
@@ -101,7 +101,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when passing no user ids' do
- let(:user_ids) { '' }
+ let(:user_id) { '' }
it 'does not add a member' do
expect(execute_service[:status]).to eq(:error)
@@ -112,7 +112,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when passing many user ids' do
- let(:user_ids) { 1.upto(101).to_a.join(',') }
+ let(:user_id) { 1.upto(101).to_a.join(',') }
it 'limits the number of users to 100' do
expect(execute_service[:status]).to eq(:error)
@@ -134,7 +134,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when passing an existing invite user id' do
- let(:user_ids) { create(:project_member, :invited, project: source).invite_email }
+ let(:user_id) { create(:project_member, :invited, project: source).invite_email }
it 'does not add a member' do
expect(execute_service[:status]).to eq(:error)
@@ -146,7 +146,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
context 'when adding a project_bot' do
let_it_be(:project_bot) { create(:user, :project_bot) }
- let(:user_ids) { project_bot.id }
+ let(:user_id) { project_bot.id }
context 'when project_bot is already a member' do
before do
@@ -213,7 +213,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when it is a net_new_user' do
- let(:additional_params) { { invite_source: '_invite_source_', user_ids: 'email@example.org' } }
+ let(:additional_params) { { invite_source: '_invite_source_', user_id: 'email@example.org' } }
it 'tracks the invite source from params' do
execute_service
@@ -248,8 +248,8 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
)
end
- context 'when it is an invite by email passed to user_ids' do
- let(:user_ids) { 'email@example.org' }
+ context 'when it is an invite by email passed to user_id' do
+ let(:user_id) { 'email@example.org' }
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
@@ -263,7 +263,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
let(:another_user) { create(:user) }
- let(:user_ids) { [member.id, another_user.id].join(',') }
+ let(:user_id) { [member.id, another_user.id].join(',') }
it 'still creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker)
@@ -326,7 +326,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when a member was already invited' do
- let(:user_ids) { create(:project_member, :invited, project: source).invite_email }
+ let(:user_id) { create(:project_member, :invited, project: source).invite_email }
let(:additional_params) do
{ invite_source: '_invite_source_', tasks_project_id: source.id, tasks_to_be_done: %w(ci code) }
end
diff --git a/spec/services/members/groups/creator_service_spec.rb b/spec/services/members/groups/creator_service_spec.rb
index 4427c4e7d9f..c3ba7c0374d 100644
--- a/spec/services/members/groups/creator_service_spec.rb
+++ b/spec/services/members/groups/creator_service_spec.rb
@@ -3,14 +3,28 @@
require 'spec_helper'
RSpec.describe Members::Groups::CreatorService do
- it_behaves_like 'member creation' do
- let_it_be(:source, reload: true) { create(:group, :public) }
- let_it_be(:member_type) { GroupMember }
- end
-
describe '.access_levels' do
it 'returns Gitlab::Access.options_with_owner' do
expect(described_class.access_levels).to eq(Gitlab::Access.sym_options_with_owner)
end
end
+
+ describe '#execute' do
+ let_it_be(:source, reload: true) { create(:group, :public) }
+ let_it_be(:user) { create(:user) }
+
+ it_behaves_like 'member creation' do
+ let_it_be(:member_type) { GroupMember }
+ end
+
+ context 'authorized projects update' do
+ it 'schedules a single project authorization update job when called multiple times' do
+ expect(AuthorizedProjectsWorker).to receive(:bulk_perform_and_wait).once
+
+ 1.upto(3) do
+ described_class.new(source, user, :maintainer).execute
+ end
+ end
+ end
+ end
end
diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb
index ab740138a8b..8213e8baae0 100644
--- a/spec/services/members/invite_service_spec.rb
+++ b/spec/services/members/invite_service_spec.rb
@@ -52,8 +52,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'with user_ids as integers' do
- let(:params) { { user_ids: [project_user.id, user_invited_by_id.id] } }
+ context 'with user_id as integers' do
+ let(:params) { { user_id: [project_user.id, user_invited_by_id.id] } }
it 'successfully creates members' do
expect_to_create_members(count: 2)
@@ -61,8 +61,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'with user_ids as strings' do
- let(:params) { { user_ids: [project_user.id.to_s, user_invited_by_id.id.to_s] } }
+ context 'with user_id as strings' do
+ let(:params) { { user_id: [project_user.id.to_s, user_invited_by_id.id.to_s] } }
it 'successfully creates members' do
expect_to_create_members(count: 2)
@@ -70,9 +70,9 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'with a mixture of emails and user_ids' do
+ context 'with a mixture of emails and user_id' do
let(:params) do
- { user_ids: [project_user.id, user_invited_by_id.id], email: %w[email@example.org email2@example.org] }
+ { user_id: [project_user.id, user_invited_by_id.id], email: %w[email@example.org email2@example.org] }
end
it 'successfully creates members' do
@@ -92,8 +92,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'with user_ids' do
- let(:params) { { user_ids: "#{project_user.id},#{user_invited_by_id.id}" } }
+ context 'with user_id' do
+ let(:params) { { user_id: "#{project_user.id},#{user_invited_by_id.id}" } }
it 'successfully creates members' do
expect_to_create_members(count: 2)
@@ -101,9 +101,9 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'with a mixture of emails and user_ids' do
+ context 'with a mixture of emails and user_id' do
let(:params) do
- { user_ids: "#{project_user.id},#{user_invited_by_id.id}", email: 'email@example.org,email2@example.org' }
+ { user_id: "#{project_user.id},#{user_invited_by_id.id}", email: 'email@example.org,email2@example.org' }
end
it 'successfully creates members' do
@@ -114,9 +114,9 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when invites formats are mixed' do
- context 'when user_ids is an array and emails is a string' do
+ context 'when user_id is an array and emails is a string' do
let(:params) do
- { user_ids: [project_user.id, user_invited_by_id.id], email: 'email@example.org,email2@example.org' }
+ { user_id: [project_user.id, user_invited_by_id.id], email: 'email@example.org,email2@example.org' }
end
it 'successfully creates members' do
@@ -125,9 +125,9 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'when user_ids is a string and emails is an array' do
+ context 'when user_id is a string and emails is an array' do
let(:params) do
- { user_ids: "#{project_user.id},#{user_invited_by_id.id}", email: %w[email@example.org email2@example.org] }
+ { user_id: "#{project_user.id},#{user_invited_by_id.id}", email: %w[email@example.org email2@example.org] }
end
it 'successfully creates members' do
@@ -147,8 +147,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'when user_ids are passed as an empty string' do
- let(:params) { { user_ids: '' } }
+ context 'when user_id are passed as an empty string' do
+ let(:params) { { user_id: '' } }
it 'returns an error' do
expect_not_to_create_members
@@ -156,8 +156,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'when user_ids and emails are both passed as empty strings' do
- let(:params) { { user_ids: '', email: '' } }
+ context 'when user_id and emails are both passed as empty strings' do
+ let(:params) { { user_id: '', email: '' } }
it 'returns an error' do
expect_not_to_create_members
@@ -166,7 +166,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when user_id is passed as an integer' do
- let(:params) { { user_ids: project_user.id } }
+ let(:params) { { user_id: project_user.id } }
it 'successfully creates member' do
expect_to_create_members(count: 1)
@@ -196,7 +196,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'with user_id and singular invalid email' do
- let(:params) { { user_ids: project_user.id, email: '_bogus_' } }
+ let(:params) { { user_id: project_user.id, email: '_bogus_' } }
it 'has partial success' do
expect_to_create_members(count: 1)
@@ -219,7 +219,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'with duplicate user ids' do
- let(:params) { { user_ids: "#{project_user.id},#{project_user.id}" } }
+ let(:params) { { user_id: "#{project_user.id},#{project_user.id}" } }
it 'only creates one member per unique invite' do
expect_to_create_members(count: 1)
@@ -228,7 +228,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'with duplicate member by adding as user id and email' do
- let(:params) { { user_ids: project_user.id, email: project_user.email } }
+ let(:params) { { user_id: project_user.id, email: project_user.email } }
it 'only creates one member per unique invite' do
expect_to_create_members(count: 1)
@@ -269,9 +269,9 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'with user_ids' do
- let(:user_ids) { 1.upto(101).to_a.join(',') }
- let(:params) { { user_ids: user_ids } }
+ context 'with user_id' do
+ let(:user_id) { 1.upto(101).to_a.join(',') }
+ let(:params) { { user_id: user_id } }
it 'limits the number of users to 100' do
expect_not_to_create_members
@@ -292,7 +292,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'with user_id' do
- let(:params) { { user_ids: project_user.id } }
+ let(:params) { { user_id: project_user.id } }
it_behaves_like 'records an onboarding progress action', :user_added
@@ -304,7 +304,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
context 'when assigning tasks to be done' do
let(:params) do
- { user_ids: project_user.id, tasks_to_be_done: %w(ci code), tasks_project_id: project.id }
+ { user_id: project_user.id, tasks_to_be_done: %w(ci code), tasks_project_id: project.id }
end
it 'creates 2 task issues', :aggregate_failures do
@@ -332,7 +332,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'with user_id' do
- let(:params) { { user_ids: user_invited_by_id.id, access_level: -1 } }
+ let(:params) { { user_id: user_invited_by_id.id, access_level: -1 } }
it 'returns an error' do
expect_not_to_create_members
@@ -341,7 +341,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'with a mix of user_id and email' do
- let(:params) { { user_ids: user_invited_by_id.id, email: project_user.email, access_level: -1 } }
+ let(:params) { { user_id: user_invited_by_id.id, email: project_user.email, access_level: -1 } }
it 'returns errors' do
expect_not_to_create_members
@@ -387,7 +387,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
context 'with user_id that already exists' do
let!(:existing_member) { create(:project_member, project: project, user: project_user) }
- let(:params) { { user_ids: existing_member.user_id } }
+ let(:params) { { user_id: existing_member.user_id } }
it 'does not add the member again and is successful' do
expect_to_create_members(count: 0)
@@ -397,7 +397,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
context 'with user_id that already exists with a lower access_level' do
let!(:existing_member) { create(:project_member, :developer, project: project, user: project_user) }
- let(:params) { { user_ids: existing_member.user_id, access_level: ProjectMember::MAINTAINER } }
+ let(:params) { { user_id: existing_member.user_id, access_level: ProjectMember::MAINTAINER } }
it 'does not add the member again and updates the access_level' do
expect_to_create_members(count: 0)
@@ -408,7 +408,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
context 'with user_id that already exists with a higher access_level' do
let!(:existing_member) { create(:project_member, :developer, project: project, user: project_user) }
- let(:params) { { user_ids: existing_member.user_id, access_level: ProjectMember::GUEST } }
+ let(:params) { { user_id: existing_member.user_id, access_level: ProjectMember::GUEST } }
it 'does not add the member again and updates the access_level' do
expect_to_create_members(count: 0)
@@ -428,7 +428,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when access_level is lower than inheriting member' do
- let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::GUEST }}
+ let(:params) { { user_id: group_member.user_id, access_level: ProjectMember::GUEST }}
it 'does not add the member and returns an error' do
msg = "Access level should be greater than or equal " \
@@ -440,7 +440,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when access_level is the same as the inheriting member' do
- let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::DEVELOPER }}
+ let(:params) { { user_id: group_member.user_id, access_level: ProjectMember::DEVELOPER }}
it 'adds the member with correct access_level' do
expect_to_create_members(count: 1)
@@ -450,7 +450,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
context 'when access_level is greater than the inheriting member' do
- let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::MAINTAINER }}
+ let(:params) { { user_id: group_member.user_id, access_level: ProjectMember::MAINTAINER }}
it 'adds the member with correct access_level' do
expect_to_create_members(count: 1)
diff --git a/spec/services/members/projects/creator_service_spec.rb b/spec/services/members/projects/creator_service_spec.rb
index 7ba183759bc..7605238c3c5 100644
--- a/spec/services/members/projects/creator_service_spec.rb
+++ b/spec/services/members/projects/creator_service_spec.rb
@@ -3,14 +3,28 @@
require 'spec_helper'
RSpec.describe Members::Projects::CreatorService do
- it_behaves_like 'member creation' do
- let_it_be(:source, reload: true) { create(:project, :public) }
- let_it_be(:member_type) { ProjectMember }
- end
-
describe '.access_levels' do
it 'returns Gitlab::Access.sym_options_with_owner' do
expect(described_class.access_levels).to eq(Gitlab::Access.sym_options_with_owner)
end
end
+
+ describe '#execute' do
+ let_it_be(:source, reload: true) { create(:project, :public) }
+ let_it_be(:user) { create(:user) }
+
+ it_behaves_like 'member creation' do
+ let_it_be(:member_type) { ProjectMember }
+ end
+
+ context 'authorized projects update' do
+ it 'schedules a single project authorization update job when called multiple times' do
+ expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to receive(:bulk_perform_in).once
+
+ 1.upto(3) do
+ described_class.new(source, user, :maintainer).execute
+ end
+ end
+ end
+ end
end
diff --git a/spec/services/merge_requests/approval_service_spec.rb b/spec/services/merge_requests/approval_service_spec.rb
index 9b064da44b8..e500102a00c 100644
--- a/spec/services/merge_requests/approval_service_spec.rb
+++ b/spec/services/merge_requests/approval_service_spec.rb
@@ -41,6 +41,12 @@ RSpec.describe MergeRequests::ApprovalService do
end
context 'with valid approval' do
+ let(:notification_service) { NotificationService.new }
+
+ before do
+ allow(service).to receive(:notification_service).and_return(notification_service)
+ end
+
it 'creates an approval note and marks pending todos as done' do
expect(SystemNoteService).to receive(:approve_mr).with(merge_request, user)
expect(merge_request.approvals).to receive(:reset)
@@ -59,9 +65,16 @@ RSpec.describe MergeRequests::ApprovalService do
service.execute(merge_request)
end
+ it 'sends a notification when approving' do
+ expect(notification_service).to receive_message_chain(:async, :approve_mr)
+ .with(merge_request, user)
+
+ service.execute(merge_request)
+ end
+
it 'removes attention requested state' do
expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new)
- .with(project: project, current_user: user, merge_request: merge_request)
+ .with(project: project, current_user: user, merge_request: merge_request, user: user)
.and_call_original
service.execute(merge_request)
diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb
index d36a2f75cfe..cd1c362a19f 100644
--- a/spec/services/merge_requests/close_service_spec.rb
+++ b/spec/services/merge_requests/close_service_spec.rb
@@ -97,7 +97,7 @@ RSpec.describe MergeRequests::CloseService do
it 'clean up environments for the merge request' do
expect_next_instance_of(::Environments::StopService) do |service|
- expect(service).to receive(:execute_for_merge_request).with(merge_request)
+ expect(service).to receive(:execute_for_merge_request_pipeline).with(merge_request)
end
described_class.new(project: project, current_user: user).execute(merge_request)
diff --git a/spec/services/merge_requests/handle_assignees_change_service_spec.rb b/spec/services/merge_requests/handle_assignees_change_service_spec.rb
index 26f53f55b0f..fa3b1614e21 100644
--- a/spec/services/merge_requests/handle_assignees_change_service_spec.rb
+++ b/spec/services/merge_requests/handle_assignees_change_service_spec.rb
@@ -89,18 +89,12 @@ RSpec.describe MergeRequests::HandleAssigneesChangeService do
it 'removes attention requested state' do
expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new)
- .with(project: project, current_user: user, merge_request: merge_request)
+ .with(project: project, current_user: user, merge_request: merge_request, user: user)
.and_call_original
execute
end
- it 'updates attention requested by of assignee' do
- execute
-
- expect(merge_request.find_assignee(assignee).updated_state_by).to eq(user)
- end
-
it 'tracks users assigned event' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_users_assigned_to_mr).once.with(users: [assignee])
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index ecb856bd1a4..78deab64b1c 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -149,7 +149,7 @@ RSpec.describe MergeRequests::MergeService do
allow(project).to receive(:default_branch).and_return(merge_request.target_branch)
end
- it 'closes GitLab issue tracker issues' do
+ it 'closes GitLab issue tracker issues', :sidekiq_inline do
issue = create :issue, project: project
commit = double('commit', safe_message: "Fixes #{issue.to_reference}", date: Time.current, authored_date: Time.current)
allow(merge_request).to receive(:commits).and_return([commit])
diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb
index 8d9a32c3e9e..f0885365f96 100644
--- a/spec/services/merge_requests/post_merge_service_spec.rb
+++ b/spec/services/merge_requests/post_merge_service_spec.rb
@@ -59,24 +59,9 @@ RSpec.describe MergeRequests::PostMergeService do
expect(diff_removal_service).to have_received(:execute)
end
- it 'marks MR as merged regardless of errors when closing issues' do
- merge_request.update!(target_branch: 'foo')
- allow(project).to receive(:default_branch).and_return('foo')
-
- issue = create(:issue, project: project)
- allow(merge_request).to receive(:visible_closing_issues_for).and_return([issue])
- expect_next_instance_of(Issues::CloseService) do |close_service|
- allow(close_service).to receive(:execute).with(issue, commit: merge_request).and_raise(RuntimeError)
- end
-
- expect { subject }.to raise_error(RuntimeError)
-
- expect(merge_request.reload).to be_merged
- end
-
it 'clean up environments for the merge request' do
expect_next_instance_of(::Environments::StopService) do |stop_environment_service|
- expect(stop_environment_service).to receive(:execute_for_merge_request).with(merge_request)
+ expect(stop_environment_service).to receive(:execute_for_merge_request_pipeline).with(merge_request)
end
subject
@@ -88,6 +73,67 @@ RSpec.describe MergeRequests::PostMergeService do
subject
end
+ context 'when there are issues to be closed' do
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ before do
+ merge_request.update!(target_branch: 'foo')
+
+ allow(project).to receive(:default_branch).and_return('foo')
+ allow(merge_request).to receive(:visible_closing_issues_for).and_return([issue])
+ end
+
+ it 'performs MergeRequests::CloseIssueWorker asynchronously' do
+ expect(MergeRequests::CloseIssueWorker)
+ .to receive(:perform_async)
+ .with(project.id, user.id, issue.id, merge_request.id)
+
+ subject
+
+ expect(merge_request.reload).to be_merged
+ end
+
+ context 'when issue is an external issue' do
+ let_it_be(:issue) { ExternalIssue.new('JIRA-123', project) }
+
+ it 'executes Issues::CloseService' do
+ expect_next_instance_of(Issues::CloseService) do |close_service|
+ expect(close_service).to receive(:execute).with(issue, commit: merge_request)
+ end
+
+ subject
+
+ expect(merge_request.reload).to be_merged
+ end
+ end
+
+ context 'when async_mr_close_issue feature flag is disabled' do
+ before do
+ stub_feature_flags(async_mr_close_issue: false)
+ end
+
+ it 'executes Issues::CloseService' do
+ expect_next_instance_of(Issues::CloseService) do |close_service|
+ expect(close_service).to receive(:execute).with(issue, commit: merge_request)
+ end
+
+ subject
+
+ expect(merge_request.reload).to be_merged
+ end
+
+ it 'marks MR as merged regardless of errors when closing issues' do
+ expect_next_instance_of(Issues::CloseService) do |close_service|
+ allow(close_service).to receive(:execute).with(issue, commit: merge_request).and_raise(RuntimeError)
+ end
+
+ expect { subject }.to raise_error(RuntimeError)
+
+ expect(merge_request.reload).to be_merged
+ end
+ end
+ end
+
context 'when the merge request has review apps' do
it 'cancels all review app deployments' do
pipeline = create(:ci_pipeline,
diff --git a/spec/services/merge_requests/push_options_handler_service_spec.rb b/spec/services/merge_requests/push_options_handler_service_spec.rb
index 348ea9ad7d4..338057f23d5 100644
--- a/spec/services/merge_requests/push_options_handler_service_spec.rb
+++ b/spec/services/merge_requests/push_options_handler_service_spec.rb
@@ -20,7 +20,17 @@ RSpec.describe MergeRequests::PushOptionsHandlerService do
let(:source_branch) { 'fix' }
let(:target_branch) { 'feature' }
let(:title) { 'my title' }
+ let(:draft_title) { 'Draft: my title' }
+ let(:draft) { true }
let(:description) { 'my description' }
+ let(:multiline_description) do
+ <<~MD.chomp
+ Line 1
+ Line 2
+ Line 3
+ MD
+ end
+
let(:label1) { 'mylabel1' }
let(:label2) { 'mylabel2' }
let(:label3) { 'mylabel3' }
@@ -64,6 +74,26 @@ RSpec.describe MergeRequests::PushOptionsHandlerService do
end
end
+ shared_examples_for 'a service that can set the multiline description of a merge request' do
+ subject(:last_mr) { MergeRequest.last }
+
+ it 'sets the multiline description' do
+ service.execute
+
+ expect(last_mr.description).to eq(multiline_description)
+ end
+ end
+
+ shared_examples_for 'a service that can set the draft of a merge request' do
+ subject(:last_mr) { MergeRequest.last }
+
+ it 'sets the draft' do
+ service.execute
+
+ expect(last_mr.draft).to eq(draft)
+ end
+ end
+
shared_examples_for 'a service that can set the milestone of a merge request' do
subject(:last_mr) { MergeRequest.last }
@@ -417,6 +447,74 @@ RSpec.describe MergeRequests::PushOptionsHandlerService do
it_behaves_like 'a service that does not create a merge request'
it_behaves_like 'a service that can set the description of a merge request'
+
+ context 'with a multiline description' do
+ let(:push_options) { { description: "Line 1\\nLine 2\\nLine 3" } }
+
+ it_behaves_like 'a service that does not create a merge request'
+ it_behaves_like 'a service that can set the multiline description of a merge request'
+ end
+ end
+
+ it_behaves_like 'with a deleted branch'
+ it_behaves_like 'with the project default branch'
+ end
+
+ describe '`draft` push option' do
+ let(:push_options) { { draft: draft } }
+
+ context 'with a new branch' do
+ let(:changes) { new_branch_changes }
+
+ it_behaves_like 'a service that does not create a merge request'
+
+ it 'adds an error to the service' do
+ service.execute
+
+ expect(service.errors).to include(error_mr_required)
+ end
+
+ context 'when coupled with the `create` push option' do
+ let(:push_options) { { create: true, draft: draft } }
+
+ it_behaves_like 'a service that can create a merge request'
+ it_behaves_like 'a service that can set the draft of a merge request'
+ end
+ end
+
+ context 'with an existing branch but no open MR' do
+ let(:changes) { existing_branch_changes }
+
+ it_behaves_like 'a service that does not create a merge request'
+
+ it 'adds an error to the service' do
+ service.execute
+
+ expect(service.errors).to include(error_mr_required)
+ end
+
+ context 'when coupled with the `create` push option' do
+ let(:push_options) { { create: true, draft: draft } }
+
+ it_behaves_like 'a service that can create a merge request'
+ it_behaves_like 'a service that can set the draft of a merge request'
+ end
+ end
+
+ context 'with an existing branch that has a merge request open' do
+ let(:changes) { existing_branch_changes }
+ let!(:merge_request) { create(:merge_request, source_project: project, source_branch: source_branch)}
+
+ it_behaves_like 'a service that does not create a merge request'
+ it_behaves_like 'a service that can set the draft of a merge request'
+ end
+
+ context 'draft title provided while `draft` push option is set to false' do
+ let(:push_options) { { create: true, draft: false, title: draft_title } }
+ let(:changes) { new_branch_changes }
+
+ it_behaves_like 'a service that can create a merge request'
+ it_behaves_like 'a service that can set the draft of a merge request'
end
it_behaves_like 'with a deleted branch'
diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb
index a47e626666b..e7aa6e74246 100644
--- a/spec/services/merge_requests/rebase_service_spec.rb
+++ b/spec/services/merge_requests/rebase_service_spec.rb
@@ -70,11 +70,13 @@ RSpec.describe MergeRequests::RebaseService do
it 'logs the error' do
expect(service).to receive(:log_error).with(exception: exception, message: described_class::REBASE_ERROR, save_message_on_model: true).and_call_original
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception,
- class: described_class.to_s,
- merge_request: merge_request_ref,
- merge_request_id: merge_request.id,
- message: described_class::REBASE_ERROR,
- save_message_on_model: true).and_call_original
+ {
+ class: described_class.to_s,
+ merge_request: merge_request_ref,
+ merge_request_id: merge_request.id,
+ message: described_class::REBASE_ERROR,
+ save_message_on_model: true
+ }).and_call_original
service.execute(merge_request)
end
diff --git a/spec/services/merge_requests/remove_approval_service_spec.rb b/spec/services/merge_requests/remove_approval_service_spec.rb
index ef6a0ec69bd..5a319e90a68 100644
--- a/spec/services/merge_requests/remove_approval_service_spec.rb
+++ b/spec/services/merge_requests/remove_approval_service_spec.rb
@@ -21,14 +21,20 @@ RSpec.describe MergeRequests::RemoveApprovalService do
context 'with a user who has approved' do
let!(:approval) { create(:approval, user: user, merge_request: merge_request) }
+ let(:notification_service) { NotificationService.new }
+
+ before do
+ allow(service).to receive(:notification_service).and_return(notification_service)
+ end
it 'removes the approval' do
expect { execute! }.to change { merge_request.approvals.size }.from(2).to(1)
end
- it 'creates an unapproval note and triggers web hook' do
+ it 'creates an unapproval note, triggers a web hook, and sends a notification' do
expect(service).to receive(:execute_hooks).with(merge_request, 'unapproved')
expect(SystemNoteService).to receive(:unapprove_mr)
+ expect(notification_service).to receive_message_chain(:async, :unapprove_mr).with(merge_request, user)
execute!
end
diff --git a/spec/services/merge_requests/remove_attention_requested_service_spec.rb b/spec/services/merge_requests/remove_attention_requested_service_spec.rb
index 450204ebfdd..576049b9f1b 100644
--- a/spec/services/merge_requests/remove_attention_requested_service_spec.rb
+++ b/spec/services/merge_requests/remove_attention_requested_service_spec.rb
@@ -3,64 +3,112 @@
require 'spec_helper'
RSpec.describe MergeRequests::RemoveAttentionRequestedService do
- let(:current_user) { create(:user) }
- let(:merge_request) { create(:merge_request, reviewers: [current_user], assignees: [current_user]) }
- let(:reviewer) { merge_request.find_reviewer(current_user) }
- let(:assignee) { merge_request.find_assignee(current_user) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:assignee_user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request, reviewers: [user], assignees: [assignee_user]) }
+
+ let(:reviewer) { merge_request.find_reviewer(user) }
+ let(:assignee) { merge_request.find_assignee(assignee_user) }
let(:project) { merge_request.project }
- let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request) }
+
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ merge_request: merge_request,
+ user: user
+ )
+ end
+
let(:result) { service.execute }
before do
+ allow(SystemNoteService).to receive(:remove_attention_request)
+
project.add_developer(current_user)
+ project.add_developer(user)
end
describe '#execute' do
- context 'invalid permissions' do
- let(:service) { described_class.new(project: project, current_user: create(:user), merge_request: merge_request) }
+ context 'when current user cannot update merge request' do
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: create(:user),
+ merge_request: merge_request,
+ user: user
+ )
+ end
it 'returns an error' do
expect(result[:status]).to eq :error
end
end
- context 'reviewer does not exist' do
- let(:service) { described_class.new(project: project, current_user: create(:user), merge_request: merge_request) }
+ context 'when user is not a reviewer nor assignee' do
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ merge_request: merge_request,
+ user: create(:user)
+ )
+ end
it 'returns an error' do
expect(result[:status]).to eq :error
end
end
- context 'reviewer exists' do
+ context 'when user is a reviewer' do
+ before do
+ reviewer.update!(state: :attention_requested)
+ end
+
it 'returns success' do
expect(result[:status]).to eq :success
end
- it 'updates reviewers state' do
+ it 'updates reviewer state' do
service.execute
reviewer.reload
expect(reviewer.state).to eq 'reviewed'
end
+ it 'creates a remove attention request system note' do
+ expect(SystemNoteService)
+ .to receive(:remove_attention_request)
+ .with(merge_request, merge_request.project, current_user, user)
+
+ service.execute
+ end
+
it_behaves_like 'invalidates attention request cache' do
- let(:users) { [current_user] }
+ let(:users) { [user] }
end
end
- context 'assignee exists' do
- let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request) }
+ context 'when user is an assignee' do
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ merge_request: merge_request,
+ user: assignee_user
+ )
+ end
before do
- assignee.update!(state: :reviewed)
+ assignee.update!(state: :attention_requested)
end
it 'returns success' do
expect(result[:status]).to eq :success
end
- it 'updates assignees state' do
+ it 'updates assignee state' do
service.execute
assignee.reload
@@ -68,14 +116,40 @@ RSpec.describe MergeRequests::RemoveAttentionRequestedService do
end
it_behaves_like 'invalidates attention request cache' do
- let(:users) { [current_user] }
+ let(:users) { [assignee_user] }
+ end
+
+ it 'creates a remove attention request system note' do
+ expect(SystemNoteService)
+ .to receive(:remove_attention_request)
+ .with(merge_request, merge_request.project, current_user, assignee_user)
+
+ service.execute
end
end
- context 'assignee is the same as reviewer' do
- let(:merge_request) { create(:merge_request, reviewers: [current_user], assignees: [current_user]) }
- let(:service) { described_class.new(project: project, current_user: current_user, merge_request: merge_request) }
- let(:assignee) { merge_request.find_assignee(current_user) }
+ context 'when user is an assignee and reviewer at the same time' do
+ let_it_be(:merge_request) { create(:merge_request, reviewers: [user], assignees: [user]) }
+
+ let(:assignee) { merge_request.find_assignee(user) }
+
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ merge_request: merge_request,
+ user: user
+ )
+ end
+
+ before do
+ reviewer.update!(state: :attention_requested)
+ assignee.update!(state: :attention_requested)
+ end
+
+ it 'returns success' do
+ expect(result[:status]).to eq :success
+ end
it 'updates reviewers and assignees state' do
service.execute
@@ -86,5 +160,24 @@ RSpec.describe MergeRequests::RemoveAttentionRequestedService do
expect(assignee.state).to eq 'reviewed'
end
end
+
+ context 'when state is already not attention_requested' do
+ before do
+ reviewer.update!(state: :reviewed)
+ end
+
+ it 'does not change state' do
+ service.execute
+ reviewer.reload
+
+ expect(reviewer.state).to eq 'reviewed'
+ end
+
+ it 'does not create a remove attention request system note' do
+ expect(SystemNoteService).not_to receive(:remove_attention_request)
+
+ service.execute
+ end
+ end
end
end
diff --git a/spec/services/merge_requests/request_attention_service_spec.rb b/spec/services/merge_requests/request_attention_service_spec.rb
new file mode 100644
index 00000000000..813a8150625
--- /dev/null
+++ b/spec/services/merge_requests/request_attention_service_spec.rb
@@ -0,0 +1,220 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::RequestAttentionService do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:assignee_user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request, reviewers: [user], assignees: [assignee_user]) }
+
+ let(:reviewer) { merge_request.find_reviewer(user) }
+ let(:assignee) { merge_request.find_assignee(assignee_user) }
+ let(:project) { merge_request.project }
+
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ merge_request: merge_request,
+ user: user
+ )
+ end
+
+ let(:result) { service.execute }
+ let(:todo_svc) { instance_double('TodoService') }
+ let(:notification_svc) { instance_double('NotificationService') }
+
+ before do
+ allow(service).to receive(:todo_service).and_return(todo_svc)
+ allow(service).to receive(:notification_service).and_return(notification_svc)
+ allow(todo_svc).to receive(:create_attention_requested_todo)
+ allow(notification_svc).to receive_message_chain(:async, :attention_requested_of_merge_request)
+ allow(SystemNoteService).to receive(:request_attention)
+
+ project.add_developer(current_user)
+ project.add_developer(user)
+ end
+
+ describe '#execute' do
+ context 'when current user cannot update merge request' do
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: create(:user),
+ merge_request: merge_request,
+ user: user
+ )
+ end
+
+ it 'returns an error' do
+ expect(result[:status]).to eq :error
+ end
+ end
+
+ context 'when user is not a reviewer nor assignee' do
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ merge_request: merge_request,
+ user: create(:user)
+ )
+ end
+
+ it 'returns an error' do
+ expect(result[:status]).to eq :error
+ end
+ end
+
+ context 'when user is a reviewer' do
+ before do
+ reviewer.update!(state: :reviewed)
+ end
+
+ it 'returns success' do
+ expect(result[:status]).to eq :success
+ end
+
+ it 'updates reviewers state' do
+ service.execute
+ reviewer.reload
+
+ expect(reviewer.state).to eq 'attention_requested'
+ end
+
+ it 'adds who toggled attention' do
+ service.execute
+ reviewer.reload
+
+ expect(reviewer.updated_state_by).to eq current_user
+ end
+
+ it 'creates a new todo for the reviewer' do
+ expect(todo_svc).to receive(:create_attention_requested_todo).with(merge_request, current_user, user)
+
+ service.execute
+ end
+
+ it 'sends email to reviewer' do
+ expect(notification_svc)
+ .to receive_message_chain(:async, :attention_requested_of_merge_request)
+ .with(merge_request, current_user, user)
+
+ service.execute
+ end
+
+ it 'removes attention requested state' do
+ expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new)
+ .with(project: project, current_user: current_user, merge_request: merge_request, user: current_user)
+ .and_call_original
+
+ service.execute
+ end
+
+ it_behaves_like 'invalidates attention request cache' do
+ let(:users) { [user] }
+ end
+ end
+
+ context 'when user is an assignee' do
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ merge_request: merge_request,
+ user: assignee_user
+ )
+ end
+
+ before do
+ assignee.update!(state: :reviewed)
+ end
+
+ it 'returns success' do
+ expect(result[:status]).to eq :success
+ end
+
+ it 'updates assignees state' do
+ service.execute
+ assignee.reload
+
+ expect(assignee.state).to eq 'attention_requested'
+ end
+
+ it 'creates a new todo for the reviewer' do
+ expect(todo_svc).to receive(:create_attention_requested_todo).with(merge_request, current_user, assignee_user)
+
+ service.execute
+ end
+
+ it 'creates a request attention system note' do
+ expect(SystemNoteService)
+ .to receive(:request_attention)
+ .with(merge_request, merge_request.project, current_user, assignee_user)
+
+ service.execute
+ end
+
+ it 'removes attention requested state' do
+ expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new)
+ .with(project: project, current_user: current_user, merge_request: merge_request, user: current_user)
+ .and_call_original
+
+ service.execute
+ end
+
+ it_behaves_like 'invalidates attention request cache' do
+ let(:users) { [assignee_user] }
+ end
+ end
+
+ context 'when user is an assignee and reviewer at the same time' do
+ let_it_be(:merge_request) { create(:merge_request, reviewers: [user], assignees: [user]) }
+
+ let(:assignee) { merge_request.find_assignee(user) }
+
+ let(:service) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ merge_request: merge_request,
+ user: user
+ )
+ end
+
+ before do
+ reviewer.update!(state: :reviewed)
+ assignee.update!(state: :reviewed)
+ end
+
+ it 'updates reviewers and assignees state' do
+ service.execute
+ reviewer.reload
+ assignee.reload
+
+ expect(reviewer.state).to eq 'attention_requested'
+ expect(assignee.state).to eq 'attention_requested'
+ end
+ end
+
+ context 'when state is attention_requested' do
+ before do
+ reviewer.update!(state: :attention_requested)
+ end
+
+ it 'does not change state' do
+ service.execute
+ reviewer.reload
+
+ expect(reviewer.state).to eq 'attention_requested'
+ end
+
+ it 'does not create a new todo for the reviewer' do
+ expect(todo_svc).not_to receive(:create_attention_requested_todo).with(merge_request, current_user, user)
+
+ service.execute
+ end
+ end
+ end
+end
diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb
index 387be8471b5..9210242a11e 100644
--- a/spec/services/merge_requests/squash_service_spec.rb
+++ b/spec/services/merge_requests/squash_service_spec.rb
@@ -222,11 +222,13 @@ RSpec.describe MergeRequests::SquashService do
it 'logs the error' do
expect(service).to receive(:log_error).with(exception: exception, message: 'Failed to squash merge request').and_call_original
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception,
- class: described_class.to_s,
- merge_request: merge_request_ref,
- merge_request_id: merge_request.id,
- message: 'Failed to squash merge request',
- save_message_on_model: false).and_call_original
+ {
+ class: described_class.to_s,
+ merge_request: merge_request_ref,
+ merge_request_id: merge_request.id,
+ message: 'Failed to squash merge request',
+ save_message_on_model: false
+ }).and_call_original
service.execute
end
diff --git a/spec/services/merge_requests/toggle_attention_requested_service_spec.rb b/spec/services/merge_requests/toggle_attention_requested_service_spec.rb
index dcaac5d2699..20bc536b21e 100644
--- a/spec/services/merge_requests/toggle_attention_requested_service_spec.rb
+++ b/spec/services/merge_requests/toggle_attention_requested_service_spec.rb
@@ -80,7 +80,7 @@ RSpec.describe MergeRequests::ToggleAttentionRequestedService do
it 'removes attention requested state' do
expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new)
- .with(project: project, current_user: current_user, merge_request: merge_request)
+ .with(project: project, current_user: current_user, merge_request: merge_request, user: current_user)
.and_call_original
service.execute
@@ -129,7 +129,7 @@ RSpec.describe MergeRequests::ToggleAttentionRequestedService do
it 'removes attention requested state' do
expect(MergeRequests::RemoveAttentionRequestedService).to receive(:new)
- .with(project: project, current_user: current_user, merge_request: merge_request)
+ .with(project: project, current_user: current_user, merge_request: merge_request, user: current_user)
.and_call_original
service.execute
diff --git a/spec/services/merge_requests/update_assignees_service_spec.rb b/spec/services/merge_requests/update_assignees_service_spec.rb
index 3a0b17c2768..f5f6f0ca301 100644
--- a/spec/services/merge_requests/update_assignees_service_spec.rb
+++ b/spec/services/merge_requests/update_assignees_service_spec.rb
@@ -113,6 +113,49 @@ RSpec.describe MergeRequests::UpdateAssigneesService do
expect { service.execute(merge_request) }
.to issue_fewer_queries_than { update_service.execute(other_mr) }
end
+
+ context 'setting state of assignees' do
+ before do
+ stub_feature_flags(mr_attention_requests: false)
+ end
+
+ it 'does not set state as attention_requested if feature flag is disabled' do
+ update_merge_request
+
+ expect(merge_request.merge_request_assignees[0].state).not_to eq('attention_requested')
+ end
+
+ context 'feature flag is enabled for current_user' do
+ before do
+ stub_feature_flags(mr_attention_requests: user)
+ end
+
+ it 'sets state as attention_requested' do
+ update_merge_request
+
+ expect(merge_request.merge_request_assignees[0].state).to eq('attention_requested')
+ expect(merge_request.merge_request_assignees[0].updated_state_by).to eq(user)
+ end
+
+ it 'uses reviewers state if it is same user as new assignee' do
+ merge_request.reviewers << user2
+
+ update_merge_request
+
+ expect(merge_request.merge_request_assignees[0].state).to eq('unreviewed')
+ end
+
+ context 'when assignee_ids matches existing assignee' do
+ let(:opts) { { assignee_ids: [user3.id] } }
+
+ it 'keeps original assignees state' do
+ update_merge_request
+
+ expect(merge_request.find_assignee(user3).state).to eq('unreviewed')
+ end
+ end
+ end
+ end
end
end
end
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index 30095ebeb50..7164ba8fac0 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -328,6 +328,49 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
update_merge_request(reviewer_ids: [user.id])
end
+
+ context 'setting state of reviewers' do
+ before do
+ stub_feature_flags(mr_attention_requests: false)
+ end
+
+ it 'does not set state as attention_requested if feature flag is disabled' do
+ update_merge_request(reviewer_ids: [user.id])
+
+ expect(merge_request.merge_request_reviewers[0].state).not_to eq('attention_requested')
+ end
+
+ context 'feature flag is enabled for current_user' do
+ before do
+ stub_feature_flags(mr_attention_requests: user)
+ end
+
+ it 'sets state as attention_requested' do
+ update_merge_request(reviewer_ids: [user2.id])
+
+ expect(merge_request.merge_request_reviewers[0].state).to eq('attention_requested')
+ expect(merge_request.merge_request_reviewers[0].updated_state_by).to eq(user)
+ end
+
+ it 'keeps original reviewers state' do
+ merge_request.find_reviewer(user2).update!(state: :unreviewed)
+
+ update_merge_request({
+ reviewer_ids: [user2.id]
+ })
+
+ expect(merge_request.find_reviewer(user2).state).to eq('unreviewed')
+ end
+
+ it 'uses reviewers state if it is same user as new assignee' do
+ merge_request.assignees << user
+
+ update_merge_request(reviewer_ids: [user.id])
+
+ expect(merge_request.merge_request_reviewers[0].state).to eq('unreviewed')
+ end
+ end
+ end
end
it 'creates a resource label event' do
@@ -1066,6 +1109,53 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
end
end
+
+ context 'setting state of assignees' do
+ before do
+ stub_feature_flags(mr_attention_requests: false)
+ end
+
+ it 'does not set state as attention_requested if feature flag is disabled' do
+ update_merge_request({
+ assignee_ids: [user2.id]
+ })
+
+ expect(merge_request.merge_request_assignees[0].state).not_to eq('attention_requested')
+ end
+
+ context 'feature flag is enabled for current_user' do
+ before do
+ stub_feature_flags(mr_attention_requests: user)
+ end
+
+ it 'sets state as attention_requested' do
+ update_merge_request({
+ assignee_ids: [user2.id]
+ })
+
+ expect(merge_request.merge_request_assignees[0].state).to eq('attention_requested')
+ expect(merge_request.merge_request_assignees[0].updated_state_by).to eq(user)
+ end
+
+ it 'keeps original assignees state' do
+ update_merge_request({
+ assignee_ids: [user3.id]
+ })
+
+ expect(merge_request.find_assignee(user3).state).to eq('unreviewed')
+ end
+
+ it 'uses reviewers state if it is same user as new assignee' do
+ merge_request.reviewers << user2
+
+ update_merge_request({
+ assignee_ids: [user2.id]
+ })
+
+ expect(merge_request.merge_request_assignees[0].state).to eq('unreviewed')
+ end
+ end
+ end
end
context 'when adding time spent' do
diff --git a/spec/services/namespaces/package_settings/update_service_spec.rb b/spec/services/namespaces/package_settings/update_service_spec.rb
index 030bc03038e..ed385f1cd7f 100644
--- a/spec/services/namespaces/package_settings/update_service_spec.rb
+++ b/spec/services/namespaces/package_settings/update_service_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe ::Namespaces::PackageSettings::UpdateService do
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the namespace package setting'
- :developer | 'updating the namespace package setting'
+ :developer | 'denying access to namespace package setting'
:reporter | 'denying access to namespace package setting'
:guest | 'denying access to namespace package setting'
:anonymous | 'denying access to namespace package setting'
@@ -91,7 +91,7 @@ RSpec.describe ::Namespaces::PackageSettings::UpdateService do
where(:user_role, :shared_examples_name) do
:maintainer | 'creating the namespace package setting'
- :developer | 'creating the namespace package setting'
+ :developer | 'denying access to namespace package setting'
:reporter | 'denying access to namespace package setting'
:guest | 'denying access to namespace package setting'
:anonymous | 'denying access to namespace package setting'
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index b0410123630..c72a9465f20 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -168,7 +168,6 @@ RSpec.describe Notes::CreateService do
before do
project_with_repo.add_maintainer(user)
end
-
context 'when eligible to have a note diff file' do
let(:new_opts) do
opts.merge(in_reply_to_discussion_id: nil,
@@ -196,6 +195,39 @@ RSpec.describe Notes::CreateService do
described_class.new(project_with_repo, user, new_opts).execute(skip_capture_diff_note_position: true)
end
end
+
+ it 'does not track ipynb note usage data' do
+ expect(::Gitlab::UsageDataCounters::IpynbDiffActivityCounter).not_to receive(:note_created)
+
+ described_class.new(project_with_repo, user, new_opts).execute
+ end
+
+ context 'is ipynb file' do
+ before do
+ allow_any_instance_of(::Gitlab::Diff::File).to receive(:ipynb?).and_return(true)
+ stub_feature_flags(ipynbdiff_notes_tracker: false)
+ end
+
+ context ':ipynbdiff_notes_tracker is off' do
+ it 'does not track ipynb note usage data' do
+ expect(::Gitlab::UsageDataCounters::IpynbDiffActivityCounter).not_to receive(:note_created)
+
+ described_class.new(project_with_repo, user, new_opts).execute
+ end
+ end
+
+ context ':ipynbdiff_notes_tracker is on' do
+ before do
+ stub_feature_flags(ipynbdiff_notes_tracker: true)
+ end
+
+ it 'tracks ipynb diff note creation' do
+ expect(::Gitlab::UsageDataCounters::IpynbDiffActivityCounter).to receive(:note_created)
+
+ described_class.new(project_with_repo, user, new_opts).execute
+ end
+ end
+ end
end
context 'when DiffNote is a reply' do
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index d2d55c5ab79..743a04eabe6 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -1869,6 +1869,61 @@ RSpec.describe NotificationService, :mailer do
let(:notification_trigger) { notification.new_merge_request(merge_request, @u_disabled) }
end
+ describe 'Approvals' do
+ let(:notification_target) { merge_request }
+ let(:maintainer) { create(:user) }
+
+ describe '#approve_mr' do
+ it 'will notify the author, subscribers, and assigned users' do
+ notification.approve_mr(merge_request, maintainer)
+
+ merge_request.assignees.each { |assignee| should_email(assignee) }
+ should_email(merge_request.author)
+ should_email(@u_watcher)
+ should_email(@u_participant_mentioned)
+ should_email(@subscribed_participant)
+ should_email(@subscriber)
+ should_email(@watcher_and_subscriber)
+ should_email(@u_guest_watcher)
+
+ should_not_email(@unsubscriber)
+ should_not_email(@u_participating)
+ should_not_email(@u_disabled)
+ should_not_email(@u_lazy_participant)
+
+ expect(email_recipients.size).to eq(8)
+ # assignee, author, @u_watcher,
+ # @u_participant_mentioned, @subscribed_participant,
+ # @subscriber, @watcher_and_subscriber, @u_guest_watcher
+ end
+ end
+
+ describe '#unapprove_mr' do
+ it 'will notify the author, subscribers, and assigned users' do
+ notification.unapprove_mr(merge_request, maintainer)
+
+ merge_request.assignees.each { |assignee| should_email(assignee) }
+ should_email(merge_request.author)
+ should_email(@u_watcher)
+ should_email(@u_participant_mentioned)
+ should_email(@subscribed_participant)
+ should_email(@subscriber)
+ should_email(@watcher_and_subscriber)
+ should_email(@u_guest_watcher)
+
+ should_not_email(@unsubscriber)
+ should_not_email(@u_participating)
+ should_not_email(@u_disabled)
+ should_not_email(@u_lazy_participant)
+
+ expect(email_recipients.size).to eq(8)
+ # assignee, author, @u_watcher,
+ # @u_participant_mentioned, @subscribed_participant,
+ # @subscriber, @watcher_and_subscriber, @u_guest_watcher
+ end
+ end
+ end
+
context 'participating' do
it_behaves_like 'participating by assignee notification' do
let(:participant) { create(:user, username: 'user-participant')}
@@ -3653,6 +3708,26 @@ RSpec.describe NotificationService, :mailer do
end
end
+ describe '#inactive_project_deletion_warning' do
+ let_it_be(:deletion_date) { Date.current }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+
+ before do
+ project.add_maintainer(maintainer)
+ end
+
+ subject { notification.inactive_project_deletion_warning(project, deletion_date) }
+
+ it "sends email to project owners and maintainers" do
+ expect { subject }.to have_enqueued_email(project, maintainer, deletion_date,
+ mail: "inactive_project_deletion_warning_email")
+ expect { subject }.not_to have_enqueued_email(project, developer, deletion_date,
+ mail: "inactive_project_deletion_warning_email")
+ end
+ end
+
def build_team(project)
@u_watcher = create_global_setting_for(create(:user), :watch)
@u_participating = create_global_setting_for(create(:user), :participating)
diff --git a/spec/services/projects/android_target_platform_detector_service_spec.rb b/spec/services/projects/android_target_platform_detector_service_spec.rb
new file mode 100644
index 00000000000..74fd320bb48
--- /dev/null
+++ b/spec/services/projects/android_target_platform_detector_service_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::AndroidTargetPlatformDetectorService do
+ let_it_be(:project) { build(:project) }
+
+ subject { described_class.new(project).execute }
+
+ before do
+ allow(Gitlab::FileFinder).to receive(:new) { finder }
+ end
+
+ context 'when project is not an Android project' do
+ let(:finder) { instance_double(Gitlab::FileFinder, find: []) }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when project is an Android project' do
+ let(:finder) { instance_double(Gitlab::FileFinder) }
+
+ before do
+ query = described_class::MANIFEST_FILE_SEARCH_QUERY
+ allow(finder).to receive(:find).with(query) { [instance_double(Gitlab::Search::FoundBlob)] }
+ end
+
+ it { is_expected.to eq :android }
+ end
+end
diff --git a/spec/services/projects/batch_open_issues_count_service_spec.rb b/spec/services/projects/batch_open_issues_count_service_spec.rb
index 17bd5f7a37b..89a4abbf9c9 100644
--- a/spec/services/projects/batch_open_issues_count_service_spec.rb
+++ b/spec/services/projects/batch_open_issues_count_service_spec.rb
@@ -5,7 +5,6 @@ require 'spec_helper'
RSpec.describe Projects::BatchOpenIssuesCountService do
let!(:project_1) { create(:project) }
let!(:project_2) { create(:project) }
- let!(:banned_user) { create(:user, :banned) }
let(:subject) { described_class.new([project_1, project_2]) }
@@ -13,41 +12,32 @@ RSpec.describe Projects::BatchOpenIssuesCountService do
before do
create(:issue, project: project_1)
create(:issue, project: project_1, confidential: true)
- create(:issue, project: project_1, author: banned_user)
+
create(:issue, project: project_2)
create(:issue, project: project_2, confidential: true)
- create(:issue, project: project_2, author: banned_user)
end
- context 'when cache is clean', :aggregate_failures do
+ context 'when cache is clean' do
it 'refreshes cache keys correctly' do
- expect(get_cache_key(project_1)).to eq(nil)
- expect(get_cache_key(project_2)).to eq(nil)
-
- subject.count_service.new(project_1).refresh_cache
- subject.count_service.new(project_2).refresh_cache
-
- expect(get_cache_key(project_1)).to eq(1)
- expect(get_cache_key(project_2)).to eq(1)
+ subject.refresh_cache_and_retrieve_data
- expect(get_cache_key(project_1, true)).to eq(2)
- expect(get_cache_key(project_2, true)).to eq(2)
+ # It does not update total issues cache
+ expect(Rails.cache.read(get_cache_key(subject, project_1))).to eq(nil)
+ expect(Rails.cache.read(get_cache_key(subject, project_2))).to eq(nil)
- expect(get_cache_key(project_1, true, true)).to eq(3)
- expect(get_cache_key(project_2, true, true)).to eq(3)
+ expect(Rails.cache.read(get_cache_key(subject, project_1, true))).to eq(1)
+ expect(Rails.cache.read(get_cache_key(subject, project_1, true))).to eq(1)
end
end
end
- def get_cache_key(project, with_confidential = false, with_hidden = false)
+ def get_cache_key(subject, project, public_key = false)
service = subject.count_service.new(project)
- if with_confidential && with_hidden
- Rails.cache.read(service.cache_key(service.class::TOTAL_COUNT_KEY))
- elsif with_confidential
- Rails.cache.read(service.cache_key(service.class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY))
+ if public_key
+ service.cache_key(service.class::PUBLIC_COUNT_KEY)
else
- Rails.cache.read(service.cache_key(service.class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY))
+ service.cache_key(service.class::TOTAL_COUNT_KEY)
end
end
end
diff --git a/spec/services/projects/blame_service_spec.rb b/spec/services/projects/blame_service_spec.rb
new file mode 100644
index 00000000000..40b2bc869dc
--- /dev/null
+++ b/spec/services/projects/blame_service_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::BlameService, :aggregate_failures do
+ subject(:service) { described_class.new(blob, commit, params) }
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:commit) { project.repository.commit }
+ let_it_be(:blob) { project.repository.blob_at('HEAD', 'README.md') }
+
+ let(:params) { { page: page } }
+ let(:page) { nil }
+
+ before do
+ stub_const("#{described_class.name}::PER_PAGE", 2)
+ end
+
+ describe '#blame' do
+ subject { service.blame }
+
+ it 'returns a correct Gitlab::Blame object' do
+ is_expected.to be_kind_of(Gitlab::Blame)
+
+ expect(subject.blob).to eq(blob)
+ expect(subject.commit).to eq(commit)
+ expect(subject.range).to eq(1..2)
+ end
+
+ describe 'Pagination range calculation' do
+ subject { service.blame.range }
+
+ context 'with page = 1' do
+ let(:page) { 1 }
+
+ it { is_expected.to eq(1..2) }
+ end
+
+ context 'with page = 2' do
+ let(:page) { 2 }
+
+ it { is_expected.to eq(3..4) }
+ end
+
+ context 'with page = 3 (overlimit)' do
+ let(:page) { 3 }
+
+ it { is_expected.to eq(1..2) }
+ end
+
+ context 'with page = 0 (incorrect)' do
+ let(:page) { 0 }
+
+ it { is_expected.to eq(1..2) }
+ end
+
+ context 'when feature flag disabled' do
+ before do
+ stub_feature_flags(blame_page_pagination: false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+
+ describe '#pagination' do
+ subject { service.pagination }
+
+ it 'returns a pagination object' do
+ is_expected.to be_kind_of(Kaminari::PaginatableArray)
+
+ expect(subject.current_page).to eq(1)
+ expect(subject.total_pages).to eq(2)
+ expect(subject.total_count).to eq(4)
+ end
+
+ context 'when feature flag disabled' do
+ before do
+ stub_feature_flags(blame_page_pagination: false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when per_page is above the global max per page limit' do
+ before do
+ stub_const("#{described_class.name}::PER_PAGE", 1000)
+ allow(blob).to receive_message_chain(:data, :lines, :count) { 500 }
+ end
+
+ it 'returns a correct pagination object' do
+ is_expected.to be_kind_of(Kaminari::PaginatableArray)
+
+ expect(subject.current_page).to eq(1)
+ expect(subject.total_pages).to eq(1)
+ expect(subject.total_count).to eq(500)
+ end
+ end
+
+ describe 'Current page' do
+ subject { service.pagination.current_page }
+
+ context 'with page = 1' do
+ let(:page) { 1 }
+
+ it { is_expected.to eq(1) }
+ end
+
+ context 'with page = 2' do
+ let(:page) { 2 }
+
+ it { is_expected.to eq(2) }
+ end
+
+ context 'with page = 3 (overlimit)' do
+ let(:page) { 3 }
+
+ it { is_expected.to eq(1) }
+ end
+
+ context 'with page = 0 (incorrect)' do
+ let(:page) { 0 }
+
+ it { is_expected.to eq(1) }
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
index 86c0ba4222c..79904e2bf72 100644
--- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
@@ -34,8 +34,6 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_
stub_digest_config('sha256:configB', 5.days.ago)
stub_digest_config('sha256:configC', 1.month.ago)
stub_digest_config('sha256:configD', nil)
-
- stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
describe '#execute' do
@@ -334,24 +332,17 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_
end
end
- where(:feature_flag_enabled, :max_list_size, :delete_tags_service_status, :expected_status, :expected_truncated) do
- false | 10 | :success | :success | false
- false | 10 | :error | :error | false
- false | 3 | :success | :success | false
- false | 3 | :error | :error | false
- false | 0 | :success | :success | false
- false | 0 | :error | :error | false
- true | 10 | :success | :success | false
- true | 10 | :error | :error | false
- true | 3 | :success | :error | true
- true | 3 | :error | :error | true
- true | 0 | :success | :success | false
- true | 0 | :error | :error | false
+ where(:max_list_size, :delete_tags_service_status, :expected_status, :expected_truncated) do
+ 10 | :success | :success | false
+ 10 | :error | :error | false
+ 3 | :success | :error | true
+ 3 | :error | :error | true
+ 0 | :success | :success | false
+ 0 | :error | :error | false
end
with_them do
before do
- stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
stub_application_setting(container_registry_cleanup_tags_service_max_list_size: max_list_size)
allow_next_instance_of(Projects::ContainerRepository::DeleteTagsService) do |service|
expect(service).to receive(:execute).and_return(status: delete_tags_service_status)
@@ -429,10 +420,10 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_
end
# We will ping the container registry for all tags *except* for C because it's cached
- expect(ContainerRegistry::Blob).to receive(:new).with(repository, "digest" => "sha256:configA").and_call_original
- expect(ContainerRegistry::Blob).to receive(:new).with(repository, "digest" => "sha256:configB").twice.and_call_original
- expect(ContainerRegistry::Blob).not_to receive(:new).with(repository, "digest" => "sha256:configC")
- expect(ContainerRegistry::Blob).to receive(:new).with(repository, "digest" => "sha256:configD").and_call_original
+ expect(ContainerRegistry::Blob).to receive(:new).with(repository, { "digest" => "sha256:configA" }).and_call_original
+ expect(ContainerRegistry::Blob).to receive(:new).with(repository, { "digest" => "sha256:configB" }).twice.and_call_original
+ expect(ContainerRegistry::Blob).not_to receive(:new).with(repository, { "digest" => "sha256:configC" })
+ expect(ContainerRegistry::Blob).to receive(:new).with(repository, { "digest" => "sha256:configD" }).and_call_original
expect(subject).to include(cached_tags_count: 1)
end
diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb
index 246ca301cfa..9e6849aa514 100644
--- a/spec/services/projects/container_repository/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb
@@ -3,6 +3,7 @@
require 'spec_helper'
RSpec.describe Projects::ContainerRepository::DeleteTagsService do
+ using RSpec::Parameterized::TableSyntax
include_context 'container repository delete tags service shared context'
let(:service) { described_class.new(project, user, params) }
@@ -17,11 +18,13 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
shared_examples 'logging a success response' do
it 'logs an info message' do
expect(service).to receive(:log_info).with(
- service_class: 'Projects::ContainerRepository::DeleteTagsService',
- message: 'deleted tags',
- container_repository_id: repository.id,
- project_id: repository.project_id,
- deleted_tags_count: tags.size
+ {
+ service_class: 'Projects::ContainerRepository::DeleteTagsService',
+ message: 'deleted tags',
+ container_repository_id: repository.id,
+ project_id: repository.project_id,
+ deleted_tags_count: tags.size
+ }
)
subject
@@ -131,10 +134,6 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
subject { service.execute(repository) }
- before do
- stub_feature_flags(container_registry_expiration_policies_throttling: false)
- end
-
context 'without permissions' do
it { is_expected.to include(status: :error) }
end
@@ -157,11 +156,39 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
end
context 'when the repository is importing' do
- before do
- repository.update_columns(migration_state: 'importing', migration_import_started_at: Time.zone.now)
+ where(:migration_state, :called_by_policy, :error_expected) do
+ 'default' | false | false
+ 'default' | true | false
+ 'pre_importing' | false | false
+ 'pre_importing' | true | true
+ 'pre_import_done' | false | false
+ 'pre_import_done' | true | true
+ 'importing' | false | true
+ 'importing' | true | true
+ 'import_done' | false | false
+ 'import_done' | true | false
+ 'import_aborted' | false | false
+ 'import_aborted' | true | false
+ 'import_skipped' | false | false
+ 'import_skipped' | true | false
end
- it { is_expected.to include(status: :error, message: 'repository importing') }
+ with_them do
+ let(:params) { { tags: tags, container_expiration_policy: called_by_policy ? true : nil } }
+
+ before do
+ repository.update_columns(migration_state: migration_state, migration_import_started_at: Time.zone.now, migration_pre_import_started_at: Time.zone.now, migration_pre_import_done_at: Time.zone.now)
+ end
+
+ it 'returns an error response if expected' do
+ if error_expected
+ expect(subject).to include(status: :error, message: 'repository importing')
+ else
+ expect(service).to receive(:delete_tags).and_return(status: :success)
+ expect(subject).not_to include(status: :error)
+ end
+ end
+ end
end
end
diff --git a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
index 74f782538c5..8d8907119f0 100644
--- a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
@@ -12,10 +12,6 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
subject { service.execute }
- before do
- stub_feature_flags(container_registry_expiration_policies_throttling: false)
- end
-
RSpec.shared_examples 'deleting tags' do
it 'deletes the tags by name' do
stub_delete_reference_requests(tags)
@@ -26,6 +22,8 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
end
context 'with tags to delete' do
+ let(:timeout) { 10 }
+
it_behaves_like 'deleting tags'
it 'succeeds when tag delete returns 404' do
@@ -50,59 +48,52 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
end
end
- context 'with throttling enabled' do
- let(:timeout) { 10 }
-
- before do
- stub_feature_flags(container_registry_expiration_policies_throttling: true)
- stub_application_setting(container_registry_delete_tags_service_timeout: timeout)
- end
-
- it_behaves_like 'deleting tags'
+ before do
+ stub_application_setting(container_registry_delete_tags_service_timeout: timeout)
+ end
- context 'with timeout' do
- context 'set to a valid value' do
- before do
- allow(Time.zone).to receive(:now).and_return(10, 15, 25) # third call to Time.zone.now will be triggering the timeout
- stub_delete_reference_requests('A' => 200)
- end
+ context 'with timeout' do
+ context 'set to a valid value' do
+ before do
+ allow(Time.zone).to receive(:now).and_return(10, 15, 25) # third call to Time.zone.now will be triggering the timeout
+ stub_delete_reference_requests('A' => 200)
+ end
- it { is_expected.to eq(status: :error, message: 'error while deleting tags', deleted: ['A'], exception_class_name: Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError.name) }
+ it { is_expected.to eq(status: :error, message: 'error while deleting tags', deleted: ['A'], exception_class_name: Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError.name) }
- it 'tracks the exception' do
- expect(::Gitlab::ErrorTracking)
- .to receive(:track_exception).with(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
+ it 'tracks the exception' do
+ expect(::Gitlab::ErrorTracking)
+ .to receive(:track_exception).with(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
- subject
- end
+ subject
end
+ end
- context 'set to 0' do
- let(:timeout) { 0 }
+ context 'set to 0' do
+ let(:timeout) { 0 }
- it_behaves_like 'deleting tags'
- end
+ it_behaves_like 'deleting tags'
+ end
- context 'set to nil' do
- let(:timeout) { nil }
+ context 'set to nil' do
+ let(:timeout) { nil }
- it_behaves_like 'deleting tags'
- end
+ it_behaves_like 'deleting tags'
end
+ end
- context 'with a network error' do
- before do
- expect(service).to receive(:delete_tags).and_raise(::Faraday::TimeoutError)
- end
+ context 'with a network error' do
+ before do
+ expect(service).to receive(:delete_tags).and_raise(::Faraday::TimeoutError)
+ end
- it { is_expected.to eq(status: :error, message: 'error while deleting tags', deleted: [], exception_class_name: ::Faraday::TimeoutError.name) }
+ it { is_expected.to eq(status: :error, message: 'error while deleting tags', deleted: [], exception_class_name: ::Faraday::TimeoutError.name) }
- it 'tracks the exception' do
- expect(::Gitlab::ErrorTracking)
- .to receive(:track_exception).with(::Faraday::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
+ it 'tracks the exception' do
+ expect(::Gitlab::ErrorTracking)
+ .to receive(:track_exception).with(::Faraday::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
- subject
- end
+ subject
end
end
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index c5c5af3cb01..cd1e629e1d2 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -141,18 +141,6 @@ RSpec.describe Projects::CreateService, '#execute' do
expect(project.project_setting).to be_persisted
end
- context 'create_project_settings feature flag is disabled' do
- before do
- stub_feature_flags(create_project_settings: false)
- end
-
- it 'builds associated project settings' do
- project = create_project(user, opts)
-
- expect(project.project_setting).to be_new_record
- end
- end
-
it_behaves_like 'storing arguments in the application context' do
let(:expected_params) { { project: subject.full_path } }
@@ -780,6 +768,45 @@ RSpec.describe Projects::CreateService, '#execute' do
create_project(user, opts)
end
+ context 'when import source is enabled' do
+ before do
+ stub_application_setting(import_sources: ['github'])
+ end
+
+ it 'does not raise an error when import_source is string' do
+ opts[:import_type] = 'github'
+
+ project = create_project(user, opts)
+
+ expect(project).to be_persisted
+ expect(project.errors).to be_blank
+ end
+
+ it 'does not raise an error when import_source is symbol' do
+ opts[:import_type] = :github
+
+ project = create_project(user, opts)
+
+ expect(project).to be_persisted
+ expect(project.errors).to be_blank
+ end
+ end
+
+ context 'when import source is disabled' do
+ before do
+ stub_application_setting(import_sources: [])
+ opts[:import_type] = 'git'
+ end
+
+ it 'raises an error' do
+ project = create_project(user, opts)
+
+ expect(project).to respond_to(:errors)
+ expect(project.errors).to have_key(:import_source_disabled)
+ expect(project.saved?).to be_falsey
+ end
+ end
+
context 'with external authorization enabled' do
before do
enable_external_authorization_service_check
@@ -797,7 +824,7 @@ RSpec.describe Projects::CreateService, '#execute' do
it 'saves the project when the user has access to the label' do
expect(::Gitlab::ExternalAuthorization)
- .to receive(:access_allowed?).with(user, 'new-label', any_args) { true }
+ .to receive(:access_allowed?).with(user, 'new-label', any_args) { true }.at_least(1).time
project = create_project(user, opts.merge({ external_authorization_classification_label: 'new-label' }))
diff --git a/spec/services/projects/group_links/create_service_spec.rb b/spec/services/projects/group_links/create_service_spec.rb
index 4ea5f2b3a53..65d3085a850 100644
--- a/spec/services/projects/group_links/create_service_spec.rb
+++ b/spec/services/projects/group_links/create_service_spec.rb
@@ -5,65 +5,104 @@ require 'spec_helper'
RSpec.describe Projects::GroupLinks::CreateService, '#execute' do
let_it_be(:user) { create :user }
let_it_be(:group) { create :group }
- let_it_be(:project) { create :project }
+ let_it_be(:project) { create(:project, namespace: create(:namespace, :with_namespace_settings)) }
- let(:group_access) { Gitlab::Access::DEVELOPER }
let(:opts) do
{
- link_group_access: group_access,
+ link_group_access: Gitlab::Access::DEVELOPER,
expires_at: nil
}
end
- subject { described_class.new(project, user, opts) }
+ subject { described_class.new(project, group, user, opts) }
- before do
- group.add_developer(user)
- end
+ shared_examples_for 'not shareable' do
+ it 'does not share and returns an error' do
+ expect do
+ result = subject.execute
- it 'adds group to project' do
- expect { subject.execute(group) }.to change { project.project_group_links.count }.from(0).to(1)
+ expect(result[:status]).to eq(:error)
+ expect(result[:http_status]).to eq(404)
+ end.not_to change { project.project_group_links.count }
+ end
end
- it 'updates authorization', :sidekiq_inline do
- expect { subject.execute(group) }.to(
- change { Ability.allowed?(user, :read_project, project) }
- .from(false).to(true))
- end
+ shared_examples_for 'shareable' do
+ it 'adds group to project' do
+ expect do
+ result = subject.execute
- it 'returns false if group is blank' do
- expect { subject.execute(nil) }.not_to change { project.project_group_links.count }
+ expect(result[:status]).to eq(:success)
+ end.to change { project.project_group_links.count }.from(0).to(1)
+ end
end
- it 'returns error if user is not allowed to share with a group' do
- expect { subject.execute(create(:group)) }.not_to change { project.project_group_links.count }
- end
+ context 'when user has proper membership to share a group' do
+ before do
+ group.add_guest(user)
+ end
- context 'with specialized project_authorization workers' do
- let_it_be(:other_user) { create(:user) }
+ it_behaves_like 'shareable'
- before do
- group.add_developer(other_user)
+ it 'updates authorization', :sidekiq_inline do
+ expect { subject.execute }.to(
+ change { Ability.allowed?(user, :read_project, project) }
+ .from(false).to(true))
+ end
+
+ context 'with specialized project_authorization workers' do
+ let_it_be(:other_user) { create(:user) }
+
+ before do
+ group.add_developer(other_user)
+ end
+
+ it 'schedules authorization update for users with access to group' do
+ expect(AuthorizedProjectsWorker).not_to(
+ receive(:bulk_perform_async)
+ )
+ expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to(
+ receive(:perform_async)
+ .with(project.id)
+ .and_call_original
+ )
+ expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
+ receive(:bulk_perform_in)
+ .with(1.hour,
+ array_including([user.id], [other_user.id]),
+ batch_delay: 30.seconds, batch_size: 100)
+ .and_call_original
+ )
+
+ subject.execute
+ end
end
- it 'schedules authorization update for users with access to group' do
- expect(AuthorizedProjectsWorker).not_to(
- receive(:bulk_perform_async)
- )
- expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to(
- receive(:perform_async)
- .with(project.id)
- .and_call_original
- )
- expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in)
- .with(1.hour,
- array_including([user.id], [other_user.id]),
- batch_delay: 30.seconds, batch_size: 100)
- .and_call_original
- )
-
- subject.execute(group)
+ context 'when sharing outside the hierarchy is disabled' do
+ let_it_be(:shared_group_parent) do
+ create(:group,
+ namespace_settings: create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: true))
+ end
+
+ let_it_be(:project, reload: true) { create(:project, group: shared_group_parent) }
+
+ it_behaves_like 'not shareable'
+
+ context 'when group is inside hierarchy' do
+ let(:group) { create(:group, :private, parent: shared_group_parent) }
+
+ it_behaves_like 'shareable'
+ end
end
end
+
+ context 'when user does not have permissions for the group' do
+ it_behaves_like 'not shareable'
+ end
+
+ context 'when group is blank' do
+ let(:group) { nil }
+
+ it_behaves_like 'not shareable'
+ end
end
diff --git a/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb b/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb
new file mode 100644
index 00000000000..4c51c8a4ac8
--- /dev/null
+++ b/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::InProductMarketingCampaignEmailsService do
+ describe '#execute' do
+ let(:user) { create(:user, email_opted_in: true) }
+ let(:project) { create(:project) }
+ let(:campaign) { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
+
+ subject(:execute) do
+ described_class.new(project, campaign).execute
+ end
+
+ context 'users can receive marketing emails' do
+ let(:owner) { create(:user, email_opted_in: true) }
+ let(:maintainer) { create(:user, email_opted_in: true) }
+ let(:developer) { create(:user, email_opted_in: true) }
+
+ before do
+ project.add_owner(owner)
+ project.add_developer(developer)
+ project.add_maintainer(maintainer)
+ end
+
+ it 'sends the email to all project members with access_level >= Developer', :aggregate_failures do
+ double = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
+
+ [owner, maintainer, developer].each do |user|
+ email = user.notification_email_or_default
+
+ expect(Notify).to receive(:build_ios_app_guide_email).with(email) { double }
+ expect(double).to receive(:deliver_later)
+ end
+
+ execute
+ end
+
+ it 'records sent emails', :aggregate_failures do
+ expect { execute }.to change { Users::InProductMarketingEmail.count }.from(0).to(3)
+
+ [owner, maintainer, developer].each do |user|
+ expect(
+ Users::InProductMarketingEmail.where(
+ user: user,
+ campaign: campaign
+ )
+ ).to exist
+ end
+ end
+
+ it 'tracks experiment :email_sent event', :experiment do
+ expect(experiment(:build_ios_app_guide_email)).to track(:email_sent)
+ .on_next_instance
+ .with_context(project: project)
+
+ execute
+ end
+ end
+
+ shared_examples 'does nothing' do
+ it 'does not send the email' do
+ email = user.notification_email_or_default
+ expect(Notify).not_to receive(:build_ios_app_guide_email).with(email)
+ execute
+ end
+
+ it 'does not create a record of the sent email' do
+ expect { execute }.not_to change { Users::InProductMarketingEmail.count }
+ end
+ end
+
+ context "when user can't receive marketing emails" do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when user.can?(:receive_notifications) is false' do
+ it 'does not send the email' do
+ allow_next_found_instance_of(User) do |user|
+ allow(user).to receive(:can?).with(:receive_notifications) { false }
+
+ email = user.notification_email_or_default
+ expect(Notify).not_to receive(:build_ios_app_guide_email).with(email)
+
+ expect(
+ Users::InProductMarketingEmail.where(
+ user: user,
+ campaign: campaign
+ )
+ ).not_to exist
+ end
+
+ execute
+ end
+ end
+
+ context 'when user is not opted in to receive marketing emails' do
+ let(:user) { create(:user, email_opted_in: false) }
+
+ it_behaves_like 'does nothing'
+ end
+ end
+
+ context 'when campaign email has already been sent to the user' do
+ before do
+ project.add_developer(user)
+ create(:in_product_marketing_email, :campaign, user: user, campaign: campaign)
+ end
+
+ it_behaves_like 'does nothing'
+ end
+
+ context "when user is a reporter" do
+ before do
+ project.add_reporter(user)
+ end
+
+ it_behaves_like 'does nothing'
+ end
+
+ context "when user is a guest" do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like 'does nothing'
+ end
+ end
+end
diff --git a/spec/services/projects/open_issues_count_service_spec.rb b/spec/services/projects/open_issues_count_service_spec.rb
index 8710d0c0267..c739fea5ecf 100644
--- a/spec/services/projects/open_issues_count_service_spec.rb
+++ b/spec/services/projects/open_issues_count_service_spec.rb
@@ -4,102 +4,89 @@ require 'spec_helper'
RSpec.describe Projects::OpenIssuesCountService, :use_clean_rails_memory_store_caching do
let(:project) { create(:project) }
- let(:user) { create(:user) }
- let(:banned_user) { create(:user, :banned) }
- subject { described_class.new(project, user) }
+ subject { described_class.new(project) }
it_behaves_like 'a counter caching service'
- before do
- create(:issue, :opened, project: project)
- create(:issue, :opened, confidential: true, project: project)
- create(:issue, :opened, author: banned_user, project: project)
- create(:issue, :closed, project: project)
-
- described_class.new(project).refresh_cache
- end
-
describe '#count' do
- shared_examples 'counts public issues, does not count hidden or confidential' do
- it 'counts only public issues' do
- expect(subject.count).to eq(1)
- end
-
- it 'uses PUBLIC_COUNT_WITHOUT_HIDDEN_KEY cache key' do
- expect(subject.cache_key).to include('project_open_public_issues_without_hidden_count')
- end
- end
-
context 'when user is nil' do
- let(:user) { nil }
+ it 'does not include confidential issues in the issue count' do
+ create(:issue, :opened, project: project)
+ create(:issue, :opened, confidential: true, project: project)
- it_behaves_like 'counts public issues, does not count hidden or confidential'
+ expect(described_class.new(project).count).to eq(1)
+ end
end
context 'when user is provided' do
+ let(:user) { create(:user) }
+
context 'when user can read confidential issues' do
before do
project.add_reporter(user)
end
- it 'includes confidential issues and does not include hidden issues in count' do
- expect(subject.count).to eq(2)
+ it 'returns the right count with confidential issues' do
+ create(:issue, :opened, project: project)
+ create(:issue, :opened, confidential: true, project: project)
+
+ expect(described_class.new(project, user).count).to eq(2)
end
- it 'uses TOTAL_COUNT_WITHOUT_HIDDEN_KEY cache key' do
- expect(subject.cache_key).to include('project_open_issues_without_hidden_count')
+ it 'uses total_open_issues_count cache key' do
+ expect(described_class.new(project, user).cache_key_name).to eq('total_open_issues_count')
end
end
- context 'when user cannot read confidential or hidden issues' do
+ context 'when user cannot read confidential issues' do
before do
project.add_guest(user)
end
- it_behaves_like 'counts public issues, does not count hidden or confidential'
- end
-
- context 'when user is an admin' do
- let_it_be(:user) { create(:user, :admin) }
-
- context 'when admin mode is enabled', :enable_admin_mode do
- it 'includes confidential and hidden issues in count' do
- expect(subject.count).to eq(3)
- end
+ it 'does not include confidential issues' do
+ create(:issue, :opened, project: project)
+ create(:issue, :opened, confidential: true, project: project)
- it 'uses TOTAL_COUNT_KEY cache key' do
- expect(subject.cache_key).to include('project_open_issues_including_hidden_count')
- end
+ expect(described_class.new(project, user).count).to eq(1)
end
- context 'when admin mode is disabled' do
- it_behaves_like 'counts public issues, does not count hidden or confidential'
+ it 'uses public_open_issues_count cache key' do
+ expect(described_class.new(project, user).cache_key_name).to eq('public_open_issues_count')
end
end
end
- end
-
- describe '#refresh_cache', :aggregate_failures do
- context 'when cache is empty' do
- it 'refreshes cache keys correctly' do
- expect(Rails.cache.read(described_class.new(project).cache_key(described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY))).to eq(1)
- expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY))).to eq(2)
- expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_KEY))).to eq(3)
- end
- end
- context 'when cache is outdated' do
- it 'refreshes cache keys correctly' do
+ describe '#refresh_cache' do
+ before do
+ create(:issue, :opened, project: project)
create(:issue, :opened, project: project)
create(:issue, :opened, confidential: true, project: project)
- create(:issue, :opened, author: banned_user, project: project)
+ end
- described_class.new(project).refresh_cache
+ context 'when cache is empty' do
+ it 'refreshes cache keys correctly' do
+ subject.refresh_cache
- expect(Rails.cache.read(described_class.new(project).cache_key(described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY))).to eq(2)
- expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY))).to eq(4)
- expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_KEY))).to eq(6)
+ expect(Rails.cache.read(subject.cache_key(described_class::PUBLIC_COUNT_KEY))).to eq(2)
+ expect(Rails.cache.read(subject.cache_key(described_class::TOTAL_COUNT_KEY))).to eq(3)
+ end
+ end
+
+ context 'when cache is outdated' do
+ before do
+ subject.refresh_cache
+ end
+
+ it 'refreshes cache keys correctly' do
+ create(:issue, :opened, project: project)
+ create(:issue, :opened, confidential: true, project: project)
+
+ subject.refresh_cache
+
+ expect(Rails.cache.read(subject.cache_key(described_class::PUBLIC_COUNT_KEY))).to eq(3)
+ expect(Rails.cache.read(subject.cache_key(described_class::TOTAL_COUNT_KEY))).to eq(5)
+ end
end
end
end
diff --git a/spec/services/projects/prometheus/alerts/create_service_spec.rb b/spec/services/projects/prometheus/alerts/create_service_spec.rb
deleted file mode 100644
index 6b9d43e4e81..00000000000
--- a/spec/services/projects/prometheus/alerts/create_service_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Prometheus::Alerts::CreateService do
- let_it_be(:project) { create(:project) }
- let_it_be(:user) { create(:user) }
-
- let(:service) { described_class.new(project: project, current_user: user, params: params) }
-
- subject { service.execute }
-
- describe '#execute' do
- context 'with params' do
- let_it_be(:environment) { create(:environment, project: project) }
-
- let_it_be(:metric) do
- create(:prometheus_metric, project: project)
- end
-
- let(:params) do
- {
- environment_id: environment.id,
- prometheus_metric_id: metric.id,
- operator: '<',
- threshold: 1.0
- }
- end
-
- it 'creates an alert' do
- expect(subject).to be_persisted
-
- expect(subject).to have_attributes(
- project: project,
- environment: environment,
- prometheus_metric: metric,
- operator: 'lt',
- threshold: 1.0
- )
- end
- end
-
- context 'without params' do
- let(:params) { {} }
-
- it 'fails to create' do
- expect(subject).to be_new_record
- expect(subject).to be_invalid
- end
- end
- end
-end
diff --git a/spec/services/projects/prometheus/alerts/destroy_service_spec.rb b/spec/services/projects/prometheus/alerts/destroy_service_spec.rb
deleted file mode 100644
index a3e9c3516c2..00000000000
--- a/spec/services/projects/prometheus/alerts/destroy_service_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Prometheus::Alerts::DestroyService do
- let_it_be(:project) { create(:project) }
- let_it_be(:user) { create(:user) }
- let_it_be(:alert) { create(:prometheus_alert, project: project) }
-
- let(:service) { described_class.new(project: project, current_user: user, params: nil) }
-
- describe '#execute' do
- subject { service.execute(alert) }
-
- it 'deletes the alert' do
- expect(subject).to be_truthy
-
- expect(alert).to be_destroyed
- end
- end
-end
diff --git a/spec/services/projects/prometheus/alerts/update_service_spec.rb b/spec/services/projects/prometheus/alerts/update_service_spec.rb
deleted file mode 100644
index ec6766221f6..00000000000
--- a/spec/services/projects/prometheus/alerts/update_service_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Prometheus::Alerts::UpdateService do
- let_it_be(:project) { create(:project) }
- let_it_be(:user) { create(:user) }
- let_it_be(:environment) { create(:environment, project: project) }
-
- let_it_be(:alert) do
- create(:prometheus_alert, project: project, environment: environment)
- end
-
- let(:service) { described_class.new(project: project, current_user: user, params: params) }
-
- let(:params) do
- {
- environment_id: alert.environment_id,
- prometheus_metric_id: alert.prometheus_metric_id,
- operator: '==',
- threshold: 2.0
- }
- end
-
- describe '#execute' do
- subject { service.execute(alert) }
-
- context 'with valid params' do
- it 'updates the alert' do
- expect(subject).to be_truthy
-
- expect(alert.reload).to have_attributes(
- operator: 'eq',
- threshold: 2.0
- )
- end
- end
-
- context 'with invalid params' do
- let(:other_environment) { create(:environment) }
-
- before do
- params[:environment_id] = other_environment.id
- end
-
- it 'fails to update' do
- expect(subject).to be_falsey
-
- expect(alert).to be_invalid
- end
- end
- end
-end
diff --git a/spec/services/projects/prometheus/metrics/destroy_service_spec.rb b/spec/services/projects/prometheus/metrics/destroy_service_spec.rb
index 17cc88b27b6..b4af81f2c87 100644
--- a/spec/services/projects/prometheus/metrics/destroy_service_spec.rb
+++ b/spec/services/projects/prometheus/metrics/destroy_service_spec.rb
@@ -12,17 +12,4 @@ RSpec.describe Projects::Prometheus::Metrics::DestroyService do
expect(PrometheusMetric.find_by(id: metric.id)).to be_nil
end
-
- context 'when metric has a prometheus alert associated' do
- it 'schedules a prometheus alert update' do
- create(:prometheus_alert, project: metric.project, prometheus_metric: metric)
-
- schedule_update_service = spy
- allow(::Clusters::Applications::ScheduleUpdateService).to receive(:new).and_return(schedule_update_service)
-
- subject.execute
-
- expect(schedule_update_service).to have_received(:execute)
- end
- end
end
diff --git a/spec/services/projects/prometheus/metrics/update_service_spec.rb b/spec/services/projects/prometheus/metrics/update_service_spec.rb
deleted file mode 100644
index bf87093150c..00000000000
--- a/spec/services/projects/prometheus/metrics/update_service_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Prometheus::Metrics::UpdateService do
- let(:metric) { create(:prometheus_metric) }
-
- it 'updates the prometheus metric' do
- expect do
- described_class.new(metric, { title: "bar" }).execute
- end.to change { metric.reload.title }.to("bar")
- end
-
- context 'when metric has a prometheus alert associated' do
- let(:schedule_update_service) { spy }
-
- before do
- create(:prometheus_alert, project: metric.project, prometheus_metric: metric)
- allow(::Clusters::Applications::ScheduleUpdateService).to receive(:new).and_return(schedule_update_service)
- end
-
- context 'when updating title' do
- it 'schedules a prometheus alert update' do
- described_class.new(metric, { title: "bar" }).execute
-
- expect(schedule_update_service).to have_received(:execute)
- end
- end
-
- context 'when updating query' do
- it 'schedules a prometheus alert update' do
- described_class.new(metric, { query: "sum(bar)" }).execute
-
- expect(schedule_update_service).to have_received(:execute)
- end
- end
-
- it 'does not schedule a prometheus alert update without title nor query being changed' do
- described_class.new(metric, { y_label: "bar" }).execute
-
- expect(schedule_update_service).not_to have_received(:execute)
- end
- end
-end
diff --git a/spec/services/projects/record_target_platforms_service_spec.rb b/spec/services/projects/record_target_platforms_service_spec.rb
index 85311f36428..22ff325a62e 100644
--- a/spec/services/projects/record_target_platforms_service_spec.rb
+++ b/spec/services/projects/record_target_platforms_service_spec.rb
@@ -5,21 +5,38 @@ require 'spec_helper'
RSpec.describe Projects::RecordTargetPlatformsService, '#execute' do
let_it_be(:project) { create(:project) }
- subject(:execute) { described_class.new(project).execute }
+ let(:detector_service) { Projects::AppleTargetPlatformDetectorService }
+
+ subject(:execute) { described_class.new(project, detector_service).execute }
+
+ context 'when detector returns target platform values' do
+ let(:detector_result) { [:ios, :osx] }
+ let(:service_result) { detector_result.map(&:to_s) }
- context 'when project is an XCode project' do
before do
- double = instance_double(Projects::AppleTargetPlatformDetectorService, execute: [:ios, :osx])
- allow(Projects::AppleTargetPlatformDetectorService).to receive(:new) { double }
+ double = instance_double(detector_service, execute: detector_result)
+ allow(detector_service).to receive(:new) { double }
end
- it 'creates a new setting record for the project', :aggregate_failures do
- expect { execute }.to change { ProjectSetting.count }.from(0).to(1)
- expect(ProjectSetting.last.target_platforms).to match_array(%w(ios osx))
+ shared_examples 'saves and returns detected target platforms' do
+ it 'creates a new setting record for the project', :aggregate_failures do
+ expect { execute }.to change { ProjectSetting.count }.from(0).to(1)
+ expect(ProjectSetting.last.target_platforms).to match_array(service_result)
+ end
+
+ it 'returns the array of stored target platforms' do
+ expect(execute).to match_array service_result
+ end
end
- it 'returns array of detected target platforms' do
- expect(execute).to match_array %w(ios osx)
+ it_behaves_like 'saves and returns detected target platforms'
+
+ context 'when detector returns a non-array value' do
+ let(:detector_service) { Projects::AndroidTargetPlatformDetectorService }
+ let(:detector_result) { :android }
+ let(:service_result) { [detector_result.to_s] }
+
+ it_behaves_like 'saves and returns detected target platforms'
end
context 'when a project has an existing setting record' do
@@ -49,9 +66,76 @@ RSpec.describe Projects::RecordTargetPlatformsService, '#execute' do
end
end
end
+
+ describe 'Build iOS guide email experiment' do
+ shared_examples 'tracks experiment assignment event' do
+ it 'tracks the assignment event', :experiment do
+ expect(experiment(:build_ios_app_guide_email))
+ .to track(:assignment)
+ .with_context(project: project)
+ .on_next_instance
+
+ execute
+ end
+ end
+
+ context 'experiment candidate' do
+ before do
+ stub_experiments(build_ios_app_guide_email: :candidate)
+ end
+
+ it 'executes a Projects::InProductMarketingCampaignEmailsService' do
+ service_double = instance_double(Projects::InProductMarketingCampaignEmailsService, execute: true)
+
+ expect(Projects::InProductMarketingCampaignEmailsService)
+ .to receive(:new).with(project, Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
+ .and_return service_double
+ expect(service_double).to receive(:execute)
+
+ execute
+ end
+
+ it_behaves_like 'tracks experiment assignment event'
+ end
+
+ shared_examples 'does not send email' do
+ it 'does not execute a Projects::InProductMarketingCampaignEmailsService' do
+ expect(Projects::InProductMarketingCampaignEmailsService).not_to receive(:new)
+
+ execute
+ end
+ end
+
+ context 'experiment control' do
+ before do
+ stub_experiments(build_ios_app_guide_email: :control)
+ end
+
+ it_behaves_like 'does not send email'
+ it_behaves_like 'tracks experiment assignment event'
+ end
+
+ context 'when project is not an iOS project' do
+ let(:detector_service) { Projects::AppleTargetPlatformDetectorService }
+ let(:detector_result) { :android }
+
+ before do
+ stub_experiments(build_ios_app_guide_email: :candidate)
+ end
+
+ it_behaves_like 'does not send email'
+
+ it 'does not track experiment assignment event', :experiment do
+ expect(experiment(:build_ios_app_guide_email))
+ .not_to track(:assignment)
+
+ execute
+ end
+ end
+ end
end
- context 'when project is not an XCode project' do
+ context 'when detector does not return any target platform values' do
before do
double = instance_double(Projects::AppleTargetPlatformDetectorService, execute: [])
allow(Projects::AppleTargetPlatformDetectorService).to receive(:new).with(project) { double }
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index 6407b8d3940..777162b6196 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -13,6 +13,7 @@ RSpec.describe Projects::UpdatePagesService do
let(:file) { fixture_file_upload("spec/fixtures/pages.zip") }
let(:empty_file) { fixture_file_upload("spec/fixtures/pages_empty.zip") }
+ let(:empty_metadata_filename) { "spec/fixtures/pages_empty.zip.meta" }
let(:metadata_filename) { "spec/fixtures/pages.zip.meta" }
let(:metadata) { fixture_file_upload(metadata_filename) if File.exist?(metadata_filename) }
@@ -91,6 +92,17 @@ RSpec.describe Projects::UpdatePagesService do
end
end
+ context 'when archive does not have pages directory' do
+ let(:file) { empty_file }
+ let(:metadata_filename) { empty_metadata_filename }
+
+ it 'returns an error' do
+ expect(execute).not_to eq(:success)
+
+ expect(GenericCommitStatus.last.description).to eq("Error: The `public/` folder is missing, or not declared in `.gitlab-ci.yml`.")
+ end
+ end
+
it 'limits pages size' do
stub_application_setting(max_pages_size: 1)
expect(execute).not_to eq(:success)
diff --git a/spec/services/prometheus/create_default_alerts_service_spec.rb b/spec/services/prometheus/create_default_alerts_service_spec.rb
deleted file mode 100644
index 0880799b589..00000000000
--- a/spec/services/prometheus/create_default_alerts_service_spec.rb
+++ /dev/null
@@ -1,92 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Prometheus::CreateDefaultAlertsService do
- let_it_be(:project) { create(:project, :repository) }
-
- let(:instance) { described_class.new(project: project) }
- let(:expected_alerts) { described_class::DEFAULT_ALERTS }
-
- describe '#execute' do
- subject(:execute) { instance.execute }
-
- shared_examples 'no alerts created' do
- it 'does not create alerts' do
- expect { execute }.not_to change { project.reload.prometheus_alerts.count }
- end
- end
-
- context 'no environment' do
- it_behaves_like 'no alerts created'
- end
-
- context 'environment exists' do
- let_it_be(:environment) { create(:environment, project: project) }
-
- context 'no found metric' do
- it_behaves_like 'no alerts created'
- end
-
- context 'metric exists' do
- before do
- create_expected_metrics!
- end
-
- context 'alert exists already' do
- before do
- create_pre_existing_alerts!(environment)
- end
-
- it_behaves_like 'no alerts created'
- end
-
- it 'creates alerts' do
- expect { execute }.to change { project.reload.prometheus_alerts.count }
- .by(expected_alerts.size)
- end
-
- it 'does not schedule an update to prometheus' do
- expect(::Clusters::Applications::ScheduleUpdateService).not_to receive(:new)
- execute
- end
-
- context 'cluster with prometheus exists' do
- let!(:cluster) { create(:cluster, :with_installed_prometheus, :provided_by_user, projects: [project]) }
-
- it 'schedules an update to prometheus' do
- expect_next_instance_of(::Clusters::Applications::ScheduleUpdateService) do |instance|
- expect(instance).to receive(:execute)
- end
-
- execute
- end
- end
-
- context 'multiple environments' do
- let!(:production) { create(:environment, project: project, name: 'production') }
-
- it 'uses the production environment' do
- expect { execute }.to change { production.reload.prometheus_alerts.count }
- .by(expected_alerts.size)
- end
- end
- end
- end
- end
-
- private
-
- def create_expected_metrics!
- expected_alerts.each do |alert_hash|
- create(:prometheus_metric, :common, identifier: alert_hash.fetch(:identifier))
- end
- end
-
- def create_pre_existing_alerts!(environment)
- expected_alerts.each do |alert_hash|
- metric = PrometheusMetric.for_identifier(alert_hash[:identifier]).first!
- create(:prometheus_alert, prometheus_metric: metric, project: project, environment: environment)
- end
- end
-end
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 85dbc39edcf..f7a22b1b92f 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -696,6 +696,21 @@ RSpec.describe QuickActions::InterpretService do
expect(message).to eq("Assigned #{developer.to_reference}.")
end
+ context 'when the reference does not match the exact case' do
+ let(:user) { create(:user) }
+ let(:content) { "/assign #{user.to_reference.upcase}" }
+
+ it 'assigns to the user' do
+ issuable.project.add_developer(user)
+
+ _, updates, message = service.execute(content, issuable)
+
+ expect(content).not_to include(user.to_reference)
+ expect(updates).to eq(assignee_ids: [user.id])
+ expect(message).to eq("Assigned #{user.to_reference}.")
+ end
+ end
+
context 'when the user has a private profile' do
let(:user) { create(:user, :private_profile) }
let(:content) { "/assign #{user.to_reference}" }
diff --git a/spec/services/service_ping/submit_service_ping_service_spec.rb b/spec/services/service_ping/submit_service_ping_service_spec.rb
index 73be8f000a9..7a8bd1910fe 100644
--- a/spec/services/service_ping/submit_service_ping_service_spec.rb
+++ b/spec/services/service_ping/submit_service_ping_service_spec.rb
@@ -51,6 +51,9 @@ RSpec.describe ServicePing::SubmitService do
let(:with_dev_ops_score_params) { { dev_ops_score: score_params[:score] } }
let(:with_conv_index_params) { { conv_index: score_params[:score] } }
let(:with_usage_data_id_params) { { conv_index: { usage_data_id: usage_data_id } } }
+ let(:service_ping_payload_url) { File.join(described_class::STAGING_BASE_URL, described_class::USAGE_DATA_PATH) }
+ let(:service_ping_errors_url) { File.join(described_class::STAGING_BASE_URL, described_class::ERROR_PATH) }
+ let(:service_ping_metadata_url) { File.join(described_class::STAGING_BASE_URL, described_class::METADATA_PATH) }
shared_examples 'does not run' do
it do
@@ -63,7 +66,7 @@ RSpec.describe ServicePing::SubmitService do
shared_examples 'does not send a blank usage ping payload' do
it do
- expect(Gitlab::HTTP).not_to receive(:post).with(subject.url, any_args)
+ expect(Gitlab::HTTP).not_to receive(:post).with(service_ping_payload_url, any_args)
expect { subject.execute }.to raise_error(described_class::SubmissionError) do |error|
expect(error.message).to include('Usage data is blank')
@@ -117,6 +120,7 @@ RSpec.describe ServicePing::SubmitService do
it 'generates service ping' do
stub_response(body: with_dev_ops_score_params)
+ stub_response(body: nil, url: service_ping_metadata_url, status: 201)
expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values).and_call_original
@@ -129,18 +133,21 @@ RSpec.describe ServicePing::SubmitService do
stub_usage_data_connections
stub_database_flavor_check
stub_application_setting(usage_ping_enabled: true)
- stub_response(body: nil, url: subject.error_url, status: 201)
+ stub_response(body: nil, url: service_ping_errors_url, status: 201)
+ stub_response(body: nil, url: service_ping_metadata_url, status: 201)
end
context 'and user requires usage stats consent' do
before do
- allow(User).to receive(:single_user).and_return(double(:user, requires_usage_stats_consent?: true))
+ allow(User).to receive(:single_user)
+ .and_return(instance_double(User, :user, requires_usage_stats_consent?: true))
end
it_behaves_like 'does not run'
end
it 'sends a POST request' do
+ stub_response(body: nil, url: service_ping_metadata_url, status: 201)
response = stub_response(body: with_dev_ops_score_params)
subject.execute
@@ -167,7 +174,8 @@ RSpec.describe ServicePing::SubmitService do
recorded_at = Time.current
usage_data = { uuid: 'uuid', recorded_at: recorded_at }
- expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values).and_return(usage_data)
+ expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values)
+ .and_return(usage_data)
subject.execute
@@ -190,7 +198,8 @@ RSpec.describe ServicePing::SubmitService do
recorded_at = Time.current
usage_data = { uuid: 'uuid', recorded_at: recorded_at }
- expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values).and_return(usage_data)
+ expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values)
+ .and_return(usage_data)
subject.execute
@@ -235,7 +244,8 @@ RSpec.describe ServicePing::SubmitService do
recorded_at = Time.current
usage_data = { uuid: 'uuid', recorded_at: recorded_at }
- expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values).and_return(usage_data)
+ expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values)
+ .and_return(usage_data)
subject.execute
@@ -268,7 +278,7 @@ RSpec.describe ServicePing::SubmitService do
context 'and usage data is nil' do
before do
- allow(ServicePing::BuildPayloadService).to receive(:execute).and_return(nil)
+ allow(ServicePing::BuildPayload).to receive(:execute).and_return(nil)
allow(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values).and_return(nil)
end
@@ -278,29 +288,33 @@ RSpec.describe ServicePing::SubmitService do
context 'if payload service fails' do
before do
stub_response(body: with_dev_ops_score_params)
- allow(ServicePing::BuildPayloadService).to receive_message_chain(:new, :execute)
+
+ allow(ServicePing::BuildPayload).to receive_message_chain(:new, :execute)
.and_raise(described_class::SubmissionError, 'SubmissionError')
end
it 'calls Gitlab::Usage::ServicePingReport .for method' do
usage_data = build_usage_data
- expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values).and_return(usage_data)
+ expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values)
+ .and_return(usage_data)
subject.execute
end
it 'submits error' do
- expect(Gitlab::HTTP).to receive(:post).with(subject.url, any_args)
+ expect(Gitlab::HTTP).to receive(:post).with(URI.join(service_ping_payload_url), any_args)
+ .and_call_original
+ expect(Gitlab::HTTP).to receive(:post).with(URI.join(service_ping_errors_url), any_args)
.and_call_original
- expect(Gitlab::HTTP).to receive(:post).with(subject.error_url, any_args)
+ expect(Gitlab::HTTP).to receive(:post).with(URI.join(service_ping_metadata_url), any_args)
.and_call_original
subject.execute
end
end
- context 'calls BuildPayloadService first' do
+ context 'calls BuildPayload first' do
before do
stub_response(body: with_dev_ops_score_params)
end
@@ -308,7 +322,7 @@ RSpec.describe ServicePing::SubmitService do
it 'returns usage data' do
usage_data = build_usage_data
- expect_next_instance_of(ServicePing::BuildPayloadService) do |service|
+ expect_next_instance_of(ServicePing::BuildPayload) do |service|
expect(service).to receive(:execute).and_return(usage_data)
end
@@ -321,7 +335,7 @@ RSpec.describe ServicePing::SubmitService do
stub_response(body: with_dev_ops_score_params, status: 404)
usage_data = build_usage_data
- allow_next_instance_of(ServicePing::BuildPayloadService) do |service|
+ allow_next_instance_of(ServicePing::BuildPayload) do |service|
allow(service).to receive(:execute).and_return(usage_data)
end
end
@@ -329,7 +343,8 @@ RSpec.describe ServicePing::SubmitService do
it 'calls Gitlab::Usage::ServicePingReport .for method' do
usage_data = build_usage_data
- expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values).and_return(usage_data)
+ expect(Gitlab::Usage::ServicePingReport).to receive(:for).with(output: :all_metrics_values)
+ .and_return(usage_data)
# SubmissionError is raised as a result of 404 in response from HTTP Request
expect { subject.execute }.to raise_error(described_class::SubmissionError)
@@ -349,38 +364,79 @@ RSpec.describe ServicePing::SubmitService do
end
it 'does not call DevOpsReport service' do
- expect(ServicePing::DevopsReportService).not_to receive(:new)
+ expect(ServicePing::DevopsReport).not_to receive(:new)
subject.execute
end
end
end
- describe '#url' do
- let(:url) { subject.url.to_s }
+ context 'metadata reporting' do
+ before do
+ stub_usage_data_connections
+ stub_database_flavor_check
+ stub_application_setting(usage_ping_enabled: true)
+ stub_response(body: with_conv_index_params)
+ end
+
+ context 'with feature flag measure_service_ping_metric_collection turned on' do
+ let(:metric_double) { instance_double(Gitlab::Usage::ServicePing::LegacyMetricTimingDecorator, duration: 123) }
+ let(:payload) do
+ {
+ metric_a: metric_double,
+ metric_group: {
+ metric_b: metric_double
+ },
+ metric_without_timing: "value",
+ recorded_at: Time.current
+ }
+ end
+
+ let(:metadata_payload) do
+ {
+ metadata: {
+ metrics: [
+ { name: 'metric_a', time_elapsed: 123 },
+ { name: 'metric_group.metric_b', time_elapsed: 123 }
+ ]
+ }
+ }
+ end
- context 'when Rails.env is production' do
before do
- stub_rails_env('production')
+ stub_feature_flags(measure_service_ping_metric_collection: true)
+
+ allow_next_instance_of(ServicePing::BuildPayload) do |service|
+ allow(service).to receive(:execute).and_return(payload)
+ end
end
- it 'points to the production Version app' do
- expect(url).to eq("#{described_class::PRODUCTION_BASE_URL}/#{described_class::USAGE_DATA_PATH}")
+ it 'submits metadata' do
+ response = stub_full_request(service_ping_metadata_url, method: :post)
+ .with(body: metadata_payload)
+
+ subject.execute
+
+ expect(response).to have_been_requested
end
end
- context 'when Rails.env is not production' do
+ context 'with feature flag measure_service_ping_metric_collection turned off' do
before do
- stub_rails_env('development')
+ stub_feature_flags(measure_service_ping_metric_collection: false)
end
- it 'points to the staging Version app' do
- expect(url).to eq("#{described_class::STAGING_BASE_URL}/#{described_class::USAGE_DATA_PATH}")
+ it 'does NOT submit metadata' do
+ response = stub_full_request(service_ping_metadata_url, method: :post)
+
+ subject.execute
+
+ expect(response).not_to have_been_requested
end
end
end
- def stub_response(url: subject.url, body:, status: 201)
+ def stub_response(url: service_ping_payload_url, body:, status: 201)
stub_full_request(url, method: :post)
.to_return(
headers: { 'Content-Type' => 'application/json' },
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index c322ec35e86..741d136b9a0 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -431,6 +431,19 @@ RSpec.describe SystemNoteService do
end
end
+ describe '.remove_timelog' do
+ let(:issue) { create(:issue, project: project) }
+ let(:timelog) { create(:timelog, user: author, issue: issue, time_spent: 1800)}
+
+ it 'calls TimeTrackingService' do
+ expect_next_instance_of(::SystemNotes::TimeTrackingService) do |service|
+ expect(service).to receive(:remove_timelog)
+ end
+
+ described_class.remove_timelog(noteable, project, author, timelog)
+ end
+ end
+
describe '.handle_merge_request_draft' do
it 'calls MergeRequestsService' do
expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service|
@@ -695,4 +708,38 @@ RSpec.describe SystemNoteService do
described_class.change_issue_type(incident, author)
end
end
+
+ describe '.add_timeline_event' do
+ let(:timeline_event) { instance_double('IncidentManagement::TimelineEvent', incident: noteable, project: project) }
+
+ it 'calls IncidentsService' do
+ expect_next_instance_of(::SystemNotes::IncidentsService) do |service|
+ expect(service).to receive(:add_timeline_event).with(timeline_event)
+ end
+
+ described_class.add_timeline_event(timeline_event)
+ end
+ end
+
+ describe '.edit_timeline_event' do
+ let(:timeline_event) { instance_double('IncidentManagement::TimelineEvent', incident: noteable, project: project) }
+
+ it 'calls IncidentsService' do
+ expect_next_instance_of(::SystemNotes::IncidentsService) do |service|
+ expect(service).to receive(:edit_timeline_event).with(timeline_event, author, was_changed: :occurred_at)
+ end
+
+ described_class.edit_timeline_event(timeline_event, author, was_changed: :occurred_at)
+ end
+ end
+
+ describe '.delete_timeline_event' do
+ it 'calls IncidentsService' do
+ expect_next_instance_of(::SystemNotes::IncidentsService) do |service|
+ expect(service).to receive(:delete_timeline_event).with(author)
+ end
+
+ described_class.delete_timeline_event(noteable, author)
+ end
+ end
end
diff --git a/spec/services/system_notes/incidents_service_spec.rb b/spec/services/system_notes/incidents_service_spec.rb
new file mode 100644
index 00000000000..d1b831e9c4c
--- /dev/null
+++ b/spec/services/system_notes/incidents_service_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SystemNotes::IncidentsService do
+ include Gitlab::Routing
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:incident) { create(:incident, project: project, author: user) }
+ let_it_be(:timeline_event) do
+ create(:incident_management_timeline_event, project: project, incident: incident, author: author)
+ end
+
+ describe '#add_timeline_event' do
+ subject { described_class.new(noteable: incident).add_timeline_event(timeline_event) }
+
+ it_behaves_like 'a system note' do
+ let(:noteable) { incident }
+ let(:action) { 'timeline_event' }
+ end
+
+ it 'posts the correct text to the system note' do
+ path = project_issues_incident_path(project, incident, anchor: "timeline_event_#{timeline_event.id}")
+ expect(subject.note).to match("added an [incident timeline event](#{path})")
+ end
+ end
+
+ describe '#edit_timeline_event' do
+ let(:was_changed) { :unknown }
+ let(:path) { project_issues_incident_path(project, incident, anchor: "timeline_event_#{timeline_event.id}") }
+
+ subject do
+ described_class.new(noteable: incident).edit_timeline_event(timeline_event, author, was_changed: was_changed)
+ end
+
+ it_behaves_like 'a system note' do
+ let(:noteable) { incident }
+ let(:action) { 'timeline_event' }
+ end
+
+ context "when only timeline event's occurred_at was changed" do
+ let(:was_changed) { :occurred_at }
+
+ it 'posts the correct text to the system note' do
+ expect(subject.note).to match("edited the event time/date on [incident timeline event](#{path})")
+ end
+ end
+
+ context "when only timeline event's note was changed" do
+ let(:was_changed) { :note }
+
+ it 'posts the correct text to the system note' do
+ expect(subject.note).to match("edited the text on [incident timeline event](#{path})")
+ end
+ end
+
+ context "when both timeline events occurred_at and note was changed" do
+ let(:was_changed) { :occurred_at_and_note }
+
+ it 'posts the correct text to the system note' do
+ expect(subject.note).to match("edited the event time/date and text on [incident timeline event](#{path})")
+ end
+ end
+
+ context "when was changed reason is unknown" do
+ let(:was_changed) { :unknown }
+
+ it 'posts the correct text to the system note' do
+ expect(subject.note).to match("edited [incident timeline event](#{path})")
+ end
+ end
+ end
+
+ describe '#delete_timeline_event' do
+ subject { described_class.new(noteable: incident).delete_timeline_event(author) }
+
+ it_behaves_like 'a system note' do
+ let(:noteable) { incident }
+ let(:action) { 'timeline_event' }
+ end
+
+ it 'posts the correct text to the system note' do
+ expect(subject.note).to match('deleted an incident timeline event')
+ end
+ end
+end
diff --git a/spec/services/system_notes/time_tracking_service_spec.rb b/spec/services/system_notes/time_tracking_service_spec.rb
index ec126cb5447..fdf18f4f29a 100644
--- a/spec/services/system_notes/time_tracking_service_spec.rb
+++ b/spec/services/system_notes/time_tracking_service_spec.rb
@@ -106,6 +106,30 @@ RSpec.describe ::SystemNotes::TimeTrackingService do
end
end
+ describe '#remove_timelog' do
+ subject { described_class.new(noteable: noteable, project: project, author: author).remove_timelog(timelog) }
+
+ context 'when the timelog has a positive time spent value' do
+ let_it_be(:noteable, reload: true) { create(:issue, project: project) }
+
+ let(:timelog) { create(:timelog, user: author, issue: noteable, time_spent: 1800, spent_at: '2022-03-30T00:00:00.000Z')}
+
+ it 'sets the note text' do
+ expect(subject.note).to eq "deleted 30m of spent time from 2022-03-30"
+ end
+ end
+
+ context 'when the timelog has a negative time spent value' do
+ let_it_be(:noteable, reload: true) { create(:issue, project: project) }
+
+ let(:timelog) { create(:timelog, user: author, issue: noteable, time_spent: -1800, spent_at: '2022-03-30T00:00:00.000Z')}
+
+ it 'sets the note text' do
+ expect(subject.note).to eq "deleted -30m of spent time from 2022-03-30"
+ end
+ end
+ end
+
describe '#change_time_spent' do
subject { described_class.new(noteable: noteable, project: project, author: author).change_time_spent }
diff --git a/spec/services/timelogs/delete_service_spec.rb b/spec/services/timelogs/delete_service_spec.rb
new file mode 100644
index 00000000000..c52cebdc5bf
--- /dev/null
+++ b/spec/services/timelogs/delete_service_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Timelogs::DeleteService do
+ let_it_be(:author) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:timelog) { create(:timelog, user: author, issue: issue, time_spent: 1800)}
+
+ let(:service) { described_class.new(timelog, user) }
+
+ describe '#execute' do
+ subject { service.execute }
+
+ context 'when the timelog exists' do
+ let(:user) { author }
+
+ it 'removes the timelog' do
+ expect { subject }.to change { Timelog.count }.by(-1)
+ end
+
+ it 'returns the removed timelog' do
+ expect(subject).to be_success
+ expect(subject.payload).to eq(timelog)
+ end
+ end
+
+ context 'when the timelog does not exist' do
+ let(:user) { create(:user) }
+ let!(:timelog) { nil }
+
+ it 'returns an error' do
+ expect(subject).to be_error
+ expect(subject.message).to eq('Timelog doesn\'t exist or you don\'t have permission to delete it')
+ expect(subject.http_status).to eq(404)
+ end
+ end
+
+ context 'when the user does not have permission' do
+ let(:user) { create(:user) }
+
+ it 'returns an error' do
+ expect(subject).to be_error
+ expect(subject.message).to eq('Timelog doesn\'t exist or you don\'t have permission to delete it')
+ expect(subject.http_status).to eq(404)
+ end
+ end
+
+ context 'when the timelog deletion fails' do
+ let(:user) { author }
+ let!(:timelog) { create(:timelog, user: author, issue: issue, time_spent: 1800)}
+
+ before do
+ allow(timelog).to receive(:destroy).and_return(false)
+ end
+
+ it 'returns an error' do
+ expect(subject).to be_error
+ expect(subject.message).to eq('Failed to remove timelog')
+ expect(subject.http_status).to eq(400)
+ end
+ end
+ end
+end
diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb
index 80a506bb1d6..45dbe83b496 100644
--- a/spec/services/users/destroy_service_spec.rb
+++ b/spec/services/users/destroy_service_spec.rb
@@ -73,10 +73,10 @@ RSpec.describe Users::DestroyService do
allow(user).to receive(:personal_projects).and_return([])
expect_next_instance_of(Snippets::BulkDestroyService) do |bulk_destroy_service|
- expect(bulk_destroy_service).to receive(:execute).with(hard_delete: true).and_call_original
+ expect(bulk_destroy_service).to receive(:execute).with({ hard_delete: true }).and_call_original
end
- service.execute(user, hard_delete: true)
+ service.execute(user, { hard_delete: true })
end
it 'does not delete project snippets that the user is the author of' do
@@ -336,35 +336,24 @@ RSpec.describe Users::DestroyService do
context 'batched nullify' do
let(:other_user) { create(:user) }
- context 'when :nullify_in_batches_on_user_deletion feature flag is enabled' do
- it 'nullifies related associations in batches' do
- expect(other_user).to receive(:nullify_dependent_associations_in_batches).and_call_original
+ it 'nullifies related associations in batches' do
+ expect(other_user).to receive(:nullify_dependent_associations_in_batches).and_call_original
- described_class.new(user).execute(other_user, skip_authorization: true)
- end
-
- it 'nullifies last_updated_issues and closed_issues' do
- issue = create(:issue, closed_by: other_user, updated_by: other_user)
-
- described_class.new(user).execute(other_user, skip_authorization: true)
-
- issue.reload
-
- expect(issue.closed_by).to be_nil
- expect(issue.updated_by).to be_nil
- end
+ described_class.new(user).execute(other_user, skip_authorization: true)
end
- context 'when :nullify_in_batches_on_user_deletion feature flag is disabled' do
- before do
- stub_feature_flags(nullify_in_batches_on_user_deletion: false)
- end
+ it 'nullifies last_updated_issues, closed_issues, resource_label_events' do
+ issue = create(:issue, closed_by: other_user, updated_by: other_user)
+ resource_label_event = create(:resource_label_event, user: other_user)
- it 'does not use batching' do
- expect(other_user).not_to receive(:nullify_dependent_associations_in_batches)
+ described_class.new(user).execute(other_user, skip_authorization: true)
- described_class.new(user).execute(other_user, skip_authorization: true)
- end
+ issue.reload
+ resource_label_event.reload
+
+ expect(issue.closed_by).to be_nil
+ expect(issue.updated_by).to be_nil
+ expect(resource_label_event.user).to be_nil
end
end
end
diff --git a/spec/services/namespaces/in_product_marketing_email_records_spec.rb b/spec/services/users/in_product_marketing_email_records_spec.rb
index d80e20135d5..0b9400dcd12 100644
--- a/spec/services/namespaces/in_product_marketing_email_records_spec.rb
+++ b/spec/services/users/in_product_marketing_email_records_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Namespaces::InProductMarketingEmailRecords do
+RSpec.describe Users::InProductMarketingEmailRecords do
let_it_be(:user) { create :user }
subject(:records) { described_class.new }
@@ -15,8 +15,9 @@ RSpec.describe Namespaces::InProductMarketingEmailRecords do
before do
allow(Users::InProductMarketingEmail).to receive(:bulk_insert!)
- records.add(user, :team_short, 0)
- records.add(user, :create, 1)
+ records.add(user, track: :team_short, series: 0)
+ records.add(user, track: :create, series: 1)
+ records.add(user, campaign: Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
end
it 'bulk inserts added records' do
@@ -31,24 +32,34 @@ RSpec.describe Namespaces::InProductMarketingEmailRecords do
end
describe '#add' do
- it 'adds a Users::InProductMarketingEmail record to its records' do
+ it 'adds a Users::InProductMarketingEmail record to its records', :aggregate_failures do
freeze_time do
- records.add(user, :team_short, 0)
- records.add(user, :create, 1)
+ records.add(user, track: :team_short, series: 0)
+ records.add(user, track: :create, series: 1)
+ records.add(user, campaign: Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
- first, second = records.records
+ first, second, third = records.records
expect(first).to be_a Users::InProductMarketingEmail
+ expect(first.campaign).to be_nil
expect(first.track.to_sym).to eq :team_short
expect(first.series).to eq 0
expect(first.created_at).to eq Time.zone.now
expect(first.updated_at).to eq Time.zone.now
expect(second).to be_a Users::InProductMarketingEmail
+ expect(second.campaign).to be_nil
expect(second.track.to_sym).to eq :create
expect(second.series).to eq 1
expect(second.created_at).to eq Time.zone.now
expect(second.updated_at).to eq Time.zone.now
+
+ expect(third).to be_a Users::InProductMarketingEmail
+ expect(third.campaign).to eq Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE
+ expect(third.track).to be_nil
+ expect(third.series).to be_nil
+ expect(third.created_at).to eq Time.zone.now
+ expect(third.updated_at).to eq Time.zone.now
end
end
end
diff --git a/spec/services/users/validate_otp_service_spec.rb b/spec/services/users/validate_manual_otp_service_spec.rb
index 46b80b2149f..d71735814f2 100644
--- a/spec/services/users/validate_otp_service_spec.rb
+++ b/spec/services/users/validate_manual_otp_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Users::ValidateOtpService do
+RSpec.describe Users::ValidateManualOtpService do
let_it_be(:user) { create(:user) }
let(:otp_code) { 42 }
@@ -25,8 +25,8 @@ RSpec.describe Users::ValidateOtpService do
allow(::Gitlab.config.forti_authenticator).to receive(:enabled).and_return(true)
end
- it 'calls FortiAuthenticator strategy' do
- expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::FortiAuthenticator) do |strategy|
+ it 'calls ManualOtp strategy' do
+ expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::FortiAuthenticator::ManualOtp) do |strategy|
expect(strategy).to receive(:validate).with(otp_code).once
end
@@ -48,4 +48,25 @@ RSpec.describe Users::ValidateOtpService do
validate
end
end
+
+ context 'unexpected error' do
+ before do
+ stub_feature_flags(forti_authenticator: user)
+ allow(::Gitlab.config.forti_authenticator).to receive(:enabled).and_return(true)
+ end
+
+ it 'returns error' do
+ error_message = "boom!"
+
+ expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::FortiAuthenticator::ManualOtp) do |strategy|
+ expect(strategy).to receive(:validate).with(otp_code).once.and_raise(StandardError, error_message)
+ end
+ expect(Gitlab::ErrorTracking).to receive(:log_exception)
+
+ result = validate
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq(error_message)
+ end
+ end
end
diff --git a/spec/services/users/validate_push_otp_service_spec.rb b/spec/services/users/validate_push_otp_service_spec.rb
new file mode 100644
index 00000000000..960b6bcd3bb
--- /dev/null
+++ b/spec/services/users/validate_push_otp_service_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::ValidatePushOtpService do
+ let_it_be(:user) { create(:user) }
+
+ subject(:validate) { described_class.new(user).execute }
+
+ context 'FortiAuthenticator' do
+ before do
+ stub_feature_flags(forti_authenticator: user)
+ allow(::Gitlab.config.forti_authenticator).to receive(:enabled).and_return(true)
+ end
+
+ it 'calls PushOtp strategy' do
+ expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::FortiAuthenticator::PushOtp) do |strategy|
+ expect(strategy).to receive(:validate).once
+ end
+
+ validate
+ end
+ end
+
+ context 'unexpected error' do
+ before do
+ stub_feature_flags(forti_authenticator: user)
+ allow(::Gitlab.config.forti_authenticator).to receive(:enabled).and_return(true)
+ end
+
+ it 'returns error' do
+ error_message = "boom!"
+
+ expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::FortiAuthenticator::PushOtp) do |strategy|
+ expect(strategy).to receive(:validate).once.and_raise(StandardError, error_message)
+ end
+ expect(Gitlab::ErrorTracking).to receive(:log_exception)
+
+ result = validate
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq(error_message)
+ end
+ end
+end
diff --git a/spec/services/work_items/delete_task_service_spec.rb b/spec/services/work_items/delete_task_service_spec.rb
new file mode 100644
index 00000000000..04944645c9b
--- /dev/null
+++ b/spec/services/work_items/delete_task_service_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::DeleteTaskService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+ let_it_be_with_refind(:task) { create(:work_item, project: project, author: developer) }
+ let_it_be_with_refind(:list_work_item) do
+ create(:work_item, project: project, description: "- [ ] #{task.to_reference}+")
+ end
+
+ let(:current_user) { developer }
+ let(:line_number_start) { 1 }
+ let(:params) do
+ {
+ line_number_start: line_number_start,
+ line_number_end: 1,
+ task: task
+ }
+ end
+
+ before_all do
+ create(:issue_link, source_id: list_work_item.id, target_id: task.id)
+ end
+
+ shared_examples 'failing WorkItems::DeleteTaskService' do |error_message|
+ it { is_expected.to be_error }
+
+ it 'does not remove work item or issue links' do
+ expect do
+ service_result
+ list_work_item.reload
+ end.to not_change(WorkItem, :count).and(
+ not_change(IssueLink, :count)
+ ).and(
+ not_change(list_work_item, :description)
+ )
+ end
+
+ it 'returns an error message' do
+ expect(service_result.errors).to contain_exactly(error_message)
+ end
+ end
+
+ describe '#execute' do
+ subject(:service_result) do
+ described_class.new(
+ work_item: list_work_item,
+ current_user: current_user,
+ lock_version: list_work_item.lock_version,
+ task_params: params
+ ).execute
+ end
+
+ context 'when work item params are valid' do
+ it { is_expected.to be_success }
+
+ it 'deletes the work item and the related issue link' do
+ expect do
+ service_result
+ end.to change(WorkItem, :count).by(-1).and(
+ change(IssueLink, :count).by(-1)
+ )
+ end
+
+ it 'removes the task list item with the work item reference' do
+ expect do
+ service_result
+ end.to change(list_work_item, :description).from(list_work_item.description).to('')
+ end
+ end
+
+ context 'when first operation fails' do
+ let(:line_number_start) { -1 }
+
+ it_behaves_like 'failing WorkItems::DeleteTaskService', 'line_number_start must be greater than 0'
+ end
+
+ context 'when last operation fails' do
+ let_it_be(:non_member_user) { create(:user) }
+
+ let(:current_user) { non_member_user }
+
+ it_behaves_like 'failing WorkItems::DeleteTaskService', 'User not authorized to delete work item'
+ end
+ end
+end
diff --git a/spec/services/work_items/task_list_reference_removal_service_spec.rb b/spec/services/work_items/task_list_reference_removal_service_spec.rb
new file mode 100644
index 00000000000..bca72da0efa
--- /dev/null
+++ b/spec/services/work_items/task_list_reference_removal_service_spec.rb
@@ -0,0 +1,151 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::TaskListReferenceRemovalService do
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:project) { create(:project, :repository).tap { |project| project.add_developer(developer) } }
+ let_it_be(:task) { create(:work_item, project: project) }
+ let_it_be(:single_line_work_item, refind: true) do
+ create(:work_item, project: project, description: "- [ ] #{task.to_reference}+ single line")
+ end
+
+ let_it_be(:multiple_line_work_item, refind: true) do
+ create(
+ :work_item,
+ project: project,
+ description: <<~MARKDOWN
+ Any text
+
+ * [ ] Item to be converted
+ #{task.to_reference}+ second line
+ third line
+ * [x] task
+
+ More text
+ MARKDOWN
+ )
+ end
+
+ let(:line_number_start) { 3 }
+ let(:line_number_end) { 5 }
+ let(:work_item) { multiple_line_work_item }
+ let(:lock_version) { work_item.lock_version }
+
+ shared_examples 'successful work item task reference removal service' do |expected_description|
+ it { is_expected.to be_success }
+
+ it 'removes the task list item containing the task reference' do
+ expect do
+ result
+ end.to change(work_item, :description).from(work_item.description).to(expected_description)
+ end
+
+ it 'creates system notes' do
+ expect do
+ result
+ end.to change(Note, :count).by(1)
+
+ expect(Note.last.note).to include('changed the description')
+ end
+ end
+
+ shared_examples 'failing work item task reference removal service' do |error_message|
+ it { is_expected.to be_error }
+
+ it 'does not change the work item description' do
+ expect do
+ result
+ work_item.reload
+ end.to not_change(work_item, :description)
+ end
+
+ it 'returns an error message' do
+ expect(result.errors).to contain_exactly(error_message)
+ end
+ end
+
+ describe '#execute' do
+ subject(:result) do
+ described_class.new(
+ work_item: work_item,
+ task: task,
+ line_number_start: line_number_start,
+ line_number_end: line_number_end,
+ lock_version: lock_version,
+ current_user: developer
+ ).execute
+ end
+
+ context 'when task mardown spans a single line' do
+ let(:line_number_start) { 1 }
+ let(:line_number_end) { 1 }
+ let(:work_item) { single_line_work_item }
+
+ it_behaves_like 'successful work item task reference removal service', ''
+
+ context 'when description does not contain a task' do
+ let_it_be(:no_matching_work_item) { create(:work_item, project: project, description: 'no matching task') }
+
+ let(:work_item) { no_matching_work_item }
+
+ it_behaves_like 'failing work item task reference removal service', 'Unable to detect a task on lines 1-1'
+ end
+
+ context 'when description reference does not exactly match the task reference' do
+ before do
+ work_item.update!(description: work_item.description.gsub(task.to_reference, "#{task.to_reference}200"))
+ end
+
+ it_behaves_like 'failing work item task reference removal service', 'Unable to detect a task on lines 1-1'
+ end
+ end
+
+ context 'when task mardown spans multiple lines' do
+ it_behaves_like 'successful work item task reference removal service', "Any text\n\n* [x] task\n\nMore text"
+ end
+
+ context 'when updating the work item fails' do
+ before do
+ work_item.title = nil
+ end
+
+ it_behaves_like 'failing work item task reference removal service', "Title can't be blank"
+ end
+
+ context 'when description is empty' do
+ let_it_be(:empty_work_item) { create(:work_item, project: project, description: '') }
+
+ let(:work_item) { empty_work_item }
+
+ it_behaves_like 'failing work item task reference removal service', "Work item description can't be blank"
+ end
+
+ context 'when line_number_start is lower than 1' do
+ let(:line_number_start) { 0 }
+
+ it_behaves_like 'failing work item task reference removal service', 'line_number_start must be greater than 0'
+ end
+
+ context 'when line_number_end is lower than line_number_start' do
+ let(:line_number_end) { line_number_start - 1 }
+
+ it_behaves_like 'failing work item task reference removal service',
+ 'line_number_end must be greater or equal to line_number_start'
+ end
+
+ context 'when lock_version is older than current' do
+ let(:lock_version) { work_item.lock_version - 1 }
+
+ it_behaves_like 'failing work item task reference removal service', 'Stale work item. Check lock version'
+ end
+
+ context 'when work item is stale before updating' do
+ it_behaves_like 'failing work item task reference removal service', 'Stale work item. Check lock version' do
+ before do
+ ::WorkItem.where(id: work_item.id).update_all(lock_version: lock_version + 1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 88f10cc2a01..e49e82f6ab6 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -199,6 +199,7 @@ RSpec.configure do |config|
config.include SidekiqMiddleware
config.include StubActionCableConnection, type: :channel
config.include StubSpamServices
+ config.include RenderedHelpers
config.include RSpec::Benchmark::Matchers, type: :benchmark
include StubFeatureFlags
@@ -292,9 +293,6 @@ RSpec.configure do |config|
# tests, until we introduce it in user settings
stub_feature_flags(forti_token_cloud: false)
- # Disable for now whilst we add more states
- stub_feature_flags(restructured_mr_widget: false)
-
# These feature flag are by default disabled and used in disaster recovery mode
stub_feature_flags(ci_queueing_disaster_recovery_disable_fair_scheduling: false)
stub_feature_flags(ci_queueing_disaster_recovery_disable_quota: false)
diff --git a/spec/support/database/query_analyzer.rb b/spec/support/database/query_analyzer.rb
index 6d6627d54b9..aaa1b3516a3 100644
--- a/spec/support/database/query_analyzer.rb
+++ b/spec/support/database/query_analyzer.rb
@@ -6,13 +6,17 @@
RSpec.configure do |config|
config.before do |example|
if example.metadata.fetch(:query_analyzers, true)
- ::Gitlab::Database::QueryAnalyzer.instance.begin!
+ ::Gitlab::Database::QueryAnalyzer.instance.begin!(
+ ::Gitlab::Database::QueryAnalyzer.instance.all_analyzers
+ )
end
end
config.after do |example|
if example.metadata.fetch(:query_analyzers, true)
- ::Gitlab::Database::QueryAnalyzer.instance.end!
+ ::Gitlab::Database::QueryAnalyzer.instance.end!(
+ ::Gitlab::Database::QueryAnalyzer.instance.all_analyzers
+ )
end
end
end
diff --git a/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb b/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb
index 4624a8ac82a..e02bf66507a 100644
--- a/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb
+++ b/spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb
@@ -5,6 +5,10 @@ RSpec.shared_examples 'a correct instrumented metric value' do |params|
let(:options) { params[:options] }
let(:metric) { described_class.new(time_frame: time_frame, options: options) }
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
if described_class.respond_to?(:relation) && described_class.relation.respond_to?(:connection)
allow(described_class.relation.connection).to receive(:transaction_open?).and_return(false)
diff --git a/spec/support/graphql/arguments.rb b/spec/support/graphql/arguments.rb
index 20e940030f8..a5bb01c31a3 100644
--- a/spec/support/graphql/arguments.rb
+++ b/spec/support/graphql/arguments.rb
@@ -40,7 +40,7 @@ module Graphql
when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]"
when Hash then "{#{new(value)}}"
when Integer, Float, Symbol then value.to_s
- when String then "\"#{value.gsub(/"/, '\\"')}\""
+ when String, GlobalID then "\"#{value.to_s.gsub(/"/, '\\"')}\""
when Time, Date then "\"#{value.iso8601}\""
when nil then 'null'
when true then 'true'
@@ -49,7 +49,7 @@ module Graphql
value.to_graphql_value
end
rescue NoMethodError
- raise ArgumentError, "Cannot represent #{value} as GraphQL literal"
+ raise ArgumentError, "Cannot represent #{value} (instance of #{value.class}) as GraphQL literal"
end
def merge(other)
diff --git a/spec/support/helpers/database/migration_testing_helpers.rb b/spec/support/helpers/database/migration_testing_helpers.rb
new file mode 100644
index 00000000000..916446e66b7
--- /dev/null
+++ b/spec/support/helpers/database/migration_testing_helpers.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Database
+ module MigrationTestingHelpers
+ def define_background_migration(name)
+ klass = Class.new do
+ # Can't simply def perform here as we won't have access to the block,
+ # similarly can't define_method(:perform, &block) here as it would change the block receiver
+ define_method(:perform) { |*args| yield(*args) }
+ end
+ stub_const("Gitlab::BackgroundMigration::#{name}", klass)
+ klass
+ end
+
+ def expect_migration_call_counts(migrations_to_calls)
+ migrations_to_calls.each do |migration, calls|
+ expect_next_instances_of(migration, calls) do |m|
+ expect(m).to receive(:perform).and_call_original
+ end
+ end
+ end
+
+ def expect_recorded_migration_runs(migrations_to_runs)
+ migrations_to_runs.each do |migration, runs|
+ path = File.join(result_dir, migration.name.demodulize)
+ if runs.zero?
+ expect(Pathname(path)).not_to be_exist
+ else
+ num_subdirs = Pathname(path).children.count(&:directory?)
+ expect(num_subdirs).to eq(runs)
+ end
+ end
+ end
+
+ def expect_migration_runs(migrations_to_run_counts)
+ expect_migration_call_counts(migrations_to_run_counts)
+
+ yield
+
+ expect_recorded_migration_runs(migrations_to_run_counts)
+ end
+ end
+end
diff --git a/spec/support/helpers/features/snippet_helpers.rb b/spec/support/helpers/features/snippet_helpers.rb
index dc718b1b212..3e32b0e4c67 100644
--- a/spec/support/helpers/features/snippet_helpers.rb
+++ b/spec/support/helpers/features/snippet_helpers.rb
@@ -67,14 +67,19 @@ module Spec
end
def snippet_fill_in_form(title: nil, content: nil, file_name: nil, description: nil, visibility: nil)
+ if content
+ snippet_fill_in_content(content)
+ # It takes some time after sending keys for the vue component to
+ # update so let Capybara wait for the content before proceeding
+ expect(page).to have_content(content)
+ end
+
snippet_fill_in_title(title) if title
snippet_fill_in_description(description) if description
snippet_fill_in_file_name(file_name) if file_name
- snippet_fill_in_content(content) if content
-
snippet_fill_in_visibility(visibility) if visibility
end
end
diff --git a/spec/support/helpers/features/sorting_helpers.rb b/spec/support/helpers/features/sorting_helpers.rb
index a6428bf8573..50b8083ebb3 100644
--- a/spec/support/helpers/features/sorting_helpers.rb
+++ b/spec/support/helpers/features/sorting_helpers.rb
@@ -21,6 +21,14 @@ module Spec
click_link(value)
end
end
+
+ # pajamas_sort_by is used to sort new pajamas dropdowns. When
+ # all of the dropdowns are converted, pajamas_sort_by can be renamed to sort_by
+ # https://gitlab.com/groups/gitlab-org/-/epics/7551
+ def pajamas_sort_by(value)
+ find('.filter-dropdown-container .dropdown').click
+ find('.dropdown-item', text: value).click
+ end
end
end
end
diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb
index b6cf78b9046..93122ca3d0c 100644
--- a/spec/support/helpers/filtered_search_helpers.rb
+++ b/spec/support/helpers/filtered_search_helpers.rb
@@ -187,4 +187,116 @@ module FilteredSearchHelpers
toggle.click if toggle.visible?
end
end
+
+ ##
+ # For use with gl-filtered-search
+ def select_tokens(*args, submit: false)
+ within '[data-testid="filtered-search-input"]' do
+ find_field('Search').click
+
+ args.each do |token|
+ # Move mouse away to prevent invoking tooltips on usernames, which blocks the search input
+ find_button('Search').hover
+
+ if token == '='
+ click_on '= is'
+ else
+ click_on token
+ end
+
+ wait_for_requests
+ end
+ end
+
+ if submit
+ send_keys :enter
+ end
+ end
+
+ def get_suggestion_count
+ all('.gl-filtered-search-suggestion').size
+ end
+
+ def submit_search_term(value)
+ click_filtered_search_bar
+ send_keys(value, :enter)
+ end
+
+ def click_filtered_search_bar
+ find('.gl-filtered-search-last-item').click
+ end
+
+ def click_token_segment(value)
+ find('.gl-filtered-search-token-segment', text: value).click
+ end
+
+ def expect_visible_suggestions_list
+ expect(page).to have_css('.gl-filtered-search-suggestion-list')
+ end
+
+ def expect_hidden_suggestions_list
+ expect(page).not_to have_css('.gl-filtered-search-suggestion-list')
+ end
+
+ def expect_suggestion(value)
+ expect(page).to have_css('.gl-filtered-search-suggestion', text: value)
+ end
+
+ def expect_no_suggestion(value)
+ expect(page).not_to have_css('.gl-filtered-search-suggestion', text: value)
+ end
+
+ def expect_suggestion_count(count)
+ expect(page).to have_css('.gl-filtered-search-suggestion', count: count)
+ end
+
+ def expect_assignee_token(value)
+ expect(page).to have_css '.gl-filtered-search-token', text: "Assignee = #{value}"
+ end
+
+ def expect_author_token(value)
+ expect(page).to have_css '.gl-filtered-search-token', text: "Author = #{value}"
+ end
+
+ def expect_label_token(value)
+ expect(page).to have_css '.gl-filtered-search-token', text: "Label = ~#{value}"
+ end
+
+ def expect_negated_label_token(value)
+ expect(page).to have_css '.gl-filtered-search-token', text: "Label != ~#{value}"
+ end
+
+ def expect_milestone_token(value)
+ expect(page).to have_css '.gl-filtered-search-token', text: "Milestone = %#{value}"
+ end
+
+ def expect_negated_milestone_token(value)
+ expect(page).to have_css '.gl-filtered-search-token', text: "Milestone != %#{value}"
+ end
+
+ def expect_epic_token(value)
+ expect(page).to have_css '.gl-filtered-search-token', text: "Epic = #{value}"
+ end
+
+ def expect_search_term(value)
+ value.split(' ').each do |term|
+ expect(page).to have_css '.gl-filtered-search-term', text: term
+ end
+ end
+
+ def expect_empty_search_term
+ expect(page).to have_css '.gl-filtered-search-term', text: ''
+ end
+
+ def expect_token_segment(value)
+ expect(page).to have_css '.gl-filtered-search-token-segment', text: value
+ end
+
+ def expect_recent_searches_history_item(value)
+ expect(page).to have_css '.gl-search-box-by-click-history-item', text: value
+ end
+
+ def expect_recent_searches_history_item_count(count)
+ expect(page).to have_css '.gl-search-box-by-click-history-item', count: count
+ end
end
diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb
index 0ad83bdeeb2..264281ef94a 100644
--- a/spec/support/helpers/gitaly_setup.rb
+++ b/spec/support/helpers/gitaly_setup.rb
@@ -31,6 +31,10 @@ module GitalySetup
expand_path('tmp/tests/gitaly')
end
+ def runtime_dir
+ expand_path('tmp/run')
+ end
+
def tmp_tests_gitaly_bin_dir
File.join(tmp_tests_gitaly_dir, '_build', 'bin')
end
@@ -102,12 +106,14 @@ module GitalySetup
Gitlab.config.repositories.storages[REPOS_STORAGE].legacy_disk_path
end
- def service_binary(service)
+ def service_cmd(service, toml = nil)
+ toml ||= config_path(service)
+
case service
when :gitaly, :gitaly2
- 'gitaly'
+ [File.join(tmp_tests_gitaly_bin_dir, 'gitaly'), toml]
when :praefect
- 'praefect'
+ [File.join(tmp_tests_gitaly_bin_dir, 'praefect'), '-config', toml]
end
end
@@ -132,14 +138,18 @@ module GitalySetup
end
def start_praefect
- start(:praefect)
+ if ENV['GITALY_PRAEFECT_WITH_DB']
+ LOGGER.debug 'Starting Praefect with database election strategy'
+ start(:praefect, File.join(tmp_tests_gitaly_dir, 'praefect-db.config.toml'))
+ else
+ LOGGER.debug 'Starting Praefect with in-memory election strategy'
+ start(:praefect)
+ end
end
def start(service, toml = nil)
toml ||= config_path(service)
- args = ["#{tmp_tests_gitaly_bin_dir}/#{service_binary(service)}"]
- args.push("-config") if service == :praefect
- args.push(toml)
+ args = service_cmd(service, toml)
# Ensure user configuration does not affect Git
# Context: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58776#note_547613780
@@ -259,6 +269,7 @@ module GitalySetup
{ 'default' => repos_path },
force: true,
options: {
+ runtime_dir: runtime_dir,
prometheus_listen_addr: 'localhost:9236'
}
)
@@ -267,12 +278,47 @@ module GitalySetup
{ 'default' => repos_path },
force: true,
options: {
- runtime_dir: File.join(gitaly_dir, "run2"),
+ runtime_dir: runtime_dir,
gitaly_socket: "gitaly2.socket",
config_filename: "gitaly2.config.toml"
}
)
- Gitlab::SetupHelper::Praefect.create_configuration(gitaly_dir, { 'praefect' => repos_path }, force: true)
+
+ # In CI we need to pre-generate both config files.
+ # For local testing we'll create the correct file on-demand.
+ if ENV['CI'] || ENV['GITALY_PRAEFECT_WITH_DB'].nil?
+ Gitlab::SetupHelper::Praefect.create_configuration(
+ gitaly_dir,
+ { 'praefect' => repos_path },
+ force: true
+ )
+ end
+
+ if ENV['CI'] || ENV['GITALY_PRAEFECT_WITH_DB']
+ Gitlab::SetupHelper::Praefect.create_configuration(
+ gitaly_dir,
+ { 'praefect' => repos_path },
+ force: true,
+ options: {
+ per_repository: true,
+ config_filename: 'praefect-db.config.toml',
+ pghost: ENV['CI'] ? 'postgres' : ENV.fetch('PGHOST'),
+ pgport: ENV['CI'] ? 5432 : ENV.fetch('PGPORT').to_i,
+ pguser: ENV['CI'] ? 'postgres' : ENV.fetch('USER')
+ }
+ )
+ end
+
+ # In CI no database is running when Gitaly is set up
+ # so scripts/gitaly-test-spawn will take care of it instead.
+ setup_praefect unless ENV['CI']
+ end
+
+ def setup_praefect
+ return unless ENV['GITALY_PRAEFECT_WITH_DB']
+
+ migrate_cmd = service_cmd(:praefect, File.join(tmp_tests_gitaly_dir, 'praefect-db.config.toml')) + ['sql-migrate']
+ system(env, *migrate_cmd, [:out, :err] => 'log/praefect-test.log')
end
def socket_path(service)
@@ -325,7 +371,7 @@ module GitalySetup
message += "- The `praefect` binary does not exist: #{praefect_binary}\n" unless File.exist?(praefect_binary)
message += "- The `git` binary does not exist: #{git_binary}\n" unless File.exist?(git_binary)
- message += "\nCheck log/gitaly-test.log for errors.\n"
+ message += "\nCheck log/gitaly-test.log & log/praefect-test.log for errors.\n"
unless ENV['CI']
message += "\nIf binaries are missing, try running `make -C tmp/tests/gitaly all WITH_BUNDLED_GIT=YesPlease`.\n"
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index ff8908e531a..db8d45f61ea 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -244,15 +244,16 @@ module GraphqlHelpers
def graphql_mutation(name, input, fields = nil, &block)
raise ArgumentError, 'Please pass either `fields` parameter or a block to `#graphql_mutation`, but not both.' if fields.present? && block_given?
+ name = name.graphql_name if name.respond_to?(:graphql_name)
mutation_name = GraphqlHelpers.fieldnamerize(name)
input_variable_name = "$#{input_variable_name_for_mutation(name)}"
mutation_field = GitlabSchema.mutation.fields[mutation_name]
fields = yield if block_given?
- fields ||= all_graphql_fields_for(mutation_field.type.to_graphql)
+ fields ||= all_graphql_fields_for(mutation_field.type.to_type_signature)
query = <<~MUTATION
- mutation(#{input_variable_name}: #{mutation_field.arguments['input'].type.to_graphql}) {
+ mutation(#{input_variable_name}: #{mutation_field.arguments['input'].type.to_type_signature}) {
#{mutation_name}(input: #{input_variable_name}) {
#{fields}
}
@@ -264,7 +265,7 @@ module GraphqlHelpers
end
def variables_for_mutation(name, input)
- graphql_input = prepare_input_for_mutation(input)
+ graphql_input = prepare_variables(input)
{ input_variable_name_for_mutation(name) => graphql_input }
end
@@ -273,25 +274,35 @@ module GraphqlHelpers
return unless variables
return variables if variables.is_a?(String)
- ::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json
+ # Combine variables into a single hash.
+ hash = ::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h))
+
+ prepare_variables(hash).to_json
end
- # Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys
+ # Recursively convert any ruby object we can pass as a variable value
+ # to an object we can serialize with JSON, using fieldname-style keys
#
- # prepare_input_for_mutation({ 'my_key' => 1 })
- # => { 'myKey' => 1}
- def prepare_input_for_mutation(input)
- input.to_h do |name, value|
- value = prepare_input_for_mutation(value) if value.is_a?(Hash)
+ # prepare_variables({ 'my_key' => 1 })
+ # => { 'myKey' => 1 }
+ # prepare_variables({ enums: [:FOO, :BAR], user_id: global_id_of(user) })
+ # => { 'enums' => ['FOO', 'BAR'], 'userId' => "gid://User/123" }
+ # prepare_variables({ nested: { hash_values: { are_supported: true } } })
+ # => { 'nested' => { 'hashValues' => { 'areSupported' => true } } }
+ def prepare_variables(input)
+ return input.map { prepare_variables(_1) } if input.is_a?(Array)
+ return input.to_s if input.is_a?(GlobalID) || input.is_a?(Symbol)
+ return input unless input.is_a?(Hash)
- [GraphqlHelpers.fieldnamerize(name), value]
+ input.to_h do |name, value|
+ [GraphqlHelpers.fieldnamerize(name), prepare_variables(value)]
end
end
def input_variable_name_for_mutation(mutation_name)
mutation_name = GraphqlHelpers.fieldnamerize(mutation_name)
mutation_field = GitlabSchema.mutation.fields[mutation_name]
- input_type = field_type(mutation_field.arguments['input'])
+ input_type = mutation_field.arguments['input'].type.unwrap.to_type_signature
GraphqlHelpers.fieldnamerize(input_type)
end
@@ -346,6 +357,10 @@ module GraphqlHelpers
end
end
+ def query_double(schema:)
+ double('query', schema: schema)
+ end
+
def wrap_fields(fields)
fields = Array.wrap(fields).map do |field|
case field
@@ -646,11 +661,11 @@ module GraphqlHelpers
end
end
- def global_id_of(model, id: nil, model_name: nil)
+ def global_id_of(model = nil, id: nil, model_name: nil)
if id || model_name
- ::Gitlab::GlobalId.build(model, id: id, model_name: model_name).to_s
+ ::Gitlab::GlobalId.as_global_id(id || model.id, model_name: model_name || model.class.name)
else
- model.to_global_id.to_s
+ model.to_global_id
end
end
@@ -683,26 +698,94 @@ module GraphqlHelpers
end
end
- # assumes query_string to be let-bound in the current context
- def execute_query(query_type, schema: empty_schema, graphql: query_string)
+ # assumes query_string and user to be let-bound in the current context
+ def execute_query(query_type, schema: empty_schema, graphql: query_string, raise_on_error: false)
schema.query(query_type)
- schema.execute(
+ r = schema.execute(
graphql,
context: { current_user: user },
variables: {}
)
+
+ if raise_on_error && r.to_h['errors'].present?
+ raise NoData, r.to_h['errors']
+ end
+
+ r
end
def empty_schema
Class.new(GraphQL::Schema) do
use GraphQL::Pagination::Connections
use Gitlab::Graphql::Pagination::Connections
+ use BatchLoader::GraphQL
lazy_resolve ::Gitlab::Graphql::Lazy, :force
end
end
+ # Wrapper around a_hash_including that supports unpacking with **
+ class UnpackableMatcher < SimpleDelegator
+ include RSpec::Matchers
+
+ attr_reader :to_hash
+
+ def initialize(hash)
+ @to_hash = hash
+ super(a_hash_including(hash))
+ end
+
+ def to_json(_opts = {})
+ to_hash.to_json
+ end
+
+ def as_json(opts = {})
+ to_hash.as_json(opts)
+ end
+ end
+
+ # Construct a matcher for GraphQL entity response objects, of the form
+ # `{ "id" => "some-gid" }`.
+ #
+ # Usage:
+ #
+ # ```ruby
+ # expect(graphql_data_at(:path, :to, :entity)).to match a_graphql_entity_for(user)
+ # ```
+ #
+ # This can be called as:
+ #
+ # ```ruby
+ # a_graphql_entity_for(project, :full_path) # also checks that `entity['fullPath'] == project.full_path
+ # a_graphql_entity_for(project, full_path: 'some/path') # same as above, with explicit values
+ # a_graphql_entity_for(user, :username, foo: 'bar') # combinations of the above
+ # a_graphql_entity_for(foo: 'bar') # if properties are defined, the model is not necessary
+ # ```
+ #
+ # Note that the model instance must not be nil, unless some properties are
+ # explicitly passed in. The following are rejected with `ArgumentError`:
+ #
+ # ```
+ # a_graphql_entity_for(nil, :username)
+ # a_graphql_entity_for(:username)
+ # a_graphql_entity_for
+ # ```
+ #
+ def a_graphql_entity_for(model = nil, *fields, **attrs)
+ raise ArgumentError, 'model is nil' if model.nil? && fields.any?
+
+ attrs.transform_keys! { GraphqlHelpers.fieldnamerize(_1) }
+ attrs['id'] = global_id_of(model).to_s if model
+ fields.each do |name|
+ attrs[GraphqlHelpers.fieldnamerize(name)] = model.public_send(name)
+ end
+
+ raise ArgumentError, 'no attributes' if attrs.empty?
+
+ UnpackableMatcher.new(attrs)
+ end
+
# A lookahead that selects everything
def positive_lookahead
double(selects?: true).tap do |selection|
diff --git a/spec/support/helpers/migrations_helpers.rb b/spec/support/helpers/migrations_helpers.rb
index afa7ee84bda..60097e301c4 100644
--- a/spec/support/helpers/migrations_helpers.rb
+++ b/spec/support/helpers/migrations_helpers.rb
@@ -1,12 +1,18 @@
# frozen_string_literal: true
module MigrationsHelpers
- def active_record_base
- Gitlab::Database.database_base_models.fetch(self.class.metadata[:database] || :main)
+ def active_record_base(database: nil)
+ database_name = database || self.class.metadata[:database] || :main
+
+ unless Gitlab::Database::DATABASE_NAMES.include?(database_name.to_s)
+ raise ArgumentError, "#{database_name} is not a valid argument"
+ end
+
+ Gitlab::Database.database_base_models[database_name] || Gitlab::Database.database_base_models[:main]
end
- def table(name)
- Class.new(active_record_base) do
+ def table(name, database: nil)
+ Class.new(active_record_base(database: database)) do
self.table_name = name
self.inheritance_column = :_type_disabled
@@ -150,6 +156,13 @@ module MigrationsHelpers
end
def migrate!
+ open_transactions = ActiveRecord::Base.connection.open_transactions
+ allow_next_instance_of(described_class) do |migration|
+ allow(migration).to receive(:transaction_open?) do
+ ActiveRecord::Base.connection.open_transactions > open_transactions
+ end
+ end
+
migration_context.up do |migration|
migration.name == described_class.name
end
diff --git a/spec/support/helpers/namespaces_test_helper.rb b/spec/support/helpers/namespaces_test_helper.rb
new file mode 100644
index 00000000000..9762c38a9bb
--- /dev/null
+++ b/spec/support/helpers/namespaces_test_helper.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module NamespacesTestHelper
+ def get_buy_minutes_path(namespace)
+ buy_minutes_subscriptions_path(selected_group: namespace.id)
+ end
+
+ def get_buy_storage_path(namespace)
+ buy_storage_subscriptions_path(selected_group: namespace.id)
+ end
+end
+
+NamespacesTestHelper.prepend_mod
diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb
index 315303401cc..e11548d0b75 100644
--- a/spec/support/helpers/navbar_structure_helper.rb
+++ b/spec/support/helpers/navbar_structure_helper.rb
@@ -93,7 +93,7 @@ module NavbarStructureHelper
)
end
- def analytics_sub_nav_item
+ def project_analytics_sub_nav_item
[
_('Value stream'),
_('CI/CD'),
@@ -102,6 +102,12 @@ module NavbarStructureHelper
_('Repository')
]
end
+
+ def group_analytics_sub_nav_item
+ [
+ _('Contribution')
+ ]
+ end
end
NavbarStructureHelper.prepend_mod
diff --git a/spec/support/helpers/next_instance_of.rb b/spec/support/helpers/next_instance_of.rb
index 95d8936588c..461d411a5ce 100644
--- a/spec/support/helpers/next_instance_of.rb
+++ b/spec/support/helpers/next_instance_of.rb
@@ -22,9 +22,15 @@ module NextInstanceOf
def stub_new(target, number, ordered = false, *new_args, &blk)
receive_new = receive(:new)
receive_new.ordered if ordered
- receive_new.exactly(number).times if number
receive_new.with(*new_args) if new_args.any?
+ if number.is_a?(Range)
+ receive_new.at_least(number.begin).times if number.begin
+ receive_new.at_most(number.end).times if number.end
+ elsif number
+ receive_new.exactly(number).times
+ end
+
target.to receive_new.and_wrap_original do |method, *original_args|
method.call(*original_args).tap(&blk)
end
diff --git a/spec/support/helpers/project_helpers.rb b/spec/support/helpers/project_helpers.rb
index 89f0163b4b6..2ea6405e48c 100644
--- a/spec/support/helpers/project_helpers.rb
+++ b/spec/support/helpers/project_helpers.rb
@@ -24,4 +24,20 @@ module ProjectHelpers
project.update!(params)
end
+
+ def create_project_with_statistics(namespace = nil, with_data: false, size_multiplier: 1)
+ project = namespace.present? ? create(:project, namespace: namespace) : create(:project)
+ project.tap do |p|
+ create(:project_statistics, project: p, with_data: with_data, size_multiplier: size_multiplier)
+ end
+ end
+
+ def grace_months_after_deletion_notification
+ (::Gitlab::CurrentSettings.inactive_projects_delete_after_months -
+ ::Gitlab::CurrentSettings.inactive_projects_send_warning_email_after_months).months
+ end
+
+ def deletion_date
+ Date.parse(grace_months_after_deletion_notification.from_now.to_s).to_s
+ end
end
diff --git a/spec/support/helpers/query_recorder.rb b/spec/support/helpers/query_recorder.rb
index d18a1d23584..01839a74e65 100644
--- a/spec/support/helpers/query_recorder.rb
+++ b/spec/support/helpers/query_recorder.rb
@@ -80,7 +80,8 @@ module ActiveRecord
if values[:cached] && skip_cached
@cached << values[:sql]
- elsif !skip_schema_queries || !values[:name]&.include?("SCHEMA")
+ elsif !ignorable?(values)
+
backtrace = @query_recorder_debug ? show_backtrace(values, duration) : nil
@log << values[:sql]
store_sql_by_source(values: values, duration: duration, backtrace: backtrace)
@@ -102,5 +103,12 @@ module ActiveRecord
def occurrences
@occurrences ||= @log.group_by(&:to_s).transform_values(&:count)
end
+
+ def ignorable?(values)
+ return true if skip_schema_queries && values[:name]&.include?("SCHEMA")
+ return true if values[:name]&.match(/License Load/)
+
+ false
+ end
end
end
diff --git a/spec/support/helpers/rendered_helpers.rb b/spec/support/helpers/rendered_helpers.rb
new file mode 100644
index 00000000000..137b7d5f708
--- /dev/null
+++ b/spec/support/helpers/rendered_helpers.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module RenderedHelpers
+ # Wraps the `rendered` in `expect` to make it the target of an expectation.
+ # Designed to read nicely for one-liners.
+ # rubocop:disable RSpec/VoidExpect
+ def expect_rendered
+ render
+ expect(rendered)
+ end
+ # rubocop:enable RSpec/VoidExpect
+end
diff --git a/spec/support/helpers/saas_test_helper.rb b/spec/support/helpers/saas_test_helper.rb
new file mode 100644
index 00000000000..a8162603cd9
--- /dev/null
+++ b/spec/support/helpers/saas_test_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module SaasTestHelper
+ def get_next_url
+ "https://next.gitlab.com"
+ end
+end
+
+SaasTestHelper.prepend_mod
diff --git a/spec/support/helpers/stub_feature_flags.rb b/spec/support/helpers/stub_feature_flags.rb
index 77f31169ecb..f1654e55b7e 100644
--- a/spec/support/helpers/stub_feature_flags.rb
+++ b/spec/support/helpers/stub_feature_flags.rb
@@ -70,4 +70,18 @@ module StubFeatureFlags
def skip_default_enabled_yaml_check
allow(Feature::Definition).to receive(:default_enabled?).and_return(false)
end
+
+ def stub_feature_flag_definition(name, opts = {})
+ opts = opts.with_defaults(
+ name: name,
+ type: 'development',
+ default_enabled: false
+ )
+
+ Feature::Definition.new("#{opts[:type]}/#{name}.yml", opts).tap do |definition|
+ all_definitions = Feature::Definition.definitions
+ all_definitions[definition.key] = definition
+ allow(Feature::Definition).to receive(:definitions).and_return(all_definitions)
+ end
+ end
end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index d49a14f7f5b..024f06cae1b 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -7,11 +7,6 @@ module StubObjectStorage
**params)
end
- def stub_object_storage_pseudonymizer
- stub_object_storage(connection_params: Pseudonymizer::Uploader.object_store_credentials,
- remote_directory: Pseudonymizer::Uploader.remote_directory)
- end
-
def stub_object_storage_uploader(
config:,
uploader:,
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index d81d0d436a1..11f469c1d27 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -374,7 +374,7 @@ module TestEnv
end
def seed_db
- Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.import
+ Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.upsert_types
end
private
diff --git a/spec/support/helpers/trial_status_widget_test_helper.rb b/spec/support/helpers/trial_status_widget_test_helper.rb
new file mode 100644
index 00000000000..d75620d17ee
--- /dev/null
+++ b/spec/support/helpers/trial_status_widget_test_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module TrialStatusWidgetTestHelper
+ def purchase_href(group)
+ new_subscriptions_path(namespace_id: group.id, plan_id: 'ultimate-plan-id')
+ end
+end
+
+TrialStatusWidgetTestHelper.prepend_mod
diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb
index 83bda6e03b1..6f22df9ae0f 100644
--- a/spec/support/helpers/workhorse_helpers.rb
+++ b/spec/support/helpers/workhorse_helpers.rb
@@ -114,16 +114,18 @@ module WorkhorseHelpers
end
params["#{key}.remote_id"] = file.remote_id if file.respond_to?(:remote_id) && file.remote_id.present?
+ params["#{key}.sha256"] = file.sha256 if file.respond_to?(:sha256) && file.sha256.present?
end
end
- def fog_to_uploaded_file(file)
+ def fog_to_uploaded_file(file, sha256: nil)
filename = File.basename(file.key)
UploadedFile.new(nil,
filename: filename,
remote_id: filename,
- size: file.content_length
+ size: file.content_length,
+ sha256: sha256
)
end
end
diff --git a/spec/support/helpers/workhorse_lfs_helpers.rb b/spec/support/helpers/workhorse_lfs_helpers.rb
new file mode 100644
index 00000000000..c9644826317
--- /dev/null
+++ b/spec/support/helpers/workhorse_lfs_helpers.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module WorkhorseLfsHelpers
+ extend self
+
+ def put_finalize(
+ lfs_tmp = nil, with_tempfile: false, verified: true, remote_object: nil,
+ args: {}, to_project: nil, size: nil, sha256: nil)
+
+ lfs_tmp ||= "#{sample_oid}012345678"
+ to_project ||= project
+ uploaded_file =
+ if with_tempfile
+ upload_path = LfsObjectUploader.workhorse_local_upload_path
+ file_path = upload_path + '/' + lfs_tmp
+
+ FileUtils.mkdir_p(upload_path)
+ FileUtils.touch(file_path)
+ File.truncate(file_path, sample_size)
+
+ UploadedFile.new(file_path, filename: File.basename(file_path), sha256: sample_oid)
+ elsif remote_object
+ fog_to_uploaded_file(remote_object, sha256: sample_oid)
+ else
+ UploadedFile.new(
+ nil,
+ size: size || sample_size,
+ sha256: sha256 || sample_oid,
+ remote_id: 'remote id'
+ )
+ end
+
+ finalize_headers = headers
+ finalize_headers.merge!(workhorse_internal_api_request_header) if verified
+
+ workhorse_finalize(
+ objects_url(to_project, sample_oid, sample_size),
+ method: :put,
+ file_key: :file,
+ params: args.merge(file: uploaded_file),
+ headers: finalize_headers,
+ send_rewritten_field: include_workhorse_jwt_header
+ )
+ end
+end
diff --git a/spec/support/import_export/common_util.rb b/spec/support/import_export/common_util.rb
index 1aa20dab6f8..9da151895a7 100644
--- a/spec/support/import_export/common_util.rb
+++ b/spec/support/import_export/common_util.rb
@@ -19,7 +19,7 @@ module ImportExport
end
def setup_reader(reader)
- if reader == :ndjson_reader && Feature.enabled?(:project_import_ndjson, default_enabled: true)
+ if reader == :ndjson_reader && Feature.enabled?(:project_import_ndjson)
allow_any_instance_of(Gitlab::ImportExport::Json::LegacyReader::File).to receive(:exist?).and_return(false)
allow_any_instance_of(Gitlab::ImportExport::Json::NdjsonReader).to receive(:exist?).and_return(true)
else
diff --git a/spec/support/matchers/background_migrations_matchers.rb b/spec/support/matchers/background_migrations_matchers.rb
index 1057639beec..b471323dd72 100644
--- a/spec/support/matchers/background_migrations_matchers.rb
+++ b/spec/support/matchers/background_migrations_matchers.rb
@@ -67,17 +67,6 @@ end
RSpec::Matchers.define :have_scheduled_batched_migration do |table_name: nil, column_name: nil, job_arguments: [], **attributes|
define_method :matches? do |migration|
- # Default arguments passed by BatchedMigrationWrapper (values don't matter here)
- expect(migration).to be_background_migration_with_arguments([
- _start_id = 1,
- _stop_id = 2,
- table_name,
- column_name,
- _sub_batch_size = 10,
- _pause_ms = 100,
- *job_arguments
- ])
-
batched_migrations =
Gitlab::Database::BackgroundMigration::BatchedMigration
.for_configuration(migration, table_name, column_name, job_arguments)
@@ -94,3 +83,11 @@ RSpec::Matchers.define :have_scheduled_batched_migration do |table_name: nil, co
expect(batched_migrations.count).to be(0)
end
end
+
+RSpec::Matchers.define :be_finalize_background_migration_of do |migration|
+ define_method :matches? do |klass|
+ expect_next_instance_of(klass) do |instance|
+ expect(instance).to receive(:finalize_background_migration).with(migration)
+ end
+ end
+end
diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb
index 3ba88c3ae71..e6d820104be 100644
--- a/spec/support/matchers/graphql_matchers.rb
+++ b/spec/support/matchers/graphql_matchers.rb
@@ -211,18 +211,13 @@ end
RSpec::Matchers.define :have_graphql_resolver do |expected|
match do |field|
- case expected
- when Method
- expect(field.type_class.resolve_proc).to eq(expected)
- else
- expect(field.type_class.resolver).to eq(expected)
- end
+ expect(field.resolver).to eq(expected)
end
end
RSpec::Matchers.define :have_graphql_extension do |expected|
match do |field|
- expect(field.type_class.extensions).to include(expected)
+ expect(field.extensions).to include(expected)
end
end
diff --git a/spec/support/matchers/make_queries.rb b/spec/support/matchers/make_queries.rb
new file mode 100644
index 00000000000..19c69240a40
--- /dev/null
+++ b/spec/support/matchers/make_queries.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+RSpec::Matchers.define :make_queries do |expected_count = nil|
+ supports_block_expectations
+
+ match do |block|
+ @recorder = ActiveRecord::QueryRecorder.new(&block)
+ @counter = @recorder.count
+ if expected_count
+ @counter == expected_count
+ else
+ @counter > 0
+ end
+ end
+
+ failure_message do |_|
+ if expected_count
+ "expected to make #{expected_count} queries but made #{@counter} queries"
+ else
+ "expected to make queries but did not make any"
+ end
+ end
+
+ failure_message_when_negated do |_|
+ if expected_count
+ "expected not to make #{expected_count} queries but received #{@counter} queries"
+ else
+ "expected not to make queries but received #{@counter} queries"
+ end
+ end
+end
diff --git a/spec/support/rspec.rb b/spec/support/rspec.rb
index b4a25fd121d..30e48b3baf1 100644
--- a/spec/support/rspec.rb
+++ b/spec/support/rspec.rb
@@ -14,6 +14,8 @@ require_relative "helpers/fast_rails_root"
require 'rubocop'
require 'rubocop/rspec/support'
+RSpec::Expectations.configuration.on_potential_false_positives = :raise
+
RSpec.configure do |config|
config.mock_with :rspec do |mocks|
mocks.verify_doubled_constant_names = true
diff --git a/spec/support/shared_contexts/email_shared_context.rb b/spec/support/shared_contexts/email_shared_context.rb
index 0dc66eeb2ee..086cdf50e9d 100644
--- a/spec/support/shared_contexts/email_shared_context.rb
+++ b/spec/support/shared_contexts/email_shared_context.rb
@@ -148,7 +148,7 @@ RSpec.shared_examples :note_handler_shared_examples do |forwardable|
end
it 'allows email to only have quoted text', if: forwardable do
- expect { receiver.execute }.not_to raise_error(Gitlab::Email::EmptyEmailError)
+ expect { receiver.execute }.not_to raise_error
end
end
diff --git a/spec/support/shared_contexts/graphql/requests/packages_shared_context.rb b/spec/support/shared_contexts/graphql/requests/packages_shared_context.rb
index 13e7ecf2669..b29a231f3a6 100644
--- a/spec/support/shared_contexts/graphql/requests/packages_shared_context.rb
+++ b/spec/support/shared_contexts/graphql/requests/packages_shared_context.rb
@@ -14,7 +14,7 @@ RSpec.shared_context 'package details setup' do
let(:user) { project.first_owner }
let(:package_details) { graphql_data_at(:package) }
let(:metadata_response) { graphql_data_at(:package, :metadata) }
- let(:first_file) { package.package_files.find { |f| global_id_of(f) == first_file_response['id'] } }
+ let(:first_file) { package.package_files.find { |f| a_graphql_entity_for(f).matches?(first_file_response) } }
let(:package_files_response) { graphql_data_at(:package, :package_files, :nodes) }
let(:first_file_response) { graphql_data_at(:package, :package_files, :nodes, 0)}
let(:first_file_response_metadata) { graphql_data_at(:package, :package_files, :nodes, 0, :file_metadata)}
diff --git a/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb
index b7966e25b38..7d51c90522a 100644
--- a/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb
+++ b/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb
@@ -57,7 +57,8 @@ RSpec.shared_context 'structured_logger' do
'job_status' => 'done',
'duration_s' => 0.0,
'completed_at' => timestamp.to_f,
- 'cpu_s' => 1.111112
+ 'cpu_s' => 1.111112,
+ 'rate_limiting_gates' => []
)
end
diff --git a/spec/support/shared_contexts/models/concerns/integrations/enable_ssl_verification_shared_context.rb b/spec/support/shared_contexts/models/concerns/integrations/enable_ssl_verification_shared_context.rb
index c698e06c2a2..fbec6f98e76 100644
--- a/spec/support/shared_contexts/models/concerns/integrations/enable_ssl_verification_shared_context.rb
+++ b/spec/support/shared_contexts/models/concerns/integrations/enable_ssl_verification_shared_context.rb
@@ -43,5 +43,9 @@ RSpec.shared_context Integrations::EnableSslVerification do
expect(names.index('enable_ssl_verification')).to eq insert_index
end
+
+ it 'does not insert the field repeatedly' do
+ expect(integration.fields.pluck(:name)).to eq(integration.fields.pluck(:name))
+ end
end
end
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index 65c7f63cf6e..ef6ff7be840 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -46,8 +46,7 @@ RSpec.shared_context 'project navbar structure' do
_('List'),
_('Boards'),
_('Service Desk'),
- _('Milestones'),
- (_('Iterations') if Gitlab.ee?)
+ _('Milestones')
]
},
{
@@ -74,6 +73,13 @@ RSpec.shared_context 'project navbar structure' do
]
},
{
+ nav_item: _('Infrastructure'),
+ nav_sub_items: [
+ _('Kubernetes clusters'),
+ _('Terraform')
+ ]
+ },
+ {
nav_item: _('Monitor'),
nav_sub_items: [
_('Metrics'),
@@ -86,16 +92,8 @@ RSpec.shared_context 'project navbar structure' do
]
},
{
- nav_item: _('Infrastructure'),
- nav_sub_items: [
- _('Kubernetes clusters'),
- _('Serverless platform'),
- _('Terraform')
- ]
- },
- {
nav_item: _('Analytics'),
- nav_sub_items: analytics_sub_nav_item
+ nav_sub_items: project_analytics_sub_nav_item
},
{
nav_item: _('Wiki'),
@@ -126,9 +124,7 @@ RSpec.shared_context 'group navbar structure' do
let(:analytics_nav_item) do
{
nav_item: _('Analytics'),
- nav_sub_items: [
- _('Contribution')
- ]
+ nav_sub_items: group_analytics_sub_nav_item
}
end
diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb
index 76db2bd82f1..483bca07ba6 100644
--- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb
@@ -28,11 +28,11 @@ RSpec.shared_context 'GroupPolicy context' do
let(:reporter_permissions) do
%i[
admin_label
+ admin_milestone
admin_issue_board
read_container_image
read_metrics_dashboard_annotation
read_prometheus
- read_package_settings
read_crm_contact
read_crm_organization
]
@@ -40,13 +40,11 @@ RSpec.shared_context 'GroupPolicy context' do
let(:developer_permissions) do
%i[
- admin_milestone
create_metrics_dashboard_annotation
delete_metrics_dashboard_annotation
update_metrics_dashboard_annotation
create_custom_emoji
create_package
- create_package_settings
read_cluster
]
end
@@ -54,6 +52,7 @@ RSpec.shared_context 'GroupPolicy context' do
let(:maintainer_permissions) do
%i[
destroy_package
+ admin_package
create_projects
create_cluster update_cluster admin_cluster add_cluster
]
diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
index a78953e8199..e50083a10e7 100644
--- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
@@ -25,7 +25,7 @@ RSpec.shared_context 'ProjectPolicy context' do
let(:base_reporter_permissions) do
%i[
- admin_issue admin_issue_link admin_label admin_issue_board_list
+ admin_issue admin_issue_link admin_label admin_milestone admin_issue_board_list
create_snippet create_incident daily_statistics create_merge_request_in download_code
download_wiki_code fork_project metrics_dashboard read_build
read_commit_status read_confidential_issues read_container_image
@@ -41,7 +41,7 @@ RSpec.shared_context 'ProjectPolicy context' do
let(:developer_permissions) do
%i[
- admin_merge_request admin_milestone admin_tag create_build
+ admin_merge_request admin_tag create_build
create_commit_status create_container_image create_deployment
create_environment create_merge_request_from
create_metrics_dashboard_annotation create_pipeline create_release
diff --git a/spec/support/shared_contexts/sentry_error_tracking_shared_context.rb b/spec/support/shared_contexts/sentry_error_tracking_shared_context.rb
index 3453f954c9d..e8ccb12e6b7 100644
--- a/spec/support/shared_contexts/sentry_error_tracking_shared_context.rb
+++ b/spec/support/shared_contexts/sentry_error_tracking_shared_context.rb
@@ -14,7 +14,7 @@ RSpec.shared_context 'sentry error tracking context' do
end
before do
- expect(project).to receive(:error_tracking_setting).at_least(:once).and_return(error_tracking_setting)
+ allow(project).to receive(:error_tracking_setting).at_least(:once).and_return(error_tracking_setting)
project.add_reporter(user)
end
diff --git a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
index 37d410a35bf..9746d287440 100644
--- a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
+++ b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
@@ -43,12 +43,12 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do
Gitlab::Usage::MetricDefinition.instance_variable_set(:@all, nil)
end
- def metric_attributes(key_path, category, value_type = 'string', instrumentation_class = '')
+ def metric_attributes(key_path, category, value_type = 'string', instrumentation_class = '', status = 'active')
{
'key_path' => key_path,
'data_category' => category,
'value_type' => value_type,
- 'status' => 'active',
+ 'status' => status,
'instrumentation_class' => instrumentation_class,
'time_frame' => 'all'
}
diff --git a/spec/support/shared_examples/ci/log_downstream_pipeline_shared_examples.rb b/spec/support/shared_examples/ci/log_downstream_pipeline_shared_examples.rb
new file mode 100644
index 00000000000..db724dcfe99
--- /dev/null
+++ b/spec/support/shared_examples/ci/log_downstream_pipeline_shared_examples.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'logs downstream pipeline creation' do
+ def record_downstream_pipeline_logs
+ logs = []
+ allow(::Gitlab::AppLogger).to receive(:info) do |args|
+ logs << args
+ end
+
+ yield
+
+ logs.find { |log| log[:message] == "downstream pipeline created" }
+ end
+
+ it 'logs details' do
+ pipeline = nil
+
+ log_entry = record_downstream_pipeline_logs do
+ pipeline = subject
+ end
+
+ expect(log_entry).to be_present
+ expect(log_entry).to eq(
+ message: "downstream pipeline created",
+ class: described_class.name,
+ root_pipeline_id: expected_root_pipeline.id,
+ downstream_pipeline_id: pipeline.id,
+ downstream_pipeline_relationship: expected_downstream_relationship,
+ hierarchy_size: expected_hierarchy_size,
+ root_pipeline_plan: expected_root_pipeline.project.actual_plan_name,
+ root_pipeline_namespace_path: expected_root_pipeline.project.namespace.full_path,
+ root_pipeline_project_path: expected_root_pipeline.project.full_path)
+ end
+end
diff --git a/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb b/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb
index c6e880635aa..a79b94209f3 100644
--- a/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb
@@ -65,3 +65,20 @@ RSpec.shared_examples 'failed response for #cancel_auto_stop' do
end
end
end
+
+RSpec.shared_examples 'avoids N+1 queries on environment detail page' do
+ render_views
+
+ before do
+ create_deployment_with_associations(sequence: 0)
+ end
+
+ it 'avoids N+1 queries' do
+ control = ActiveRecord::QueryRecorder.new { get :show, params: environment_params }
+
+ create_deployment_with_associations(sequence: 1)
+ create_deployment_with_associations(sequence: 2)
+
+ expect { get :show, params: environment_params }.not_to exceed_query_limit(control.count).with_threshold(34)
+ end
+end
diff --git a/spec/support/shared_examples/controllers/repository_lfs_file_load_shared_examples.rb b/spec/support/shared_examples/controllers/repository_lfs_file_load_shared_examples.rb
index fadf428125a..9cf35325202 100644
--- a/spec/support/shared_examples/controllers/repository_lfs_file_load_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/repository_lfs_file_load_shared_examples.rb
@@ -44,8 +44,10 @@ RSpec.shared_examples 'a controller that can serve LFS files' do |options = {}|
expect(controller).to receive(:send_file)
.with(
File.join(lfs_uploader.root, lfs_uploader.store_dir, lfs_uploader.filename),
- filename: filename,
- disposition: 'attachment')
+ {
+ filename: filename,
+ disposition: 'attachment'
+ })
subject
diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb
index 5c44cb7f04b..c93d8e3d511 100644
--- a/spec/support/shared_examples/features/content_editor_shared_examples.rb
+++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb
@@ -23,6 +23,8 @@ RSpec.shared_examples 'edits content using the content editor' do
describe 'code block bubble menu' do
it 'shows a code block bubble menu for a code block' do
+ find(content_editor_testid).send_keys [:enter, :enter]
+
find(content_editor_testid).send_keys '```js ' # trigger input rule
find(content_editor_testid).send_keys 'var a = 0'
find(content_editor_testid).send_keys [:shift, :left]
@@ -32,6 +34,8 @@ RSpec.shared_examples 'edits content using the content editor' do
end
it 'sets code block type to "javascript" for `js`' do
+ find(content_editor_testid).send_keys [:enter, :enter]
+
find(content_editor_testid).send_keys '```js '
find(content_editor_testid).send_keys 'var a = 0'
@@ -39,6 +43,8 @@ RSpec.shared_examples 'edits content using the content editor' do
end
it 'sets code block type to "Custom (nomnoml)" for `nomnoml`' do
+ find(content_editor_testid).send_keys [:enter, :enter]
+
find(content_editor_testid).send_keys '```nomnoml '
find(content_editor_testid).send_keys 'test'
diff --git a/spec/support/shared_examples/features/dependency_proxy_shared_examples.rb b/spec/support/shared_examples/features/dependency_proxy_shared_examples.rb
index 5d1488502d2..6fd844f0e5f 100644
--- a/spec/support/shared_examples/features/dependency_proxy_shared_examples.rb
+++ b/spec/support/shared_examples/features/dependency_proxy_shared_examples.rb
@@ -17,7 +17,7 @@ end
RSpec.shared_examples 'a successful manifest pull' do
it 'sends a file' do
- expect(controller).to receive(:send_file).with(manifest.file.path, type: manifest.content_type)
+ expect(controller).to receive(:send_file).with(manifest.file.path, { type: manifest.content_type })
subject
end
diff --git a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
index ccd063faac4..2fff4137934 100644
--- a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
+++ b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
@@ -48,7 +48,7 @@ RSpec.shared_examples 'an editable merge request' do
end
page.within '.reviewer' do
- expect(page).to have_content user.username
+ expect(page).to have_content user.name
end
page.within '.milestone' do
diff --git a/spec/support/shared_examples/features/inviting_groups_shared_examples.rb b/spec/support/shared_examples/features/inviting_groups_shared_examples.rb
new file mode 100644
index 00000000000..4921676a065
--- /dev/null
+++ b/spec/support/shared_examples/features/inviting_groups_shared_examples.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'inviting groups search results' do
+ context 'with instance admin considerations' do
+ let_it_be(:group_to_invite) { create(:group) }
+
+ context 'when user is an admin' do
+ let_it_be(:admin) { create(:admin) }
+
+ before do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ end
+
+ it 'shows groups where the admin has no direct membership' do
+ visit members_page_path
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_invite)
+ expect_not_to_have_group(group)
+ end
+ end
+
+ it 'shows groups where the admin has at least guest level membership' do
+ group_to_invite.add_guest(admin)
+
+ visit members_page_path
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_invite)
+ expect_not_to_have_group(group)
+ end
+ end
+ end
+
+ context 'when user is not an admin' do
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ it 'does not show groups where the user has no direct membership' do
+ visit members_page_path
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_not_to_have_group(group_to_invite)
+ expect_not_to_have_group(group)
+ end
+ end
+
+ it 'shows groups where the user has at least guest level membership' do
+ group_to_invite.add_guest(user)
+
+ visit members_page_path
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_invite)
+ expect_not_to_have_group(group)
+ end
+ end
+ end
+ end
+
+ context 'when user is not an admin and there are hierarchy considerations' do
+ let_it_be(:group_outside_hierarchy) { create(:group) }
+
+ before_all do
+ group.add_owner(user)
+ group_within_hierarchy.add_owner(user)
+ group_outside_hierarchy.add_owner(user)
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ it 'does not show self or ancestors', :aggregate_failures do
+ group_sibling = create(:group, parent: group)
+ group_sibling.add_owner(user)
+
+ visit members_page_path_within_hierarchy
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_outside_hierarchy)
+ expect_to_have_group(group_sibling)
+ expect_not_to_have_group(group)
+ expect_not_to_have_group(group_within_hierarchy)
+ end
+ end
+
+ context 'when sharing with groups outside the hierarchy is enabled' do
+ it 'shows groups within and outside the hierarchy in search results' do
+ visit members_page_path
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_within_hierarchy)
+ expect_to_have_group(group_outside_hierarchy)
+ end
+ end
+ end
+
+ context 'when sharing with groups outside the hierarchy is disabled' do
+ before do
+ group.update!(prevent_sharing_groups_outside_hierarchy: true)
+ end
+
+ it 'shows only groups within the hierarchy in search results' do
+ visit members_page_path
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_within_hierarchy)
+ expect_not_to_have_group(group_outside_hierarchy)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/manage_applications_shared_examples.rb b/spec/support/shared_examples/features/manage_applications_shared_examples.rb
index 3a8267b21da..442264e7ae4 100644
--- a/spec/support/shared_examples/features/manage_applications_shared_examples.rb
+++ b/spec/support/shared_examples/features/manage_applications_shared_examples.rb
@@ -9,11 +9,9 @@ RSpec.shared_examples 'manage applications' do
visit new_application_path
expect(page).to have_content 'Add new application'
- expect(find('#doorkeeper_application_expire_access_tokens')).to be_checked
fill_in :doorkeeper_application_name, with: application_name
fill_in :doorkeeper_application_redirect_uri, with: application_redirect_uri
- uncheck :doorkeeper_application_expire_access_tokens
check :doorkeeper_application_scopes_read_user
click_on 'Save application'
@@ -25,8 +23,6 @@ RSpec.shared_examples 'manage applications' do
click_on 'Edit'
- expect(find('#doorkeeper_application_expire_access_tokens')).not_to be_checked
-
application_name_changed = "#{application_name} changed"
fill_in :doorkeeper_application_name, with: application_name_changed
diff --git a/spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb b/spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb
index 9d023d9514a..4565108b5e4 100644
--- a/spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb
+++ b/spec/support/shared_examples/features/multiple_assignees_mr_shared_examples.rb
@@ -4,7 +4,7 @@ RSpec.shared_examples 'multiple assignees merge request' do |action, save_button
it "#{action} a MR with multiple assignees", :js do
find('.js-assignee-search').click
page.within '.dropdown-menu-user' do
- click_link user.name
+ click_link user.name unless action == 'creates'
click_link user2.name
end
diff --git a/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb b/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb
index bbde448a1a1..a44a699c878 100644
--- a/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb
+++ b/spec/support/shared_examples/features/multiple_assignees_widget_mr_shared_examples.rb
@@ -4,7 +4,7 @@ RSpec.shared_examples 'multiple assignees widget merge request' do |action, save
it "#{action} a MR with multiple assignees", :js do
find('.js-assignee-search').click
page.within '.dropdown-menu-user' do
- click_link user.name
+ click_link user.name unless action == 'creates'
click_link user2.name
end
diff --git a/spec/support/shared_examples/features/multiple_reviewers_mr_shared_examples.rb b/spec/support/shared_examples/features/multiple_reviewers_mr_shared_examples.rb
index ad6ca3e1900..48cde90bd9b 100644
--- a/spec/support/shared_examples/features/multiple_reviewers_mr_shared_examples.rb
+++ b/spec/support/shared_examples/features/multiple_reviewers_mr_shared_examples.rb
@@ -40,7 +40,7 @@ RSpec.shared_examples 'multiple reviewers merge request' do |action, save_button
# Closing dropdown to persist
click_link 'Edit'
- expect(page).to have_content user2.username
+ expect(page).to have_content user2.name
end
end
end
diff --git a/spec/support/shared_examples/features/packages_shared_examples.rb b/spec/support/shared_examples/features/packages_shared_examples.rb
index ded30f32314..323bd4f5171 100644
--- a/spec/support/shared_examples/features/packages_shared_examples.rb
+++ b/spec/support/shared_examples/features/packages_shared_examples.rb
@@ -97,9 +97,9 @@ def click_sort_option(option, ascending)
wait_for_requests
end
- find('button.gl-dropdown-toggle').click
+ find('[data-testid="registry-sort-dropdown"]').click
- page.within('.dropdown-menu') do
+ page.within('[data-testid="registry-sort-dropdown"] .dropdown-menu') do
click_button option
end
diff --git a/spec/support/shared_examples/features/sidebar_shared_examples.rb b/spec/support/shared_examples/features/sidebar_shared_examples.rb
index 11d216ff4b6..af3ea0600a2 100644
--- a/spec/support/shared_examples/features/sidebar_shared_examples.rb
+++ b/spec/support/shared_examples/features/sidebar_shared_examples.rb
@@ -108,7 +108,11 @@ RSpec.shared_examples 'issue boards sidebar' do
wait_for_requests
- expect(page).to have_content('This issue is confidential')
+ expect(page).to have_content(
+ _('Only project members with at least' \
+ ' Reporter role can view or be' \
+ ' notified about this issue.')
+ )
end
end
end
diff --git a/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb
index 41b1964cff0..8081c51577a 100644
--- a/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb
@@ -233,6 +233,23 @@ RSpec.shared_examples 'User creates wiki page' do
.and have_content("Last edited by #{user.name}")
.and have_content("My awesome wiki!")
end
+
+ context 'when a server side validation error is returned' do
+ it "still displays edit form", :js do
+ click_link("New page")
+
+ page.within(".wiki-form") do
+ fill_in(:wiki_title, with: "home")
+ fill_in(:wiki_content, with: "My awesome home page!")
+ end
+
+ # Submits page with a name already in use to trigger a validation error
+ click_button("Create page")
+
+ expect(page).to have_field(:wiki_title)
+ expect(page).to have_field(:wiki_content)
+ end
+ end
end
it "shows the emoji autocompletion dropdown", :js do
diff --git a/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb
new file mode 100644
index 00000000000..b989dbc6524
--- /dev/null
+++ b/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+# Requres:
+# * subject with a 'resolve' name
+# * Defined expected timeline event via `let(:expected_timeline_event) { instance_double(...) }`
+RSpec.shared_examples 'creating an incident timeline event' do
+ it 'creates a timeline event' do
+ expect { resolve }.to change(IncidentManagement::TimelineEvent, :count).by(1)
+ end
+
+ it 'responds with a timeline event', :aggregate_failures do
+ response = resolve
+ timeline_event = IncidentManagement::TimelineEvent.last!
+
+ expect(response).to match(timeline_event: timeline_event, errors: be_empty)
+
+ expect(timeline_event.promoted_from_note).to eq(expected_timeline_event.promoted_from_note)
+ expect(timeline_event.note).to eq(expected_timeline_event.note)
+ expect(timeline_event.occurred_at.to_s).to eq(expected_timeline_event.occurred_at)
+ expect(timeline_event.incident).to eq(expected_timeline_event.incident)
+ expect(timeline_event.author).to eq(expected_timeline_event.author)
+ end
+end
+
+# Requres
+# * subject with a 'resolve' name
+# * a user factory with a 'current_user' name
+RSpec.shared_examples 'failing to create an incident timeline event' do
+ context 'when a user has no permissions to create timeline event' do
+ before do
+ project.add_guest(current_user)
+ end
+
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+end
+
+# Requres:
+# * subject with a 'resolve' name
+RSpec.shared_examples 'responding with an incident timeline errors' do |errors:|
+ it 'returns errors' do
+ expect(resolve).to eq(timeline_event: nil, errors: errors)
+ end
+end
diff --git a/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb b/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb
index 3d6fec85490..da8562161e7 100644
--- a/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb
@@ -4,7 +4,11 @@ RSpec.shared_examples 'group and projects packages resolver' do
context 'without sort' do
let_it_be(:npm_package) { create(:package, project: project) }
- it { is_expected.to contain_exactly(npm_package) }
+ it 'returns the proper packages' do
+ expect(::Packages::Package).not_to receive(:preload_pipelines)
+
+ expect(subject).to contain_exactly(npm_package)
+ end
end
context 'with sorting and filtering' do
diff --git a/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb
index 37a805902a9..6d6e7b761f6 100644
--- a/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb
@@ -101,7 +101,7 @@ RSpec.shared_examples 'sorted paginated query' do |conditions = {}|
context 'when sorting' do
it 'sorts correctly' do
- expect(results).to eq all_records
+ expect(results).to match all_records
end
context 'when paginating' do
@@ -110,17 +110,17 @@ RSpec.shared_examples 'sorted paginated query' do |conditions = {}|
let(:rest) { all_records.drop(first_param) }
it 'paginates correctly' do
- expect(results).to eq first_page
+ expect(results).to match first_page
fwds = pagination_query(sort_argument.merge(after: end_cursor))
post_graphql(fwds, current_user: current_user)
- expect(results).to eq rest
+ expect(results).to match rest
bwds = pagination_query(sort_argument.merge(before: start_cursor))
post_graphql(bwds, current_user: current_user)
- expect(results).to eq first_page
+ expect(results).to match first_page
end
end
@@ -130,7 +130,7 @@ RSpec.shared_examples 'sorted paginated query' do |conditions = {}|
it 'fetches last elements without error' do
post_graphql(pagination_query(params), current_user: current_user)
- expect(results.first).to eq(all_records.last)
+ expect(results.first).to match all_records.last
end
end
end
diff --git a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
index 3caf153c2fa..cf9c36fafe8 100644
--- a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
@@ -6,7 +6,7 @@ RSpec.shared_examples 'Gitlab-style deprecations' do
expect { subject(deprecation_reason: 'foo') }.to raise_error(
ArgumentError,
'Use `deprecated` property instead of `deprecation_reason`. ' \
- 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-arguments-and-enum-values'
+ 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-schema-items'
)
end
diff --git a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb
index a3c67210a4a..e886ec65b02 100644
--- a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb
@@ -796,8 +796,8 @@ RSpec.shared_examples 'trace with enabled live trace feature' do
end
end
- describe '#archived_trace_exist?' do
- subject { trace.archived_trace_exist? }
+ describe '#archived?' do
+ subject { trace.archived? }
context 'when trace does not exist' do
it { is_expected.to be_falsy }
diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb
index bea7cca2744..beec072e474 100644
--- a/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb
+++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb
@@ -62,8 +62,8 @@ shared_examples 'deployment metrics examples' do
describe '#deployment_frequency' do
subject { stage_summary.fourth[:value] }
- it 'includes the unit: `per day`' do
- expect(stage_summary.fourth[:unit]).to eq _('per day')
+ it 'includes the unit: `/day`' do
+ expect(stage_summary.fourth[:unit]).to eq _('/day')
end
before do
diff --git a/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb
new file mode 100644
index 00000000000..67d739b79ab
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'reconfigures connection stack' do |db_config_name|
+ before do
+ skip_if_multiple_databases_not_setup
+
+ # Due to lib/gitlab/database/load_balancing/configuration.rb:92 requiring RequestStore
+ # we cannot use stub_feature_flags(force_no_sharing_primary_model: true)
+ Gitlab::Database.database_base_models.each do |_, model_class|
+ allow(model_class.load_balancer.configuration).to receive(:use_dedicated_connection?).and_return(true)
+ end
+
+ ActiveRecord::Base.establish_connection(db_config_name.to_sym) # rubocop:disable Database/EstablishConnection
+
+ expect(Gitlab::Database.db_config_name(ActiveRecord::Base.connection)) # rubocop:disable Database/MultipleDatabases
+ .to eq(db_config_name)
+ end
+
+ around do |example|
+ with_reestablished_active_record_base do
+ example.run
+ end
+ end
+
+ def validate_connections!
+ model_connections = Gitlab::Database.database_base_models.to_h do |db_config_name, model_class|
+ [model_class, Gitlab::Database.db_config_name(model_class.connection)]
+ end
+
+ expect(model_connections).to eq(Gitlab::Database.database_base_models.invert)
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb
deleted file mode 100644
index 2633a89eeee..00000000000
--- a/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb
+++ /dev/null
@@ -1,162 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'network policy common specs' do
- let(:name) { 'example-name' }
- let(:namespace) { 'example-namespace' }
- let(:labels) { nil }
-
- describe '#generate' do
- subject { policy.generate }
-
- it { is_expected.to eq(Kubeclient::Resource.new(policy.resource)) }
- end
-
- describe 'as_json' do
- let(:json_policy) do
- {
- name: name,
- namespace: namespace,
- creation_timestamp: nil,
- manifest: YAML.dump(policy.resource.deep_stringify_keys),
- is_autodevops: false,
- is_enabled: true,
- environment_ids: []
- }
- end
-
- subject { policy.as_json }
-
- it { is_expected.to eq(json_policy) }
- end
-
- describe 'autodevops?' do
- subject { policy.autodevops? }
-
- let(:labels) { { chart: chart } }
- let(:chart) { nil }
-
- it { is_expected.to be false }
-
- context 'with non-autodevops chart' do
- let(:chart) { 'foo' }
-
- it { is_expected.to be false }
- end
-
- context 'with autodevops chart' do
- let(:chart) { 'auto-deploy-app-0.6.0' }
-
- it { is_expected.to be true }
- end
- end
-
- describe 'enabled?' do
- subject { policy.enabled? }
-
- let(:selector) { nil }
-
- it { is_expected.to be true }
-
- context 'with empty selector' do
- let(:selector) { {} }
-
- it { is_expected.to be true }
- end
-
- context 'with nil matchLabels in selector' do
- let(:selector) { { matchLabels: nil } }
-
- it { is_expected.to be true }
- end
-
- context 'with empty matchLabels in selector' do
- let(:selector) { { matchLabels: {} } }
-
- it { is_expected.to be true }
- end
-
- context 'with disabled_by label in matchLabels in selector' do
- let(:selector) do
- { matchLabels: { Gitlab::Kubernetes::NetworkPolicyCommon::DISABLED_BY_LABEL => 'gitlab' } }
- end
-
- it { is_expected.to be false }
- end
- end
-
- describe 'enable' do
- subject { policy.enabled? }
-
- let(:selector) { nil }
-
- before do
- policy.enable
- end
-
- it { is_expected.to be true }
-
- context 'with empty selector' do
- let(:selector) { {} }
-
- it { is_expected.to be true }
- end
-
- context 'with nil matchLabels in selector' do
- let(:selector) { { matchLabels: nil } }
-
- it { is_expected.to be true }
- end
-
- context 'with empty matchLabels in selector' do
- let(:selector) { { matchLabels: {} } }
-
- it { is_expected.to be true }
- end
-
- context 'with disabled_by label in matchLabels in selector' do
- let(:selector) do
- { matchLabels: { Gitlab::Kubernetes::NetworkPolicyCommon::DISABLED_BY_LABEL => 'gitlab' } }
- end
-
- it { is_expected.to be true }
- end
- end
-
- describe 'disable' do
- subject { policy.enabled? }
-
- let(:selector) { nil }
-
- before do
- policy.disable
- end
-
- it { is_expected.to be false }
-
- context 'with empty selector' do
- let(:selector) { {} }
-
- it { is_expected.to be false }
- end
-
- context 'with nil matchLabels in selector' do
- let(:selector) { { matchLabels: nil } }
-
- it { is_expected.to be false }
- end
-
- context 'with empty matchLabels in selector' do
- let(:selector) { { matchLabels: {} } }
-
- it { is_expected.to be false }
- end
-
- context 'with disabled_by label in matchLabels in selector' do
- let(:selector) do
- { matchLabels: { Gitlab::Kubernetes::NetworkPolicyCommon::DISABLED_BY_LABEL => 'gitlab' } }
- end
-
- it { is_expected.to be false }
- end
- end
-end
diff --git a/spec/support/shared_examples/merge_request_author_auto_assign_shared_examples.rb b/spec/support/shared_examples/merge_request_author_auto_assign_shared_examples.rb
new file mode 100644
index 00000000000..d4986975f03
--- /dev/null
+++ b/spec/support/shared_examples/merge_request_author_auto_assign_shared_examples.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'merge request author auto assign' do
+ it 'populates merge request author as assignee' do
+ expect(find('.js-assignee-search')).to have_content(user.name)
+ expect(page).not_to have_content 'Assign yourself'
+ end
+end
diff --git a/spec/support/shared_examples/models/chat_integration_shared_examples.rb b/spec/support/shared_examples/models/chat_integration_shared_examples.rb
index e6b270c6188..fa10b03fa90 100644
--- a/spec/support/shared_examples/models/chat_integration_shared_examples.rb
+++ b/spec/support/shared_examples/models/chat_integration_shared_examples.rb
@@ -199,7 +199,7 @@ RSpec.shared_examples "chat integration" do |integration_name|
{
title: "Awesome wiki_page",
content: "Some text describing some thing or another",
- format: "md",
+ format: :markdown,
message: "user created page: Awesome wiki_page"
}
end
diff --git a/spec/support/shared_examples/models/concerns/integrations/reset_secret_fields_shared_examples.rb b/spec/support/shared_examples/models/concerns/integrations/reset_secret_fields_shared_examples.rb
new file mode 100644
index 00000000000..873f858e432
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/integrations/reset_secret_fields_shared_examples.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples Integrations::ResetSecretFields do
+ describe '#exposing_secrets_fields' do
+ it 'returns an array of strings' do
+ expect(integration.exposing_secrets_fields).to be_a(Array)
+ expect(integration.exposing_secrets_fields).to all(be_a(String))
+ end
+ end
+
+ describe '#reset_secret_fields?' do
+ let(:exposing_fields) { integration.exposing_secrets_fields }
+
+ it 'returns false if no exposing field has changed' do
+ exposing_fields.each do |field|
+ allow(integration).to receive("#{field}_changed?").and_return(false)
+ end
+
+ expect(integration.send(:reset_secret_fields?)).to be(false)
+ end
+
+ it 'returns true if any exposing field has changed' do
+ exposing_fields.each do |field|
+ allow(integration).to receive("#{field}_changed?").and_return(true)
+
+ other_exposing_fields = exposing_fields.without(field)
+ other_exposing_fields.each do |other_field|
+ allow(integration).to receive("#{other_field}_changed?").and_return(false)
+ end
+
+ expect(integration.send(:reset_secret_fields?)).to be(true)
+ end
+ end
+ end
+
+ describe 'validation callback' do
+ before do
+ # Store a value in each password field
+ integration.secret_fields.each do |field|
+ integration.public_send("#{field}=", 'old value')
+ end
+
+ # Treat values as persisted
+ integration.reset_updated_properties
+ integration.instance_variable_set('@old_data_fields', nil) if integration.supports_data_fields?
+ end
+
+ context 'when an exposing field has changed' do
+ let(:exposing_field) { integration.exposing_secrets_fields.first }
+
+ before do
+ integration.public_send("#{exposing_field}=", 'new value')
+ end
+
+ it 'clears all secret fields' do
+ integration.valid?
+
+ integration.secret_fields.each do |field|
+ expect(integration.public_send(field)).to be_nil
+ expect(integration.properties[field]).to be_nil if integration.properties.present?
+ expect(integration.data_fields[field]).to be_nil if integration.supports_data_fields?
+ end
+ end
+
+ context 'when a secret field has been updated' do
+ let(:secret_field) { integration.secret_fields.first }
+ let(:other_secret_fields) { integration.secret_fields.without(secret_field) }
+ let(:new_value) { 'new value' }
+
+ before do
+ integration.public_send("#{secret_field}=", new_value)
+ end
+
+ it 'does not clear this secret field' do
+ integration.valid?
+
+ expect(integration.public_send(secret_field)).to eq('new value')
+
+ other_secret_fields.each do |field|
+ expect(integration.public_send(field)).to be_nil
+ end
+ end
+
+ context 'when a secret field has been updated with the same value' do
+ let(:new_value) { 'old value' }
+
+ it 'does not clear this secret field' do
+ integration.valid?
+
+ expect(integration.public_send(secret_field)).to eq('old value')
+
+ other_secret_fields.each do |field|
+ expect(integration.public_send(field)).to be_nil
+ end
+ end
+ end
+ end
+ end
+
+ context 'when no exposing field has changed' do
+ it 'does not clear any secret fields' do
+ integration.valid?
+
+ integration.secret_fields.each do |field|
+ expect(integration.public_send(field)).to eq('old value')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
index da5c35c970a..2e062cda4e9 100644
--- a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
@@ -45,9 +45,33 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
end
it "notifies about #{event_type} events" do
+ expect(chat_integration).not_to receive(:log_error)
+
chat_integration.execute(data)
+
expect(WebMock).to have_requested(:post, stubbed_resolved_hostname)
end
+
+ context 'when the response is not successful' do
+ let!(:stubbed_resolved_hostname) do
+ stub_full_request(webhook_url, method: :post)
+ .to_return(status: 409, body: 'error message')
+ .request_pattern.uri_pattern.to_s
+ end
+
+ it 'logs an error' do
+ expect(chat_integration).to receive(:log_error).with(
+ 'SlackMattermostNotifier HTTP error response',
+ request_host: 'example.gitlab.com',
+ response_code: 409,
+ response_body: 'error message'
+ )
+
+ chat_integration.execute(data)
+
+ expect(WebMock).to have_requested(:post, stubbed_resolved_hostname)
+ end
+ end
end
shared_examples "untriggered #{integration_name} integration" do |event_type: nil, branches_to_be_notified: nil|
@@ -59,8 +83,9 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
stub_full_request(webhook_url, method: :post).request_pattern.uri_pattern.to_s
end
- it "notifies about #{event_type} events" do
+ it "does not notify about #{event_type} events" do
chat_integration.execute(data)
+
expect(WebMock).not_to have_requested(:post, stubbed_resolved_hostname)
end
end
diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb
index a329a6dca91..e293d10964b 100644
--- a/spec/support/shared_examples/models/member_shared_examples.rb
+++ b/spec/support/shared_examples/models/member_shared_examples.rb
@@ -77,312 +77,309 @@ RSpec.shared_examples '#valid_level_roles' do |entity_name|
end
RSpec.shared_examples_for "member creation" do
- let_it_be(:user) { create(:user) }
let_it_be(:admin) { create(:admin) }
- describe '#execute' do
- it 'returns a Member object', :aggregate_failures do
- member = described_class.new(source, user, :maintainer).execute
-
- expect(member).to be_a member_type
- expect(member).to be_persisted
- end
+ it 'returns a Member object', :aggregate_failures do
+ member = described_class.new(source, user, :maintainer).execute
- context 'when adding a project_bot' do
- let_it_be(:project_bot) { create(:user, :project_bot) }
-
- before_all do
- source.add_owner(user)
- end
+ expect(member).to be_a member_type
+ expect(member).to be_persisted
+ end
- context 'when project_bot is already a member' do
- before do
- source.add_developer(project_bot)
- end
+ context 'when adding a project_bot' do
+ let_it_be(:project_bot) { create(:user, :project_bot) }
- it 'does not update the member' do
- member = described_class.new(source, project_bot, :maintainer, current_user: user).execute
+ before_all do
+ source.add_owner(user)
+ end
- expect(source.users.reload).to include(project_bot)
- expect(member).to be_persisted
- expect(member.access_level).to eq(Gitlab::Access::DEVELOPER)
- expect(member.errors.full_messages).to include(/not authorized to update member/)
- end
+ context 'when project_bot is already a member' do
+ before do
+ source.add_developer(project_bot)
end
- context 'when project_bot is not already a member' do
- it 'adds the member' do
- member = described_class.new(source, project_bot, :maintainer, current_user: user).execute
+ it 'does not update the member' do
+ member = described_class.new(source, project_bot, :maintainer, current_user: user).execute
- expect(source.users.reload).to include(project_bot)
- expect(member).to be_persisted
- end
+ expect(source.users.reload).to include(project_bot)
+ expect(member).to be_persisted
+ expect(member.access_level).to eq(Gitlab::Access::DEVELOPER)
+ expect(member.errors.full_messages).to include(/not authorized to update member/)
end
end
- context 'when admin mode is enabled', :enable_admin_mode, :aggregate_failures do
- it 'sets members.created_by to the given admin current_user' do
- member = described_class.new(source, user, :maintainer, current_user: admin).execute
+ context 'when project_bot is not already a member' do
+ it 'adds the member' do
+ member = described_class.new(source, project_bot, :maintainer, current_user: user).execute
+ expect(source.users.reload).to include(project_bot)
expect(member).to be_persisted
- expect(source.users.reload).to include(user)
- expect(member.created_by).to eq(admin)
end
end
+ end
- context 'when admin mode is disabled' do
- it 'rejects setting members.created_by to the given admin current_user', :aggregate_failures do
- member = described_class.new(source, user, :maintainer, current_user: admin).execute
+ context 'when admin mode is enabled', :enable_admin_mode, :aggregate_failures do
+ it 'sets members.created_by to the given admin current_user' do
+ member = described_class.new(source, user, :maintainer, current_user: admin).execute
- expect(member).not_to be_persisted
- expect(source.users.reload).not_to include(user)
- expect(member.errors.full_messages).to include(/not authorized to create member/)
- end
+ expect(member).to be_persisted
+ expect(source.users.reload).to include(user)
+ expect(member.created_by).to eq(admin)
end
+ end
- it 'sets members.expires_at to the given expires_at' do
- member = described_class.new(source, user, :maintainer, expires_at: Date.new(2016, 9, 22)).execute
+ context 'when admin mode is disabled' do
+ it 'rejects setting members.created_by to the given admin current_user', :aggregate_failures do
+ member = described_class.new(source, user, :maintainer, current_user: admin).execute
- expect(member.expires_at).to eq(Date.new(2016, 9, 22))
+ expect(member).not_to be_persisted
+ expect(source.users.reload).not_to include(user)
+ expect(member.errors.full_messages).to include(/not authorized to create member/)
end
+ end
- described_class.access_levels.each do |sym_key, int_access_level|
- it "accepts the :#{sym_key} symbol as access level", :aggregate_failures do
- expect(source.users).not_to include(user)
+ it 'sets members.expires_at to the given expires_at' do
+ member = described_class.new(source, user, :maintainer, expires_at: Date.new(2016, 9, 22)).execute
- member = described_class.new(source, user.id, sym_key).execute
+ expect(member.expires_at).to eq(Date.new(2016, 9, 22))
+ end
- expect(member.access_level).to eq(int_access_level)
- expect(source.users.reload).to include(user)
- end
+ described_class.access_levels.each do |sym_key, int_access_level|
+ it "accepts the :#{sym_key} symbol as access level", :aggregate_failures do
+ expect(source.users).not_to include(user)
+
+ member = described_class.new(source, user.id, sym_key).execute
- it "accepts the #{int_access_level} integer as access level", :aggregate_failures do
+ expect(member.access_level).to eq(int_access_level)
+ expect(source.users.reload).to include(user)
+ end
+
+ it "accepts the #{int_access_level} integer as access level", :aggregate_failures do
+ expect(source.users).not_to include(user)
+
+ member = described_class.new(source, user.id, int_access_level).execute
+
+ expect(member.access_level).to eq(int_access_level)
+ expect(source.users.reload).to include(user)
+ end
+ end
+
+ context 'with no current_user' do
+ context 'when called with a known user id' do
+ it 'adds the user as a member' do
expect(source.users).not_to include(user)
- member = described_class.new(source, user.id, int_access_level).execute
+ described_class.new(source, user.id, :maintainer).execute
- expect(member.access_level).to eq(int_access_level)
expect(source.users.reload).to include(user)
end
end
- context 'with no current_user' do
- context 'when called with a known user id' do
- it 'adds the user as a member' do
- expect(source.users).not_to include(user)
+ context 'when called with an unknown user id' do
+ it 'does not add the user as a member' do
+ expect(source.users).not_to include(user)
- described_class.new(source, user.id, :maintainer).execute
+ described_class.new(source, non_existing_record_id, :maintainer).execute
- expect(source.users.reload).to include(user)
- end
+ expect(source.users.reload).not_to include(user)
end
+ end
- context 'when called with an unknown user id' do
- it 'does not add the user as a member' do
- expect(source.users).not_to include(user)
+ context 'when called with a user object' do
+ it 'adds the user as a member' do
+ expect(source.users).not_to include(user)
- described_class.new(source, non_existing_record_id, :maintainer).execute
+ described_class.new(source, user, :maintainer).execute
- expect(source.users.reload).not_to include(user)
- end
+ expect(source.users.reload).to include(user)
+ end
+ end
+
+ context 'when called with a requester user object' do
+ before do
+ source.request_access(user)
end
- context 'when called with a user object' do
- it 'adds the user as a member' do
- expect(source.users).not_to include(user)
+ it 'adds the requester as a member', :aggregate_failures do
+ expect(source.users).not_to include(user)
+ expect(source.requesters.exists?(user_id: user)).to be_truthy
+ expect do
described_class.new(source, user, :maintainer).execute
+ end.to raise_error(Gitlab::Access::AccessDeniedError)
- expect(source.users.reload).to include(user)
- end
+ expect(source.users.reload).not_to include(user)
+ expect(source.requesters.reload.exists?(user_id: user)).to be_truthy
end
+ end
- context 'when called with a requester user object' do
- before do
- source.request_access(user)
- end
-
- it 'adds the requester as a member', :aggregate_failures do
- expect(source.users).not_to include(user)
- expect(source.requesters.exists?(user_id: user)).to be_truthy
+ context 'when called with a known user email' do
+ it 'adds the user as a member' do
+ expect(source.users).not_to include(user)
- expect do
- described_class.new(source, user, :maintainer).execute
- end.to raise_error(Gitlab::Access::AccessDeniedError)
+ described_class.new(source, user.email, :maintainer).execute
- expect(source.users.reload).not_to include(user)
- expect(source.requesters.reload.exists?(user_id: user)).to be_truthy
- end
+ expect(source.users.reload).to include(user)
end
+ end
- context 'when called with a known user email' do
- it 'adds the user as a member' do
- expect(source.users).not_to include(user)
+ context 'when called with an unknown user email' do
+ it 'creates an invited member' do
+ expect(source.users).not_to include(user)
- described_class.new(source, user.email, :maintainer).execute
+ described_class.new(source, 'user@example.com', :maintainer).execute
- expect(source.users.reload).to include(user)
- end
+ expect(source.members.invite.pluck(:invite_email)).to include('user@example.com')
end
+ end
- context 'when called with an unknown user email' do
- it 'creates an invited member' do
- expect(source.users).not_to include(user)
+ context 'when called with an unknown user email starting with a number' do
+ it 'creates an invited member', :aggregate_failures do
+ email_starting_with_number = "#{user.id}_email@example.com"
- described_class.new(source, 'user@example.com', :maintainer).execute
+ described_class.new(source, email_starting_with_number, :maintainer).execute
- expect(source.members.invite.pluck(:invite_email)).to include('user@example.com')
- end
+ expect(source.members.invite.pluck(:invite_email)).to include(email_starting_with_number)
+ expect(source.users.reload).not_to include(user)
end
+ end
+ end
- context 'when called with an unknown user email starting with a number' do
- it 'creates an invited member', :aggregate_failures do
- email_starting_with_number = "#{user.id}_email@example.com"
+ context 'when current_user can update member', :enable_admin_mode do
+ it 'creates the member' do
+ expect(source.users).not_to include(user)
- described_class.new(source, email_starting_with_number, :maintainer).execute
+ described_class.new(source, user, :maintainer, current_user: admin).execute
- expect(source.members.invite.pluck(:invite_email)).to include(email_starting_with_number)
- expect(source.users.reload).not_to include(user)
- end
- end
+ expect(source.users.reload).to include(user)
end
- context 'when current_user can update member', :enable_admin_mode do
- it 'creates the member' do
+ context 'when called with a requester user object' do
+ before do
+ source.request_access(user)
+ end
+
+ it 'adds the requester as a member', :aggregate_failures do
expect(source.users).not_to include(user)
+ expect(source.requesters.exists?(user_id: user)).to be_truthy
described_class.new(source, user, :maintainer, current_user: admin).execute
expect(source.users.reload).to include(user)
+ expect(source.requesters.reload.exists?(user_id: user)).to be_falsy
end
+ end
+ end
- context 'when called with a requester user object' do
- before do
- source.request_access(user)
- end
+ context 'when current_user cannot update member' do
+ it 'does not create the member', :aggregate_failures do
+ expect(source.users).not_to include(user)
- it 'adds the requester as a member', :aggregate_failures do
- expect(source.users).not_to include(user)
- expect(source.requesters.exists?(user_id: user)).to be_truthy
+ member = described_class.new(source, user, :maintainer, current_user: user).execute
- described_class.new(source, user, :maintainer, current_user: admin).execute
+ expect(source.users.reload).not_to include(user)
+ expect(member).not_to be_persisted
+ end
- expect(source.users.reload).to include(user)
- expect(source.requesters.reload.exists?(user_id: user)).to be_falsy
- end
+ context 'when called with a requester user object' do
+ before do
+ source.request_access(user)
end
- end
- context 'when current_user cannot update member' do
- it 'does not create the member', :aggregate_failures do
+ it 'does not destroy the requester', :aggregate_failures do
expect(source.users).not_to include(user)
+ expect(source.requesters.exists?(user_id: user)).to be_truthy
- member = described_class.new(source, user, :maintainer, current_user: user).execute
+ described_class.new(source, user, :maintainer, current_user: user).execute
expect(source.users.reload).not_to include(user)
- expect(member).not_to be_persisted
+ expect(source.requesters.exists?(user_id: user)).to be_truthy
end
+ end
+ end
- context 'when called with a requester user object' do
- before do
- source.request_access(user)
- end
+ context 'when member already exists' do
+ before do
+ source.add_user(user, :developer)
+ end
- it 'does not destroy the requester', :aggregate_failures do
- expect(source.users).not_to include(user)
- expect(source.requesters.exists?(user_id: user)).to be_truthy
+ context 'with no current_user' do
+ it 'updates the member' do
+ expect(source.users).to include(user)
- described_class.new(source, user, :maintainer, current_user: user).execute
+ described_class.new(source, user, :maintainer).execute
- expect(source.users.reload).not_to include(user)
- expect(source.requesters.exists?(user_id: user)).to be_truthy
- end
+ expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MAINTAINER)
end
end
- context 'when member already exists' do
- before do
- source.add_user(user, :developer)
- end
-
- context 'with no current_user' do
- it 'updates the member' do
- expect(source.users).to include(user)
+ context 'when current_user can update member', :enable_admin_mode do
+ it 'updates the member' do
+ expect(source.users).to include(user)
- described_class.new(source, user, :maintainer).execute
+ described_class.new(source, user, :maintainer, current_user: admin).execute
- expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MAINTAINER)
- end
+ expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MAINTAINER)
end
+ end
- context 'when current_user can update member', :enable_admin_mode do
- it 'updates the member' do
- expect(source.users).to include(user)
+ context 'when current_user cannot update member' do
+ it 'does not update the member' do
+ expect(source.users).to include(user)
- described_class.new(source, user, :maintainer, current_user: admin).execute
+ described_class.new(source, user, :maintainer, current_user: user).execute
- expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MAINTAINER)
- end
+ expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::DEVELOPER)
end
+ end
+ end
- context 'when current_user cannot update member' do
- it 'does not update the member' do
- expect(source.users).to include(user)
+ context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
+ let(:task_project) { source.is_a?(Group) ? create(:project, group: source) : source }
- described_class.new(source, user, :maintainer, current_user: user).execute
+ it 'creates a member_task with the correct attributes', :aggregate_failures do
+ described_class.new(source, user, :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id).execute
- expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::DEVELOPER)
- end
- end
- end
+ member = source.members.last
- context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do
- let(:task_project) { source.is_a?(Group) ? create(:project, group: source) : source }
+ expect(member.tasks_to_be_done).to match_array([:ci, :code])
+ expect(member.member_task.project).to eq(task_project)
+ end
- it 'creates a member_task with the correct attributes', :aggregate_failures do
- described_class.new(source, user, :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id).execute
+ context 'with an already existing member' do
+ before do
+ source.add_user(user, :developer)
+ end
- member = source.members.last
+ it 'does not update tasks to be done if tasks already exist', :aggregate_failures do
+ member = source.members.find_by(user_id: user.id)
+ create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci))
- expect(member.tasks_to_be_done).to match_array([:ci, :code])
+ expect do
+ described_class.new(source,
+ user,
+ :developer,
+ tasks_to_be_done: %w(issues),
+ tasks_project_id: task_project.id).execute
+ end.not_to change(MemberTask, :count)
+
+ member.reset
+ expect(member.tasks_to_be_done).to match_array([:code, :ci])
expect(member.member_task.project).to eq(task_project)
end
- context 'with an already existing member' do
- before do
- source.add_user(user, :developer)
- end
-
- it 'does not update tasks to be done if tasks already exist', :aggregate_failures do
- member = source.members.find_by(user_id: user.id)
- create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci))
-
- expect do
- described_class.new(source,
- user,
- :developer,
- tasks_to_be_done: %w(issues),
- tasks_project_id: task_project.id).execute
- end.not_to change(MemberTask, :count)
-
- member.reset
- expect(member.tasks_to_be_done).to match_array([:code, :ci])
- expect(member.member_task.project).to eq(task_project)
- end
-
- it 'adds tasks to be done if they do not exist', :aggregate_failures do
- expect do
- described_class.new(source,
- user,
- :developer,
- tasks_to_be_done: %w(issues),
- tasks_project_id: task_project.id).execute
- end.to change(MemberTask, :count).by(1)
-
- member = source.members.find_by(user_id: user.id)
- expect(member.tasks_to_be_done).to match_array([:issues])
- expect(member.member_task.project).to eq(task_project)
- end
+ it 'adds tasks to be done if they do not exist', :aggregate_failures do
+ expect do
+ described_class.new(source,
+ user,
+ :developer,
+ tasks_to_be_done: %w(issues),
+ tasks_project_id: task_project.id).execute
+ end.to change(MemberTask, :count).by(1)
+
+ member = source.members.find_by(user_id: user.id)
+ expect(member.tasks_to_be_done).to match_array([:issues])
+ expect(member.member_task.project).to eq(task_project)
end
end
end
diff --git a/spec/support/shared_examples/models/reviewer_state_shared_examples.rb b/spec/support/shared_examples/models/reviewer_state_shared_examples.rb
deleted file mode 100644
index f1392768b06..00000000000
--- a/spec/support/shared_examples/models/reviewer_state_shared_examples.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'having reviewer state' do
- describe 'mr_attention_requests feature flag is disabled' do
- before do
- stub_feature_flags(mr_attention_requests: false)
- end
-
- it { is_expected.to have_attributes(state: 'unreviewed') }
- end
-
- describe 'mr_attention_requests feature flag is enabled' do
- it { is_expected.to have_attributes(state: 'attention_requested') }
- end
-end
diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb
index 03e9dd65e33..6f17231a040 100644
--- a/spec/support/shared_examples/models/wiki_shared_examples.rb
+++ b/spec/support/shared_examples/models/wiki_shared_examples.rb
@@ -392,41 +392,161 @@ RSpec.shared_examples 'wiki model' do
end
describe '#create_page' do
- it 'creates a new wiki page' do
- expect(subject.create_page('test page', 'this is content')).not_to eq(false)
- expect(subject.list_pages.count).to eq(1)
- end
+ shared_examples 'create_page tests' do
+ it 'creates a new wiki page' do
+ expect(subject.create_page('test page', 'this is content')).not_to eq(false)
+ expect(subject.list_pages.count).to eq(1)
+ end
- it 'returns false when a duplicate page exists' do
- subject.create_page('test page', 'content')
+ it 'returns false when a duplicate page exists' do
+ subject.create_page('test page', 'content')
- expect(subject.create_page('test page', 'content')).to eq(false)
- end
+ expect(subject.create_page('test page', 'content')).to eq(false)
+ end
- it 'stores an error message when a duplicate page exists' do
- 2.times { subject.create_page('test page', 'content') }
+ it 'stores an error message when a duplicate page exists' do
+ 2.times { subject.create_page('test page', 'content') }
- expect(subject.error_message).to match(/Duplicate page:/)
- end
+ expect(subject.error_message).to match(/Duplicate page:/)
+ end
+
+ it 'sets the correct commit message' do
+ subject.create_page('test page', 'some content', :markdown, 'commit message')
+
+ expect(subject.list_pages.first.page.version.message).to eq('commit message')
+ end
+
+ it 'sets the correct commit email' do
+ subject.create_page('test page', 'content')
+
+ expect(user.commit_email).not_to eq(user.email)
+ expect(commit.author_email).to eq(user.commit_email)
+ expect(commit.committer_email).to eq(user.commit_email)
+ end
+
+ it 'runs after_wiki_activity callbacks' do
+ expect(subject).to receive(:after_wiki_activity)
- it 'sets the correct commit message' do
- subject.create_page('test page', 'some content', :markdown, 'commit message')
+ subject.create_page('Test Page', 'This is content')
+ end
+
+ it 'cannot create two pages with the same title but different format' do
+ subject.create_page('test page', 'content', :markdown)
+ subject.create_page('test page', 'content', :rdoc)
+
+ expect(subject.error_message).to match(/Duplicate page:/)
+ end
+
+ it 'cannot create two pages with the same title but different capitalization' do
+ subject.create_page('test page', 'content')
+ subject.create_page('Test page', 'content')
+
+ expect(subject.error_message).to match(/Duplicate page:/)
+ end
- expect(subject.list_pages.first.page.version.message).to eq('commit message')
+ it 'cannot create two pages with the same title, different capitalization, and different format' do
+ subject.create_page('test page', 'content')
+ subject.create_page('Test page', 'content', :rdoc)
+
+ expect(subject.error_message).to match(/Duplicate page:/)
+ end
end
- it 'sets the correct commit email' do
- subject.create_page('test page', 'content')
+ it_behaves_like 'create_page tests' do
+ it 'returns false if a page exists already in the repository', :aggregate_failures do
+ subject.create_page('test page', 'content')
- expect(user.commit_email).not_to eq(user.email)
- expect(commit.author_email).to eq(user.commit_email)
- expect(commit.committer_email).to eq(user.commit_email)
+ allow(subject).to receive(:file_exists_by_regex?).and_return(false)
+
+ expect(subject.create_page('test page', 'content')).to eq false
+ expect(subject.error_message).to match(/Duplicate page:/)
+ end
+
+ it 'returns false if it has an invalid format', :aggregate_failures do
+ expect(subject.create_page('test page', 'content', :foobar)).to eq false
+ expect(subject.error_message).to match(/Invalid format selected/)
+ end
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:new_file, :format, :existing_repo_files, :success) do
+ 'foo' | :markdown | [] | true
+ 'foo' | :rdoc | [] | true
+ 'foo' | :asciidoc | [] | true
+ 'foo' | :org | [] | true
+ 'foo' | :textile | [] | false
+ 'foo' | :creole | [] | false
+ 'foo' | :rest | [] | false
+ 'foo' | :mediawiki | [] | false
+ 'foo' | :pod | [] | false
+ 'foo' | :plaintext | [] | false
+ 'foo' | :markdown | ['foo.md'] | false
+ 'foo' | :markdown | ['foO.md'] | false
+ 'foO' | :markdown | ['foo.md'] | false
+ 'foo' | :markdown | ['foo.mdfoo'] | true
+ 'foo' | :markdown | ['foo.markdown'] | false
+ 'foo' | :markdown | ['foo.mkd'] | false
+ 'foo' | :markdown | ['foo.mkdn'] | false
+ 'foo' | :markdown | ['foo.mdown'] | false
+ 'foo' | :markdown | ['foo.adoc'] | false
+ 'foo' | :markdown | ['foo.asciidoc'] | false
+ 'foo' | :markdown | ['foo.org'] | false
+ 'foo' | :markdown | ['foo.rdoc'] | false
+ 'foo' | :markdown | ['foo.textile'] | false
+ 'foo' | :markdown | ['foo.creole'] | false
+ 'foo' | :markdown | ['foo.rest'] | false
+ 'foo' | :markdown | ['foo.rest.txt'] | false
+ 'foo' | :markdown | ['foo.rst'] | false
+ 'foo' | :markdown | ['foo.rst.txt'] | false
+ 'foo' | :markdown | ['foo.rst.txtfoo'] | true
+ 'foo' | :markdown | ['foo.mediawiki'] | false
+ 'foo' | :markdown | ['foo.wiki'] | false
+ 'foo' | :markdown | ['foo.pod'] | false
+ 'foo' | :markdown | ['foo.txt'] | false
+ 'foo' | :markdown | ['foo.Md'] | false
+ 'foo' | :markdown | ['foo.jpg'] | true
+ 'foo' | :rdoc | ['foo.md'] | false
+ 'foo' | :rdoc | ['foO.md'] | false
+ 'foO' | :rdoc | ['foo.md'] | false
+ 'foo' | :asciidoc | ['foo.md'] | false
+ 'foo' | :org | ['foo.md'] | false
+ 'foo' | :markdown | ['dir/foo.md'] | true
+ '/foo' | :markdown | ['foo.md'] | false
+ './foo' | :markdown | ['foo.md'] | false
+ '../foo' | :markdown | ['foo.md'] | false
+ '../../foo' | :markdown | ['foo.md'] | false
+ '../../foo' | :markdown | ['dir/foo.md'] | true
+ 'dir/foo' | :markdown | ['foo.md'] | true
+ 'dir/foo' | :markdown | ['dir/foo.md'] | false
+ 'dir/foo' | :markdown | ['dir/foo.rdoc'] | false
+ '/dir/foo' | :markdown | ['dir/foo.rdoc'] | false
+ './dir/foo' | :markdown | ['dir/foo.rdoc'] | false
+ '../dir/foo' | :markdown | ['dir/foo.rdoc'] | false
+ '../dir/../foo' | :markdown | ['dir/foo.rdoc'] | true
+ '../dir/../foo' | :markdown | ['foo.rdoc'] | false
+ '../dir/../dir/foo' | :markdown | ['dir/foo.rdoc'] | false
+ '../dir/../another/foo' | :markdown | ['dir/foo.rdoc'] | true
+ 'another/dir/foo' | :markdown | ['dir/foo.md'] | true
+ 'foo bar' | :markdown | ['foo-bar.md'] | false
+ 'foo bar' | :markdown | ['foo-bar.md'] | true
+ 'föö'.encode('ISO-8859-1') | :markdown | ['f��.md'] | false
+ end
+
+ with_them do
+ specify do
+ allow(subject.repository).to receive(:ls_files).and_return(existing_repo_files)
+
+ expect(subject.create_page(new_file, 'content', format)).to eq success
+ end
+ end
end
- it 'runs after_wiki_activity callbacks' do
- expect(subject).to receive(:after_wiki_activity)
+ context 'when feature flag :gitaly_replace_wiki_create_page is disabled' do
+ before do
+ stub_feature_flags(gitaly_replace_wiki_create_page: false)
+ end
- subject.create_page('Test Page', 'This is content')
+ it_behaves_like 'create_page tests'
end
end
@@ -452,7 +572,7 @@ RSpec.shared_examples 'wiki model' do
expect(subject).to receive(:after_wiki_activity)
expect(update_page).to eq true
- page = subject.find_page(updated_title.presence || original_title)
+ page = subject.find_page(expected_title)
expect(page.raw_content).to eq(updated_content)
expect(page.path).to eq(expected_path)
@@ -467,23 +587,25 @@ RSpec.shared_examples 'wiki model' do
shared_context 'common examples' do
using RSpec::Parameterized::TableSyntax
- where(:original_title, :original_format, :updated_title, :updated_format, :expected_path) do
- 'test page' | :markdown | 'new test page' | :markdown | 'new-test-page.md'
- 'test page' | :markdown | 'test page' | :markdown | 'test-page.md'
- 'test page' | :markdown | 'test page' | :asciidoc | 'test-page.asciidoc'
+ where(:original_title, :original_format, :updated_title, :updated_format, :expected_title, :expected_path) do
+ 'test page' | :markdown | 'new test page' | :markdown | 'new test page' | 'new-test-page.md'
+ 'test page' | :markdown | 'test page' | :markdown | 'test page' | 'test-page.md'
+ 'test page' | :markdown | 'test page' | :asciidoc | 'test page' | 'test-page.asciidoc'
+
+ 'test page' | :markdown | 'new dir/new test page' | :markdown | 'new dir/new test page' | 'new-dir/new-test-page.md'
+ 'test page' | :markdown | 'new dir/test page' | :markdown | 'new dir/test page' | 'new-dir/test-page.md'
- 'test page' | :markdown | 'new dir/new test page' | :markdown | 'new-dir/new-test-page.md'
- 'test page' | :markdown | 'new dir/test page' | :markdown | 'new-dir/test-page.md'
+ 'test dir/test page' | :markdown | 'new dir/new test page' | :markdown | 'new dir/new test page' | 'new-dir/new-test-page.md'
+ 'test dir/test page' | :markdown | 'test dir/test page' | :markdown | 'test dir/test page' | 'test-dir/test-page.md'
+ 'test dir/test page' | :markdown | 'test dir/test page' | :asciidoc | 'test dir/test page' | 'test-dir/test-page.asciidoc'
- 'test dir/test page' | :markdown | 'new dir/new test page' | :markdown | 'new-dir/new-test-page.md'
- 'test dir/test page' | :markdown | 'test dir/test page' | :markdown | 'test-dir/test-page.md'
- 'test dir/test page' | :markdown | 'test dir/test page' | :asciidoc | 'test-dir/test-page.asciidoc'
+ 'test dir/test page' | :markdown | 'new test page' | :markdown | 'new test page' | 'new-test-page.md'
+ 'test dir/test page' | :markdown | 'test page' | :markdown | 'test page' | 'test-page.md'
- 'test dir/test page' | :markdown | 'new test page' | :markdown | 'new-test-page.md'
- 'test dir/test page' | :markdown | 'test page' | :markdown | 'test-page.md'
+ 'test page' | :markdown | nil | :markdown | 'test page' | 'test-page.md'
+ 'test.page' | :markdown | nil | :markdown | 'test.page' | 'test.page.md'
- 'test page' | :markdown | nil | :markdown | 'test-page.md'
- 'test.page' | :markdown | nil | :markdown | 'test.page.md'
+ 'testpage' | :markdown | './testpage' | :markdown | 'testpage' | 'testpage.md'
end
end
@@ -497,16 +619,23 @@ RSpec.shared_examples 'wiki model' do
shared_context 'extended examples' do
using RSpec::Parameterized::TableSyntax
- where(:original_title, :original_format, :updated_title, :updated_format, :expected_path) do
- 'test page' | :markdown | 'new test page' | :asciidoc | 'new-test-page.asciidoc'
- 'test page' | :markdown | 'new dir/new test page' | :asciidoc | 'new-dir/new-test-page.asciidoc'
- 'test dir/test page' | :markdown | 'new dir/new test page' | :asciidoc | 'new-dir/new-test-page.asciidoc'
- 'test dir/test page' | :markdown | 'new test page' | :asciidoc | 'new-test-page.asciidoc'
- 'test page' | :markdown | nil | :asciidoc | 'test-page.asciidoc'
- 'test dir/test page' | :markdown | nil | :asciidoc | 'test-dir/test-page.asciidoc'
- 'test dir/test page' | :markdown | nil | :markdown | 'test-dir/test-page.md'
- 'test page' | :markdown | '' | :markdown | 'test-page.md'
- 'test.page' | :markdown | '' | :markdown | 'test.page.md'
+ where(:original_title, :original_format, :updated_title, :updated_format, :expected_title, :expected_path) do
+ 'test page' | :markdown | 'new test page' | :asciidoc | 'new test page' | 'new-test-page.asciidoc'
+ 'test page' | :markdown | 'new dir/new test page' | :asciidoc | 'new dir/new test page' | 'new-dir/new-test-page.asciidoc'
+ 'test dir/test page' | :markdown | 'new dir/new test page' | :asciidoc | 'new dir/new test page' | 'new-dir/new-test-page.asciidoc'
+ 'test dir/test page' | :markdown | 'new test page' | :asciidoc | 'new test page' | 'new-test-page.asciidoc'
+ 'test page' | :markdown | nil | :asciidoc | 'test page' | 'test-page.asciidoc'
+ 'test dir/test page' | :markdown | nil | :asciidoc | 'test dir/test page' | 'test-dir/test-page.asciidoc'
+ 'test dir/test page' | :markdown | nil | :markdown | 'test dir/test page' | 'test-dir/test-page.md'
+ 'test page' | :markdown | '' | :markdown | 'test page' | 'test-page.md'
+ 'test.page' | :markdown | '' | :markdown | 'test.page' | 'test.page.md'
+ 'testpage' | :markdown | '../testpage' | :markdown | 'testpage' | 'testpage.md'
+ 'dir/testpage' | :markdown | 'dir/../testpage' | :markdown | 'testpage' | 'testpage.md'
+ 'dir/testpage' | :markdown | './dir/testpage' | :markdown | 'dir/testpage' | 'dir/testpage.md'
+ 'dir/testpage' | :markdown | '../dir/testpage' | :markdown | 'dir/testpage' | 'dir/testpage.md'
+ 'dir/testpage' | :markdown | '../dir/../testpage' | :markdown | 'testpage' | 'testpage.md'
+ 'dir/testpage' | :markdown | '../dir/../dir/testpage' | :markdown | 'dir/testpage' | 'dir/testpage.md'
+ 'dir/testpage' | :markdown | '../dir/../another/testpage' | :markdown | 'another/testpage' | 'another/testpage.md'
end
end
@@ -547,16 +676,6 @@ RSpec.shared_examples 'wiki model' do
end
end
end
-
- context 'when feature flag :gitaly_replace_wiki_update_page is disabled' do
- before do
- stub_feature_flags(gitaly_replace_wiki_update_page: false)
- end
-
- it_behaves_like 'update_page tests' do
- include_context 'common examples'
- end
- end
end
describe '#delete_page' do
diff --git a/spec/support/shared_examples/nav_sidebar_shared_examples.rb b/spec/support/shared_examples/nav_sidebar_shared_examples.rb
index 3e500683712..4b815988bc5 100644
--- a/spec/support/shared_examples/nav_sidebar_shared_examples.rb
+++ b/spec/support/shared_examples/nav_sidebar_shared_examples.rb
@@ -27,7 +27,7 @@ end
RSpec.shared_examples 'sidebar includes snowplow attributes' do |track_action, track_label, track_property|
specify do
- allow(view).to receive(:tracking_enabled?).and_return(true)
+ stub_application_setting(snowplow_enabled: true)
render
diff --git a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
index c1eccafa987..f5c41416763 100644
--- a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
@@ -21,6 +21,7 @@ RSpec.shared_examples 'returns repositories for allowed users' do |user_type, sc
expect(json_response.map { |repository| repository['id'] }).to contain_exactly(
root_repository.id, test_repository.id)
expect(response.body).not_to include('tags')
+ expect(response.body).not_to include('tags_count')
end
it 'returns a matching schema' do
@@ -29,7 +30,11 @@ RSpec.shared_examples 'returns repositories for allowed users' do |user_type, sc
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('registry/repositories')
end
+ end
+end
+RSpec.shared_examples 'returns tags for allowed users' do |user_type, scope|
+ context "for #{user_type}" do
context 'with tags param' do
let(:url) { "/#{scope}s/#{object.id}/registry/repositories?tags=true" }
@@ -169,10 +174,12 @@ RSpec.shared_examples 'reconciling migration_state' do
end
end
- context 'import_failed response' do
- let(:status) { 'import_failed' }
+ %w[import_canceled import_failed].each do |status|
+ context "#{status} response" do
+ let(:status) { status }
- it_behaves_like 'retrying the import'
+ it_behaves_like 'retrying the import'
+ end
end
context 'pre_import_in_progress response' do
@@ -192,17 +199,11 @@ RSpec.shared_examples 'reconciling migration_state' do
end
end
- context 'pre_import_failed response' do
- let(:status) { 'pre_import_failed' }
-
- it_behaves_like 'retrying the pre_import'
- end
-
- %w[pre_import_canceled import_canceled].each do |canceled_status|
- context "#{canceled_status} response" do
- let(:status) { canceled_status }
+ %w[pre_import_canceled pre_import_failed].each do |status|
+ context "#{status} response" do
+ let(:status) { status }
- it_behaves_like 'enforcing states coherence to', 'import_skipped'
+ it_behaves_like 'retrying the pre_import'
end
end
end
diff --git a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
index da9d254039b..e534a02e562 100644
--- a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
@@ -67,11 +67,15 @@ RSpec.shared_examples 'group and project boards query' do
let(:sort_param) { }
let(:first_param) { 2 }
+ def pagination_results_data(nodes)
+ nodes
+ end
+
let(:all_records) do
if board_parent.multiple_issue_boards_available?
- boards.map { |board| global_id_of(board) }
+ boards.map { |board| a_graphql_entity_for(board) }
else
- [global_id_of(boards.first)]
+ [a_graphql_entity_for(boards.first)]
end
end
end
diff --git a/spec/support/shared_examples/requests/api/graphql/noteable_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/noteable_shared_examples.rb
index 7e1f4500779..9033a8b4d3a 100644
--- a/spec/support/shared_examples/requests/api/graphql/noteable_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/noteable_shared_examples.rb
@@ -12,9 +12,9 @@ RSpec.shared_examples 'a noteable graphql type we can query' do
def expected
noteable.discussions.map do |discussion|
- include(
- 'id' => global_id_of(discussion),
- 'replyId' => global_id_of(discussion, id: discussion.reply_id),
+ a_graphql_entity_for(
+ discussion,
+ 'replyId' => global_id_of(discussion, id: discussion.reply_id).to_s,
'createdAt' => discussion.created_at.iso8601,
'notes' => include(
'nodes' => have_attributes(size: discussion.notes.size)
@@ -50,8 +50,8 @@ RSpec.shared_examples 'a noteable graphql type we can query' do
post_graphql(query(fields), current_user: current_user)
- data = graphql_data_at(*path_to_noteable, :discussions, :nodes, :noteable, :id)
- expect(data[0]).to eq(global_id_of(noteable))
+ entities = graphql_data_at(*path_to_noteable, :discussions, :nodes, :noteable)
+ expect(entities).to all(match(a_graphql_entity_for(noteable)))
end
end
@@ -62,10 +62,10 @@ RSpec.shared_examples 'a noteable graphql type we can query' do
def expected
noteable.notes.map do |note|
- include(
- 'id' => global_id_of(note),
- 'project' => include('id' => global_id_of(project)),
- 'author' => include('id' => global_id_of(note.author)),
+ a_graphql_entity_for(
+ note,
+ 'project' => a_graphql_entity_for(project),
+ 'author' => a_graphql_entity_for(note.author),
'createdAt' => note.created_at.iso8601,
'body' => eq(note.note)
)
diff --git a/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb
index 127b1a6d4c4..9f7ec6e90e9 100644
--- a/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb
@@ -104,7 +104,7 @@ RSpec.shared_examples 'group and project packages query' do
}
end
- let(:expected_packages) { sorted_packages.map { |package| global_id_of(package) } }
+ let(:expected_packages) { sorted_packages.map { |package| global_id_of(package).to_s } }
let(:data_path) { [resource_type, :packages] }
@@ -191,4 +191,91 @@ RSpec.shared_examples 'group and project packages query' do
it { is_expected.to include({ "name" => versionless_package.name }) }
end
end
+
+ context 'when reading pipelines' do
+ let(:npm_pipelines) { create_list(:ci_pipeline, 6, project: project1) }
+ let(:npm_pipeline_gids) { npm_pipelines.sort_by(&:id).map(&:to_gid).map(&:to_s).reverse }
+ let(:composer_pipelines) { create_list(:ci_pipeline, 6, project: project2) }
+ let(:composer_pipeline_gids) { composer_pipelines.sort_by(&:id).map(&:to_gid).map(&:to_s).reverse }
+ let(:npm_end_cursor) { graphql_data_npm_package.dig('pipelines', 'pageInfo', 'endCursor') }
+ let(:npm_start_cursor) { graphql_data_npm_package.dig('pipelines', 'pageInfo', 'startCursor') }
+ let(:pipelines_nodes) do
+ <<~QUERY
+ nodes {
+ id
+ }
+ pageInfo {
+ startCursor
+ endCursor
+ }
+ QUERY
+ end
+
+ before do
+ resource.add_maintainer(current_user)
+
+ npm_pipelines.each do |pipeline|
+ create(:package_build_info, package: npm_package, pipeline: pipeline)
+ end
+
+ composer_pipelines.each do |pipeline|
+ create(:package_build_info, package: composer_package, pipeline: pipeline)
+ end
+ end
+
+ it 'loads the second page with pagination first correctly' do
+ run_query(first: 2)
+ expect(npm_pipeline_ids).to eq(npm_pipeline_gids[0..1])
+ expect(composer_pipeline_ids).to eq(composer_pipeline_gids[0..1])
+
+ run_query(first: 2, after: npm_end_cursor)
+ expect(npm_pipeline_ids).to eq(npm_pipeline_gids[2..3])
+ expect(composer_pipeline_ids).to be_empty
+ end
+
+ it 'loads the second page with pagination last correctly' do
+ run_query(last: 2)
+ expect(npm_pipeline_ids).to eq(npm_pipeline_gids[4..5])
+ expect(composer_pipeline_ids).to eq(composer_pipeline_gids[4..5])
+
+ run_query(last: 2, before: npm_start_cursor)
+ expect(npm_pipeline_ids).to eq(npm_pipeline_gids[2..3])
+ expect(composer_pipeline_ids).to eq(composer_pipeline_gids[4..5])
+ end
+
+ def run_query(args)
+ pipelines_field = query_graphql_field('pipelines', args, pipelines_nodes)
+
+ packages_nodes = <<~QUERY
+ nodes {
+ id
+ #{pipelines_field}
+ }
+ QUERY
+
+ query = graphql_query_for(
+ resource_type,
+ { 'fullPath' => resource.full_path },
+ query_graphql_field('packages', {}, packages_nodes)
+ )
+
+ post_graphql(query, current_user: current_user)
+ end
+
+ def npm_pipeline_ids
+ graphql_data_npm_package.dig('pipelines', 'nodes').map { |pipeline| pipeline['id'] }
+ end
+
+ def composer_pipeline_ids
+ graphql_data_composer_package.dig('pipelines', 'nodes').map { |pipeline| pipeline['id'] }
+ end
+
+ def graphql_data_npm_package
+ graphql_data_at(resource_type, :packages, :nodes).find { |pkg| pkg['id'] == npm_package.to_gid.to_s }
+ end
+
+ def graphql_data_composer_package
+ graphql_data_at(resource_type, :packages, :nodes).find { |pkg| pkg['id'] == composer_package.to_gid.to_s }
+ end
+ end
end
diff --git a/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb
index ab93f54111b..b4019d7c232 100644
--- a/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb
@@ -28,14 +28,10 @@ RSpec.shared_examples 'a package with files' do
end
it 'has the basic package files data' do
- expect(first_file_response).to include(
- 'id' => global_id_of(first_file),
- 'fileName' => first_file.file_name,
- 'size' => first_file.size.to_s,
- 'downloadPath' => first_file.download_path,
- 'fileSha1' => first_file.file_sha1,
- 'fileMd5' => first_file.file_md5,
- 'fileSha256' => first_file.file_sha256
+ expect(first_file_response).to match a_graphql_entity_for(
+ first_file,
+ :file_name, :download_path, :file_sha1, :file_md5, :file_sha256,
+ 'size' => first_file.size.to_s
)
end
diff --git a/spec/support/shared_examples/requests/api/graphql/projects/alert_management/integrations_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/projects/alert_management/integrations_shared_examples.rb
index c134f7d1839..3c5f25baaa1 100644
--- a/spec/support/shared_examples/requests/api/graphql/projects/alert_management/integrations_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/projects/alert_management/integrations_shared_examples.rb
@@ -30,14 +30,12 @@ RSpec.shared_examples 'GraphQL query with several integrations requested' do |gr
it 'returns the correct properties of the integrations', :aggregate_failures do
post_graphql(multi_selection_query, current_user: current_user)
- expect(graphql_data.dig('project', 'ai', 'nodes')).to include(
- 'id' => global_id_of(active_http_integration),
- 'name' => active_http_integration.name
+ expect(graphql_data.dig('project', 'ai', 'nodes')).to match a_graphql_entity_for(
+ active_http_integration, :name
)
- expect(graphql_data.dig('project', 'ii', 'nodes')).to include(
- 'id' => global_id_of(inactive_http_integration),
- 'name' => inactive_http_integration.name
+ expect(graphql_data.dig('project', 'ii', 'nodes')).to match a_graphql_entity_for(
+ inactive_http_integration, :name
)
end
diff --git a/spec/support/shared_examples/requests/api/milestones_shared_examples.rb b/spec/support/shared_examples/requests/api/milestones_shared_examples.rb
index 249a7b7cdac..1ea11ba3d7c 100644
--- a/spec/support/shared_examples/requests/api/milestones_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/milestones_shared_examples.rb
@@ -203,16 +203,16 @@ RSpec.shared_examples 'group and project milestones' do |route_definition|
end
describe "DELETE #{route_definition}/:milestone_id" do
- it "rejects a member with reporter access from deleting a milestone" do
- reporter = create(:user)
- milestone.resource_parent.add_reporter(reporter)
+ it "rejects a member with guest access from deleting a milestone" do
+ guest = create(:user)
+ milestone.resource_parent.add_guest(guest)
- delete api(resource_route, reporter)
+ delete api(resource_route, guest)
expect(response).to have_gitlab_http_status(:forbidden)
end
- it 'deletes the milestone when the user has developer access to the project' do
+ it 'deletes the milestone when the user has reporter access to the project' do
delete api(resource_route, user)
expect(project.milestones.find_by_id(milestone.id)).to be_nil
diff --git a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
index 68cb91d7414..d4417b23a5f 100644
--- a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
+++ b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
@@ -149,6 +149,7 @@ RSpec.shared_examples 'rate-limited token requests' do
arguments = a_hash_including({
message: 'Rack_Attack',
+ status: 429,
env: :throttle,
remote_ip: '127.0.0.1',
request_method: request_method,
@@ -314,6 +315,7 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do
arguments = a_hash_including({
message: 'Rack_Attack',
+ status: 429,
env: :throttle,
remote_ip: '127.0.0.1',
request_method: request_method,
@@ -391,14 +393,16 @@ RSpec.shared_examples 'tracking when dry-run mode is set' do
end
it 'logs RackAttack info into structured logs' do
- arguments = a_hash_including({
- message: 'Rack_Attack',
- env: :track,
- remote_ip: '127.0.0.1',
- matched: throttle_name
- })
+ expect(Gitlab::AuthLogger).to receive(:error) do |arguments|
+ expect(arguments).to include(
+ message: 'Rack_Attack',
+ env: :track,
+ remote_ip: '127.0.0.1',
+ matched: throttle_name
+ )
- expect(Gitlab::AuthLogger).to receive(:error).with(arguments)
+ expect(arguments).not_to have_key(:status)
+ end
(1 + requests_per_period).times do
do_request
@@ -576,6 +580,7 @@ RSpec.shared_examples 'rate-limited unauthenticated requests' do
arguments = a_hash_including({
message: 'Rack_Attack',
+ status: 429,
env: :throttle,
remote_ip: '127.0.0.1',
request_method: 'GET',
diff --git a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb
index fcd52cdf7fa..e1baa594f3c 100644
--- a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb
+++ b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
-RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false|
+RSpec.shared_examples 'avoid N+1 on environments serialization' do
it 'avoids N+1 database queries with grouping', :request_store do
create_environment_with_associations(project)
diff --git a/spec/support/shared_examples/services/issuable/destroy_service_shared_examples.rb b/spec/support/shared_examples/services/issuable/destroy_service_shared_examples.rb
index e776c098fa0..31571b1ffb9 100644
--- a/spec/support/shared_examples/services/issuable/destroy_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/issuable/destroy_service_shared_examples.rb
@@ -1,21 +1,33 @@
# frozen_string_literal: true
-shared_examples_for 'service deleting todos' do
+shared_examples_for 'service scheduling async deletes' do
it 'destroys associated todos asynchronously' do
- expect(TodosDestroyer::DestroyedIssuableWorker)
+ expect(worker_class)
.to receive(:perform_async)
.with(issuable.id, issuable.class.name)
subject.execute(issuable)
end
-end
-shared_examples_for 'service deleting label links' do
- it 'destroys associated label links asynchronously' do
- expect(Issuable::LabelLinksDestroyWorker)
+ it 'works inside a transaction' do
+ expect(worker_class)
.to receive(:perform_async)
.with(issuable.id, issuable.class.name)
- subject.execute(issuable)
+ ApplicationRecord.transaction do
+ subject.execute(issuable)
+ end
+ end
+end
+
+shared_examples_for 'service deleting todos' do
+ it_behaves_like 'service scheduling async deletes' do
+ let(:worker_class) { TodosDestroyer::DestroyedIssuableWorker }
+ end
+end
+
+shared_examples_for 'service deleting label links' do
+ it_behaves_like 'service scheduling async deletes' do
+ let(:worker_class) { Issuable::LabelLinksDestroyWorker }
end
end
diff --git a/spec/support/shared_examples/services/jira/requests/base_shared_examples.rb b/spec/support/shared_examples/services/jira/requests/base_shared_examples.rb
index c4f6273b46c..5e49bdd706c 100644
--- a/spec/support/shared_examples/services/jira/requests/base_shared_examples.rb
+++ b/spec/support/shared_examples/services/jira/requests/base_shared_examples.rb
@@ -66,18 +66,12 @@ RSpec.shared_examples 'a service that handles Jira API errors' do
it 'logs the error' do
stub_client_and_raise(Timeout::Error, 'foo')
- expect(Gitlab::ProjectServiceLogger).to receive(:error).with(
- hash_including(
- client_url: be_present,
- message: 'Error sending message',
- service_class: described_class.name,
- error: hash_including(
- exception_class: Timeout::Error.name,
- exception_message: 'foo',
- exception_backtrace: be_present
- )
- )
+ expect(jira_integration).to receive(:log_exception).with(
+ kind_of(Timeout::Error),
+ message: 'Error sending message',
+ client_url: jira_integration.url
)
+
expect(subject).to be_error
end
diff --git a/spec/support/shared_examples/work_item_base_types_importer.rb b/spec/support/shared_examples/work_item_base_types_importer.rb
index 68e37171ea2..593670ac4b8 100644
--- a/spec/support/shared_examples/work_item_base_types_importer.rb
+++ b/spec/support/shared_examples/work_item_base_types_importer.rb
@@ -1,10 +1,48 @@
# frozen_string_literal: true
RSpec.shared_examples 'work item base types importer' do
- it 'creates all base work item types' do
- # Fixtures need to run on a pristine DB, but the test suite preloads the base types before(:suite)
+ it "creates all base work item types if they don't exist" do
WorkItems::Type.delete_all
expect { subject }.to change(WorkItems::Type, :count).from(0).to(WorkItems::Type::BASE_TYPES.count)
+
+ types_in_db = WorkItems::Type.all.map { |type| type.slice(:base_type, :icon_name, :name).symbolize_keys }
+ expected_types = WorkItems::Type::BASE_TYPES.map do |type, attributes|
+ attributes.slice(:icon_name, :name).merge(base_type: type.to_s)
+ end
+
+ expect(types_in_db).to match_array(expected_types)
+ expect(WorkItems::Type.all).to all(be_valid)
+ end
+
+ it 'upserts base work item types if they already exist' do
+ first_type = WorkItems::Type.first
+ original_name = first_type.name
+
+ first_type.update!(name: original_name.upcase)
+
+ expect do
+ subject
+ first_type.reload
+ end.to not_change(WorkItems::Type, :count).and(
+ change(first_type, :name).from(original_name.upcase).to(original_name)
+ )
+ end
+
+ it 'executes a single INSERT query' do
+ expect { subject }.to make_queries_matching(/INSERT/, 1)
+ end
+
+ context 'when some base types exist' do
+ before do
+ WorkItems::Type.limit(1).delete_all
+ end
+
+ it 'inserts all types and does nothing if some already existed' do
+ expect { subject }.to make_queries_matching(/INSERT/, 1).and(
+ change(WorkItems::Type, :count).by(1)
+ )
+ expect(WorkItems::Type.count).to eq(WorkItems::Type::BASE_TYPES.count)
+ end
end
end
diff --git a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
index 26731f34ed6..3d4e840fe2d 100644
--- a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
+++ b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
@@ -205,4 +205,123 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d
end
end
end
+
+ describe 'executing an entire migration', :freeze_time, if: Gitlab::Database.has_config?(tracking_database) do
+ include Gitlab::Database::DynamicModelHelpers
+
+ let(:migration_class) do
+ Class.new(Gitlab::BackgroundMigration::BatchedMigrationJob) do
+ def perform(matching_status)
+ each_sub_batch(
+ operation_name: :update_all,
+ batching_scope: -> (relation) { relation.where(status: matching_status) }
+ ) do |sub_batch|
+ sub_batch.update_all(some_column: 0)
+ end
+ end
+ end
+ end
+
+ let!(:migration) do
+ create(
+ :batched_background_migration,
+ :active,
+ table_name: table_name,
+ column_name: :id,
+ max_value: migration_records,
+ batch_size: batch_size,
+ sub_batch_size: sub_batch_size,
+ job_class_name: 'ExampleDataMigration',
+ job_arguments: [1]
+ )
+ end
+
+ let(:table_name) { 'example_data' }
+ let(:batch_size) { 5 }
+ let(:sub_batch_size) { 2 }
+ let(:number_of_batches) { 10 }
+ let(:migration_records) { batch_size * number_of_batches }
+
+ let(:connection) { Gitlab::Database.database_base_models[tracking_database].connection }
+ let(:example_data) { define_batchable_model(table_name, connection: connection) }
+
+ around do |example|
+ Gitlab::Database::SharedModel.using_connection(connection) do
+ example.run
+ end
+ end
+
+ before do
+ # Create example table populated with test data to migrate.
+ #
+ # Test data should have two records that won't be updated:
+ # - one record beyond the migration's range
+ # - one record that doesn't match the migration job's batch condition
+ connection.execute(<<~SQL)
+ CREATE TABLE #{table_name} (
+ id integer primary key,
+ some_column integer,
+ status smallint);
+
+ INSERT INTO #{table_name} (id, some_column, status)
+ SELECT generate_series, generate_series, 1
+ FROM generate_series(1, #{migration_records + 1});
+
+ UPDATE #{table_name}
+ SET status = 0
+ WHERE some_column = #{migration_records - 5};
+ SQL
+
+ stub_feature_flags(execute_batched_migrations_on_schedule: true)
+
+ stub_const('Gitlab::BackgroundMigration::ExampleDataMigration', migration_class)
+ end
+
+ subject(:full_migration_run) do
+ # process all batches, then do an extra execution to mark the job as finished
+ (number_of_batches + 1).times do
+ described_class.new.perform
+
+ travel_to((migration.interval + described_class::INTERVAL_VARIANCE).seconds.from_now)
+ end
+ end
+
+ it 'marks the migration record as finished' do
+ expect { full_migration_run }.to change { migration.reload.status }.from(1).to(3) # active -> finished
+ end
+
+ it 'creates job records for each processed batch', :aggregate_failures do
+ expect { full_migration_run }.to change { migration.reload.batched_jobs.count }.from(0)
+
+ final_min_value = migration.batched_jobs.reduce(1) do |next_min_value, batched_job|
+ expect(batched_job.min_value).to eq(next_min_value)
+
+ batched_job.max_value + 1
+ end
+
+ final_max_value = final_min_value - 1
+ expect(final_max_value).to eq(migration_records)
+ end
+
+ it 'marks all job records as succeeded', :aggregate_failures do
+ expect { full_migration_run }.to change { migration.reload.batched_jobs.count }.from(0)
+
+ expect(migration.batched_jobs).to all(be_succeeded)
+ end
+
+ it 'updates matching records in the range', :aggregate_failures do
+ expect { full_migration_run }
+ .to change { example_data.where('status = 1 AND some_column <> 0').count }
+ .from(migration_records).to(1)
+
+ record_outside_range = example_data.last
+
+ expect(record_outside_range.status).to eq(1)
+ expect(record_outside_range.some_column).not_to eq(0)
+ end
+
+ it 'does not update non-matching records in the range' do
+ expect { full_migration_run }.not_to change { example_data.where('status <> 1 AND some_column <> 0').count }
+ end
+ end
end
diff --git a/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb b/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb
index 4751d91efde..77c4a3431e2 100644
--- a/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb
+++ b/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb
@@ -202,6 +202,8 @@ RSpec.shared_examples 'can collect git garbage' do |update_statistics: true|
before do
expect(subject).to receive(:get_lease_uuid).and_return(lease_uuid)
+
+ statistics_keys.delete(:repository_size)
end
it_behaves_like 'it calls Gitaly'
diff --git a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
index d9105981b4b..2741b2a9de7 100644
--- a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
+++ b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
@@ -19,7 +19,13 @@ RSpec.shared_examples 'reenqueuer' do
describe '#perform' do
it 'tries to obtain a lease' do
- expect_to_obtain_exclusive_lease(subject.lease_key)
+ lease_key = if subject.respond_to?(:set_custom_lease_key)
+ subject.set_custom_lease_key(*job_args)
+ else
+ subject.lease_key
+ end
+
+ expect_to_obtain_exclusive_lease(lease_key)
subject_perform
end
diff --git a/spec/support_specs/helpers/active_record/query_recorder_spec.rb b/spec/support_specs/helpers/active_record/query_recorder_spec.rb
index f1af9ceffb9..d6c52b22449 100644
--- a/spec/support_specs/helpers/active_record/query_recorder_spec.rb
+++ b/spec/support_specs/helpers/active_record/query_recorder_spec.rb
@@ -78,12 +78,14 @@ RSpec.describe ActiveRecord::QueryRecorder do
end
describe 'detecting the right number of calls and their origin' do
- it 'detects two separate queries' do
- control = ActiveRecord::QueryRecorder.new query_recorder_debug: true do
+ let(:control) do
+ ActiveRecord::QueryRecorder.new query_recorder_debug: true do
2.times { TestQueries.count }
TestQueries.first
end
+ end
+ it 'detects two separate queries' do
# Check #find_query
expect(control.find_query(/.*/, 0).size)
.to eq(control.data.keys.size)
@@ -98,8 +100,8 @@ RSpec.describe ActiveRecord::QueryRecorder do
expect(control.log.size).to eq(3)
# Ensure memoization value match the raw value above
expect(control.count).to eq(control.log.size)
- # Ensure we have only two sources of queries
- expect(control.data.keys.size).to eq(1)
+ # Ensure we have two sources of queries
+ expect(control.data.keys.size).to eq(2)
end
end
end
diff --git a/spec/support_specs/helpers/graphql_helpers_spec.rb b/spec/support_specs/helpers/graphql_helpers_spec.rb
index fae29ec32f5..f567097af6f 100644
--- a/spec/support_specs/helpers/graphql_helpers_spec.rb
+++ b/spec/support_specs/helpers/graphql_helpers_spec.rb
@@ -10,6 +10,81 @@ RSpec.describe GraphqlHelpers do
query.tr("\n", ' ').gsub(/\s+/, ' ').strip
end
+ describe 'a_graphql_entity_for' do
+ context 'when no arguments are passed' do
+ it 'raises an error' do
+ expect { a_graphql_entity_for }.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'when the model is nil, with no properties' do
+ it 'raises an error' do
+ expect { a_graphql_entity_for(nil) }.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'when the model is nil, any fields are passed' do
+ it 'raises an error' do
+ expect { a_graphql_entity_for(nil, :username) }.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'with no model' do
+ it 'behaves like hash-inclusion with camel-casing' do
+ response = { 'foo' => 1, 'bar' => 2, 'camelCased' => 3 }
+
+ expect(response).to match a_graphql_entity_for(foo: 1, camel_cased: 3)
+ expect(response).not_to match a_graphql_entity_for(missing: 5)
+ end
+ end
+
+ context 'with just a model' do
+ it 'only considers the ID' do
+ user = build_stubbed(:user)
+ response = { 'username' => 'foo', 'id' => global_id_of(user).to_s }
+
+ expect(response).to match a_graphql_entity_for(user)
+ end
+ end
+
+ context 'with a model and some method names' do
+ it 'also considers the method names' do
+ user = build_stubbed(:user)
+ response = { 'username' => user.username, 'id' => global_id_of(user).to_s }
+
+ expect(response).to match a_graphql_entity_for(user, :username)
+ expect(response).not_to match a_graphql_entity_for(user, :name)
+ end
+ end
+
+ context 'with a model and some other properties' do
+ it 'behaves like the superset' do
+ user = build_stubbed(:user)
+ response = { 'username' => 'foo', 'id' => global_id_of(user).to_s }
+
+ expect(response).to match a_graphql_entity_for(user, username: 'foo')
+ expect(response).not_to match a_graphql_entity_for(user, name: 'foo')
+ end
+ end
+
+ context 'with a model, method names, and some other properties' do
+ it 'behaves like the superset' do
+ user = build_stubbed(:user)
+ response = {
+ 'username' => user.username,
+ 'name' => user.name,
+ 'foo' => 'bar',
+ 'baz' => 'fop',
+ 'id' => global_id_of(user).to_s
+ }
+
+ expect(response).to match a_graphql_entity_for(user, :username, :name, foo: 'bar')
+ expect(response).to match a_graphql_entity_for(user, :name, foo: 'bar')
+ expect(response).not_to match a_graphql_entity_for(user, :name, bar: 'foo')
+ end
+ end
+ end
+
describe 'graphql_dig_at' do
it 'transforms symbol keys to graphql field names' do
data = { 'camelCased' => 'names' }
diff --git a/spec/support_specs/helpers/migrations_helpers_spec.rb b/spec/support_specs/helpers/migrations_helpers_spec.rb
new file mode 100644
index 00000000000..b82eddad9bc
--- /dev/null
+++ b/spec/support_specs/helpers/migrations_helpers_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MigrationsHelpers do
+ let(:helper_class) do
+ Class.new.tap do |klass|
+ klass.include described_class
+ allow(klass).to receive(:metadata).and_return(metadata)
+ end
+ end
+
+ let(:metadata) { {} }
+ let(:helper) { helper_class.new }
+
+ describe '#active_record_base' do
+ it 'returns the main base model' do
+ expect(helper.active_record_base).to eq(ActiveRecord::Base)
+ end
+
+ context 'ci database configured' do
+ before do
+ skip_if_multiple_databases_not_setup
+ end
+
+ it 'returns the CI base model' do
+ expect(helper.active_record_base(database: :ci)).to eq(Ci::ApplicationRecord)
+ end
+ end
+
+ context 'ci database not configured' do
+ before do
+ skip_if_multiple_databases_are_setup
+ end
+
+ it 'returns the main base model' do
+ expect(helper.active_record_base(database: :ci)).to eq(ActiveRecord::Base)
+ end
+ end
+
+ it 'raises ArgumentError for bad database argument' do
+ expect { helper.active_record_base(database: :non_existent) }.to raise_error(ArgumentError)
+ end
+ end
+
+ describe '#table' do
+ it 'creates a class based on main base model' do
+ klass = helper.table(:projects)
+ expect(klass.connection_specification_name).to eq('ActiveRecord::Base')
+ end
+
+ context 'ci database configured' do
+ before do
+ skip_if_multiple_databases_not_setup
+ end
+
+ it 'create a class based on the CI base model' do
+ klass = helper.table(:ci_builds, database: :ci)
+ expect(klass.connection_specification_name).to eq('Ci::ApplicationRecord')
+ end
+ end
+
+ context 'ci database not configured' do
+ before do
+ skip_if_multiple_databases_are_setup
+ end
+
+ it 'creates a class based on main base model' do
+ klass = helper.table(:ci_builds, database: :ci)
+ expect(klass.connection_specification_name).to eq('ActiveRecord::Base')
+ end
+ end
+ end
+end
diff --git a/spec/support_specs/helpers/stub_feature_flags_spec.rb b/spec/support_specs/helpers/stub_feature_flags_spec.rb
index 9b35fe35259..a59d8a20a40 100644
--- a/spec/support_specs/helpers/stub_feature_flags_spec.rb
+++ b/spec/support_specs/helpers/stub_feature_flags_spec.rb
@@ -47,13 +47,13 @@ RSpec.describe StubFeatureFlags do
it { expect(Feature.enabled?(feature_name)).to eq(expected_result) }
it { expect(Feature.disabled?(feature_name)).not_to eq(expected_result) }
- context 'default_enabled does not impact feature state' do
+ context 'default_enabled_if_undefined does not impact feature state' do
before do
allow(dummy_definition).to receive(:default_enabled).and_return(true)
end
- it { expect(Feature.enabled?(feature_name, default_enabled: true)).to eq(expected_result) }
- it { expect(Feature.disabled?(feature_name, default_enabled: true)).not_to eq(expected_result) }
+ it { expect(Feature.enabled?(feature_name, default_enabled_if_undefined: true)).to eq(expected_result) }
+ it { expect(Feature.disabled?(feature_name, default_enabled_if_undefined: true)).not_to eq(expected_result) }
end
end
end
@@ -83,13 +83,13 @@ RSpec.describe StubFeatureFlags do
it { expect(Feature.enabled?(feature_name, actor(tested_actor))).to eq(expected_result) }
it { expect(Feature.disabled?(feature_name, actor(tested_actor))).not_to eq(expected_result) }
- context 'default_enabled does not impact feature state' do
+ context 'default_enabled_if_undefined does not impact feature state' do
before do
allow(dummy_definition).to receive(:default_enabled).and_return(true)
end
- it { expect(Feature.enabled?(feature_name, actor(tested_actor), default_enabled: true)).to eq(expected_result) }
- it { expect(Feature.disabled?(feature_name, actor(tested_actor), default_enabled: true)).not_to eq(expected_result) }
+ it { expect(Feature.enabled?(feature_name, actor(tested_actor), default_enabled_if_undefined: true)).to eq(expected_result) }
+ it { expect(Feature.disabled?(feature_name, actor(tested_actor), default_enabled_if_undefined: true)).not_to eq(expected_result) }
end
end
end
diff --git a/spec/tasks/dev_rake_spec.rb b/spec/tasks/dev_rake_spec.rb
index 73b1604aa10..fa093db414f 100644
--- a/spec/tasks/dev_rake_spec.rb
+++ b/spec/tasks/dev_rake_spec.rb
@@ -116,7 +116,7 @@ RSpec.describe 'dev rake tasks' do
allow(configurations).to receive(:configs_for).with(env_name: Rails.env, name: 'ci').and_return(ci_configuration)
end
- subject(:load_task) { run_rake_task('dev:setup_ci_db') }
+ subject(:load_task) { run_rake_task('dev:copy_db:ci') }
let(:ci_configuration) { instance_double(ActiveRecord::DatabaseConfigurations::HashConfig, name: 'ci', database: '__test_db_ci') }
@@ -128,14 +128,14 @@ RSpec.describe 'dev rake tasks' do
expect(Rake::Task['dev:terminate_all_connections']).to receive(:invoke)
- run_rake_task('dev:copy_db:ci')
+ load_task
end
context 'when the database already exists' do
it 'prints out a warning' do
expect(ApplicationRecord.connection).to receive(:create_database).and_raise(ActiveRecord::DatabaseAlreadyExists)
- expect { run_rake_task('dev:copy_db:ci') }.to output(/Database '#{ci_configuration.database}' already exists/).to_stderr
+ expect { load_task }.to output(/Database '#{ci_configuration.database}' already exists/).to_stderr
end
end
end
diff --git a/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
index 25a3723fbaa..1c8a1c6a171 100644
--- a/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
+++ b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe 'gitlab:artifacts namespace rake task', :silence_stdout do
subject { run_rake_task('gitlab:artifacts:migrate') }
let!(:artifact) { create(:ci_job_artifact, :archive, file_store: store) }
- let!(:job_trace) { create(:ci_job_artifact, :trace, file_store: store) }
+ let!(:job_log) { create(:ci_job_artifact, :trace, file_store: store) }
context 'when local storage is used' do
let(:store) { ObjectStorage::Store::LOCAL }
@@ -29,7 +29,7 @@ RSpec.describe 'gitlab:artifacts namespace rake task', :silence_stdout do
subject
expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
- expect(job_trace.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
+ expect(job_log.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
end
end
@@ -38,7 +38,7 @@ RSpec.describe 'gitlab:artifacts namespace rake task', :silence_stdout do
subject
expect(artifact.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
- expect(job_trace.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
+ expect(job_log.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
end
end
end
@@ -51,7 +51,7 @@ RSpec.describe 'gitlab:artifacts namespace rake task', :silence_stdout do
subject
expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
- expect(job_trace.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
+ expect(job_log.reload.file_store).to eq(ObjectStorage::Store::REMOTE)
end
end
end
@@ -62,7 +62,7 @@ RSpec.describe 'gitlab:artifacts namespace rake task', :silence_stdout do
subject { run_rake_task('gitlab:artifacts:migrate_to_local') }
let!(:artifact) { create(:ci_job_artifact, :archive, file_store: store) }
- let!(:job_trace) { create(:ci_job_artifact, :trace, file_store: store) }
+ let!(:job_log) { create(:ci_job_artifact, :trace, file_store: store) }
context 'when remote storage is used' do
let(:store) { ObjectStorage::Store::REMOTE }
@@ -72,7 +72,7 @@ RSpec.describe 'gitlab:artifacts namespace rake task', :silence_stdout do
subject
expect(artifact.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
- expect(job_trace.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
+ expect(job_log.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
end
end
end
@@ -84,7 +84,7 @@ RSpec.describe 'gitlab:artifacts namespace rake task', :silence_stdout do
subject
expect(artifact.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
- expect(job_trace.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
+ expect(job_log.reload.file_store).to eq(ObjectStorage::Store::LOCAL)
end
end
end
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index 6080948403d..52a0a9a7385 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -377,21 +377,6 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
expect(tar_lines).to include(a_string_matching(repo_name))
end
end
-
- def move_repository_to_secondary(record)
- Gitlab::GitalyClient::StorageSettings.allow_disk_access do
- default_shard_legacy_path = Gitlab.config.repositories.storages.default.legacy_disk_path
- secondary_legacy_path = Gitlab.config.repositories.storages[second_storage_name].legacy_disk_path
- dst_dir = File.join(secondary_legacy_path, File.dirname(record.disk_path))
-
- FileUtils.mkdir_p(dst_dir) unless Dir.exist?(dst_dir)
-
- FileUtils.mv(
- File.join(default_shard_legacy_path, record.disk_path + '.git'),
- File.join(secondary_legacy_path, record.disk_path + '.git')
- )
- end
- end
end
context 'no concurrency' do
@@ -405,6 +390,66 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
it_behaves_like 'includes repositories in all repository storages'
end
+
+ context 'REPOSITORIES_STORAGES set' do
+ before do
+ stub_env('REPOSITORIES_STORAGES', default_storage_name)
+ end
+
+ it 'includes repositories in default repository storage', :aggregate_failures do
+ project_a = create(:project, :repository)
+ project_snippet_a = create(:project_snippet, :repository, project: project_a, author: project_a.first_owner)
+ project_b = create(:project, :repository, repository_storage: second_storage_name)
+ project_snippet_b = create(:project_snippet, :repository, project: project_b, author: project_b.first_owner)
+ project_snippet_b.snippet_repository.update!(shard: project_b.project_repository.shard)
+ create(:wiki_page, container: project_a)
+ create(:design, :with_file, issue: create(:issue, project: project_a))
+
+ move_repository_to_secondary(project_b)
+ move_repository_to_secondary(project_snippet_b)
+
+ expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process
+
+ tar_contents, exit_status = Gitlab::Popen.popen(
+ %W{tar -tvf #{backup_tar} repositories}
+ )
+
+ tar_lines = tar_contents.lines.grep(/\.bundle/)
+
+ expect(exit_status).to eq(0)
+
+ [
+ "#{project_a.disk_path}/.+/001.bundle",
+ "#{project_a.disk_path}.wiki/.+/001.bundle",
+ "#{project_a.disk_path}.design/.+/001.bundle",
+ "#{project_snippet_a.disk_path}/.+/001.bundle"
+ ].each do |repo_name|
+ expect(tar_lines).to include(a_string_matching(repo_name))
+ end
+
+ [
+ "#{project_b.disk_path}/.+/001.bundle",
+ "#{project_snippet_b.disk_path}/.+/001.bundle"
+ ].each do |repo_name|
+ expect(tar_lines).not_to include(a_string_matching(repo_name))
+ end
+ end
+ end
+
+ def move_repository_to_secondary(record)
+ Gitlab::GitalyClient::StorageSettings.allow_disk_access do
+ default_shard_legacy_path = Gitlab.config.repositories.storages.default.legacy_disk_path
+ secondary_legacy_path = Gitlab.config.repositories.storages[second_storage_name].legacy_disk_path
+ dst_dir = File.join(secondary_legacy_path, File.dirname(record.disk_path))
+
+ FileUtils.mkdir_p(dst_dir) unless Dir.exist?(dst_dir)
+
+ FileUtils.mv(
+ File.join(default_shard_legacy_path, record.disk_path + '.git'),
+ File.join(secondary_legacy_path, record.disk_path + '.git')
+ )
+ end
+ end
end
context 'concurrency settings' do
@@ -420,7 +465,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
stub_env('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 2)
expect(::Backup::Repositories).to receive(:new)
- .with(anything, strategy: anything)
+ .with(anything, strategy: anything, storages: [])
.and_call_original
expect(::Backup::GitalyBackup).to receive(:new).with(anything, max_parallelism: 5, storage_parallelism: 2, incremental: false).and_call_original
diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb
index 73f3b55e12e..e340d568269 100644
--- a/spec/tasks/gitlab/db_rake_spec.rb
+++ b/spec/tasks/gitlab/db_rake_spec.rb
@@ -697,6 +697,34 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
run_rake_task('gitlab:db:migration_testing:sample_background_migrations', '[100]')
end
end
+
+ describe '#sample_batched_background_migrations' do
+ let(:batched_runner) { instance_double(::Gitlab::Database::Migrations::TestBatchedBackgroundRunner) }
+
+ it 'delegates to the migration runner for the main database with a default sample duration' do
+ expect(::Gitlab::Database::Migrations::Runner).to receive(:batched_background_migrations)
+ .with(for_database: 'main').and_return(batched_runner)
+ expect(batched_runner).to receive(:run_jobs).with(for_duration: 30.minutes)
+
+ run_rake_task('gitlab:db:migration_testing:sample_batched_background_migrations')
+ end
+
+ it 'delegates to the migration runner for a specified database with a default sample duration' do
+ expect(::Gitlab::Database::Migrations::Runner).to receive(:batched_background_migrations)
+ .with(for_database: 'ci').and_return(batched_runner)
+ expect(batched_runner).to receive(:run_jobs).with(for_duration: 30.minutes)
+
+ run_rake_task('gitlab:db:migration_testing:sample_batched_background_migrations', '[ci]')
+ end
+
+ it 'delegates to the migration runner for a specified database and sample duration' do
+ expect(::Gitlab::Database::Migrations::Runner).to receive(:batched_background_migrations)
+ .with(for_database: 'ci').and_return(batched_runner)
+ expect(batched_runner).to receive(:run_jobs).with(for_duration: 100.seconds)
+
+ run_rake_task('gitlab:db:migration_testing:sample_batched_background_migrations', '[ci, 100]')
+ end
+ end
end
describe '#execute_batched_migrations' do
diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb
index b3fb592c2e3..78e9c8e9c62 100644
--- a/spec/tooling/danger/project_helper_spec.rb
+++ b/spec/tooling/danger/project_helper_spec.rb
@@ -101,6 +101,15 @@ RSpec.describe Tooling::Danger::ProjectHelper do
'Rakefile' | [:backend]
'FOO_VERSION' | [:backend]
+ 'lib/scripts/bar.rb' | [:backend, :tooling]
+ 'lib/scripts/bar.js' | [:frontend, :tooling]
+ 'scripts/bar.rb' | [:backend, :tooling]
+ 'scripts/bar.js' | [:frontend, :tooling]
+ 'lib/scripts/subdir/bar.rb' | [:backend, :tooling]
+ 'lib/scripts/subdir/bar.js' | [:frontend, :tooling]
+ 'scripts/subdir/bar.rb' | [:backend, :tooling]
+ 'scripts/subdir/bar.js' | [:frontend, :tooling]
+
'Dangerfile' | [:tooling]
'danger/bundle_size/Dangerfile' | [:tooling]
'ee/danger/bundle_size/Dangerfile' | [:tooling]
diff --git a/spec/tooling/danger/specs_spec.rb b/spec/tooling/danger/specs_spec.rb
index b2454960a7b..6c1fbbb903d 100644
--- a/spec/tooling/danger/specs_spec.rb
+++ b/spec/tooling/danger/specs_spec.rb
@@ -118,7 +118,8 @@ RSpec.describe Tooling::Danger::Specs do
"- expect(foo).to match(['bar'])",
"- expect(foo).to match ['bar']",
"- expect(foo).to eq(['bar'])",
- "- expect(foo).to eq ['bar']"
+ "- expect(foo).to eq ['bar']",
+ "+ expect(foo).to eq([])"
] + matching_lines
end
@@ -126,7 +127,7 @@ RSpec.describe Tooling::Danger::Specs do
allow(specs.helper).to receive(:changed_lines).with(filename).and_return(changed_lines)
end
- it 'returns added, modified, and renamed_after files by default' do
+ it 'returns all lines using an array equality matcher' do
expect(specs.added_line_matching_match_with_array(filename)).to match_array(matching_lines)
end
end
diff --git a/spec/tooling/fixtures/find_codeowners/dir0/dir1/dir2/file2 b/spec/tooling/fixtures/find_codeowners/dir0/dir1/dir2/file2
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/spec/tooling/fixtures/find_codeowners/dir0/dir1/dir2/file2
diff --git a/spec/tooling/fixtures/find_codeowners/dir0/dir1/file1 b/spec/tooling/fixtures/find_codeowners/dir0/dir1/file1
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/spec/tooling/fixtures/find_codeowners/dir0/dir1/file1
diff --git a/spec/tooling/fixtures/find_codeowners/dir0/file0 b/spec/tooling/fixtures/find_codeowners/dir0/file0
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/spec/tooling/fixtures/find_codeowners/dir0/file0
diff --git a/spec/tooling/fixtures/find_codeowners/file b/spec/tooling/fixtures/find_codeowners/file
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/spec/tooling/fixtures/find_codeowners/file
diff --git a/spec/tooling/lib/tooling/find_codeowners_spec.rb b/spec/tooling/lib/tooling/find_codeowners_spec.rb
new file mode 100644
index 00000000000..b29c5f35ec9
--- /dev/null
+++ b/spec/tooling/lib/tooling/find_codeowners_spec.rb
@@ -0,0 +1,199 @@
+# frozen_string_literal: true
+
+require_relative '../../../../tooling/lib/tooling/find_codeowners'
+
+RSpec.describe Tooling::FindCodeowners do
+ let(:subject) { described_class.new }
+ let(:root) { File.expand_path('../../fixtures/find_codeowners', __dir__) }
+
+ describe '#execute' do
+ before do
+ allow(subject).to receive(:load_config).and_return(
+ '[Section name]': {
+ '@group': {
+ allow: {
+ keywords: %w[dir0 file],
+ patterns: ['/%{keyword}/**/*', '/%{keyword}']
+ },
+ deny: {
+ keywords: %w[file0],
+ patterns: ['**/%{keyword}']
+ }
+ }
+ }
+ )
+ end
+
+ it 'prints CODEOWNERS as configured' do
+ expect do
+ Dir.chdir(root) do
+ subject.execute
+ end
+ end.to output(<<~CODEOWNERS).to_stdout
+ [Section name]
+ /dir0/dir1 @group
+ /file @group
+ CODEOWNERS
+ end
+ end
+
+ describe '#load_definitions' do
+ it 'expands the allow and deny list with keywords and patterns' do
+ subject.load_definitions.each do |section, group_defintions|
+ group_defintions.each do |group, definitions|
+ expect(definitions[:allow]).to be_an(Array)
+ expect(definitions[:deny]).to be_an(Array)
+ end
+ end
+ end
+
+ it 'expands the auth group' do
+ auth = subject.load_definitions.dig(
+ :'[Authentication and Authorization]',
+ :'@gitlab-org/manage/authentication-and-authorization')
+
+ expect(auth).to eq(
+ allow: %w[
+ /{,ee/}app/**/*password*{/**/*,}
+ /{,ee/}config/**/*password*{/**/*,}
+ /{,ee/}lib/**/*password*{/**/*,}
+ /{,ee/}app/**/*auth*{/**/*,}
+ /{,ee/}config/**/*auth*{/**/*,}
+ /{,ee/}lib/**/*auth*{/**/*,}
+ /{,ee/}app/**/*token*{/**/*,}
+ /{,ee/}config/**/*token*{/**/*,}
+ /{,ee/}lib/**/*token*{/**/*,}
+ ],
+ deny: %w[
+ **/*author.*{/**/*,}
+ **/*author_*{/**/*,}
+ **/*authored*{/**/*,}
+ **/*authoring*{/**/*,}
+ **/*.png*{/**/*,}
+ **/*.svg*{/**/*,}
+ **/*deploy_token*{/**/*,}
+ **/*runner{,s}_token*{/**/*,}
+ **/*job_token*{/**/*,}
+ **/*autocomplete_tokens*{/**/*,}
+ **/*dast_site_token*{/**/*,}
+ **/*reset_prometheus_token*{/**/*,}
+ **/*reset_registration_token*{/**/*,}
+ **/*runners_registration_token*{/**/*,}
+ **/*terraform_registry_token*{/**/*,}
+ **/*tokenizer*{/**/*,}
+ **/*filtered_search*{/**/*,}
+ **/*/alert_management/*{/**/*,}
+ **/*/analytics/*{/**/*,}
+ **/*/bitbucket/*{/**/*,}
+ **/*/clusters/*{/**/*,}
+ **/*/clusters_list/*{/**/*,}
+ **/*/dast/*{/**/*,}
+ **/*/dast_profiles/*{/**/*,}
+ **/*/dast_site_tokens/*{/**/*,}
+ **/*/dast_site_validation/*{/**/*,}
+ **/*/dependency_proxy/*{/**/*,}
+ **/*/error_tracking/*{/**/*,}
+ **/*/google_api/*{/**/*,}
+ **/*/google_cloud/*{/**/*,}
+ **/*/jira_connect/*{/**/*,}
+ **/*/kubernetes/*{/**/*,}
+ **/*/protected_environments/*{/**/*,}
+ **/*/config/feature_flags/development/jira_connect_*{/**/*,}
+ **/*/config/metrics/*{/**/*,}
+ **/*/app/controllers/groups/dependency_proxy_auth_controller.rb*{/**/*,}
+ **/*/app/finders/ci/auth_job_finder.rb*{/**/*,}
+ **/*/ee/config/metrics/*{/**/*,}
+ **/*/lib/gitlab/conan_token.rb*{/**/*,}
+ ]
+ )
+ end
+ end
+
+ describe '#load_config' do
+ it 'loads the config with symbolized keys' do
+ config = subject.load_config
+
+ expect_hash_keys_to_be_symbols(config)
+ end
+
+ context 'when YAML has safe_load_file' do
+ before do
+ allow(YAML).to receive(:respond_to?).with(:safe_load_file).and_return(true)
+ end
+
+ it 'calls safe_load_file' do
+ expect(YAML).to receive(:safe_load_file)
+
+ subject.load_config
+ end
+ end
+
+ context 'when YAML does not have safe_load_file' do
+ before do
+ allow(YAML).to receive(:respond_to?).with(:safe_load_file).and_return(false)
+ end
+
+ it 'calls load_file' do
+ expect(YAML).to receive(:safe_load)
+
+ subject.load_config
+ end
+ end
+
+ def expect_hash_keys_to_be_symbols(object)
+ if object.is_a?(Hash)
+ object.each do |key, value|
+ expect(key).to be_a(Symbol)
+
+ expect_hash_keys_to_be_symbols(value)
+ end
+ end
+ end
+ end
+
+ describe '#path_matches?' do
+ let(:pattern) { 'pattern' }
+ let(:path) { 'path' }
+
+ it 'passes flags we are expecting to File.fnmatch?' do
+ expected_flags =
+ ::File::FNM_DOTMATCH | ::File::FNM_PATHNAME | ::File::FNM_EXTGLOB
+
+ expect(File).to receive(:fnmatch?).with(pattern, path, expected_flags)
+
+ subject.path_matches?(pattern, path)
+ end
+ end
+
+ describe '#consolidate_paths' do
+ before do
+ allow(subject).to receive(:find_dir_maxdepth_1).and_return(<<~LINES)
+ dir
+ dir/0
+ dir/2
+ dir/3
+ dir/1
+ LINES
+ end
+
+ context 'when the directory has the same number of entries' do
+ let(:input_paths) { %W[dir/0\n dir/1\n dir/2\n dir/3\n] }
+
+ it 'consolidates into the directory' do
+ paths = subject.consolidate_paths(input_paths)
+
+ expect(paths).to eq(["dir\n"])
+ end
+ end
+
+ context 'when the directory has different number of entries' do
+ let(:input_paths) { %W[dir/0\n dir/1\n dir/2\n] }
+
+ it 'returns the original paths' do
+ paths = subject.consolidate_paths(input_paths)
+
+ expect(paths).to eq(input_paths)
+ end
+ end
+ end
+end
diff --git a/spec/tooling/quality/test_level_spec.rb b/spec/tooling/quality/test_level_spec.rb
index c72e90dc713..98034eb4b0a 100644
--- a/spec/tooling/quality/test_level_spec.rb
+++ b/spec/tooling/quality/test_level_spec.rb
@@ -11,13 +11,6 @@ RSpec.describe Quality::TestLevel do
end
end
- context 'when level is geo' do
- it 'returns a pattern' do
- expect(subject.pattern(:geo))
- .to eq("spec/**{,/**/}*_spec.rb")
- end
- end
-
context 'when level is frontend_fixture' do
it 'returns a pattern' do
expect(subject.pattern(:frontend_fixture))
@@ -93,13 +86,6 @@ RSpec.describe Quality::TestLevel do
end
end
- context 'when level is geo' do
- it 'returns a regexp' do
- expect(subject.regexp(:geo))
- .to eq(%r{spec/})
- end
- end
-
context 'when level is frontend_fixture' do
it 'returns a regexp' do
expect(subject.regexp(:frontend_fixture))
diff --git a/spec/views/admin/application_settings/general.html.haml_spec.rb b/spec/views/admin/application_settings/general.html.haml_spec.rb
index 7d28175d134..503e41eabc9 100644
--- a/spec/views/admin/application_settings/general.html.haml_spec.rb
+++ b/spec/views/admin/application_settings/general.html.haml_spec.rb
@@ -6,13 +6,16 @@ RSpec.describe 'admin/application_settings/general.html.haml' do
let(:app_settings) { build(:application_setting) }
let(:user) { create(:admin) }
+ before do
+ assign(:application_setting, app_settings)
+ allow(view).to receive(:current_user).and_return(user)
+ end
+
describe 'sourcegraph integration' do
let(:sourcegraph_flag) { true }
before do
- assign(:application_setting, app_settings)
allow(Gitlab::Sourcegraph).to receive(:feature_available?).and_return(sourcegraph_flag)
- allow(view).to receive(:current_user).and_return(user)
end
context 'when sourcegraph feature is enabled' do
@@ -35,11 +38,6 @@ RSpec.describe 'admin/application_settings/general.html.haml' do
end
describe 'prompt user about registration features' do
- before do
- assign(:application_setting, app_settings)
- allow(view).to receive(:current_user).and_return(user)
- end
-
context 'when service ping is enabled' do
before do
stub_application_setting(usage_ping_enabled: true)
@@ -60,4 +58,14 @@ RSpec.describe 'admin/application_settings/general.html.haml' do
it_behaves_like 'renders registration features prompt', :application_setting_disabled_repository_size_limit
end
end
+
+ describe 'add license' do
+ before do
+ render
+ end
+
+ it 'does not show the Add License section' do
+ expect(rendered).not_to have_css('#js-add-license-toggle')
+ end
+ end
end
diff --git a/spec/views/devise/shared/_signin_box.html.haml_spec.rb b/spec/views/devise/shared/_signin_box.html.haml_spec.rb
index 8b1af1866dc..e2aa0bb9870 100644
--- a/spec/views/devise/shared/_signin_box.html.haml_spec.rb
+++ b/spec/views/devise/shared/_signin_box.html.haml_spec.rb
@@ -28,6 +28,20 @@ RSpec.describe 'devise/shared/_signin_box' do
end
end
+ describe 'Base form' do
+ before do
+ stub_devise
+ allow(view).to receive(:captcha_enabled?).and_return(false)
+ allow(view).to receive(:captcha_on_login_required?).and_return(false)
+ end
+
+ it 'renders user_login label' do
+ render
+
+ expect(rendered).to have_content(_('Username or email'))
+ end
+ end
+
def stub_devise
allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user])
allow(view).to receive(:resource).and_return(spy)
diff --git a/spec/views/devise/shared/_signup_box.html.haml_spec.rb b/spec/views/devise/shared/_signup_box.html.haml_spec.rb
index 1f0cd213f7b..b0730e6fc54 100644
--- a/spec/views/devise/shared/_signup_box.html.haml_spec.rb
+++ b/spec/views/devise/shared/_signup_box.html.haml_spec.rb
@@ -3,28 +3,41 @@
require 'spec_helper'
RSpec.describe 'devise/shared/_signup_box' do
+ let(:button_text) { '_button_text_' }
+ let(:terms_path) { '_terms_path_' }
+
+ let(:translation_com) do
+ s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted "\
+ "the GitLab %{link_start}Terms of Use and Privacy Policy%{link_end}")
+ end
+
+ let(:translation_non_com) do
+ s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted "\
+ "the %{link_start}Terms of Use and Privacy Policy%{link_end}")
+ end
+
before do
stub_devise
allow(view).to receive(:show_omniauth_providers).and_return(false)
allow(view).to receive(:url).and_return('_url_')
- allow(view).to receive(:terms_path).and_return('_terms_path_')
- allow(view).to receive(:button_text).and_return('_button_text_')
+ allow(view).to receive(:terms_path).and_return(terms_path)
+ allow(view).to receive(:button_text).and_return(button_text)
allow(view).to receive(:signup_username_data_attributes).and_return({})
stub_template 'devise/shared/_error_messages.html.haml' => ''
end
+ def text(translation)
+ format(translation,
+ button_text: button_text,
+ link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>",
+ link_end: '</a>')
+ end
+
context 'when terms are enforced' do
before do
allow(Gitlab::CurrentSettings.current_application_settings).to receive(:enforce_terms?).and_return(true)
end
- it 'shows expected text with placeholders' do
- render
-
- expect(rendered).to have_content('By clicking _button_text_')
- expect(rendered).to have_link('Terms of Use and Privacy Policy')
- end
-
context 'when on .com' do
before do
allow(Gitlab).to receive(:com?).and_return(true)
@@ -33,7 +46,7 @@ RSpec.describe 'devise/shared/_signup_box' do
it 'shows expected GitLab text' do
render
- expect(rendered).to have_content('I have read and accepted the GitLab Terms')
+ expect(rendered).to include(text(translation_com))
end
end
@@ -45,7 +58,7 @@ RSpec.describe 'devise/shared/_signup_box' do
it 'shows expected text without GitLab' do
render
- expect(rendered).to have_content('I have read and accepted the Terms')
+ expect(rendered).to include(text(translation_non_com))
end
end
end
@@ -59,7 +72,7 @@ RSpec.describe 'devise/shared/_signup_box' do
it 'shows expected text with placeholders' do
render
- expect(rendered).not_to have_content('By clicking')
+ expect(rendered).not_to include(text(translation_com))
end
end
diff --git a/spec/views/groups/runners/_group_runners.html.haml_spec.rb b/spec/views/groups/runners/_group_runners.html.haml_spec.rb
deleted file mode 100644
index 3a8686ab046..00000000000
--- a/spec/views/groups/runners/_group_runners.html.haml_spec.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'groups/runners/group_runners.html.haml' do
- describe 'render' do
- let_it_be(:user) { create(:user) }
- let_it_be(:group) { create(:group) }
-
- before do
- @group = group
- allow(view).to receive(:current_user).and_return(user)
- allow(view).to receive(:reset_registration_token_group_settings_ci_cd_path).and_return('banana_url')
- end
-
- context 'when group runner registration is allowed' do
- before do
- allow(view).to receive(:can?).with(user, :register_group_runners, group).and_return(true)
- end
-
- it 'enables the Remove group button for a group' do
- render 'groups/runners/group_runners', group: group
-
- expect(rendered).to have_selector '#js-install-runner'
- expect(rendered).not_to have_content 'Please contact an admin to register runners.'
- end
- end
-
- context 'when group runner registration is not allowed' do
- before do
- allow(view).to receive(:can?).with(user, :register_group_runners, group).and_return(false)
- end
-
- it 'does not enable the the Remove group button for a group' do
- render 'groups/runners/group_runners', group: group
-
- expect(rendered).to have_content 'Please contact an admin to register runners.'
- expect(rendered).not_to have_selector '#js-install-runner'
- end
- end
- end
-end
diff --git a/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb b/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb
deleted file mode 100644
index 5438fea85ee..00000000000
--- a/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'groups/runners/sort_dropdown.html.haml' do
- describe 'render' do
- describe 'when a sort option is not selected' do
- it 'renders a default sort option' do
- render 'groups/runners/sort_dropdown'
-
- expect(rendered).to have_content _('Created date')
- end
- end
-
- describe 'when a sort option is selected' do
- before do
- assign(:sort, 'contacted_asc')
- render 'groups/runners/sort_dropdown'
- end
-
- it 'renders the selected sort option' do
- expect(rendered).to have_content _('Last Contact')
- end
- end
- end
-end
diff --git a/spec/views/help/instance_configuration.html.haml_spec.rb b/spec/views/help/instance_configuration.html.haml_spec.rb
index c4542046a9d..fbf84a5d272 100644
--- a/spec/views/help/instance_configuration.html.haml_spec.rb
+++ b/spec/views/help/instance_configuration.html.haml_spec.rb
@@ -21,6 +21,7 @@ RSpec.describe 'help/instance_configuration' do
expect(rendered).to have_link(nil, href: '#size-limits')
expect(rendered).to have_link(nil, href: '#package-registry')
expect(rendered).to have_link(nil, href: '#rate-limits')
+ expect(rendered).to have_link(nil, href: '#ci-cd-limits')
end
it 'has several sections' do
@@ -31,6 +32,7 @@ RSpec.describe 'help/instance_configuration' do
expect(rendered).to have_css('h2#size-limits')
expect(rendered).to have_css('h2#package-registry')
expect(rendered).to have_css('h2#rate-limits')
+ expect(rendered).to have_css('h2#ci-cd-limits')
end
end
end
diff --git a/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb
index 8c9d1b32671..428e9cc8490 100644
--- a/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_group.html.haml_spec.rb
@@ -101,7 +101,7 @@ RSpec.describe 'layouts/nav/sidebar/_group' do
end
end
- describe 'Kubernetes menu' do
+ describe 'Kubernetes menu', :request_store do
it 'has a link to the group cluster list path' do
render
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index 22e925e22ae..3943355bffd 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -537,24 +537,6 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end
describe 'Infrastructure' do
- describe 'Serverless platform' do
- it 'has a link to the serverless page' do
- render
-
- expect(rendered).to have_link('Serverless platform', href: project_serverless_functions_path(project))
- end
-
- describe 'when the user does not have access' do
- let(:user) { nil }
-
- it 'does not have a link to the serverless page' do
- render
-
- expect(rendered).not_to have_link('Serverless platform')
- end
- end
- end
-
describe 'Terraform' do
it 'has a link to the terraform page' do
render
diff --git a/spec/views/profiles/keys/_form.html.haml_spec.rb b/spec/views/profiles/keys/_form.html.haml_spec.rb
index ba8394178d9..c807512a11a 100644
--- a/spec/views/profiles/keys/_form.html.haml_spec.rb
+++ b/spec/views/profiles/keys/_form.html.haml_spec.rb
@@ -15,8 +15,6 @@ RSpec.describe 'profiles/keys/_form.html.haml' do
context 'when the form partial is used' do
before do
- allow(view).to receive(:ssh_key_expires_field_description).and_return('Key can still be used after expiration.')
-
render
end
@@ -37,7 +35,7 @@ RSpec.describe 'profiles/keys/_form.html.haml' do
it 'has the expires at field', :aggregate_failures do
expect(rendered).to have_field('Expiration date', type: 'date')
expect(page.find_field('Expiration date')['min']).to eq(l(1.day.from_now, format: "%Y-%m-%d"))
- expect(rendered).to have_text('Key can still be used after expiration.')
+ expect(rendered).to have_text('Key becomes invalid on this date')
end
it 'has the validation warning', :aggregate_failures do
diff --git a/spec/views/profiles/keys/_key.html.haml_spec.rb b/spec/views/profiles/keys/_key.html.haml_spec.rb
index ed8026d2453..1040541332d 100644
--- a/spec/views/profiles/keys/_key.html.haml_spec.rb
+++ b/spec/views/profiles/keys/_key.html.haml_spec.rb
@@ -59,11 +59,7 @@ RSpec.describe 'profiles/keys/_key.html.haml' do
end
context 'when the key has expired' do
- let_it_be(:key) do
- create(:personal_key,
- user: user,
- expires_at: 2.days.ago)
- end
+ let_it_be(:key) { create(:personal_key, :expired, user: user) }
it 'renders "Expired:" as the expiration date label' do
render
@@ -91,8 +87,6 @@ RSpec.describe 'profiles/keys/_key.html.haml' do
where(:valid, :expiry, :result) do
false | 2.days.from_now | 'Key type is forbidden. Must be DSA, ECDSA, ED25519, ECDSA_SK, or ED25519_SK'
- false | 2.days.ago | 'Key type is forbidden. Must be DSA, ECDSA, ED25519, ECDSA_SK, or ED25519_SK'
- true | 2.days.ago | 'Key usable beyond expiration date.'
true | 2.days.from_now | ''
end
diff --git a/spec/views/projects/clusters/clusters/gcp/_form.html.haml_spec.rb b/spec/views/projects/clusters/clusters/gcp/_form.html.haml_spec.rb
deleted file mode 100644
index 5120998ded6..00000000000
--- a/spec/views/projects/clusters/clusters/gcp/_form.html.haml_spec.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'clusters/clusters/gcp/_form' do
- let(:admin) { create(:admin) }
- let(:environment) { create(:environment) }
- let(:gcp_cluster) { create(:cluster, :provided_by_gcp) }
- let(:clusterable) { ClusterablePresenter.fabricate(environment.project, current_user: admin) }
-
- before do
- assign(:environment, environment)
- assign(:gcp_cluster, gcp_cluster)
- allow(view).to receive(:clusterable).and_return(clusterable)
- allow(view).to receive(:url_for).and_return('#')
- allow(view).to receive(:token_in_session).and_return('')
- end
-
- context 'with all feature flags enabled' do
- it 'has a cloud run checkbox' do
- render
-
- expect(rendered).to have_selector("input[id='cluster_provider_gcp_attributes_cloud_run']")
- end
- end
-end
diff --git a/spec/views/projects/issues/show.html.haml_spec.rb b/spec/views/projects/issues/show.html.haml_spec.rb
index b2d208f038a..3f1496a24ce 100644
--- a/spec/views/projects/issues/show.html.haml_spec.rb
+++ b/spec/views/projects/issues/show.html.haml_spec.rb
@@ -26,14 +26,14 @@ RSpec.describe 'projects/issues/show' do
it 'shows "Closed (moved)" if an issue has been moved and closed' do
render
- expect(rendered).to have_selector('.status-box-issue-closed:not(.hidden)', text: 'Closed (moved)')
+ expect(rendered).to have_selector('.issuable-status-badge-closed:not(.hidden)', text: 'Closed (moved)')
end
it 'shows "Closed (moved)" if an issue has been moved and discussion is locked' do
allow(issue).to receive(:discussion_locked).and_return(true)
render
- expect(rendered).to have_selector('.status-box-issue-closed:not(.hidden)', text: 'Closed (moved)')
+ expect(rendered).to have_selector('.issuable-status-badge-closed:not(.hidden)', text: 'Closed (moved)')
end
it 'links "moved" to the new issue the original issue was moved to' do
@@ -47,7 +47,7 @@ RSpec.describe 'projects/issues/show' do
render
- expect(rendered).not_to have_selector('.status-box-issue-closed:not(.hidden)', text: 'Closed (moved)')
+ expect(rendered).not_to have_selector('.issuable-status-badge-closed:not(.hidden)', text: 'Closed (moved)')
end
end
@@ -75,7 +75,7 @@ RSpec.describe 'projects/issues/show' do
it 'shows "Closed (duplicated)" if an issue has been duplicated' do
render
- expect(rendered).to have_selector('.status-box-issue-closed:not(.hidden)', text: 'Closed (duplicated)')
+ expect(rendered).to have_selector('.issuable-status-badge-closed:not(.hidden)', text: 'Closed (duplicated)')
end
it 'links "duplicated" to the new issue the original issue was duplicated to' do
@@ -97,14 +97,14 @@ RSpec.describe 'projects/issues/show' do
it 'shows "Closed" if an issue has not been moved or duplicated' do
render
- expect(rendered).to have_selector('.status-box-issue-closed:not(.hidden)', text: 'Closed')
+ expect(rendered).to have_selector('.issuable-status-badge-closed:not(.hidden)', text: 'Closed')
end
it 'shows "Closed" if discussion is locked' do
allow(issue).to receive(:discussion_locked).and_return(true)
render
- expect(rendered).to have_selector('.status-box-issue-closed:not(.hidden)', text: 'Closed')
+ expect(rendered).to have_selector('.issuable-status-badge-closed:not(.hidden)', text: 'Closed')
end
end
@@ -117,14 +117,14 @@ RSpec.describe 'projects/issues/show' do
it 'shows "Open" if an issue has been moved' do
render
- expect(rendered).to have_selector('.status-box-open:not(.hidden)', text: 'Open')
+ expect(rendered).to have_selector('.issuable-status-badge-open:not(.hidden)', text: 'Open')
end
it 'shows "Open" if discussion is locked' do
allow(issue).to receive(:discussion_locked).and_return(true)
render
- expect(rendered).to have_selector('.status-box-open:not(.hidden)', text: 'Open')
+ expect(rendered).to have_selector('.issuable-status-badge-open:not(.hidden)', text: 'Open')
end
end
diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb
index 6ffd0936003..86a4b25f746 100644
--- a/spec/views/projects/merge_requests/show.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb
@@ -16,7 +16,6 @@ RSpec.describe 'projects/merge_requests/show.html.haml', :aggregate_failures do
render
expect(rendered).to have_css('a', visible: true, text: 'Mark as draft')
- expect(rendered).to have_css('a', visible: false, text: 'Reopen')
expect(rendered).to have_css('a', visible: true, text: 'Close')
end
end
@@ -31,7 +30,6 @@ RSpec.describe 'projects/merge_requests/show.html.haml', :aggregate_failures do
expect(rendered).not_to have_css('a', visible: true, text: 'Mark as draft')
expect(rendered).to have_css('a', visible: true, text: 'Reopen')
- expect(rendered).to have_css('a', visible: false, text: 'Close')
end
context 'when source project does not exist' do
@@ -40,8 +38,7 @@ RSpec.describe 'projects/merge_requests/show.html.haml', :aggregate_failures do
render
- expect(rendered).to have_css('a', visible: false, text: 'Reopen')
- expect(rendered).to have_css('a', visible: false, text: 'Close')
+ expect(rendered).not_to have_css('a', visible: false, text: 'Reopen')
end
end
end
diff --git a/spec/views/projects/project_members/index.html.haml_spec.rb b/spec/views/projects/project_members/index.html.haml_spec.rb
index 7186a5f1766..0446e1a7fc8 100644
--- a/spec/views/projects/project_members/index.html.haml_spec.rb
+++ b/spec/views/projects/project_members/index.html.haml_spec.rb
@@ -4,8 +4,7 @@ require 'spec_helper'
RSpec.describe 'projects/project_members/index', :aggregate_failures do
let_it_be(:user) { create(:user) }
- let_it_be(:source) { create(:project, :empty_repo) }
- let_it_be(:project) { ProjectPresenter.new(source, current_user: user) }
+ let_it_be(:project) { create(:project, :empty_repo, :with_namespace_settings).present(current_user: user) }
before do
allow(view).to receive(:project_members_app_data_json).and_return({})
diff --git a/spec/views/projects/settings/operations/show.html.haml_spec.rb b/spec/views/projects/settings/operations/show.html.haml_spec.rb
index c0ec86a41a7..8853b34074a 100644
--- a/spec/views/projects/settings/operations/show.html.haml_spec.rb
+++ b/spec/views/projects/settings/operations/show.html.haml_spec.rb
@@ -52,30 +52,6 @@ RSpec.describe 'projects/settings/operations/show' do
end
end
- describe 'Operations > Prometheus' do
- context 'when settings_operations_prometheus_service flag is enabled' do
- it 'renders the Operations Settings page' do
- render
-
- expect(rendered).to have_content _('Prometheus')
- expect(rendered).to have_content _('Link Prometheus monitoring to GitLab.')
- expect(rendered).to have_content _('To use a Prometheus installed on a cluster, deactivate the manual configuration.')
- end
- end
-
- context 'when settings_operations_prometheus_service is disabled' do
- before do
- stub_feature_flags(settings_operations_prometheus_service: false)
- end
-
- it 'renders the Operations Settings page' do
- render
-
- expect(rendered).not_to have_content _('Auto configuration settings are used unless you override their values here.')
- end
- end
- end
-
describe 'Operations > Tracing' do
context 'Settings page ' do
it 'renders the Tracing Settings page' do
diff --git a/spec/views/shared/access_tokens/_table.html.haml_spec.rb b/spec/views/shared/access_tokens/_table.html.haml_spec.rb
index fca2fc3183c..74de9e12d04 100644
--- a/spec/views/shared/access_tokens/_table.html.haml_spec.rb
+++ b/spec/views/shared/access_tokens/_table.html.haml_spec.rb
@@ -6,7 +6,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do
let(:type) { 'token' }
let(:type_plural) { 'tokens' }
let(:empty_message) { nil }
- let(:token_expiry_enforced?) { false }
let(:impersonation) { false }
let_it_be(:user) { create(:user) }
@@ -14,12 +13,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do
let_it_be(:resource) { false }
before do
- stub_licensed_features(enforce_personal_access_token_expiration: true)
- allow(Gitlab::CurrentSettings).to receive(:enforce_pat_expiration?).and_return(false)
-
- allow(view).to receive(:personal_access_token_expiration_enforced?).and_return(token_expiry_enforced?)
- allow(view).to receive(:show_profile_token_expiry_notification?).and_return(true)
-
if resource
resource.add_maintainer(user)
end
@@ -51,22 +44,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do
expect(rendered).not_to have_content 'To see all the user\'s personal access tokens you must impersonate them first.'
expect(rendered).not_to have_selector 'th', text: 'Role'
end
-
- context 'if token expiration is enforced' do
- let(:token_expiry_enforced?) { true }
-
- it 'does not show the subtext' do
- expect(rendered).not_to have_content 'Personal access tokens are not revoked upon expiration.'
- end
- end
-
- context 'if token expiration is not enforced' do
- let(:token_expiry_enforced?) { false }
-
- it 'does show the subtext' do
- expect(rendered).to have_content 'Personal access tokens are not revoked upon expiration.'
- end
- end
end
context 'if impersonation' do
@@ -124,16 +101,16 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do
context 'with tokens' do
let_it_be(:tokens) do
[
- create(:personal_access_token, user: user, name: 'Access token', last_used_at: 1.day.ago, expires_at: nil),
- create(:personal_access_token, user: user, expires_at: 5.days.ago),
- create(:personal_access_token, user: user, expires_at: Time.now),
- create(:personal_access_token, user: user, expires_at: 5.days.from_now, scopes: [:read_api, :read_user])
+ create(:personal_access_token, user: user, name: 'Access token', last_used_at: 4.days.from_now, expires_at: nil, scopes: [:read_api, :read_user]),
+ create(:personal_access_token, user: user, expires_at: 1.day.from_now, scopes: [:read_api, :read_user])
]
end
+ let_it_be(:expired_token) { build(:personal_access_token, name: "Expired token", expires_at: 2.days.ago).tap { |t| t.save!(validate: false) } }
+
it 'has the correct content', :aggregate_failures do
# Heading content
- expect(rendered).to have_content 'Active tokens (4)'
+ expect(rendered).to have_content 'Active tokens (2)'
# Table headers
expect(rendered).to have_selector 'th', text: 'Token name'
@@ -144,17 +121,15 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do
# Table contents
expect(rendered).to have_content 'Access token'
+ expect(rendered).not_to have_content 'Expired token'
expect(rendered).to have_content 'read_api, read_user'
expect(rendered).to have_content 'no scopes selected'
expect(rendered).to have_content Time.now.to_date.to_s(:medium)
- expect(rendered).to have_content l(1.day.ago, format: "%b %d, %Y")
-
- # Expiry
- expect(rendered).to have_content 'Expired', count: 2
+ expect(rendered).to have_content l(4.days.from_now, format: "%b %d, %Y")
# Revoke buttons
expect(rendered).to have_link 'Revoke', href: 'path/', class: 'btn-danger-secondary', count: 1
- expect(rendered).to have_link 'Revoke', href: 'path/', count: 4
+ expect(rendered).to have_link 'Revoke', href: 'path/', count: 2
end
context 'without the last used time' do
diff --git a/spec/views/shared/notes/_form.html.haml_spec.rb b/spec/views/shared/notes/_form.html.haml_spec.rb
index b7bad4c5d78..ccf1e08b7e7 100644
--- a/spec/views/shared/notes/_form.html.haml_spec.rb
+++ b/spec/views/shared/notes/_form.html.haml_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe 'shared/notes/_form' do
let(:note) { build(:"note_on_#{noteable}", project: project) }
it 'says that markdown and quick actions are supported' do
- expect(rendered).to have_content('Markdown and quick actions are supported')
+ expect(rendered).to have_content('Supports Markdown. For quick actions, type /.')
end
end
end
diff --git a/spec/workers/authorized_project_update/project_create_worker_spec.rb b/spec/workers/authorized_project_update/project_create_worker_spec.rb
deleted file mode 100644
index 5226ab30de7..00000000000
--- a/spec/workers/authorized_project_update/project_create_worker_spec.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe AuthorizedProjectUpdate::ProjectCreateWorker do
- let_it_be(:group) { create(:group, :private) }
- let_it_be(:group_project) { create(:project, group: group) }
- let_it_be(:group_user) { create(:user) }
-
- let(:access_level) { Gitlab::Access::MAINTAINER }
-
- subject(:worker) { described_class.new }
-
- it 'calls AuthorizedProjectUpdate::ProjectCreateService' do
- expect_next_instance_of(AuthorizedProjectUpdate::ProjectCreateService) do |service|
- expect(service).to(receive(:execute))
- end
-
- worker.perform(group_project.id)
- end
-
- it 'returns ServiceResponse.success' do
- result = worker.perform(group_project.id)
-
- expect(result.success?).to be_truthy
- end
-
- context 'idempotence' do
- before do
- create(:group_member, access_level: access_level, group: group, user: group_user)
- ProjectAuthorization.delete_all
- end
-
- include_examples 'an idempotent worker' do
- let(:job_args) { group_project.id }
-
- it 'creates project authorization' do
- subject
-
- project_authorization = ProjectAuthorization.where(
- project_id: group_project.id,
- user_id: group_user.id,
- access_level: access_level)
-
- expect(project_authorization).to exist
- expect(ProjectAuthorization.count).to eq(1)
- end
- end
- end
-end
diff --git a/spec/workers/authorized_project_update/project_group_link_create_worker_spec.rb b/spec/workers/authorized_project_update/project_group_link_create_worker_spec.rb
deleted file mode 100644
index 7c4ad4ce641..00000000000
--- a/spec/workers/authorized_project_update/project_group_link_create_worker_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker do
- let_it_be(:group) { create(:group, :private) }
- let_it_be(:group_project) { create(:project, group: group) }
- let_it_be(:shared_with_group) { create(:group, :private) }
- let_it_be(:user) { create(:user) }
-
- let(:access_level) { Gitlab::Access::MAINTAINER }
-
- subject(:worker) { described_class.new }
-
- it 'calls AuthorizedProjectUpdate::ProjectCreateService' do
- expect_next_instance_of(AuthorizedProjectUpdate::ProjectGroupLinkCreateService) do |service|
- expect(service).to(receive(:execute))
- end
-
- worker.perform(group_project.id, shared_with_group.id)
- end
-
- it 'returns ServiceResponse.success' do
- result = worker.perform(group_project.id, shared_with_group.id)
-
- expect(result.success?).to be_truthy
- end
-
- context 'idempotence' do
- before do
- create(:group_member, group: shared_with_group, user: user, access_level: access_level)
- create(:project_group_link, project: group_project, group: shared_with_group)
- ProjectAuthorization.delete_all
- end
-
- include_examples 'an idempotent worker' do
- let(:job_args) { [group_project.id, shared_with_group.id] }
-
- it 'creates project authorization' do
- subject
-
- project_authorization = ProjectAuthorization.where(
- project_id: group_project.id,
- user_id: user.id,
- access_level: access_level)
-
- expect(project_authorization).to exist
- expect(ProjectAuthorization.count).to eq(1)
- end
- end
- end
-end
diff --git a/spec/workers/build_finished_worker_spec.rb b/spec/workers/build_finished_worker_spec.rb
index 2ca7837066b..b4b986662d2 100644
--- a/spec/workers/build_finished_worker_spec.rb
+++ b/spec/workers/build_finished_worker_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe BuildFinishedWorker do
before do
stub_feature_flags(ci_build_finished_worker_namespace_changed: build.project)
- expect(Ci::Build).to receive(:find_by).with(id: build.id).and_return(build)
+ expect(Ci::Build).to receive(:find_by).with({ id: build.id }).and_return(build)
end
it 'calculates coverage and calls hooks', :aggregate_failures do
diff --git a/spec/workers/bulk_imports/pipeline_worker_spec.rb b/spec/workers/bulk_imports/pipeline_worker_spec.rb
index 3578fec5bc0..209ae8862b6 100644
--- a/spec/workers/bulk_imports/pipeline_worker_spec.rb
+++ b/spec/workers/bulk_imports/pipeline_worker_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe BulkImports::PipelineWorker do
def run; end
- def self.ndjson_pipeline?
+ def self.file_extraction_pipeline?
false
end
end
@@ -222,14 +222,14 @@ RSpec.describe BulkImports::PipelineWorker do
end
end
- context 'when ndjson pipeline' do
- let(:ndjson_pipeline) do
+ context 'when file extraction pipeline' do
+ let(:file_extraction_pipeline) do
Class.new do
def initialize(_); end
def run; end
- def self.ndjson_pipeline?
+ def self.file_extraction_pipeline?
true
end
@@ -249,11 +249,11 @@ RSpec.describe BulkImports::PipelineWorker do
end
before do
- stub_const('NdjsonPipeline', ndjson_pipeline)
+ stub_const('NdjsonPipeline', file_extraction_pipeline)
allow_next_instance_of(BulkImports::Groups::Stage) do |instance|
allow(instance).to receive(:pipelines)
- .and_return([[0, ndjson_pipeline]])
+ .and_return([[0, file_extraction_pipeline]])
end
end
@@ -278,7 +278,7 @@ RSpec.describe BulkImports::PipelineWorker do
expect(described_class)
.to receive(:perform_in)
.with(
- described_class::NDJSON_PIPELINE_PERFORM_DELAY,
+ described_class::FILE_EXTRACTION_PIPELINE_PERFORM_DELAY,
pipeline_tracker.id,
pipeline_tracker.stage,
entity.id
diff --git a/spec/workers/ci/build_finished_worker_spec.rb b/spec/workers/ci/build_finished_worker_spec.rb
index 839723ac2fc..e9e7a057f98 100644
--- a/spec/workers/ci/build_finished_worker_spec.rb
+++ b/spec/workers/ci/build_finished_worker_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Ci::BuildFinishedWorker do
before do
stub_feature_flags(ci_build_finished_worker_namespace_changed: build.project)
- expect(Ci::Build).to receive(:find_by).with(id: build.id).and_return(build)
+ expect(Ci::Build).to receive(:find_by).with({ id: build.id }).and_return(build)
end
it 'calculates coverage and calls hooks', :aggregate_failures do
diff --git a/spec/workers/cleanup_container_repository_worker_spec.rb b/spec/workers/cleanup_container_repository_worker_spec.rb
index 6723ea2049d..edb815f426d 100644
--- a/spec/workers/cleanup_container_repository_worker_spec.rb
+++ b/spec/workers/cleanup_container_repository_worker_spec.rb
@@ -13,11 +13,11 @@ RSpec.describe CleanupContainerRepositoryWorker, :clean_gitlab_redis_shared_stat
let(:service) { instance_double(Projects::ContainerRepository::CleanupTagsService) }
context 'bulk delete api' do
- let(:params) { { key: 'value', 'container_expiration_policy' => false } }
+ let(:params) { { key: 'value' } }
it 'executes the destroy service' do
expect(Projects::ContainerRepository::CleanupTagsService).to receive(:new)
- .with(repository, user, params.merge('container_expiration_policy' => false))
+ .with(repository, user, params)
.and_return(service)
expect(service).to receive(:execute)
@@ -36,40 +36,5 @@ RSpec.describe CleanupContainerRepositoryWorker, :clean_gitlab_redis_shared_stat
end.not_to raise_error
end
end
-
- context 'container expiration policy' do
- let(:params) { { key: 'value', 'container_expiration_policy' => true } }
-
- before do
- allow(ContainerRepository)
- .to receive(:find_by_id).with(repository.id).and_return(repository)
- end
-
- it 'executes the destroy service' do
- expect(repository).to receive(:start_expiration_policy!).and_call_original
- expect(repository).to receive(:reset_expiration_policy_started_at!).and_call_original
- expect(Projects::ContainerRepository::CleanupTagsService).to receive(:new)
- .with(repository, nil, params.merge('container_expiration_policy' => true))
- .and_return(service)
-
- expect(service).to receive(:execute).and_return(status: :success)
-
- subject.perform(nil, repository.id, params)
- expect(repository.reload.expiration_policy_started_at).to be_nil
- end
-
- it "doesn't reset the expiration policy started at if the destroy service returns an error" do
- expect(repository).to receive(:start_expiration_policy!).and_call_original
- expect(repository).not_to receive(:reset_expiration_policy_started_at!)
- expect(Projects::ContainerRepository::CleanupTagsService).to receive(:new)
- .with(repository, nil, params.merge('container_expiration_policy' => true))
- .and_return(service)
-
- expect(service).to receive(:execute).and_return(status: :error, message: 'timeout while deleting tags')
-
- subject.perform(nil, repository.id, params)
- expect(repository.reload.expiration_policy_started_at).not_to be_nil
- end
- end
end
end
diff --git a/spec/workers/clusters/applications/activate_service_worker_spec.rb b/spec/workers/clusters/applications/activate_service_worker_spec.rb
index 019bfe7a750..d13ff76613c 100644
--- a/spec/workers/clusters/applications/activate_service_worker_spec.rb
+++ b/spec/workers/clusters/applications/activate_service_worker_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe Clusters::Applications::ActivateServiceWorker, '#perform' do
context 'cluster does not exist' do
it 'does not raise Record Not Found error' do
- expect { described_class.new.perform(0, 'ignored in this context') }.not_to raise_error(ActiveRecord::RecordNotFound)
+ expect { described_class.new.perform(0, 'ignored in this context') }.not_to raise_error
end
end
end
diff --git a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
index b5252294b27..3cd82b8bf4d 100644
--- a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
+++ b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb
@@ -83,19 +83,23 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- github_identifiers: github_identifiers,
- message: 'starting importer',
- project_id: project.id,
- importer: 'klass_name'
+ {
+ github_identifiers: github_identifiers,
+ message: 'starting importer',
+ project_id: project.id,
+ importer: 'klass_name'
+ }
)
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- github_identifiers: github_identifiers,
- message: 'importer finished',
- project_id: project.id,
- importer: 'klass_name'
+ {
+ github_identifiers: github_identifiers,
+ message: 'importer finished',
+ project_id: project.id,
+ importer: 'klass_name'
+ }
)
worker.import(project, client, { 'number' => 10, 'github_id' => 1 })
@@ -120,10 +124,12 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- github_identifiers: github_identifiers,
- message: 'starting importer',
- project_id: project.id,
- importer: 'klass_name'
+ {
+ github_identifiers: github_identifiers,
+ message: 'starting importer',
+ project_id: project.id,
+ importer: 'klass_name'
+ }
)
expect(Gitlab::Import::ImportFailureService)
diff --git a/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb b/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
index aeb86f5aa8c..1e088929f66 100644
--- a/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
+++ b/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
@@ -38,17 +38,21 @@ RSpec.describe Gitlab::GithubImport::StageMethods do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'starting stage',
- project_id: project.id,
- import_stage: 'DummyStage'
+ {
+ message: 'starting stage',
+ project_id: project.id,
+ import_stage: 'DummyStage'
+ }
)
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'stage finished',
- project_id: project.id,
- import_stage: 'DummyStage'
+ {
+ message: 'stage finished',
+ project_id: project.id,
+ import_stage: 'DummyStage'
+ }
)
worker.perform(project.id)
@@ -70,18 +74,22 @@ RSpec.describe Gitlab::GithubImport::StageMethods do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'starting stage',
- project_id: project.id,
- import_stage: 'DummyStage'
+ {
+ message: 'starting stage',
+ project_id: project.id,
+ import_stage: 'DummyStage'
+ }
)
expect(Gitlab::Import::ImportFailureService)
.to receive(:track)
.with(
- project_id: project.id,
- exception: exception,
- error_source: 'DummyStage',
- fail_import: false
+ {
+ project_id: project.id,
+ exception: exception,
+ error_source: 'DummyStage',
+ fail_import: false
+ }
).and_call_original
expect { worker.perform(project.id) }
@@ -125,9 +133,11 @@ RSpec.describe Gitlab::GithubImport::StageMethods do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'starting stage',
- project_id: project.id,
- import_stage: 'DummyStage'
+ {
+ message: 'starting stage',
+ project_id: project.id,
+ import_stage: 'DummyStage'
+ }
)
expect(Gitlab::Import::ImportFailureService)
diff --git a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb
index cbffb8f3870..3cb83a7a5d7 100644
--- a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb
+++ b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb
@@ -524,13 +524,5 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do
end
it { is_expected.to eq(capacity) }
-
- context 'with feature flag disabled' do
- before do
- stub_feature_flags(container_registry_expiration_policies_throttling: false)
- end
-
- it { is_expected.to eq(0) }
- end
end
end
diff --git a/spec/workers/container_expiration_policy_worker_spec.rb b/spec/workers/container_expiration_policy_worker_spec.rb
index 2cfb613865d..ef6266aeba3 100644
--- a/spec/workers/container_expiration_policy_worker_spec.rb
+++ b/spec/workers/container_expiration_policy_worker_spec.rb
@@ -11,15 +11,13 @@ RSpec.describe ContainerExpirationPolicyWorker do
describe '#perform' do
subject { worker.perform }
- shared_examples 'not executing any policy' do
- it 'does not run any policy' do
- expect(ContainerExpirationPolicyService).not_to receive(:new)
+ context 'process cleanups' do
+ it 'calls the limited capacity worker' do
+ expect(ContainerExpirationPolicies::CleanupContainerRepositoryWorker).to receive(:perform_with_capacity)
subject
end
- end
- shared_examples 'handling a taken exclusive lease' do
context 'with exclusive lease taken' do
before do
stub_exclusive_lease_taken(worker.lease_key, timeout: 5.hours)
@@ -34,82 +32,6 @@ RSpec.describe ContainerExpirationPolicyWorker do
end
end
- context 'with throttling enabled' do
- before do
- stub_feature_flags(container_registry_expiration_policies_throttling: true)
- end
-
- it 'calls the limited capacity worker' do
- expect(ContainerExpirationPolicies::CleanupContainerRepositoryWorker).to receive(:perform_with_capacity)
-
- subject
- end
-
- it_behaves_like 'handling a taken exclusive lease'
- end
-
- context 'with throttling disabled' do
- before do
- stub_feature_flags(container_registry_expiration_policies_throttling: false)
- end
-
- context 'with no container expiration policies' do
- it_behaves_like 'not executing any policy'
- end
-
- context 'with container expiration policies' do
- let_it_be(:container_expiration_policy, reload: true) { create(:container_expiration_policy, :runnable) }
- let_it_be(:container_repository) { create(:container_repository, project: container_expiration_policy.project) }
-
- context 'a valid policy' do
- it 'runs the policy' do
- expect(ContainerExpirationPolicyService)
- .to receive(:new).with(container_expiration_policy.project, nil).and_call_original
- expect(CleanupContainerRepositoryWorker).to receive(:perform_async).once.and_call_original
-
- expect { subject }.not_to raise_error
- end
- end
-
- context 'a disabled policy' do
- before do
- container_expiration_policy.disable!
- end
-
- it_behaves_like 'not executing any policy'
- end
-
- context 'a policy that is not due for a run' do
- before do
- container_expiration_policy.update_column(:next_run_at, 2.minutes.from_now)
- end
-
- it_behaves_like 'not executing any policy'
- end
-
- context 'a policy linked to no container repository' do
- before do
- container_expiration_policy.container_repositories.delete_all
- end
-
- it_behaves_like 'not executing any policy'
- end
-
- context 'an invalid policy' do
- before do
- container_expiration_policy.update_column(:name_regex, '*production')
- end
-
- it 'disables the policy and tracks an error' do
- expect(ContainerExpirationPolicyService).not_to receive(:new).with(container_expiration_policy, nil)
- expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(described_class::InvalidPolicyError), container_expiration_policy_id: container_expiration_policy.id)
-
- expect { subject }.to change { container_expiration_policy.reload.enabled }.from(true).to(false)
- end
- end
- end
- end
-
context 'process stale ongoing cleanups' do
let_it_be(:stuck_cleanup) { create(:container_repository, :cleanup_ongoing, expiration_policy_started_at: 1.day.ago) }
let_it_be(:container_repository1) { create(:container_repository, :cleanup_scheduled) }
diff --git a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
index 81fa28dc603..a57a9e3b2e8 100644
--- a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
+++ b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
@@ -23,273 +23,669 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
shared_examples 'no action' do
it 'does not queue or change any repositories' do
+ expect(worker).not_to receive(:handle_next_migration)
+ expect(worker).not_to receive(:handle_aborted_migration)
+
subject
expect(container_repository.reload).to be_default
end
end
- shared_examples 're-enqueuing based on capacity' do |capacity_limit: 4|
- context 'below capacity' do
+ context 'with container_registry_migration_phase2_enqueuer_loop disabled' do
+ before do
+ stub_feature_flags(container_registry_migration_phase2_enqueuer_loop: false)
+ end
+
+ shared_examples 're-enqueuing based on capacity' do |capacity_limit: 4|
+ context 'below capacity' do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:capacity).and_return(capacity_limit)
+ end
+
+ it 're-enqueues the worker' do
+ expect(described_class).to receive(:perform_async)
+ expect(described_class).to receive(:perform_in).with(7.seconds)
+
+ subject
+ end
+
+ context 'enqueue_twice feature flag disabled' do
+ before do
+ stub_feature_flags(container_registry_migration_phase2_enqueue_twice: false)
+ end
+
+ it 'only enqueues the worker once' do
+ expect(described_class).to receive(:perform_async)
+ expect(described_class).not_to receive(:perform_in)
+
+ subject
+ end
+ end
+ end
+
+ context 'above capacity' do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:capacity).and_return(-1)
+ end
+
+ it 'does not re-enqueue the worker' do
+ expect(described_class).not_to receive(:perform_async)
+ expect(described_class).not_to receive(:perform_in).with(7.seconds)
+
+ subject
+ end
+ end
+ end
+
+ context 'with qualified repository' do
before do
- allow(ContainerRegistry::Migration).to receive(:capacity).and_return(capacity_limit)
+ allow_worker(on: :next_repository) do |repository|
+ allow(repository).to receive(:migration_pre_import).and_return(:ok)
+ end
end
- it 're-enqueues the worker' do
- expect(described_class).to receive(:perform_async)
+ shared_examples 'starting the next import' do
+ it 'starts the pre-import for the next qualified repository' do
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'pre_importing'
+ )
- subject
+ expect { subject }.to make_queries_matching(/LIMIT 2/)
+
+ expect(container_repository.reload).to be_pre_importing
+ end
+ end
+
+ it_behaves_like 'starting the next import'
+
+ context 'when the new pre-import maxes out the capacity' do
+ before do
+ # set capacity to 10
+ stub_feature_flags(
+ container_registry_migration_phase2_capacity_25: false
+ )
+
+ # Plus 2 created above gives 9 importing repositories
+ create_list(:container_repository, 7, :importing)
+ end
+
+ it 'does not re-enqueue the worker' do
+ expect(described_class).not_to receive(:perform_async)
+ expect(described_class).not_to receive(:perform_in)
+
+ subject
+ end
+ end
+
+ it_behaves_like 're-enqueuing based on capacity'
+
+ context 'max tag count is 0' do
+ before do
+ stub_application_setting(container_registry_import_max_tags_count: 0)
+ # Add 8 tags to the next repository
+ stub_container_registry_tags(
+ repository: container_repository.path, tags: %w(a b c d e f g h), with_manifest: true
+ )
+ end
+
+ it_behaves_like 'starting the next import'
+ end
+ end
+
+ context 'migrations are disabled' do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:enabled?).and_return(false)
+ end
+
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(migration_enabled: false)
+ end
end
end
context 'above capacity' do
before do
- allow(ContainerRegistry::Migration).to receive(:capacity).and_return(-1)
+ create(:container_repository, :importing)
+ create(:container_repository, :importing)
+ allow(ContainerRegistry::Migration).to receive(:capacity).and_return(1)
+ end
+
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(below_capacity: false, max_capacity_setting: 1)
+ end
end
it 'does not re-enqueue the worker' do
- expect(described_class).not_to receive(:perform_async)
+ expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_async)
+ expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_in)
subject
end
end
- end
- context 'with qualified repository' do
- before do
- method = worker.method(:next_repository)
- allow(worker).to receive(:next_repository) do
- next_qualified_repository = method.call
- allow(next_qualified_repository).to receive(:migration_pre_import).and_return(:ok)
- next_qualified_repository
+ context 'too soon before previous completed import step' do
+ where(:state, :timestamp) do
+ :import_done | :migration_import_done_at
+ :pre_import_done | :migration_pre_import_done_at
+ :import_aborted | :migration_aborted_at
+ :import_skipped | :migration_skipped_at
end
- end
- it 'starts the pre-import for the next qualified repository' do
- expect_log_extra_metadata(
- import_type: 'next',
- container_repository_id: container_repository.id,
- container_repository_path: container_repository.path,
- container_repository_migration_state: 'pre_importing'
- )
+ with_them do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes)
+ create(:container_repository, state, timestamp => 1.minute.ago)
+ end
- subject
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(waiting_time_passed: false, current_waiting_time_setting: 45.minutes)
+ end
+ end
+ end
- expect(container_repository.reload).to be_pre_importing
+ context 'when last completed repository has nil timestamps' do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes)
+ create(:container_repository, migration_state: 'import_done')
+ end
+
+ it 'continues to try the next import' do
+ expect { subject }.to change { container_repository.reload.migration_state }
+ end
+ end
end
- context 'when the new pre-import maxes out the capacity' do
- before do
- # set capacity to 10
- stub_feature_flags(
- container_registry_migration_phase2_capacity_25: false
- )
+ context 'when an aborted import is available' do
+ let_it_be(:aborted_repository) { create(:container_repository, :import_aborted) }
- # Plus 2 created above gives 9 importing repositories
- create_list(:container_repository, 7, :importing)
+ context 'with a successful registry request' do
+ before do
+ allow_worker(on: :next_aborted_repository) do |repository|
+ allow(repository).to receive(:migration_import).and_return(:ok)
+ allow(repository.gitlab_api_client).to receive(:import_status).and_return('import_failed')
+ end
+ end
+
+ it 'retries the import for the aborted repository' do
+ expect_log_extra_metadata(
+ import_type: 'retry',
+ container_repository_id: aborted_repository.id,
+ container_repository_path: aborted_repository.path,
+ container_repository_migration_state: 'importing'
+ )
+
+ subject
+
+ expect(aborted_repository.reload).to be_importing
+ expect(container_repository.reload).to be_default
+ end
+
+ it_behaves_like 're-enqueuing based on capacity'
end
- it 'does not re-enqueue the worker' do
- expect(described_class).not_to receive(:perform_async)
+ context 'when an error occurs' do
+ it 'does not abort that migration' do
+ allow_worker(on: :next_aborted_repository) do |repository|
+ allow(repository).to receive(:retry_aborted_migration).and_raise(StandardError)
+ end
- subject
+ expect_log_extra_metadata(
+ import_type: 'retry',
+ container_repository_id: aborted_repository.id,
+ container_repository_path: aborted_repository.path,
+ container_repository_migration_state: 'import_aborted'
+ )
+
+ subject
+
+ expect(aborted_repository.reload).to be_import_aborted
+ expect(container_repository.reload).to be_default
+ end
end
end
- it_behaves_like 're-enqueuing based on capacity'
- end
+ context 'when no repository qualifies' do
+ include_examples 'an idempotent worker' do
+ before do
+ allow(ContainerRepository).to receive(:ready_for_import).and_return(ContainerRepository.none)
+ end
- context 'migrations are disabled' do
- before do
- allow(ContainerRegistry::Migration).to receive(:enabled?).and_return(false)
+ it_behaves_like 'no action'
+ end
end
- it_behaves_like 'no action' do
+ context 'over max tag count' do
before do
- expect_log_extra_metadata(migration_enabled: false)
+ stub_application_setting(container_registry_import_max_tags_count: 2)
end
- end
- end
- context 'above capacity' do
- before do
- create(:container_repository, :importing)
- create(:container_repository, :importing)
- allow(ContainerRegistry::Migration).to receive(:capacity).and_return(1)
+ it 'skips the repository' do
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'import_skipped',
+ tags_count_too_high: true,
+ max_tags_count_setting: 2
+ )
+
+ subject
+
+ expect(container_repository.reload).to be_import_skipped
+ expect(container_repository.migration_skipped_reason).to eq('too_many_tags')
+ expect(container_repository.migration_skipped_at).not_to be_nil
+ end
+
+ context 're-enqueuing' do
+ before do
+ # skipping will also re-enqueue, so we isolate the capacity behavior here
+ allow_worker(on: :next_repository) do |repository|
+ allow(repository).to receive(:skip_import).and_return(true)
+ end
+ end
+
+ it_behaves_like 're-enqueuing based on capacity', capacity_limit: 3
+ end
end
- it_behaves_like 'no action' do
+ context 'when an error occurs' do
before do
- expect_log_extra_metadata(below_capacity: false, max_capacity_setting: 1)
+ allow(ContainerRegistry::Migration).to receive(:max_tags_count).and_raise(StandardError)
end
- end
- it 'does not re-enqueue the worker' do
- expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_async)
+ it 'aborts the import' do
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'import_aborted'
+ )
+
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
+ instance_of(StandardError),
+ next_repository_id: container_repository.id
+ )
- subject
+ subject
+
+ expect(container_repository.reload).to be_import_aborted
+ end
end
- end
- context 'too soon before previous completed import step' do
- where(:state, :timestamp) do
- :import_done | :migration_import_done_at
- :pre_import_done | :migration_pre_import_done_at
- :import_aborted | :migration_aborted_at
- :import_skipped | :migration_skipped_at
+ context 'with the exclusive lease taken' do
+ let(:lease_key) { worker.send(:lease_key) }
+
+ before do
+ stub_exclusive_lease_taken(lease_key, timeout: 30.minutes)
+ end
+
+ it 'does not perform' do
+ expect(worker).not_to receive(:runnable?)
+ expect(worker).not_to receive(:re_enqueue_if_capacity)
+
+ subject
+ end
end
+ end
- with_them do
+ context 'with container_registry_migration_phase2_enqueuer_loop enabled' do
+ context 'migrations are disabled' do
before do
- allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes)
- create(:container_repository, state, timestamp => 1.minute.ago)
+ allow(ContainerRegistry::Migration).to receive(:enabled?).and_return(false)
end
it_behaves_like 'no action' do
before do
- expect_log_extra_metadata(waiting_time_passed: false, current_waiting_time_setting: 45.minutes)
+ expect_log_extra_metadata(migration_enabled: false)
end
end
end
- context 'when last completed repository has nil timestamps' do
+ context 'with no repository qualifies' do
+ include_examples 'an idempotent worker' do
+ before do
+ allow(ContainerRepository).to receive(:ready_for_import).and_return(ContainerRepository.none)
+ end
+
+ it_behaves_like 'no action'
+ end
+ end
+
+ context 'when multiple aborted imports are available' do
+ let_it_be(:aborted_repository1) { create(:container_repository, :import_aborted) }
+ let_it_be(:aborted_repository2) { create(:container_repository, :import_aborted) }
+
before do
- allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes)
- create(:container_repository, migration_state: 'import_done')
+ container_repository.update!(created_at: 30.seconds.ago)
end
- it 'continues to try the next import' do
- expect { subject }.to change { container_repository.reload.migration_state }
+ context 'with successful registry requests' do
+ before do
+ allow_worker(on: :next_aborted_repository) do |repository|
+ allow(repository).to receive(:migration_import).and_return(:ok)
+ allow(repository.gitlab_api_client).to receive(:import_status).and_return('import_failed')
+ end
+ end
+
+ it 'retries the import for the aborted repository' do
+ expect_log_info(
+ [
+ {
+ import_type: 'retry',
+ container_repository_id: aborted_repository1.id,
+ container_repository_path: aborted_repository1.path,
+ container_repository_migration_state: 'importing'
+ },
+ {
+ import_type: 'retry',
+ container_repository_id: aborted_repository2.id,
+ container_repository_path: aborted_repository2.path,
+ container_repository_migration_state: 'importing'
+ }
+ ]
+ )
+
+ expect(worker).to receive(:handle_next_migration).and_call_original
+
+ subject
+
+ expect(aborted_repository1.reload).to be_importing
+ expect(aborted_repository2.reload).to be_importing
+ end
+ end
+
+ context 'when an error occurs' do
+ it 'does abort that migration' do
+ allow_worker(on: :next_aborted_repository) do |repository|
+ allow(repository).to receive(:retry_aborted_migration).and_raise(StandardError)
+ end
+
+ expect_log_info(
+ [
+ {
+ import_type: 'retry',
+ container_repository_id: aborted_repository1.id,
+ container_repository_path: aborted_repository1.path,
+ container_repository_migration_state: 'import_aborted'
+ }
+ ]
+ )
+
+ subject
+
+ expect(aborted_repository1.reload).to be_import_aborted
+ expect(aborted_repository2.reload).to be_import_aborted
+ end
end
end
- end
- context 'when an aborted import is available' do
- let_it_be(:aborted_repository) { create(:container_repository, :import_aborted) }
+ context 'when multiple qualified repositories are available' do
+ let_it_be(:container_repository2) { create(:container_repository, created_at: 2.days.ago) }
- context 'with a successful registry request' do
before do
- method = worker.method(:next_aborted_repository)
- allow(worker).to receive(:next_aborted_repository) do
- next_aborted_repository = method.call
- allow(next_aborted_repository).to receive(:migration_import).and_return(:ok)
- allow(next_aborted_repository.gitlab_api_client).to receive(:import_status).and_return('import_failed')
- next_aborted_repository
+ allow_worker(on: :next_repository) do |repository|
+ allow(repository).to receive(:migration_pre_import).and_return(:ok)
end
- end
- it 'retries the import for the aborted repository' do
- expect_log_extra_metadata(
- import_type: 'retry',
- container_repository_id: aborted_repository.id,
- container_repository_path: aborted_repository.path,
- container_repository_migration_state: 'importing'
+ stub_container_registry_tags(
+ repository: container_repository2.path,
+ tags: %w(tag4 tag5 tag6),
+ with_manifest: true
)
+ end
- subject
+ shared_examples 'starting all the next imports' do
+ it 'starts the pre-import for the next qualified repositories' do
+ expect_log_info(
+ [
+ {
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'pre_importing'
+ },
+ {
+ import_type: 'next',
+ container_repository_id: container_repository2.id,
+ container_repository_path: container_repository2.path,
+ container_repository_migration_state: 'pre_importing'
+ }
+ ]
+ )
+
+ expect(worker).to receive(:handle_next_migration).exactly(3).times.and_call_original
+
+ expect { subject }.to make_queries_matching(/LIMIT 2/)
+
+ expect(container_repository.reload).to be_pre_importing
+ expect(container_repository2.reload).to be_pre_importing
+ end
+ end
- expect(aborted_repository.reload).to be_importing
- expect(container_repository.reload).to be_default
+ it_behaves_like 'starting all the next imports'
+
+ context 'when the new pre-import maxes out the capacity' do
+ before do
+ # set capacity to 10
+ stub_feature_flags(
+ container_registry_migration_phase2_capacity_25: false
+ )
+
+ # Plus 2 created above gives 9 importing repositories
+ create_list(:container_repository, 7, :importing)
+ end
+
+ it 'starts the pre-import only for one qualified repository' do
+ expect_log_info(
+ [
+ {
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'pre_importing'
+ }
+ ]
+ )
+
+ subject
+
+ expect(container_repository.reload).to be_pre_importing
+ expect(container_repository2.reload).to be_default
+ end
end
- it_behaves_like 're-enqueuing based on capacity'
+ context 'max tag count is 0' do
+ before do
+ stub_application_setting(container_registry_import_max_tags_count: 0)
+ # Add 8 tags to the next repository
+ stub_container_registry_tags(
+ repository: container_repository.path, tags: %w(a b c d e f g h), with_manifest: true
+ )
+ end
+
+ it_behaves_like 'starting all the next imports'
+ end
+
+ context 'when the deadline is hit' do
+ it 'does not handle the second qualified repository' do
+ expect(worker).to receive(:loop_deadline).and_return(5.seconds.from_now, 2.seconds.ago)
+ expect(worker).to receive(:handle_next_migration).once.and_call_original
+
+ subject
+
+ expect(container_repository.reload).to be_pre_importing
+ expect(container_repository2.reload).to be_default
+ end
+ end
end
- context 'when an error occurs' do
- it 'does not abort that migration' do
- method = worker.method(:next_aborted_repository)
- allow(worker).to receive(:next_aborted_repository) do
- next_aborted_repository = method.call
- allow(next_aborted_repository).to receive(:retry_aborted_migration).and_raise(StandardError)
- next_aborted_repository
+ context 'when a mix of aborted imports and qualified repositories are available' do
+ let_it_be(:aborted_repository) { create(:container_repository, :import_aborted) }
+
+ before do
+ allow_worker(on: :next_aborted_repository) do |repository|
+ allow(repository).to receive(:migration_import).and_return(:ok)
+ allow(repository.gitlab_api_client).to receive(:import_status).and_return('import_failed')
end
- expect_log_extra_metadata(
- import_type: 'retry',
- container_repository_id: aborted_repository.id,
- container_repository_path: aborted_repository.path,
- container_repository_migration_state: 'import_aborted'
+ allow_worker(on: :next_repository) do |repository|
+ allow(repository).to receive(:migration_pre_import).and_return(:ok)
+ end
+ end
+
+ it 'retries the aborted repository and start the migration on the qualified repository' do
+ expect_log_info(
+ [
+ {
+ import_type: 'retry',
+ container_repository_id: aborted_repository.id,
+ container_repository_path: aborted_repository.path,
+ container_repository_migration_state: 'importing'
+ },
+ {
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'pre_importing'
+ }
+ ]
)
subject
- expect(aborted_repository.reload).to be_import_aborted
- expect(container_repository.reload).to be_default
+ expect(aborted_repository.reload).to be_importing
+ expect(container_repository.reload).to be_pre_importing
end
end
- end
- context 'when no repository qualifies' do
- include_examples 'an idempotent worker' do
+ context 'above capacity' do
before do
- allow(ContainerRepository).to receive(:ready_for_import).and_return(ContainerRepository.none)
+ create(:container_repository, :importing)
+ create(:container_repository, :importing)
+ allow(ContainerRegistry::Migration).to receive(:capacity).and_return(1)
end
- it_behaves_like 'no action'
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(below_capacity: false, max_capacity_setting: 1)
+ end
+ end
end
- end
- context 'over max tag count' do
- before do
- stub_application_setting(container_registry_import_max_tags_count: 2)
- end
+ context 'too soon before previous completed import step' do
+ where(:state, :timestamp) do
+ :import_done | :migration_import_done_at
+ :pre_import_done | :migration_pre_import_done_at
+ :import_aborted | :migration_aborted_at
+ :import_skipped | :migration_skipped_at
+ end
- it 'skips the repository' do
- expect_log_extra_metadata(
- import_type: 'next',
- container_repository_id: container_repository.id,
- container_repository_path: container_repository.path,
- container_repository_migration_state: 'import_skipped',
- tags_count_too_high: true,
- max_tags_count_setting: 2
- )
+ with_them do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes)
+ create(:container_repository, state, timestamp => 1.minute.ago)
+ end
- subject
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(waiting_time_passed: false, current_waiting_time_setting: 45.minutes)
+ end
+ end
+ end
- expect(container_repository.reload).to be_import_skipped
- expect(container_repository.migration_skipped_reason).to eq('too_many_tags')
- expect(container_repository.migration_skipped_at).not_to be_nil
+ context 'when last completed repository has nil timestamps' do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes)
+ create(:container_repository, migration_state: 'import_done')
+ end
+
+ it 'continues to try the next import' do
+ expect { subject }.to change { container_repository.reload.migration_state }
+ end
+ end
end
- it_behaves_like 're-enqueuing based on capacity', capacity_limit: 3
- end
+ context 'over max tag count' do
+ before do
+ stub_application_setting(container_registry_import_max_tags_count: 2)
+ end
- context 'when an error occurs' do
- before do
- allow(ContainerRegistry::Migration).to receive(:max_tags_count).and_raise(StandardError)
+ it 'skips the repository' do
+ expect_log_info(
+ [
+ {
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'import_skipped',
+ container_repository_migration_skipped_reason: 'too_many_tags'
+ }
+ ]
+ )
+
+ expect(worker).to receive(:handle_next_migration).twice.and_call_original
+ # skipping the migration will re_enqueue the job
+ expect(described_class).to receive(:enqueue_a_job)
+
+ subject
+
+ expect(container_repository.reload).to be_import_skipped
+ expect(container_repository.migration_skipped_reason).to eq('too_many_tags')
+ expect(container_repository.migration_skipped_at).not_to be_nil
+ end
end
- it 'aborts the import' do
- expect_log_extra_metadata(
- import_type: 'next',
- container_repository_id: container_repository.id,
- container_repository_path: container_repository.path,
- container_repository_migration_state: 'import_aborted'
- )
+ context 'when an error occurs' do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:max_tags_count).and_raise(StandardError)
+ end
- expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
- instance_of(StandardError),
- next_repository_id: container_repository.id
- )
+ it 'aborts the import' do
+ expect_log_info(
+ [
+ {
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'import_aborted'
+ }
+ ]
+ )
- subject
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
+ instance_of(StandardError),
+ next_repository_id: container_repository.id
+ )
- expect(container_repository.reload).to be_import_aborted
- end
- end
+ # aborting the migration will re_enqueue the job
+ expect(described_class).to receive(:enqueue_a_job)
- context 'with the exclusive lease taken' do
- let(:lease_key) { worker.send(:lease_key) }
+ subject
- before do
- stub_exclusive_lease_taken(lease_key, timeout: 30.minutes)
+ expect(container_repository.reload).to be_import_aborted
+ end
end
- it 'does not perform' do
- expect(worker).not_to receive(:runnable?)
- expect(worker).not_to receive(:re_enqueue_if_capacity)
+ context 'with the exclusive lease taken' do
+ let(:lease_key) { worker.send(:lease_key) }
- subject
+ before do
+ stub_exclusive_lease_taken(lease_key, timeout: 30.minutes)
+ end
+
+ it 'does not perform' do
+ expect(worker).not_to receive(:handle_aborted_migration)
+ expect(worker).not_to receive(:handle_next_migration)
+
+ subject
+ end
end
end
@@ -298,5 +694,29 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
expect(worker).to receive(:log_extra_metadata_on_done).with(key, value)
end
end
+
+ def expect_log_info(expected_multiple_arguments)
+ expected_multiple_arguments.each do |extras|
+ expect(worker.logger).to receive(:info).with(worker.structured_payload(extras))
+ end
+ end
+
+ def allow_worker(on:)
+ method_repository = worker.method(on)
+ allow(worker).to receive(on) do
+ repository = method_repository.call
+
+ yield repository if repository
+
+ repository
+ end
+ end
+ end
+
+ describe 'worker attributes' do
+ it 'has deduplication set' do
+ expect(described_class.get_deduplicate_strategy).to eq(:until_executing)
+ expect(described_class.get_deduplication_options).to include(ttl: 30.minutes)
+ end
end
end
diff --git a/spec/workers/container_registry/migration/guard_worker_spec.rb b/spec/workers/container_registry/migration/guard_worker_spec.rb
index 299d1204af3..c52a3fc5d54 100644
--- a/spec/workers/container_registry/migration/guard_worker_spec.rb
+++ b/spec/workers/container_registry/migration/guard_worker_spec.rb
@@ -25,57 +25,94 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
allow(::Gitlab).to receive(:com?).and_return(true)
end
- shared_examples 'handling long running migrations' do
+ shared_examples 'handling long running migrations' do |timeout:|
before do
allow_next_found_instance_of(ContainerRepository) do |repository|
allow(repository).to receive(:migration_cancel).and_return(migration_cancel_response)
end
end
- context 'migration is canceled' do
- let(:migration_cancel_response) { { status: :ok } }
-
- it 'will not abort the migration' do
+ shared_examples 'aborting the migration' do
+ it 'will abort the migration' do
expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1)
expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id])
+ expect(ContainerRegistry::Migration).to receive(timeout).and_call_original
expect { subject }
- .to change(import_skipped_migrations, :count)
+ .to change(import_aborted_migrations, :count).by(1)
+ .and change { stale_migration.reload.migration_state }.to('import_aborted')
+ .and not_change { ongoing_migration.migration_state }
+ end
+
+ context 'registry_migration_guard_thresholds feature flag disabled' do
+ before do
+ stub_feature_flags(registry_migration_guard_thresholds: false)
+ end
+
+ it 'falls back on the hardcoded value' do
+ expect(ContainerRegistry::Migration).not_to receive(:pre_import_timeout)
- expect(stale_migration.reload.migration_state).to eq('import_skipped')
- expect(stale_migration.reload.migration_skipped_reason).to eq('migration_canceled')
+ expect { subject }
+ .to change { stale_migration.reload.migration_state }.to('import_aborted')
+ end
end
end
- context 'migration cancelation fails with an error' do
- let(:migration_cancel_response) { { status: :error } }
+ context 'migration is canceled' do
+ let(:migration_cancel_response) { { status: :ok } }
- it 'will abort the migration' do
- expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
- expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1)
- expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id])
+ before do
+ stub_application_setting(container_registry_import_max_retries: 3)
+ end
- expect { subject }
- .to change(import_aborted_migrations, :count).by(1)
- .and change { stale_migration.reload.migration_state }.to('import_aborted')
- .and not_change { ongoing_migration.migration_state }
+ context 'when the retry limit has been reached' do
+ before do
+ stale_migration.update_column(:migration_retries_count, 2)
+ end
+
+ it 'will not abort the migration' do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id])
+ expect(ContainerRegistry::Migration).to receive(timeout).and_call_original
+
+ expect { subject }
+ .to change(import_skipped_migrations, :count)
+
+ expect(stale_migration.reload.migration_state).to eq('import_skipped')
+ expect(stale_migration.reload.migration_skipped_reason).to eq('migration_canceled')
+ end
+
+ context 'registry_migration_guard_thresholds feature flag disabled' do
+ before do
+ stub_feature_flags(registry_migration_guard_thresholds: false)
+ end
+
+ it 'falls back on the hardcoded value' do
+ expect(ContainerRegistry::Migration).not_to receive(timeout)
+
+ expect { subject }
+ .to change { stale_migration.reload.migration_state }.to('import_skipped')
+ end
+ end
+ end
+
+ context 'when the retry limit has not been reached' do
+ it_behaves_like 'aborting the migration'
end
end
+ context 'migration cancelation fails with an error' do
+ let(:migration_cancel_response) { { status: :error } }
+
+ it_behaves_like 'aborting the migration'
+ end
+
context 'migration receives bad request with a new status' do
let(:migration_cancel_response) { { status: :bad_request, migration_state: :import_done } }
- it 'will abort the migration' do
- expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
- expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1)
- expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id])
-
- expect { subject }
- .to change(import_aborted_migrations, :count).by(1)
- .and change { stale_migration.reload.migration_state }.to('import_aborted')
- .and not_change { ongoing_migration.migration_state }
- end
+ it_behaves_like 'aborting the migration'
end
end
@@ -96,13 +133,15 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
context 'with pre_importing stale migrations' do
let(:ongoing_migration) { create(:container_repository, :pre_importing) }
- let(:stale_migration) { create(:container_repository, :pre_importing, migration_pre_import_started_at: 35.minutes.ago) }
+ let(:stale_migration) { create(:container_repository, :pre_importing, migration_pre_import_started_at: 11.minutes.ago) }
let(:import_status) { 'test' }
before do
allow_next_instance_of(ContainerRegistry::GitlabApiClient) do |client|
allow(client).to receive(:import_status).and_return(import_status)
end
+
+ stub_application_setting(container_registry_pre_import_timeout: 10.minutes.to_i)
end
it 'will abort the migration' do
@@ -122,13 +161,13 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
context 'the client returns pre_import_in_progress' do
let(:import_status) { 'pre_import_in_progress' }
- it_behaves_like 'handling long running migrations'
+ it_behaves_like 'handling long running migrations', timeout: :pre_import_timeout
end
end
context 'with pre_import_done stale migrations' do
let(:ongoing_migration) { create(:container_repository, :pre_import_done) }
- let(:stale_migration) { create(:container_repository, :pre_import_done, migration_pre_import_done_at: 35.minutes.ago) }
+ let(:stale_migration) { create(:container_repository, :pre_import_done, migration_pre_import_done_at: 11.minutes.ago) }
before do
allow(::ContainerRegistry::Migration).to receive(:max_step_duration).and_return(5.minutes)
@@ -151,13 +190,15 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
context 'with importing stale migrations' do
let(:ongoing_migration) { create(:container_repository, :importing) }
- let(:stale_migration) { create(:container_repository, :importing, migration_import_started_at: 35.minutes.ago) }
+ let(:stale_migration) { create(:container_repository, :importing, migration_import_started_at: 11.minutes.ago) }
let(:import_status) { 'test' }
before do
allow_next_instance_of(ContainerRegistry::GitlabApiClient) do |client|
allow(client).to receive(:import_status).and_return(import_status)
end
+
+ stub_application_setting(container_registry_import_timeout: 10.minutes.to_i)
end
it 'will abort the migration' do
@@ -177,7 +218,7 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
context 'the client returns import_in_progress' do
let(:import_status) { 'import_in_progress' }
- it_behaves_like 'handling long running migrations'
+ it_behaves_like 'handling long running migrations', timeout: :import_timeout
end
end
end
@@ -195,4 +236,11 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
end
end
end
+
+ describe 'worker attributes' do
+ it 'has deduplication set' do
+ expect(described_class.get_deduplicate_strategy).to eq(:until_executed)
+ expect(described_class.get_deduplication_options).to include(ttl: 5.minutes)
+ end
+ end
end
diff --git a/spec/workers/create_commit_signature_worker_spec.rb b/spec/workers/create_commit_signature_worker_spec.rb
index 0e31faf47af..9d3c63efc8a 100644
--- a/spec/workers/create_commit_signature_worker_spec.rb
+++ b/spec/workers/create_commit_signature_worker_spec.rb
@@ -10,8 +10,8 @@ RSpec.describe CreateCommitSignatureWorker do
let(:x509_commit) { instance_double(Gitlab::X509::Commit) }
before do
- allow(Project).to receive(:find_by).with(id: project.id).and_return(project)
- allow(project).to receive(:commits_by).with(oids: commit_shas).and_return(commits)
+ allow(Project).to receive(:find_by).with({ id: project.id }).and_return(project)
+ allow(project).to receive(:commits_by).with({ oids: commit_shas }).and_return(commits)
end
subject { described_class.new.perform(commit_shas, project.id) }
diff --git a/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb b/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb
index 116026ea8f7..e5024c568cb 100644
--- a/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb
+++ b/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb
@@ -62,6 +62,15 @@ RSpec.describe Database::CiNamespaceMirrorsConsistencyCheckWorker do
expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result)
worker.perform
end
+
+ it 'calls the consistency_fix_service to fix the inconsistencies' do
+ allow_next_instance_of(Database::ConsistencyFixService) do |instance|
+ expect(instance).to receive(:execute).with(
+ ids: [missing_namespace.id]
+ ).and_call_original
+ end
+ worker.perform
+ end
end
end
end
diff --git a/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb b/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb
index b6bd825ffcd..f8e950d8917 100644
--- a/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb
+++ b/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Database::CiProjectMirrorsConsistencyCheckWorker do
before do
redis_shared_state_cleanup!
stub_feature_flags(ci_project_mirrors_consistency_check: true)
- create_list(:project, 10) # This will also create Ci::NameSpaceMirror objects
+ create_list(:project, 10) # This will also create Ci::ProjectMirror objects
missing_project.delete
allow_next_instance_of(Database::ConsistencyCheckService) do |instance|
@@ -62,6 +62,15 @@ RSpec.describe Database::CiProjectMirrorsConsistencyCheckWorker do
expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result)
worker.perform
end
+
+ it 'calls the consistency_fix_service to fix the inconsistencies' do
+ expect_next_instance_of(Database::ConsistencyFixService) do |instance|
+ expect(instance).to receive(:execute).with(
+ ids: [missing_project.id]
+ ).and_call_original
+ end
+ worker.perform
+ end
end
end
end
diff --git a/spec/workers/delete_diff_files_worker_spec.rb b/spec/workers/delete_diff_files_worker_spec.rb
index cf26dbabb97..c124847ca45 100644
--- a/spec/workers/delete_diff_files_worker_spec.rb
+++ b/spec/workers/delete_diff_files_worker_spec.rb
@@ -34,11 +34,13 @@ RSpec.describe DeleteDiffFilesWorker do
end
it 'rollsback if something goes wrong' do
+ error = RuntimeError.new('something went wrong')
+
expect(MergeRequestDiffFile).to receive_message_chain(:where, :delete_all)
- .and_raise
+ .and_raise(error)
expect { described_class.new.perform(merge_request_diff.id) }
- .to raise_error
+ .to raise_error(error)
merge_request_diff.reload
diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb
index 52f2c692b8c..4046b670640 100644
--- a/spec/workers/delete_user_worker_spec.rb
+++ b/spec/workers/delete_user_worker_spec.rb
@@ -16,9 +16,9 @@ RSpec.describe DeleteUserWorker do
it "uses symbolized keys" do
expect_next_instance_of(Users::DestroyService) do |service|
- expect(service).to receive(:execute).with(user, test: "test")
+ expect(service).to receive(:execute).with(user, { test: "test" })
end
- described_class.new.perform(current_user.id, user.id, "test" => "test")
+ described_class.new.perform(current_user.id, user.id, { "test" => "test" })
end
end
diff --git a/spec/workers/deployments/hooks_worker_spec.rb b/spec/workers/deployments/hooks_worker_spec.rb
index 29b3e8d3ee4..a9240b45360 100644
--- a/spec/workers/deployments/hooks_worker_spec.rb
+++ b/spec/workers/deployments/hooks_worker_spec.rb
@@ -10,6 +10,16 @@ RSpec.describe Deployments::HooksWorker do
allow(ProjectServiceWorker).to receive(:perform_async)
end
+ it 'logs deployment and project IDs as metadata' do
+ deployment = create(:deployment, :running)
+ project = deployment.project
+
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:deployment_project_id, project.id)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:deployment_id, deployment.id)
+
+ worker.perform(deployment_id: deployment.id, status_changed_at: Time.current)
+ end
+
it 'executes project services for deployment_hooks' do
deployment = create(:deployment, :running)
project = deployment.project
diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb
index 0351b500747..0c83a692ca8 100644
--- a/spec/workers/every_sidekiq_worker_spec.rb
+++ b/spec/workers/every_sidekiq_worker_spec.rb
@@ -126,8 +126,6 @@ RSpec.describe 'Every Sidekiq worker' do
'ApproveBlockedPendingApprovalUsersWorker' => 3,
'ArchiveTraceWorker' => 3,
'AuthorizedKeysWorker' => 3,
- 'AuthorizedProjectUpdate::ProjectCreateWorker' => 3,
- 'AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker' => 3,
'AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker' => 3,
'AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker' => 3,
'AuthorizedProjectUpdate::UserRefreshFromReplicaWorker' => 3,
@@ -229,7 +227,6 @@ RSpec.describe 'Every Sidekiq worker' do
'Epics::UpdateEpicsDatesWorker' => 3,
'ErrorTrackingIssueLinkWorker' => 3,
'Experiments::RecordConversionEventWorker' => 3,
- 'ExpireBuildInstanceArtifactsWorker' => 3,
'ExpireJobCacheWorker' => 3,
'ExpirePipelineCacheWorker' => 3,
'ExportCsvWorker' => 3,
@@ -243,7 +240,6 @@ RSpec.describe 'Every Sidekiq worker' do
'Geo::DesignRepositorySyncWorker' => 1,
'Geo::DestroyWorker' => 3,
'Geo::EventWorker' => 3,
- 'Geo::FileDownloadWorker' => 3,
'Geo::FileRegistryRemovalWorker' => 3,
'Geo::FileRemovalWorker' => 3,
'Geo::ProjectSyncWorker' => 1,
@@ -352,7 +348,6 @@ RSpec.describe 'Every Sidekiq worker' do
'Namespaces::RefreshRootStatisticsWorker' => 3,
'Namespaces::RootStatisticsWorker' => 3,
'Namespaces::ScheduleAggregationWorker' => 3,
- 'NetworkPolicyMetricsWorker' => 3,
'NewEpicWorker' => 3,
'NewIssueWorker' => 3,
'NewMergeRequestWorker' => 3,
@@ -386,12 +381,13 @@ RSpec.describe 'Every Sidekiq worker' do
'ProjectDailyStatisticsWorker' => 3,
'ProjectDestroyWorker' => 3,
'ProjectExportWorker' => false,
- 'ProjectImportScheduleWorker' => false,
+ 'ProjectImportScheduleWorker' => 1,
'ProjectScheduleBulkRepositoryShardMovesWorker' => 3,
'ProjectServiceWorker' => 3,
'ProjectTemplateExportWorker' => false,
'ProjectUpdateRepositoryStorageWorker' => 3,
'Projects::GitGarbageCollectWorker' => false,
+ 'Projects::InactiveProjectsDeletionNotificationWorker' => 3,
'Projects::PostCreationWorker' => 3,
'Projects::ScheduleBulkRepositoryShardMovesWorker' => 3,
'Projects::UpdateRepositoryStorageWorker' => 3,
@@ -414,9 +410,9 @@ RSpec.describe 'Every Sidekiq worker' do
'RepositoryCleanupWorker' => 3,
'RepositoryForkWorker' => 5,
'RepositoryImportWorker' => false,
- 'RepositoryPushAuditEventWorker' => 3,
'RepositoryRemoveRemoteWorker' => 3,
'RepositoryUpdateMirrorWorker' => false,
+ 'RepositoryPushAuditEventWorker' => 3,
'RepositoryUpdateRemoteMirrorWorker' => 3,
'RequirementsManagement::ImportRequirementsCsvWorker' => 3,
'RequirementsManagement::ProcessRequirementsReportsWorker' => 3,
diff --git a/spec/workers/expire_build_instance_artifacts_worker_spec.rb b/spec/workers/expire_build_instance_artifacts_worker_spec.rb
deleted file mode 100644
index 38318447b5f..00000000000
--- a/spec/workers/expire_build_instance_artifacts_worker_spec.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ExpireBuildInstanceArtifactsWorker do
- include RepoHelpers
-
- let(:worker) { described_class.new }
-
- describe '#perform' do
- before do
- worker.perform(build.id)
- end
-
- context 'with expired artifacts' do
- context 'when associated project is valid' do
- let(:build) { create(:ci_build, :artifacts, :expired) }
-
- it 'does expire' do
- expect(build.reload.artifacts_expired?).to be_truthy
- end
-
- it 'does remove files' do
- expect(build.reload.artifacts_file.present?).to be_falsey
- end
-
- it 'does remove the job artifact record' do
- expect(build.reload.job_artifacts_archive).to be_nil
- end
- end
- end
-
- context 'with not yet expired artifacts' do
- let_it_be(:build) do
- create(:ci_build, :artifacts, artifacts_expire_at: Time.current + 7.days)
- end
-
- it 'does not expire' do
- expect(build.reload.artifacts_expired?).to be_falsey
- end
-
- it 'does not remove files' do
- expect(build.reload.artifacts_file.present?).to be_truthy
- end
-
- it 'does not remove the job artifact record' do
- expect(build.reload.job_artifacts_archive).not_to be_nil
- end
- end
-
- context 'without expire date' do
- let(:build) { create(:ci_build, :artifacts) }
-
- it 'does not expire' do
- expect(build.reload.artifacts_expired?).to be_falsey
- end
-
- it 'does not remove files' do
- expect(build.reload.artifacts_file.present?).to be_truthy
- end
-
- it 'does not remove the job artifact record' do
- expect(build.reload.job_artifacts_archive).not_to be_nil
- end
- end
-
- context 'for expired artifacts' do
- let(:build) { create(:ci_build, :expired) }
-
- it 'is still expired' do
- expect(build.reload.artifacts_expired?).to be_truthy
- end
- end
- end
-end
diff --git a/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb b/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb
index dd976eef28b..5f60dfc8ca1 100644
--- a/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb
@@ -17,14 +17,16 @@ RSpec.describe Gitlab::GithubImport::Stage::FinishImportWorker do
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
- message: 'GitHub project import finished',
- import_stage: 'Gitlab::GithubImport::Stage::FinishImportWorker',
- object_counts: {
- 'fetched' => {},
- 'imported' => {}
- },
- project_id: project.id,
- duration_s: 3.01
+ {
+ message: 'GitHub project import finished',
+ import_stage: 'Gitlab::GithubImport::Stage::FinishImportWorker',
+ object_counts: {
+ 'fetched' => {},
+ 'imported' => {}
+ },
+ project_id: project.id,
+ duration_s: 3.01
+ }
)
worker.import(double(:client), project)
diff --git a/spec/workers/merge_requests/close_issue_worker_spec.rb b/spec/workers/merge_requests/close_issue_worker_spec.rb
new file mode 100644
index 00000000000..5e6bdc2a43e
--- /dev/null
+++ b/spec/workers/merge_requests/close_issue_worker_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::CloseIssueWorker do
+ subject(:worker) { described_class.new }
+
+ describe '#perform' do
+ let!(:user) { create(:user) }
+ let!(:project) { create(:project) }
+ let!(:issue) { create(:issue, project: project) }
+ let!(:merge_request) { create(:merge_request, source_project: project) }
+
+ it 'calls the close issue service' do
+ expect_next_instance_of(Issues::CloseService, project: project, current_user: user) do |service|
+ expect(service).to receive(:execute).with(issue, commit: merge_request)
+ end
+
+ subject.perform(project.id, user.id, issue.id, merge_request.id)
+ end
+
+ shared_examples 'when object does not exist' do
+ it 'does not call the close issue service' do
+ expect(Issues::CloseService).not_to receive(:new)
+
+ expect { subject.perform(project.id, user.id, issue.id, merge_request.id) }
+ .not_to raise_exception
+ end
+ end
+
+ context 'when the project does not exist' do
+ before do
+ project.destroy!
+ end
+
+ it_behaves_like 'when object does not exist'
+ end
+
+ context 'when the user does not exist' do
+ before do
+ user.destroy!
+ end
+
+ it_behaves_like 'when object does not exist'
+ end
+
+ context 'when the issue does not exist' do
+ before do
+ issue.destroy!
+ end
+
+ it_behaves_like 'when object does not exist'
+ end
+
+ context 'when the merge request does not exist' do
+ before do
+ merge_request.destroy!
+ end
+
+ it_behaves_like 'when object does not exist'
+ end
+ end
+end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index 9b33e559c71..3951c20c048 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -354,7 +354,7 @@ RSpec.describe PostReceive do
context 'webhook' do
it 'fetches the correct project' do
- expect(Project).to receive(:find_by).with(id: project.id)
+ expect(Project).to receive(:find_by).with({ id: project.id })
perform
end
diff --git a/spec/workers/project_service_worker_spec.rb b/spec/workers/project_service_worker_spec.rb
index 7813d011274..55ec07ff79c 100644
--- a/spec/workers/project_service_worker_spec.rb
+++ b/spec/workers/project_service_worker_spec.rb
@@ -2,26 +2,38 @@
require 'spec_helper'
RSpec.describe ProjectServiceWorker, '#perform' do
- let(:worker) { described_class.new }
- let(:integration) { Integrations::Jira.new }
+ let_it_be(:integration) { create(:jira_integration) }
- before do
- allow(Integration).to receive(:find).and_return(integration)
- end
+ let(:worker) { described_class.new }
it 'executes integration with given data' do
data = { test: 'test' }
- expect(integration).to receive(:execute).with(data)
- worker.perform(1, data)
+ expect_next_found_instance_of(integration.class) do |integration|
+ expect(integration).to receive(:execute).with(data)
+ end
+
+ worker.perform(integration.id, data)
end
it 'logs error messages' do
error = StandardError.new('invalid URL')
- allow(integration).to receive(:execute).and_raise(error)
- expect(Gitlab::ErrorTracking).to receive(:log_exception).with(error, integration_class: 'Integrations::Jira')
+ expect_next_found_instance_of(integration.class) do |integration|
+ expect(integration).to receive(:execute).and_raise(error)
+ expect(integration).to receive(:log_exception).with(error)
+ end
+
+ worker.perform(integration.id, {})
+ end
+
+ context 'when integration cannot be found' do
+ it 'completes silently and does not log an error' do
+ expect(Gitlab::IntegrationsLogger).not_to receive(:error)
- worker.perform(1, {})
+ expect do
+ worker.perform(non_existing_record_id, {})
+ end.not_to raise_error
+ end
end
end
diff --git a/spec/services/projects/after_import_service_spec.rb b/spec/workers/projects/after_import_worker_spec.rb
index a16aec891a9..332b547bb66 100644
--- a/spec/services/projects/after_import_service_spec.rb
+++ b/spec/workers/projects/after_import_worker_spec.rb
@@ -2,11 +2,12 @@
require 'spec_helper'
-RSpec.describe Projects::AfterImportService do
+RSpec.describe Projects::AfterImportWorker do
include GitHelpers
- subject { described_class.new(project) }
+ subject { worker.perform(project.id) }
+ let(:worker) { described_class.new }
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:sha) { project.commit.sha }
@@ -24,7 +25,7 @@ RSpec.describe Projects::AfterImportService do
end
it 'performs housekeeping' do
- subject.execute
+ subject
expect(housekeeping_service).to have_received(:execute)
end
@@ -34,7 +35,7 @@ RSpec.describe Projects::AfterImportService do
repository.write_ref('refs/pull/1/head', sha)
repository.write_ref('refs/pull/1/merge', sha)
- subject.execute
+ subject
end
it 'removes refs/pull/**/*' do
@@ -48,7 +49,7 @@ RSpec.describe Projects::AfterImportService do
before do
repository.write_ref("refs/#{name}/tmp", sha)
- subject.execute
+ subject
end
it "does not remove refs/#{name}/tmp" do
@@ -62,13 +63,14 @@ RSpec.describe Projects::AfterImportService do
let(:exception) { StandardError.new('after import error') }
before do
- allow(repository)
- .to receive(:delete_all_refs_except)
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository).to receive(:delete_all_refs_except)
.and_raise(exception)
+ end
end
it 'throws after import error' do
- expect { subject.execute }.to raise_exception('after import error')
+ expect { subject }.to raise_exception('after import error')
end
end
@@ -88,30 +90,28 @@ RSpec.describe Projects::AfterImportService do
'error.message' => exception.to_s
}).and_call_original
- subject.execute
+ subject
end
end
context 'when after import action throw retriable exception one time' do
let(:exception) { GRPC::DeadlineExceeded.new }
- before do
- expect(repository)
- .to receive(:delete_all_refs_except)
- .and_raise(exception)
- expect(repository)
- .to receive(:delete_all_refs_except)
- .and_call_original
-
- subject.execute
- end
-
it 'removes refs/pull/**/*' do
+ subject
+
expect(rugged.references.map(&:name))
.not_to include(%r{\Arefs/pull/})
end
it 'records the failures in the database', :aggregate_failures do
+ expect_next_instance_of(Repository) do |repository|
+ expect(repository).to receive(:delete_all_refs_except).and_raise(exception)
+ expect(repository).to receive(:delete_all_refs_except).and_call_original
+ end
+
+ subject
+
import_failure = ImportFailure.last
expect(import_failure.source).to eq('delete_all_refs')
diff --git a/spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb b/spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb
new file mode 100644
index 00000000000..0e7b4ea504c
--- /dev/null
+++ b/spec/workers/projects/inactive_projects_deletion_cron_worker_spec.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::InactiveProjectsDeletionCronWorker do
+ include ProjectHelpers
+
+ describe "#perform" do
+ subject(:worker) { described_class.new }
+
+ let_it_be(:admin_user) { create(:user, :admin) }
+ let_it_be(:non_admin_user) { create(:user) }
+ let_it_be(:new_blank_project) do
+ create_project_with_statistics.tap do |project|
+ project.update!(last_activity_at: Time.current)
+ end
+ end
+
+ let_it_be(:inactive_blank_project) do
+ create_project_with_statistics.tap do |project|
+ project.update!(last_activity_at: 13.months.ago)
+ end
+ end
+
+ let_it_be(:inactive_large_project) do
+ create_project_with_statistics(with_data: true, size_multiplier: 2.gigabytes)
+ .tap { |project| project.update!(last_activity_at: 2.years.ago) }
+ end
+
+ let_it_be(:active_large_project) do
+ create_project_with_statistics(with_data: true, size_multiplier: 2.gigabytes)
+ .tap { |project| project.update!(last_activity_at: 1.month.ago) }
+ end
+
+ before do
+ stub_application_setting(inactive_projects_min_size_mb: 5)
+ stub_application_setting(inactive_projects_send_warning_email_after_months: 12)
+ stub_application_setting(inactive_projects_delete_after_months: 14)
+ end
+
+ context 'when delete inactive projects feature is disabled' do
+ before do
+ stub_application_setting(delete_inactive_projects: false)
+ end
+
+ it 'does not invoke Projects::InactiveProjectsDeletionNotificationWorker' do
+ expect(::Projects::InactiveProjectsDeletionNotificationWorker).not_to receive(:perform_in)
+ expect(::Projects::DestroyService).not_to receive(:new)
+
+ worker.perform
+ end
+
+ it 'does not delete the inactive projects' do
+ worker.perform
+
+ expect(inactive_large_project.reload.pending_delete).to eq(false)
+ end
+ end
+
+ context 'when delete inactive projects feature is enabled' do
+ before do
+ stub_application_setting(delete_inactive_projects: true)
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(inactive_projects_deletion: false)
+ end
+
+ it 'does not invoke Projects::InactiveProjectsDeletionNotificationWorker' do
+ expect(::Projects::InactiveProjectsDeletionNotificationWorker).not_to receive(:perform_in)
+ expect(::Projects::DestroyService).not_to receive(:new)
+
+ worker.perform
+ end
+
+ it 'does not delete the inactive projects' do
+ worker.perform
+
+ expect(inactive_large_project.reload.pending_delete).to eq(false)
+ end
+ end
+
+ context 'when feature flag is enabled', :clean_gitlab_redis_shared_state, :sidekiq_inline do
+ let_it_be(:delay) { anything }
+
+ before do
+ stub_feature_flags(inactive_projects_deletion: true)
+ end
+
+ it 'invokes Projects::InactiveProjectsDeletionNotificationWorker for inactive projects' do
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis).to receive(:hset).with('inactive_projects_deletion_warning_email_notified',
+ "project:#{inactive_large_project.id}", Date.current)
+ end
+ expect(::Projects::InactiveProjectsDeletionNotificationWorker).to receive(:perform_in).with(
+ delay, inactive_large_project.id, deletion_date).and_call_original
+ expect(::Projects::DestroyService).not_to receive(:new)
+
+ worker.perform
+ end
+
+ it 'does not invoke InactiveProjectsDeletionNotificationWorker for already notified inactive projects' do
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.hset('inactive_projects_deletion_warning_email_notified', "project:#{inactive_large_project.id}",
+ Date.current.to_s)
+ end
+
+ expect(::Projects::InactiveProjectsDeletionNotificationWorker).not_to receive(:perform_in)
+ expect(::Projects::DestroyService).not_to receive(:new)
+
+ worker.perform
+ end
+
+ it 'invokes Projects::DestroyService for projects that are inactive even after being notified' do
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.hset('inactive_projects_deletion_warning_email_notified', "project:#{inactive_large_project.id}",
+ 15.months.ago.to_date.to_s)
+ end
+
+ expect(::Projects::InactiveProjectsDeletionNotificationWorker).not_to receive(:perform_in)
+ expect(::Projects::DestroyService).to receive(:new).with(inactive_large_project, admin_user, {})
+ .at_least(:once).and_call_original
+
+ worker.perform
+
+ expect(inactive_large_project.reload.pending_delete).to eq(true)
+
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.hget('inactive_projects_deletion_warning_email_notified',
+ "project:#{inactive_large_project.id}")).to be_nil
+ end
+ end
+ end
+
+ it_behaves_like 'an idempotent worker'
+ end
+ end
+end
diff --git a/spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb b/spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb
new file mode 100644
index 00000000000..3ddfec0d346
--- /dev/null
+++ b/spec/workers/projects/inactive_projects_deletion_notification_worker_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::InactiveProjectsDeletionNotificationWorker do
+ describe "#perform" do
+ subject(:worker) { described_class.new }
+
+ let_it_be(:deletion_date) { Date.current }
+ let_it_be(:non_existing_project_id) { non_existing_record_id }
+ let_it_be(:project) { create(:project) }
+
+ it 'invokes NotificationService and calls inactive_project_deletion_warning' do
+ expect_next_instance_of(NotificationService) do |notification|
+ expect(notification).to receive(:inactive_project_deletion_warning).with(project, deletion_date)
+ end
+
+ worker.perform(project.id, deletion_date)
+ end
+
+ it 'adds the project_id to redis key that tracks the deletion warning emails' do
+ worker.perform(project.id, deletion_date)
+
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.hget('inactive_projects_deletion_warning_email_notified',
+ "project:#{project.id}")).to eq(Date.current.to_s)
+ end
+ end
+
+ it 'rescues and logs the exception if project does not exist' do
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(ActiveRecord::RecordNotFound),
+ { project_id: non_existing_project_id })
+
+ worker.perform(non_existing_project_id, deletion_date)
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [project.id, deletion_date] }
+ end
+ end
+end
diff --git a/spec/workers/projects/record_target_platforms_worker_spec.rb b/spec/workers/projects/record_target_platforms_worker_spec.rb
index eb53e3f8608..01852f252b7 100644
--- a/spec/workers/projects/record_target_platforms_worker_spec.rb
+++ b/spec/workers/projects/record_target_platforms_worker_spec.rb
@@ -7,11 +7,11 @@ RSpec.describe Projects::RecordTargetPlatformsWorker do
let_it_be(:swift) { create(:programming_language, name: 'Swift') }
let_it_be(:objective_c) { create(:programming_language, name: 'Objective-C') }
+ let_it_be(:java) { create(:programming_language, name: 'Java') }
+ let_it_be(:kotlin) { create(:programming_language, name: 'Kotlin') }
let_it_be(:project) { create(:project, :repository, detected_repository_languages: true) }
let(:worker) { described_class.new }
- let(:service_result) { %w(ios osx watchos) }
- let(:service_double) { instance_double(Projects::RecordTargetPlatformsService, execute: service_result) }
let(:lease_key) { "#{described_class.name.underscore}:#{project.id}" }
let(:lease_timeout) { described_class::LEASE_TIMEOUT }
@@ -21,16 +21,20 @@ RSpec.describe Projects::RecordTargetPlatformsWorker do
stub_exclusive_lease(lease_key, timeout: lease_timeout)
end
- shared_examples 'performs detection' do
- it 'creates and executes a Projects::RecordTargetPlatformService instance for the project', :aggregate_failures do
- expect(Projects::RecordTargetPlatformsService).to receive(:new).with(project) { service_double }
+ shared_examples 'performs detection' do |detector_service_class|
+ let(:service_double) { instance_double(detector_service_class, execute: service_result) }
+
+ it "creates and executes a #{detector_service_class} instance for the project", :aggregate_failures do
+ expect(Projects::RecordTargetPlatformsService).to receive(:new)
+ .with(project, detector_service_class) { service_double }
expect(service_double).to receive(:execute)
perform
end
it 'logs extra metadata on done', :aggregate_failures do
- expect(Projects::RecordTargetPlatformsService).to receive(:new).with(project) { service_double }
+ expect(Projects::RecordTargetPlatformsService).to receive(:new)
+ .with(project, detector_service_class) { service_double }
expect(worker).to receive(:log_extra_metadata_on_done).with(:target_platforms, service_result)
perform
@@ -45,19 +49,68 @@ RSpec.describe Projects::RecordTargetPlatformsWorker do
end
end
- context 'when project uses Swift programming language' do
- let!(:repository_language) { create(:repository_language, project: project, programming_language: swift) }
+ def create_language(language)
+ create(:repository_language, project: project, programming_language: language)
+ end
+
+ context 'when project uses programming language for Apple platform' do
+ let(:service_result) { %w(ios osx watchos) }
+
+ context 'when project uses Swift programming language' do
+ before do
+ create_language(swift)
+ end
+
+ it_behaves_like 'performs detection', Projects::AppleTargetPlatformDetectorService
+ end
+
+ context 'when project uses Objective-C programming language' do
+ before do
+ create_language(objective_c)
+ end
- include_examples 'performs detection'
+ it_behaves_like 'performs detection', Projects::AppleTargetPlatformDetectorService
+ end
end
- context 'when project uses Objective-C programming language' do
- let!(:repository_language) { create(:repository_language, project: project, programming_language: objective_c) }
+ context 'when project uses programming language for Android platform' do
+ let(:feature_enabled) { true }
+ let(:service_result) { %w(android) }
+
+ before do
+ stub_feature_flags(detect_android_projects: feature_enabled)
+ end
+
+ context 'when project uses Java' do
+ before do
+ create_language(java)
+ end
+
+ it_behaves_like 'performs detection', Projects::AndroidTargetPlatformDetectorService
+
+ context 'when feature flag is disabled' do
+ let(:feature_enabled) { false }
+
+ it_behaves_like 'does nothing'
+ end
+ end
+
+ context 'when project uses Kotlin' do
+ before do
+ create_language(kotlin)
+ end
+
+ it_behaves_like 'performs detection', Projects::AndroidTargetPlatformDetectorService
- include_examples 'performs detection'
+ context 'when feature flag is disabled' do
+ let(:feature_enabled) { false }
+
+ it_behaves_like 'does nothing'
+ end
+ end
end
- context 'when the project does not contain programming languages for Apple platforms' do
+ context 'when the project does not use programming languages for Apple or Android platforms' do
it_behaves_like 'does nothing'
end
diff --git a/spec/workers/prometheus/create_default_alerts_worker_spec.rb b/spec/workers/prometheus/create_default_alerts_worker_spec.rb
index 887d677c95f..d935bb20a29 100644
--- a/spec/workers/prometheus/create_default_alerts_worker_spec.rb
+++ b/spec/workers/prometheus/create_default_alerts_worker_spec.rb
@@ -5,63 +5,9 @@ require 'spec_helper'
RSpec.describe Prometheus::CreateDefaultAlertsWorker do
let_it_be(:project) { create(:project) }
- let(:worker) { described_class.new }
- let(:logger) { worker.send(:logger) }
- let(:service) { instance_double(Prometheus::CreateDefaultAlertsService) }
- let(:service_result) { ServiceResponse.success }
-
subject { described_class.new.perform(project.id) }
- before do
- allow(Prometheus::CreateDefaultAlertsService)
- .to receive(:new).with(project: project)
- .and_return(service)
- allow(service).to receive(:execute)
- .and_return(service_result)
- end
-
- it_behaves_like 'an idempotent worker' do
- let(:job_args) { [project.id] }
-
- it 'calls the service' do
- expect(service).to receive(:execute)
-
- subject
- end
-
- context 'project is nil' do
- let(:job_args) { [nil] }
-
- it 'does not call the service' do
- expect(service).not_to receive(:execute)
-
- subject
- end
- end
-
- context 'when service returns an error' do
- let(:error_message) { 'some message' }
- let(:service_result) { ServiceResponse.error(message: error_message) }
-
- it 'succeeds and logs the error' do
- expect(logger)
- .to receive(:info)
- .with(a_hash_including('message' => error_message))
- .exactly(worker_exec_times).times
-
- subject
- end
- end
- end
-
- context 'when service raises an exception' do
- let(:error_message) { 'some exception' }
- let(:exception) { StandardError.new(error_message) }
-
- it 're-raises exception' do
- allow(service).to receive(:execute).and_raise(exception)
-
- expect { subject }.to raise_error(exception)
- end
+ it 'does nothing' do
+ expect { subject }.not_to change { PrometheusAlert.count }
end
end
diff --git a/spec/workers/ssh_keys/expired_notification_worker_spec.rb b/spec/workers/ssh_keys/expired_notification_worker_spec.rb
index be38391ff8c..26d9460d73e 100644
--- a/spec/workers/ssh_keys/expired_notification_worker_spec.rb
+++ b/spec/workers/ssh_keys/expired_notification_worker_spec.rb
@@ -16,12 +16,12 @@ RSpec.describe SshKeys::ExpiredNotificationWorker, type: :worker do
let_it_be(:user) { create(:user) }
context 'with a large batch' do
+ let_it_be_with_reload(:keys) { create_list(:key, 20, :expired_today, user: user) }
+
before do
stub_const("SshKeys::ExpiredNotificationWorker::BATCH_SIZE", 5)
end
- let_it_be_with_reload(:keys) { create_list(:key, 20, expires_at: Time.current, user: user) }
-
it 'updates all keys regardless of batch size' do
worker.perform
@@ -30,7 +30,7 @@ RSpec.describe SshKeys::ExpiredNotificationWorker, type: :worker do
end
context 'with expiring key today' do
- let_it_be_with_reload(:expired_today) { create(:key, expires_at: Time.current, user: user) }
+ let_it_be_with_reload(:expired_today) { create(:key, :expired_today, user: user) }
it 'invoke the notification service' do
expect_next_instance_of(Keys::ExpiryNotificationService) do |expiry_service|
@@ -52,7 +52,7 @@ RSpec.describe SshKeys::ExpiredNotificationWorker, type: :worker do
end
context 'when key has expired in the past' do
- let_it_be(:expired_past) { create(:key, expires_at: 1.day.ago, user: user) }
+ let_it_be(:expired_past) { create(:key, :expired, user: user) }
it 'does not update notified column' do
expect { worker.perform }.not_to change { expired_past.reload.expiry_notification_delivered_at }
@@ -60,7 +60,7 @@ RSpec.describe SshKeys::ExpiredNotificationWorker, type: :worker do
context 'when key has already been notified of expiration' do
before do
- expired_past.update!(expiry_notification_delivered_at: 1.day.ago)
+ expired_past.update_attribute(:expiry_notification_delivered_at, 1.day.ago)
end
it 'does not update notified column' do
diff --git a/spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb b/spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb
index 0a1d4a14ad0..e907d035020 100644
--- a/spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb
+++ b/spec/workers/ssh_keys/expiring_soon_notification_worker_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe SshKeys::ExpiringSoonNotificationWorker, type: :worker do
end
context 'when key has expired in the past' do
- let_it_be(:expired_past) { create(:key, expires_at: 1.day.ago, user: user) }
+ let_it_be(:expired_past) { create(:key, :expired, user: user) }
it 'does not update notified column' do
expect { worker.perform }.not_to change { expired_past.reload.before_expiry_notification_delivered_at }