summaryrefslogtreecommitdiff
path: root/spec/frontend
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 20:02:30 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 20:02:30 +0000
commit41fe97390ceddf945f3d967b8fdb3de4c66b7dea (patch)
tree9c8d89a8624828992f06d892cd2f43818ff5dcc8 /spec/frontend
parent0804d2dc31052fb45a1efecedc8e06ce9bc32862 (diff)
downloadgitlab-ce-41fe97390ceddf945f3d967b8fdb3de4c66b7dea.tar.gz
Add latest changes from gitlab-org/gitlab@14-9-stable-eev14.9.0-rc42
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/__helpers__/flush_promises.js1
-rw-r--r--spec/frontend/__helpers__/mocks/axios_utils.js2
-rw-r--r--spec/frontend/__helpers__/vuex_action_helper.js1
-rw-r--r--spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap45
-rw-r--r--spec/frontend/access_tokens/components/expires_at_field_spec.js18
-rw-r--r--spec/frontend/admin/applications/components/__snapshots__/delete_application_spec.js.snap20
-rw-r--r--spec/frontend/admin/applications/components/delete_application_spec.js69
-rw-r--r--spec/frontend/admin/topics/components/__snapshots__/remove_avatar_spec.js.snap20
-rw-r--r--spec/frontend/admin/topics/components/remove_avatar_spec.js85
-rw-r--r--spec/frontend/admin/users/components/user_actions_spec.js20
-rw-r--r--spec/frontend/api_spec.js22
-rw-r--r--spec/frontend/attention_requests/components/navigation_popover_spec.js86
-rw-r--r--spec/frontend/authentication/webauthn/util_spec.js17
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap3
-rw-r--r--spec/frontend/blob/components/blob_header_spec.js15
-rw-r--r--spec/frontend/blob/components/mock_data.js2
-rw-r--r--spec/frontend/blob/csv/csv_viewer_spec.js26
-rw-r--r--spec/frontend/blob_edit/blob_bundle_spec.js6
-rw-r--r--spec/frontend/boards/components/board_form_spec.js14
-rw-r--r--spec/frontend/boards/components/boards_selector_spec.js1
-rw-r--r--spec/frontend/boards/mock_data.js31
-rw-r--r--spec/frontend/boards/stores/actions_spec.js72
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js40
-rw-r--r--spec/frontend/branches/ajax_loading_spinner_spec.js32
-rw-r--r--spec/frontend/ci_secure_files/components/secure_files_list_spec.js139
-rw-r--r--spec/frontend/ci_secure_files/mock_data.js18
-rw-r--r--spec/frontend/clusters/agents/components/create_token_button_spec.js257
-rw-r--r--spec/frontend/clusters/agents/components/token_table_spec.js43
-rw-r--r--spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap7
-rw-r--r--spec/frontend/clusters/components/new_cluster_spec.js4
-rw-r--r--spec/frontend/clusters/mock_data.js57
-rw-r--r--spec/frontend/clusters_list/components/agent_table_spec.js17
-rw-r--r--spec/frontend/clusters_list/components/agent_token_spec.js76
-rw-r--r--spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js63
-rw-r--r--spec/frontend/clusters_list/components/clusters_actions_spec.js122
-rw-r--r--spec/frontend/clusters_list/components/clusters_empty_state_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/clusters_main_view_spec.js149
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js10
-rw-r--r--spec/frontend/clusters_list/components/install_agent_modal_spec.js75
-rw-r--r--spec/frontend/code_navigation/components/app_spec.js19
-rw-r--r--spec/frontend/code_quality_walkthrough/components/__snapshots__/step_spec.js.snap174
-rw-r--r--spec/frontend/code_quality_walkthrough/components/step_spec.js156
-rw-r--r--spec/frontend/content_editor/components/content_editor_alert_spec.js17
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js77
-rw-r--r--spec/frontend/content_editor/components/editor_state_observer_spec.js63
-rw-r--r--spec/frontend/content_editor/components/loading_indicator_spec.js71
-rw-r--r--spec/frontend/content_editor/components/toolbar_button_spec.js2
-rw-r--r--spec/frontend/content_editor/components/toolbar_link_button_spec.js2
-rw-r--r--spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js2
-rw-r--r--spec/frontend/content_editor/extensions/attachment_spec.js17
-rw-r--r--spec/frontend/content_editor/extensions/paste_markdown_spec.js127
-rw-r--r--spec/frontend/content_editor/markdown_processing_spec_helper.js2
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js39
-rw-r--r--spec/frontend/content_editor/services/markdown_deserializer_spec.js62
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js13
-rw-r--r--spec/frontend/content_editor/services/markdown_sourcemap_spec.js29
-rw-r--r--spec/frontend/content_editor/test_utils.js20
-rw-r--r--spec/frontend/contributors/store/getters_spec.js2
-rw-r--r--spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap28
-rw-r--r--spec/frontend/cycle_analytics/__snapshots__/total_time_spec.js.snap28
-rw-r--r--spec/frontend/cycle_analytics/base_spec.js3
-rw-r--r--spec/frontend/cycle_analytics/limit_warning_component_spec.js41
-rw-r--r--spec/frontend/cycle_analytics/total_time_spec.js (renamed from spec/frontend/cycle_analytics/total_time_component_spec.js)6
-rw-r--r--spec/frontend/cycle_analytics/value_stream_filters_spec.js54
-rw-r--r--spec/frontend/cycle_analytics/value_stream_metrics_spec.js6
-rw-r--r--spec/frontend/deploy_tokens/components/revoke_button_spec.js5
-rw-r--r--spec/frontend/diffs/components/diff_view_spec.js12
-rw-r--r--spec/frontend/diffs/components/hidden_files_warning_spec.js18
-rw-r--r--spec/frontend/diffs/store/getters_versions_dropdowns_spec.js37
-rw-r--r--spec/frontend/dirty_submit/dirty_submit_form_spec.js33
-rw-r--r--spec/frontend/editor/source_editor_ci_schema_ext_spec.js9
-rw-r--r--spec/frontend/environment.js1
-rw-r--r--spec/frontend/environments/delete_environment_modal_spec.js31
-rw-r--r--spec/frontend/environments/enable_review_app_modal_spec.js14
-rw-r--r--spec/frontend/environments/environment_actions_spec.js17
-rw-r--r--spec/frontend/environments/environment_folder_spec.js132
-rw-r--r--spec/frontend/environments/environment_item_spec.js2
-rw-r--r--spec/frontend/environments/environments_app_spec.js497
-rw-r--r--spec/frontend/environments/graphql/resolvers_spec.js31
-rw-r--r--spec/frontend/environments/new_environment_folder_spec.js100
-rw-r--r--spec/frontend/environments/new_environment_item_spec.js2
-rw-r--r--spec/frontend/environments/new_environments_app_spec.js329
-rw-r--r--spec/frontend/error_tracking/components/error_details_spec.js6
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js78
-rw-r--r--spec/frontend/error_tracking_settings/components/app_spec.js141
-rw-r--r--spec/frontend/fixtures/merge_requests.rb19
-rw-r--r--spec/frontend/fixtures/runner.rb48
-rw-r--r--spec/frontend/google_cloud/components/app_spec.js5
-rw-r--r--spec/frontend/google_cloud/components/gcp_regions_form_spec.js59
-rw-r--r--spec/frontend/google_cloud/components/gcp_regions_list_spec.js79
-rw-r--r--spec/frontend/google_cloud/components/home_spec.js3
-rw-r--r--spec/frontend/google_cloud/components/revoke_oauth_spec.js47
-rw-r--r--spec/frontend/google_cloud/components/service_accounts_form_spec.js2
-rw-r--r--spec/frontend/google_tag_manager/index_spec.js44
-rw-r--r--spec/frontend/graphql_shared/utils_spec.js2
-rw-r--r--spec/frontend/header_search/components/header_search_autocomplete_items_spec.js13
-rw-r--r--spec/frontend/header_search/store/actions_spec.js16
-rw-r--r--spec/frontend/header_search/store/getters_spec.js100
-rw-r--r--spec/frontend/header_search/store/mutations_spec.js4
-rw-r--r--spec/frontend/ide/components/file_templates/bar_spec.js8
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js4
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js58
-rw-r--r--spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js2
-rw-r--r--spec/frontend/incidents/components/incidents_list_spec.js116
-rw-r--r--spec/frontend/incidents/mocks/incidents.json8
-rw-r--r--spec/frontend/integrations/edit/components/active_checkbox_spec.js9
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js144
-rw-r--r--spec/frontend/integrations/edit/components/sections/connection_spec.js77
-rw-r--r--spec/frontend/integrations/edit/components/sections/jira_issues_spec.js34
-rw-r--r--spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js34
-rw-r--r--spec/frontend/integrations/edit/components/trigger_fields_spec.js6
-rw-r--r--spec/frontend/integrations/edit/mock_data.js8
-rw-r--r--spec/frontend/integrations/edit/store/getters_spec.js21
-rw-r--r--spec/frontend/invite_members/components/invite_groups_modal_spec.js95
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js2
-rw-r--r--spec/frontend/invite_members/components/invite_modal_base_spec.js36
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_block_spec.js6
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_list_spec.js5
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js29
-rw-r--r--spec/frontend/issues/list/utils_spec.js37
-rw-r--r--spec/frontend/issues/show/components/description_spec.js83
-rw-r--r--spec/frontend/issues/show/components/header_actions_spec.js71
-rw-r--r--spec/frontend/issues/show/components/incidents/incident_tabs_spec.js10
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/app_spec.js56
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/sign_in_legacy_button_spec.js (renamed from spec/frontend/jira_connect/subscriptions/components/sign_in_button_spec.js)8
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js204
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/user_link_spec.js45
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js111
-rw-r--r--spec/frontend/jira_connect/subscriptions/pkce_spec.js48
-rw-r--r--spec/frontend/jobs/components/job_app_spec.js1
-rw-r--r--spec/frontend/jobs/components/job_log_controllers_spec.js24
-rw-r--r--spec/frontend/jobs/components/job_sidebar_retry_button_spec.js13
-rw-r--r--spec/frontend/jobs/components/sidebar_spec.js43
-rw-r--r--spec/frontend/jobs/components/stages_dropdown_spec.js209
-rw-r--r--spec/frontend/jobs/components/table/graphql/cache_config_spec.js67
-rw-r--r--spec/frontend/jobs/components/table/job_table_app_spec.js59
-rw-r--r--spec/frontend/jobs/mock_data.js84
-rw-r--r--spec/frontend/lib/utils/array_utility_spec.js13
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js25
-rw-r--r--spec/frontend/lib/utils/ignore_while_pending_spec.js136
-rw-r--r--spec/frontend/lib/utils/resize_observer_spec.js41
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js128
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js53
-rw-r--r--spec/frontend/loading_icon_for_legacy_js_spec.js43
-rw-r--r--spec/frontend/members/components/action_buttons/user_action_buttons_spec.js10
-rw-r--r--spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js54
-rw-r--r--spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js33
-rw-r--r--spec/frontend/merge_request_tabs_spec.js1
-rw-r--r--spec/frontend/monitoring/components/charts/time_series_spec.js15
-rw-r--r--spec/frontend/notes/components/note_header_spec.js20
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js21
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js30
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js10
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js32
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js106
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap2
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js26
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js25
-rw-r--r--spec/frontend/pages/projects/forks/new/components/app_spec.js13
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_form_spec.js28
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js73
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js123
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap14
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js8
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js12
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/mock_data.js1
-rw-r--r--spec/frontend/pages/projects/pages_domains/form_spec.js82
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_form_spec.js14
-rw-r--r--spec/frontend/performance_bar/components/detailed_metric_spec.js24
-rw-r--r--spec/frontend/performance_bar/components/performance_bar_app_spec.js11
-rw-r--r--spec/frontend/performance_bar/components/request_selector_spec.js31
-rw-r--r--spec/frontend/performance_bar/index_spec.js30
-rw-r--r--spec/frontend/persistent_user_callout_spec.js100
-rw-r--r--spec/frontend/pipeline_editor/components/commit/commit_form_spec.js23
-rw-r--r--spec/frontend/pipeline_editor/components/commit/commit_section_spec.js1
-rw-r--r--spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js27
-rw-r--r--spec/frontend/pipeline_editor/components/editor/text_editor_spec.js11
-rw-r--r--spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js49
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_app_spec.js86
-rw-r--r--spec/frontend/pipeline_wizard/components/input_spec.js79
-rw-r--r--spec/frontend/pipeline_wizard/components/step_spec.js227
-rw-r--r--spec/frontend/pipeline_wizard/components/widgets/list_spec.js212
-rw-r--r--spec/frontend/pipeline_wizard/components/widgets_spec.js49
-rw-r--r--spec/frontend/pipeline_wizard/components/wrapper_spec.js250
-rw-r--r--spec/frontend/pipeline_wizard/mock/yaml.js85
-rw-r--r--spec/frontend/pipeline_wizard/pipeline_wizard_spec.js102
-rw-r--r--spec/frontend/pipeline_wizard/validators_spec.js22
-rw-r--r--spec/frontend/pipelines/components/jobs/jobs_app_spec.js29
-rw-r--r--spec/frontend/pipelines/components/pipelines_filtered_search_spec.js2
-rw-r--r--spec/frontend/pipelines/header_component_spec.js25
-rw-r--r--spec/frontend/pipelines/pipeline_labels_spec.js168
-rw-r--r--spec/frontend/pipelines/pipeline_url_spec.js216
-rw-r--r--spec/frontend/pipelines/pipelines_ci_templates_spec.js107
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js76
-rw-r--r--spec/frontend/pipelines/pipelines_table_spec.js63
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js47
-rw-r--r--spec/frontend/protected_branches/protected_branch_create_spec.js114
-rw-r--r--spec/frontend/protected_branches/protected_branch_edit_spec.js92
-rw-r--r--spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap10
-rw-r--r--spec/frontend/ref/stores/mutations_spec.js8
-rw-r--r--spec/frontend/releases/components/asset_links_form_spec.js4
-rw-r--r--spec/frontend/releases/stores/modules/detail/getters_spec.js23
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js106
-rw-r--r--spec/frontend/repository/components/blob_edit_spec.js100
-rw-r--r--spec/frontend/repository/components/blob_viewers/audio_viewer_spec.js23
-rw-r--r--spec/frontend/repository/components/blob_viewers/csv_viewer_spec.js27
-rw-r--r--spec/frontend/repository/components/blob_viewers/download_viewer_spec.js11
-rw-r--r--spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js11
-rw-r--r--spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js26
-rw-r--r--spec/frontend/repository/components/breadcrumbs_spec.js14
-rw-r--r--spec/frontend/repository/mock_data.js17
-rw-r--r--spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js4
-rw-r--r--spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js4
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js74
-rw-r--r--spec/frontend/runner/components/cells/runner_actions_cell_spec.js223
-rw-r--r--spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js2
-rw-r--r--spec/frontend/runner/components/runner_delete_button_spec.js233
-rw-r--r--spec/frontend/runner/components/runner_jobs_spec.js4
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js40
-rw-r--r--spec/frontend/runner/components/runner_pagination_spec.js18
-rw-r--r--spec/frontend/runner/components/runner_pause_button_spec.js24
-rw-r--r--spec/frontend/runner/components/runner_projects_spec.js4
-rw-r--r--spec/frontend/runner/components/runner_update_form_spec.js2
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js83
-rw-r--r--spec/frontend/runner/mock_data.js24
-rw-r--r--spec/frontend/search/topbar/components/app_spec.js33
-rw-r--r--spec/frontend/security_configuration/components/feature_card_spec.js2
-rw-r--r--spec/frontend/security_configuration/components/training_provider_list_spec.js189
-rw-r--r--spec/frontend/security_configuration/graphql/cache_utils_spec.js108
-rw-r--r--spec/frontend/security_configuration/mock_data.js63
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js27
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js41
-rw-r--r--spec/frontend/sidebar/components/attention_requested_toggle_spec.js62
-rw-r--r--spec/frontend/sidebar/components/incidents/escalation_status_spec.js52
-rw-r--r--spec/frontend/sidebar/components/incidents/escalation_utils_spec.js18
-rw-r--r--spec/frontend/sidebar/components/incidents/mock_data.js39
-rw-r--r--spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js207
-rw-r--r--spec/frontend/sidebar/mock_data.js29
-rw-r--r--spec/frontend/sidebar/sidebar_assignees_spec.js31
-rw-r--r--spec/frontend/sidebar/sidebar_mediator_spec.js3
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap1
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap1
-rw-r--r--spec/frontend/terraform/components/empty_state_spec.js2
-rw-r--r--spec/frontend/test_setup.js1
-rw-r--r--spec/frontend/toggle_buttons_spec.js115
-rw-r--r--spec/frontend/toggles/index_spec.js6
-rw-r--r--spec/frontend/tracking/tracking_spec.js27
-rw-r--r--spec/frontend/users_select/index_spec.js35
-rw-r--r--spec/frontend/vue_mr_widget/components/extensions/child_content_spec.js40
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js47
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js29
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js6
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js114
-rw-r--r--spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js8
-rw-r--r--spec/frontend/vue_mr_widget/extentions/code_quality/index_spec.js145
-rw-r--r--spec/frontend/vue_mr_widget/extentions/code_quality/mock_data.js87
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/content_transition_spec.js.snap41
-rw-r--r--spec/frontend/vue_shared/components/color_picker/color_picker_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/content_transition_spec.js109
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js225
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/markdown/header_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap10
-rw-r--r--spec/frontend/vue_shared/components/notes/noteable_warning_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap216
-rw-r--r--spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js50
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/utils_spec.js27
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js127
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js122
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js96
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js102
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js102
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js135
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/user_select_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js40
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js6
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js40
-rw-r--r--spec/frontend/work_items/mock_data.js60
-rw-r--r--spec/frontend/work_items/pages/create_work_item_spec.js24
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js45
-rw-r--r--spec/frontend/work_items/router_spec.js1
286 files changed, 10413 insertions, 4111 deletions
diff --git a/spec/frontend/__helpers__/flush_promises.js b/spec/frontend/__helpers__/flush_promises.js
index 5287a060753..eefc2ed7c17 100644
--- a/spec/frontend/__helpers__/flush_promises.js
+++ b/spec/frontend/__helpers__/flush_promises.js
@@ -1,3 +1,4 @@
export default function flushPromises() {
+ // eslint-disable-next-line no-restricted-syntax
return new Promise(setImmediate);
}
diff --git a/spec/frontend/__helpers__/mocks/axios_utils.js b/spec/frontend/__helpers__/mocks/axios_utils.js
index 674563b9f28..b1efd29dc8d 100644
--- a/spec/frontend/__helpers__/mocks/axios_utils.js
+++ b/spec/frontend/__helpers__/mocks/axios_utils.js
@@ -25,6 +25,7 @@ const onRequest = () => {
// Use setImmediate to alloow the response interceptor to finish
const onResponse = (config) => {
activeRequests -= 1;
+ // eslint-disable-next-line no-restricted-syntax
setImmediate(() => {
events.emit('response', config);
});
@@ -43,6 +44,7 @@ const subscribeToResponse = (predicate = () => true) =>
// If a request has been made synchronously, setImmediate waits for it to be
// processed and the counter incremented.
+ // eslint-disable-next-line no-restricted-syntax
setImmediate(listener);
});
diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js
index e482a8fbc71..68203b544ef 100644
--- a/spec/frontend/__helpers__/vuex_action_helper.js
+++ b/spec/frontend/__helpers__/vuex_action_helper.js
@@ -116,6 +116,7 @@ export default (
payload,
);
+ // eslint-disable-next-line no-restricted-syntax
return (result || new Promise((resolve) => setImmediate(resolve)))
.catch((error) => {
validateResults();
diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
index 0b86c10ea46..dd742419d32 100644
--- a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
+++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
@@ -1,25 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`~/access_tokens/components/expires_at_field should render datepicker with input info 1`] = `
-<gl-datepicker-stub
- ariallabel=""
- autocomplete=""
- container=""
- displayfield="true"
- firstday="0"
- inputlabel="Enter date"
- mindate="Mon Jul 06 2020 00:00:00 GMT+0000 (Greenwich Mean Time)"
- placeholder="YYYY-MM-DD"
- theme=""
+<gl-form-group-stub
+ label="Expiration date"
+ label-for="personal_access_token_expires_at"
+ labeldescription=""
+ optionaltext="(optional)"
>
- <gl-form-input-stub
- autocomplete="off"
- class="datepicker gl-datepicker-input"
- data-qa-selector="expiry_date_field"
- id="personal_access_token_expires_at"
- inputmode="none"
- name="personal_access_token[expires_at]"
+ <gl-datepicker-stub
+ ariallabel=""
+ autocomplete=""
+ container=""
+ displayfield="true"
+ firstday="0"
+ inputlabel="Enter date"
+ mindate="Mon Jul 06 2020 00:00:00 GMT+0000 (Greenwich Mean Time)"
placeholder="YYYY-MM-DD"
- />
-</gl-datepicker-stub>
+ theme=""
+ >
+ <gl-form-input-stub
+ autocomplete="off"
+ class="datepicker gl-datepicker-input"
+ data-qa-selector="expiry_date_field"
+ id="personal_access_token_expires_at"
+ inputmode="none"
+ name="personal_access_token[expires_at]"
+ placeholder="YYYY-MM-DD"
+ />
+ </gl-datepicker-stub>
+</gl-form-group-stub>
`;
diff --git a/spec/frontend/access_tokens/components/expires_at_field_spec.js b/spec/frontend/access_tokens/components/expires_at_field_spec.js
index 4a2815e6931..fc8edcb573f 100644
--- a/spec/frontend/access_tokens/components/expires_at_field_spec.js
+++ b/spec/frontend/access_tokens/components/expires_at_field_spec.js
@@ -4,15 +4,17 @@ import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
describe('~/access_tokens/components/expires_at_field', () => {
let wrapper;
- const createComponent = () => {
+ const defaultPropsData = {
+ inputAttrs: {
+ id: 'personal_access_token_expires_at',
+ name: 'personal_access_token[expires_at]',
+ placeholder: 'YYYY-MM-DD',
+ },
+ };
+
+ const createComponent = (propsData = defaultPropsData) => {
wrapper = shallowMount(ExpiresAtField, {
- propsData: {
- inputAttrs: {
- id: 'personal_access_token_expires_at',
- name: 'personal_access_token[expires_at]',
- placeholder: 'YYYY-MM-DD',
- },
- },
+ propsData,
});
};
diff --git a/spec/frontend/admin/applications/components/__snapshots__/delete_application_spec.js.snap b/spec/frontend/admin/applications/components/__snapshots__/delete_application_spec.js.snap
new file mode 100644
index 00000000000..459a113b6d1
--- /dev/null
+++ b/spec/frontend/admin/applications/components/__snapshots__/delete_application_spec.js.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DeleteApplication the modal component form matches the snapshot 1`] = `
+<form
+ action="application/path/1"
+ method="post"
+>
+ <input
+ name="_method"
+ type="hidden"
+ value="delete"
+ />
+
+ <input
+ name="authenticity_token"
+ type="hidden"
+ value="mock-csrf-token"
+ />
+</form>
+`;
diff --git a/spec/frontend/admin/applications/components/delete_application_spec.js b/spec/frontend/admin/applications/components/delete_application_spec.js
new file mode 100644
index 00000000000..20119b64952
--- /dev/null
+++ b/spec/frontend/admin/applications/components/delete_application_spec.js
@@ -0,0 +1,69 @@
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import DeleteApplication from '~/admin/applications/components/delete_application.vue';
+
+const path = 'application/path/1';
+const name = 'Application name';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+describe('DeleteApplication', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(DeleteApplication, {
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findForm = () => wrapper.find('form');
+
+ beforeEach(() => {
+ setFixtures(`
+ <button class="js-application-delete-button" data-path="${path}" data-name="${name}">Destroy</button>
+ `);
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('the modal component', () => {
+ beforeEach(() => {
+ wrapper.vm.$refs.deleteModal.show = jest.fn();
+ document.querySelector('.js-application-delete-button').click();
+ });
+
+ it('displays the modal component', () => {
+ const modal = findModal();
+
+ expect(modal.exists()).toBe(true);
+ expect(modal.props('title')).toBe('Confirm destroy application');
+ expect(modal.text()).toBe(`Are you sure that you want to destroy ${name}`);
+ });
+
+ describe('form', () => {
+ it('matches the snapshot', () => {
+ expect(findForm().element).toMatchSnapshot();
+ });
+
+ describe('form submission', () => {
+ let formSubmitSpy;
+
+ beforeEach(() => {
+ formSubmitSpy = jest.spyOn(wrapper.vm.$refs.deleteForm, 'submit');
+ findModal().vm.$emit('primary');
+ });
+
+ it('submits the form on the modal primary action', () => {
+ expect(formSubmitSpy).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/topics/components/__snapshots__/remove_avatar_spec.js.snap b/spec/frontend/admin/topics/components/__snapshots__/remove_avatar_spec.js.snap
new file mode 100644
index 00000000000..00f742c3614
--- /dev/null
+++ b/spec/frontend/admin/topics/components/__snapshots__/remove_avatar_spec.js.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RemoveAvatar the modal component form matches the snapshot 1`] = `
+<form
+ action="topic/path/1"
+ method="post"
+>
+ <input
+ name="_method"
+ type="hidden"
+ value="delete"
+ />
+
+ <input
+ name="authenticity_token"
+ type="hidden"
+ value="mock-csrf-token"
+ />
+</form>
+`;
diff --git a/spec/frontend/admin/topics/components/remove_avatar_spec.js b/spec/frontend/admin/topics/components/remove_avatar_spec.js
new file mode 100644
index 00000000000..d4656f0a199
--- /dev/null
+++ b/spec/frontend/admin/topics/components/remove_avatar_spec.js
@@ -0,0 +1,85 @@
+import { GlButton, GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import RemoveAvatar from '~/admin/topics/components/remove_avatar.vue';
+
+const modalID = 'fake-id';
+const path = 'topic/path/1';
+
+jest.mock('lodash/uniqueId', () => () => 'fake-id');
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+describe('RemoveAvatar', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(RemoveAvatar, {
+ provide: {
+ path,
+ },
+ directives: {
+ GlModal: createMockDirective(),
+ },
+ });
+ };
+
+ const findButton = () => wrapper.findComponent(GlButton);
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findForm = () => wrapper.find('form');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('the button component', () => {
+ it('displays the remove button', () => {
+ const button = findButton();
+
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe('Remove avatar');
+ });
+
+ it('contains the correct modal ID', () => {
+ const buttonModalId = getBinding(findButton().element, 'gl-modal').value;
+
+ expect(buttonModalId).toBe(modalID);
+ });
+ });
+
+ describe('the modal component', () => {
+ it('displays the modal component', () => {
+ const modal = findModal();
+
+ expect(modal.exists()).toBe(true);
+ expect(modal.props('title')).toBe('Confirm remove avatar');
+ expect(modal.text()).toBe('Avatar will be removed. Are you sure?');
+ });
+
+ it('contains the correct modal ID', () => {
+ expect(findModal().props('modalId')).toBe(modalID);
+ });
+
+ describe('form', () => {
+ it('matches the snapshot', () => {
+ expect(findForm().element).toMatchSnapshot();
+ });
+
+ describe('form submission', () => {
+ let formSubmitSpy;
+
+ beforeEach(() => {
+ formSubmitSpy = jest.spyOn(wrapper.vm.$refs.deleteForm, 'submit');
+ findModal().vm.$emit('primary');
+ });
+
+ it('submits the form on the modal primary action', () => {
+ expect(formSubmitSpy).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js
index 43313424553..b90a30b5b89 100644
--- a/spec/frontend/admin/users/components/user_actions_spec.js
+++ b/spec/frontend/admin/users/components/user_actions_spec.js
@@ -77,6 +77,12 @@ describe('AdminUserActions component', () => {
expect(findActionsDropdown().exists()).toBe(true);
});
+ it('renders the tooltip', () => {
+ const tooltip = getBinding(findActionsDropdown().element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(I18N_USER_ACTIONS.userAdministration);
+ });
+
describe('when there are actions that require confirmation', () => {
beforeEach(() => {
initComponent({ actions: CONFIRMATION_ACTIONS });
@@ -152,7 +158,7 @@ describe('AdminUserActions component', () => {
describe('when `showButtonLabels` prop is `false`', () => {
beforeEach(() => {
- initComponent({ actions: [EDIT, ...CONFIRMATION_ACTIONS] });
+ initComponent({ actions: [EDIT] });
});
it('does not render "Edit" button label', () => {
@@ -163,16 +169,11 @@ describe('AdminUserActions component', () => {
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(I18N_USER_ACTIONS.edit);
});
-
- it('does not render "User administration" dropdown button label', () => {
- expect(findActionsDropdown().props('text')).toBe(I18N_USER_ACTIONS.userAdministration);
- expect(findActionsDropdown().props('textSrOnly')).toBe(true);
- });
});
describe('when `showButtonLabels` prop is `true`', () => {
beforeEach(() => {
- initComponent({ actions: [EDIT, ...CONFIRMATION_ACTIONS], showButtonLabels: true });
+ initComponent({ actions: [EDIT], showButtonLabels: true });
});
it('renders "Edit" button label', () => {
@@ -181,10 +182,5 @@ describe('AdminUserActions component', () => {
expect(findEditButton().text()).toBe(I18N_USER_ACTIONS.edit);
expect(tooltip).not.toBeDefined();
});
-
- it('renders "User administration" dropdown button label', () => {
- expect(findActionsDropdown().props('text')).toBe(I18N_USER_ACTIONS.userAdministration);
- expect(findActionsDropdown().props('textSrOnly')).toBe(false);
- });
});
});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 75faf6d66fa..bc3e12d3fc4 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -1619,6 +1619,28 @@ describe('Api', () => {
});
});
+ describe('projectSecureFiles', () => {
+ it('fetches secure files for a project', async () => {
+ const projectId = 1;
+ const secureFiles = [
+ {
+ 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.onGet(expectedUrl).reply(httpStatus.OK, secureFiles);
+ const { data } = await Api.projectSecureFiles(projectId, {});
+
+ expect(data).toEqual(secureFiles);
+ });
+ });
+
describe('Feature Flag User List', () => {
let expectedUrl;
let projectId;
diff --git a/spec/frontend/attention_requests/components/navigation_popover_spec.js b/spec/frontend/attention_requests/components/navigation_popover_spec.js
new file mode 100644
index 00000000000..d0231afbdc4
--- /dev/null
+++ b/spec/frontend/attention_requests/components/navigation_popover_spec.js
@@ -0,0 +1,86 @@
+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 { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
+
+let wrapper;
+let dismiss;
+
+function createComponent(provideData = {}, shouldShowCallout = true) {
+ wrapper = shallowMount(NavigationPopover, {
+ provide: {
+ message: ['Test'],
+ observerElSelector: '.js-test',
+ observerElToggledClass: 'show',
+ featureName: 'attention_requests',
+ popoverTarget: '.js-test-popover',
+ ...provideData,
+ },
+ stubs: {
+ UserCalloutDismisser: makeMockUserCalloutDismisser({
+ dismiss,
+ shouldShowCallout,
+ }),
+ GlSprintf,
+ },
+ });
+}
+
+describe('Attention requests navigation popover', () => {
+ beforeEach(() => {
+ setFixtures('<div><div class="js-test-popover"></div><div class="js-test"></div></div>');
+ dismiss = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('hides popover if callout is disabled', () => {
+ createComponent({}, false);
+
+ expect(wrapper.findComponent(GlPopover).exists()).toBe(false);
+ });
+
+ it('shows popover if callout is enabled', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(GlPopover).exists()).toBe(true);
+ });
+
+ it.each`
+ isDesktop | device | expectedPlacement
+ ${true} | ${'desktop'} | ${'left'}
+ ${false} | ${'mobile'} | ${'bottom'}
+ `(
+ 'sets popover position to $expectedPlacement on $device',
+ ({ isDesktop, expectedPlacement }) => {
+ jest.spyOn(bp, 'isDesktop').mockReturnValue(isDesktop);
+
+ createComponent();
+
+ expect(wrapper.findComponent(GlPopover).props('placement')).toBe(expectedPlacement);
+ },
+ );
+
+ it('calls dismiss when clicking action button', () => {
+ createComponent();
+
+ wrapper
+ .findComponent(GlButton)
+ .vm.$emit('click', { preventDefault() {}, stopPropagation() {} });
+
+ expect(dismiss).toHaveBeenCalled();
+ });
+
+ it('shows icon in text', () => {
+ createComponent({ showAttentionIcon: true, message: ['%{strongStart}Test%{strongEnd}'] });
+
+ const icon = wrapper.findComponent(GlIcon);
+
+ expect(icon.exists()).toBe(true);
+ expect(icon.props('name')).toBe('attention');
+ });
+});
diff --git a/spec/frontend/authentication/webauthn/util_spec.js b/spec/frontend/authentication/webauthn/util_spec.js
index c9b8bfd8679..bc44b47d0ba 100644
--- a/spec/frontend/authentication/webauthn/util_spec.js
+++ b/spec/frontend/authentication/webauthn/util_spec.js
@@ -1,4 +1,4 @@
-import { base64ToBuffer, bufferToBase64 } from '~/authentication/webauthn/util';
+import { base64ToBuffer, bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util';
const encodedString = 'SGVsbG8gd29ybGQh';
const stringBytes = [72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33];
@@ -16,4 +16,19 @@ describe('Webauthn utils', () => {
const buffer = base64ToBuffer(encodedString);
expect(bufferToBase64(buffer)).toBe(encodedString);
});
+
+ describe('base64ToBase64Url', () => {
+ it.each`
+ argument | expectedResult
+ ${'asd+'} | ${'asd-'}
+ ${'asd/'} | ${'asd_'}
+ ${'asd='} | ${'asd'}
+ ${'+asd'} | ${'-asd'}
+ ${'/asd'} | ${'_asd'}
+ ${'=asd'} | ${'=asd'}
+ ${'a+bc/def=ghigjk=='} | ${'a-bc_def=ghigjk'}
+ `('returns $expectedResult when argument is $argument', ({ argument, expectedResult }) => {
+ expect(base64ToBase64Url(argument)).toBe(expectedResult);
+ });
+ });
});
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 b3d93906445..5926836d9c1 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
@@ -21,12 +21,13 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = `
class="gl-sm-display-flex file-actions"
>
<viewer-switcher-stub
+ docicon="document"
value="simple"
/>
<default-actions-stub
activeviewer="simple"
- rawpath="/flightjs/flight/snippets/51/raw"
+ rawpath="https://testing.com/flightjs/flight/snippets/51/raw"
/>
</div>
</div>
diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js
index 8e1b03c6126..ee42c2387ae 100644
--- a/spec/frontend/blob/components/blob_header_spec.js
+++ b/spec/frontend/blob/components/blob_header_spec.js
@@ -159,5 +159,20 @@ describe('Blob Header Default Actions', () => {
await nextTick();
expect(wrapper.vm.$emit).not.toHaveBeenCalled();
});
+
+ it('sets different icons depending on the blob file type', async () => {
+ factory();
+ expect(wrapper.vm.blobSwitcherDocIcon).toBe('document');
+ await wrapper.setProps({
+ blob: {
+ ...Blob,
+ richViewer: {
+ ...Blob.richViewer,
+ fileType: 'csv',
+ },
+ },
+ });
+ expect(wrapper.vm.blobSwitcherDocIcon).toBe('table');
+ });
});
});
diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js
index 9a345921f16..b5803bf0cbc 100644
--- a/spec/frontend/blob/components/mock_data.js
+++ b/spec/frontend/blob/components/mock_data.js
@@ -22,7 +22,7 @@ export const Blob = {
binary: false,
name: 'dummy.md',
path: 'foo/bar/dummy.md',
- rawPath: '/flightjs/flight/snippets/51/raw',
+ rawPath: 'https://testing.com/flightjs/flight/snippets/51/raw',
size: 75,
simpleViewer: {
...SimpleViewerMock,
diff --git a/spec/frontend/blob/csv/csv_viewer_spec.js b/spec/frontend/blob/csv/csv_viewer_spec.js
index 17973c709c1..ff96193a20c 100644
--- a/spec/frontend/blob/csv/csv_viewer_spec.js
+++ b/spec/frontend/blob/csv/csv_viewer_spec.js
@@ -2,6 +2,7 @@ import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { getAllByRole } from '@testing-library/dom';
import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import Papa from 'papaparse';
import CsvViewer from '~/blob/csv/csv_viewer.vue';
import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue';
@@ -11,10 +12,15 @@ const brokenCsv = '{\n "json": 1,\n "key": [1, 2, 3]\n}';
describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => {
let wrapper;
- const createComponent = ({ csv = validCsv, mountFunction = shallowMount } = {}) => {
+ const createComponent = ({
+ csv = validCsv,
+ remoteFile = false,
+ mountFunction = shallowMount,
+ } = {}) => {
wrapper = mountFunction(CsvViewer, {
propsData: {
csv,
+ remoteFile,
},
});
};
@@ -73,4 +79,22 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => {
expect(getAllByRole(wrapper.element, 'row', { name: /Three/i })).toHaveLength(1);
});
});
+
+ describe('when csv prop is path and indicates a remote file', () => {
+ it('should render call parse with download flag true', async () => {
+ const path = 'path/to/remote/file.csv';
+ jest.spyOn(Papa, 'parse').mockImplementation((_, { complete }) => {
+ complete({ data: validCsv.split(','), errors: [] });
+ });
+
+ createComponent({ csv: path, remoteFile: true });
+ expect(Papa.parse).toHaveBeenCalledWith(path, {
+ download: true,
+ skipEmptyLines: true,
+ complete: expect.any(Function),
+ });
+ await nextTick;
+ expect(wrapper.vm.items).toEqual(validCsv.split(','));
+ });
+ });
});
diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js
index 8986dfbfa9c..2c9ddfaf867 100644
--- a/spec/frontend/blob_edit/blob_bundle_spec.js
+++ b/spec/frontend/blob_edit/blob_bundle_spec.js
@@ -25,7 +25,7 @@ describe('BlobBundle', () => {
setFixtures(`
<div class="js-edit-blob-form" data-blob-filename="blah">
<button class="js-commit-button"></button>
- <a class="btn btn-cancel" href="#"></a>
+ <button id='cancel-changes'></button>
</div>`);
blobBundle();
@@ -42,7 +42,7 @@ describe('BlobBundle', () => {
});
it('removes beforeunload listener when cancel link is clicked', () => {
- $('.btn.btn-cancel').click();
+ $('#cancel-changes').click();
expect(window.onbeforeunload).toBeNull();
});
@@ -61,7 +61,7 @@ describe('BlobBundle', () => {
data-human-access="owner"
data-merge-request-path="path/to/mr">
<button id='commit-changes' class="js-commit-button"></button>
- <a class="btn btn-cancel" href="#"></a>
+ <button id='cancel-changes'></button>
</div>
</div>`);
diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js
index 5678da2a246..c976ba7525b 100644
--- a/spec/frontend/boards/components/board_form_spec.js
+++ b/spec/frontend/boards/components/board_form_spec.js
@@ -1,6 +1,6 @@
import { GlModal } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import setWindowLocation from 'helpers/set_window_location_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import BoardForm from '~/boards/components/board_form.vue';
@@ -22,6 +22,8 @@ const currentBoard = {
labels: [],
milestone: {},
assignee: {},
+ iteration: {},
+ iterationCadence: {},
weight: null,
hideBacklogList: false,
hideClosedList: false,
@@ -37,11 +39,11 @@ describe('BoardForm', () => {
let wrapper;
let mutate;
- const findModal = () => wrapper.find(GlModal);
+ const findModal = () => wrapper.findComponent(GlModal);
const findModalActionPrimary = () => findModal().props('actionPrimary');
- const findForm = () => wrapper.find('[data-testid="board-form"]');
- const findFormWrapper = () => wrapper.find('[data-testid="board-form-wrapper"]');
- const findDeleteConfirmation = () => wrapper.find('[data-testid="delete-confirmation-message"]');
+ const findForm = () => wrapper.findByTestId('board-form');
+ const findFormWrapper = () => wrapper.findByTestId('board-form-wrapper');
+ const findDeleteConfirmation = () => wrapper.findByTestId('delete-confirmation-message');
const findInput = () => wrapper.find('#board-new-name');
const store = createStore({
@@ -52,7 +54,7 @@ describe('BoardForm', () => {
});
const createComponent = (props, data) => {
- wrapper = shallowMount(BoardForm, {
+ wrapper = shallowMountExtended(BoardForm, {
propsData: { ...defaultProps, ...props },
data() {
return {
diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js
index 26a5bf34595..0c044deb78c 100644
--- a/spec/frontend/boards/components/boards_selector_spec.js
+++ b/spec/frontend/boards/components/boards_selector_spec.js
@@ -41,6 +41,7 @@ describe('BoardsSelector', () => {
...defaultStore,
actions: {
setError: jest.fn(),
+ setBoardConfig: jest.fn(),
},
getters: {
isGroupBoard: () => isGroupBoard,
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 24096fddea6..ec9342cffc2 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -8,6 +8,37 @@ import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue';
+export const mockBoard = {
+ milestone: {
+ id: 'gid://gitlab/Milestone/114',
+ title: '14.9',
+ },
+ iteration: {
+ id: 'gid://gitlab/Iteration/124',
+ title: 'Iteration 9',
+ },
+ assignee: {
+ id: 'gid://gitlab/User/1',
+ username: 'admin',
+ },
+ labels: {
+ nodes: [{ id: 'gid://gitlab/Label/32', title: 'Deliverable' }],
+ },
+ weight: 2,
+};
+
+export const mockBoardConfig = {
+ milestoneId: 'gid://gitlab/Milestone/114',
+ milestoneTitle: '14.9',
+ iterationId: 'gid://gitlab/Iteration/124',
+ iterationTitle: 'Iteration 9',
+ assigneeId: 'gid://gitlab/User/1',
+ assigneeUsername: 'admin',
+ labels: [{ id: 'gid://gitlab/Label/32', title: 'Deliverable' }],
+ labelIds: ['gid://gitlab/Label/32'],
+ weight: 2,
+};
+
export const boardObj = {
id: 1,
name: 'test',
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 0eca0cb3ee5..ad661a31556 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -32,6 +32,8 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import projectBoardMilestones from '~/boards/graphql/project_board_milestones.query.graphql';
import groupBoardMilestones from '~/boards/graphql/group_board_milestones.query.graphql';
import {
+ mockBoard,
+ mockBoardConfig,
mockLists,
mockListsById,
mockIssue,
@@ -60,6 +62,52 @@ beforeEach(() => {
window.gon = { features: {} };
});
+describe('fetchBoard', () => {
+ const payload = {
+ fullPath: 'gitlab-org',
+ fullBoardId: 'gid://gitlab/Board/1',
+ boardType: 'project',
+ };
+
+ const queryResponse = {
+ data: {
+ workspace: {
+ board: mockBoard,
+ },
+ },
+ };
+
+ it('should commit mutation RECEIVE_BOARD_SUCCESS and dispatch setBoardConfig on success', async () => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
+
+ await testAction({
+ action: actions.fetchBoard,
+ payload,
+ expectedMutations: [
+ {
+ type: types.RECEIVE_BOARD_SUCCESS,
+ payload: mockBoard,
+ },
+ ],
+ expectedActions: [{ type: 'setBoardConfig', payload: mockBoard }],
+ });
+ });
+
+ it('should commit mutation RECEIVE_BOARD_FAILURE on failure', async () => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
+
+ await testAction({
+ action: actions.fetchBoard,
+ payload,
+ expectedMutations: [
+ {
+ type: types.RECEIVE_BOARD_FAILURE,
+ },
+ ],
+ });
+ });
+});
+
describe('setInitialBoardData', () => {
it('sets data object', () => {
const mockData = {
@@ -67,13 +115,21 @@ describe('setInitialBoardData', () => {
bar: 'baz',
};
- return testAction(
- actions.setInitialBoardData,
- mockData,
- {},
- [{ type: types.SET_INITIAL_BOARD_DATA, payload: mockData }],
- [],
- );
+ return testAction({
+ action: actions.setInitialBoardData,
+ payload: mockData,
+ expectedMutations: [{ type: types.SET_INITIAL_BOARD_DATA, payload: mockData }],
+ });
+ });
+});
+
+describe('setBoardConfig', () => {
+ it('sets board config object from board object', () => {
+ return testAction({
+ action: actions.setBoardConfig,
+ payload: mockBoard,
+ expectedMutations: [{ type: types.SET_BOARD_CONFIG, payload: mockBoardConfig }],
+ });
});
});
@@ -87,7 +143,7 @@ describe('setFilters', () => {
},
],
[
- "and use 'assigneeWildcardId' as filter variable for 'assigneId' param",
+ "and use 'assigneeWildcardId' as filter variable for 'assigneeId' param",
{
filters: { assigneeId: 'None' },
filterVariables: { assigneeWildcardId: 'NONE', not: {} },
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index 0e830258327..738737bf4b6 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -4,6 +4,7 @@ import * as types from '~/boards/stores/mutation_types';
import mutations from '~/boards/stores/mutations';
import defaultState from '~/boards/stores/state';
import {
+ mockBoard,
mockLists,
rawIssue,
mockIssue,
@@ -33,6 +34,27 @@ describe('Board Store Mutations', () => {
state = defaultState();
});
+ describe('RECEIVE_BOARD_SUCCESS', () => {
+ it('Should set board to state', () => {
+ mutations[types.RECEIVE_BOARD_SUCCESS](state, mockBoard);
+
+ expect(state.board).toEqual({
+ ...mockBoard,
+ labels: mockBoard.labels.nodes,
+ });
+ });
+ });
+
+ describe('RECEIVE_BOARD_FAILURE', () => {
+ it('Should set error in state', () => {
+ mutations[types.RECEIVE_BOARD_FAILURE](state);
+
+ expect(state.error).toEqual(
+ 'An error occurred while fetching the board. Please reload the page.',
+ );
+ });
+ });
+
describe('SET_INITIAL_BOARD_DATA', () => {
it('Should set initial Boards data to state', () => {
const allowSubEpics = true;
@@ -40,9 +62,6 @@ describe('Board Store Mutations', () => {
const fullPath = 'gitlab-org';
const boardType = 'group';
const disabled = false;
- const boardConfig = {
- milestoneTitle: 'Milestone 1',
- };
const issuableType = issuableTypes.issue;
mutations[types.SET_INITIAL_BOARD_DATA](state, {
@@ -51,7 +70,6 @@ describe('Board Store Mutations', () => {
fullPath,
boardType,
disabled,
- boardConfig,
issuableType,
});
@@ -60,11 +78,23 @@ describe('Board Store Mutations', () => {
expect(state.fullPath).toEqual(fullPath);
expect(state.boardType).toEqual(boardType);
expect(state.disabled).toEqual(disabled);
- expect(state.boardConfig).toEqual(boardConfig);
expect(state.issuableType).toEqual(issuableType);
});
});
+ describe('SET_BOARD_CONFIG', () => {
+ it('Should set board config data o state', () => {
+ const boardConfig = {
+ milestoneId: 1,
+ milestoneTitle: 'Milestone 1',
+ };
+
+ mutations[types.SET_BOARD_CONFIG](state, boardConfig);
+
+ expect(state.boardConfig).toEqual(boardConfig);
+ });
+ });
+
describe('RECEIVE_BOARD_LISTS_SUCCESS', () => {
it('Should set boardLists to state', () => {
mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, initialBoardListsState);
diff --git a/spec/frontend/branches/ajax_loading_spinner_spec.js b/spec/frontend/branches/ajax_loading_spinner_spec.js
deleted file mode 100644
index 31cc7b99e42..00000000000
--- a/spec/frontend/branches/ajax_loading_spinner_spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
-
-describe('Ajax Loading Spinner', () => {
- let ajaxLoadingSpinnerElement;
- let fauxEvent;
- beforeEach(() => {
- document.body.innerHTML = `
- <div>
- <a class="js-ajax-loading-spinner"
- data-remote
- href="http://goesnowhere.nothing/whereami">
- Remove me
- </a></div>`;
- AjaxLoadingSpinner.init();
- ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner');
- fauxEvent = { target: ajaxLoadingSpinnerElement };
- });
-
- afterEach(() => {
- document.body.innerHTML = '';
- });
-
- it('`ajaxBeforeSend` event handler sets current icon to spinner and disables link', () => {
- expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).toBeNull();
- expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(false);
-
- AjaxLoadingSpinner.ajaxBeforeSend(fauxEvent);
-
- expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).not.toBeNull();
- expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(true);
- });
-});
diff --git a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
new file mode 100644
index 00000000000..042376c71e8
--- /dev/null
+++ b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
@@ -0,0 +1,139 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import { mount } from '@vue/test-utils';
+import axios from '~/lib/utils/axios_utils';
+import SecureFilesList from '~/ci_secure_files/components/secure_files_list.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import { secureFiles } from '../mock_data';
+
+const dummyApiVersion = 'v3000';
+const dummyProjectId = 1;
+const dummyUrlRoot = '/gitlab';
+const dummyGon = {
+ api_version: dummyApiVersion,
+ relative_url_root: dummyUrlRoot,
+};
+let originalGon;
+const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProjectId}/secure_files`;
+
+describe('SecureFilesList', () => {
+ let wrapper;
+ let mock;
+
+ beforeEach(() => {
+ originalGon = window.gon;
+ window.gon = { ...dummyGon };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ window.gon = originalGon;
+ });
+
+ const createWrapper = (props = {}) => {
+ wrapper = mount(SecureFilesList, {
+ provide: { projectId: dummyProjectId },
+ ...props,
+ });
+ };
+
+ const findRows = () => wrapper.findAll('tbody tr');
+ const findRowAt = (i) => findRows().at(i);
+ const findCell = (i, col) => findRowAt(i).findAll('td').at(col);
+ const findHeaderAt = (i) => wrapper.findAll('thead th').at(i);
+ const findPagination = () => wrapper.findAll('ul.pagination');
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ describe('when secure files exist in a project', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onGet(expectedUrl).reply(200, secureFiles);
+
+ createWrapper();
+ await waitForPromises();
+ });
+
+ it('displays a table with expected headers', () => {
+ const headers = ['Filename', 'Permissions', 'Uploaded'];
+ headers.forEach((header, i) => {
+ expect(findHeaderAt(i).text()).toBe(header);
+ });
+ });
+
+ it('displays a table with rows', () => {
+ expect(findRows()).toHaveLength(secureFiles.length);
+
+ 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);
+ });
+ });
+
+ describe('when no secure files exist in a project', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onGet(expectedUrl).reply(200, []);
+
+ createWrapper();
+ await waitForPromises();
+ });
+
+ it('displays a table with expected headers', () => {
+ const headers = ['Filename', 'Permissions', 'Uploaded'];
+ headers.forEach((header, i) => {
+ expect(findHeaderAt(i).text()).toBe(header);
+ });
+ });
+
+ it('displays a table with a no records message', () => {
+ expect(findCell(0, 0).text()).toBe('There are no records to show');
+ });
+ });
+
+ describe('pagination', () => {
+ it('displays the pagination component with there are more than 20 items', async () => {
+ mock = new MockAdapter(axios);
+ mock.onGet(expectedUrl).reply(200, secureFiles, { 'x-total': 30 });
+
+ createWrapper();
+ await waitForPromises();
+
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('does not display the pagination component with there are 20 items', async () => {
+ mock = new MockAdapter(axios);
+ mock.onGet(expectedUrl).reply(200, secureFiles, { 'x-total': 20 });
+
+ createWrapper();
+ await waitForPromises();
+
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('loading state', () => {
+ it('displays the loading icon while waiting for the backend request', () => {
+ mock = new MockAdapter(axios);
+ mock.onGet(expectedUrl).reply(200, secureFiles);
+ createWrapper();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not display the loading icon after the backend request has completed', async () => {
+ mock = new MockAdapter(axios);
+ mock.onGet(expectedUrl).reply(200, secureFiles);
+
+ createWrapper();
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci_secure_files/mock_data.js b/spec/frontend/ci_secure_files/mock_data.js
new file mode 100644
index 00000000000..5a9e16d1ad6
--- /dev/null
+++ b/spec/frontend/ci_secure_files/mock_data.js
@@ -0,0 +1,18 @@
+export const secureFiles = [
+ {
+ id: 1,
+ name: 'myfile.jks',
+ checksum: '16630b189ab34b2e3504f4758e1054d2e478deda510b2b08cc0ef38d12e80aac',
+ checksum_algorithm: 'sha256',
+ permissions: 'read_only',
+ created_at: '2022-02-22T22:22:22.222Z',
+ },
+ {
+ id: 2,
+ name: 'myotherfile.jks',
+ checksum: '16630b189ab34b2e3504f4758e1054d2e478deda510b2b08cc0ef38d12e80aa2',
+ checksum_algorithm: 'sha256',
+ permissions: 'execute',
+ created_at: '2022-02-22T22:22:22.222Z',
+ },
+];
diff --git a/spec/frontend/clusters/agents/components/create_token_button_spec.js b/spec/frontend/clusters/agents/components/create_token_button_spec.js
new file mode 100644
index 00000000000..b9a3a851e57
--- /dev/null
+++ b/spec/frontend/clusters/agents/components/create_token_button_spec.js
@@ -0,0 +1,257 @@
+import { GlButton, GlTooltip, GlModal, GlFormInput, GlFormTextarea, GlAlert } 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 { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import {
+ EVENT_LABEL_MODAL,
+ EVENT_ACTIONS_OPEN,
+ TOKEN_NAME_LIMIT,
+ TOKEN_STATUS_ACTIVE,
+ MAX_LIST_COUNT,
+} from '~/clusters/agents/constants';
+import createNewAgentToken from '~/clusters/agents/graphql/mutations/create_new_agent_token.mutation.graphql';
+import getClusterAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql';
+import AgentToken from '~/clusters_list/components/agent_token.vue';
+import CreateTokenButton from '~/clusters/agents/components/create_token_button.vue';
+import {
+ clusterAgentToken,
+ getTokenResponse,
+ createAgentTokenErrorResponse,
+} from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('CreateTokenButton', () => {
+ let wrapper;
+ let apolloProvider;
+ let trackingSpy;
+ let createResponse;
+
+ const clusterAgentId = 'cluster-agent-id';
+ 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 = {
+ clusterAgentId,
+ cursor,
+ };
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findBtn = () => wrapper.findComponent(GlButton);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+ const findTextarea = () => wrapper.findComponent(GlFormTextarea);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
+ const findAgentInstructions = () => findModal().findComponent(AgentToken);
+ const findButtonByVariant = (variant) =>
+ findModal()
+ .findAll(GlButton)
+ .wrappers.find((button) => button.props('variant') === variant);
+ const findActionButton = () => findButtonByVariant('confirm');
+ const findCancelButton = () => wrapper.findByTestId('agent-token-close-button');
+
+ const expectDisabledAttribute = (element, disabled) => {
+ if (disabled) {
+ expect(element.attributes('disabled')).toBe('true');
+ } else {
+ expect(element.attributes('disabled')).toBeUndefined();
+ }
+ };
+
+ const createMockApolloProvider = ({ mutationResponse }) => {
+ createResponse = jest.fn().mockResolvedValue(mutationResponse);
+
+ return createMockApollo([[createNewAgentToken, createResponse]]);
+ };
+
+ const writeQuery = () => {
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: getClusterAgentQuery,
+ data: getTokenResponse.data,
+ variables: {
+ agentName,
+ projectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
+ ...cursor,
+ },
+ });
+ };
+
+ const createWrapper = async ({ provideData = {} } = {}) => {
+ wrapper = shallowMountExtended(CreateTokenButton, {
+ apolloProvider,
+ provide: {
+ ...defaultProvide,
+ ...provideData,
+ },
+ propsData,
+ stubs: {
+ GlModal,
+ GlTooltip,
+ },
+ });
+ wrapper.vm.$refs.modal.hide = jest.fn();
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ };
+
+ const mockCreatedResponse = (mutationResponse) => {
+ apolloProvider = createMockApolloProvider({ mutationResponse });
+ writeQuery();
+
+ createWrapper();
+
+ findInput().vm.$emit('input', 'new-token');
+ findTextarea().vm.$emit('input', 'new-token-description');
+ findActionButton().vm.$emit('click');
+
+ return waitForPromises();
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ apolloProvider = null;
+ createResponse = null;
+ });
+
+ describe('create agent token action', () => {
+ it('displays create agent token button', () => {
+ expect(findBtn().text()).toBe('Create token');
+ });
+
+ describe('when user cannot create token', () => {
+ beforeEach(() => {
+ createWrapper({ provideData: { canAdminCluster: false } });
+ });
+
+ it('disabled the button', () => {
+ expect(findBtn().attributes('disabled')).toBe('true');
+ });
+
+ it('shows a disabled tooltip', () => {
+ expect(findTooltip().attributes('title')).toBe(
+ 'Requires a Maintainer or greater role to perform these actions',
+ );
+ });
+ });
+
+ describe('when user can create a token and clicks the button', () => {
+ beforeEach(() => {
+ findBtn().vm.$emit('click');
+ });
+
+ it('displays a token creation modal', () => {
+ expect(findModal().isVisible()).toBe(true);
+ });
+
+ describe('initial state', () => {
+ it('renders an input for the token name', () => {
+ expect(findInput().exists()).toBe(true);
+ expectDisabledAttribute(findInput(), false);
+ expect(findInput().attributes('max-length')).toBe(TOKEN_NAME_LIMIT.toString());
+ });
+
+ it('renders a textarea for the token description', () => {
+ expect(findTextarea().exists()).toBe(true);
+ expectDisabledAttribute(findTextarea(), false);
+ });
+
+ it('renders a cancel button', () => {
+ expect(findCancelButton().isVisible()).toBe(true);
+ expectDisabledAttribute(findCancelButton(), false);
+ });
+
+ it('renders a disabled next button', () => {
+ expect(findActionButton().text()).toBe('Create token');
+ expectDisabledAttribute(findActionButton(), true);
+ });
+
+ it('sends tracking event for modal shown', () => {
+ findModal().vm.$emit('show');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, {
+ label: EVENT_LABEL_MODAL,
+ });
+ });
+ });
+
+ describe('when user inputs the token name', () => {
+ beforeEach(() => {
+ expectDisabledAttribute(findActionButton(), true);
+ findInput().vm.$emit('input', 'new-token');
+ });
+
+ it('enables the next button', () => {
+ expectDisabledAttribute(findActionButton(), false);
+ });
+ });
+
+ describe('when user clicks the create-token button', () => {
+ beforeEach(async () => {
+ const loadingResponse = new Promise(() => {});
+ await mockCreatedResponse(loadingResponse);
+
+ findInput().vm.$emit('input', 'new-token');
+ findActionButton().vm.$emit('click');
+ });
+
+ it('disables the create-token button', () => {
+ expectDisabledAttribute(findActionButton(), true);
+ });
+
+ it('hides the cancel button', () => {
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
+
+ describe('creating a new token', () => {
+ beforeEach(async () => {
+ await mockCreatedResponse(clusterAgentToken);
+ });
+
+ it('creates a token', () => {
+ expect(createResponse).toHaveBeenCalledWith({
+ input: { clusterAgentId, name: 'new-token', description: 'new-token-description' },
+ });
+ });
+
+ it('shows agent instructions', () => {
+ expect(findAgentInstructions().exists()).toBe(true);
+ });
+
+ it('renders a close button', () => {
+ expect(findActionButton().isVisible()).toBe(true);
+ expect(findActionButton().text()).toBe('Close');
+ expectDisabledAttribute(findActionButton(), false);
+ });
+ });
+
+ describe('error creating a new token', () => {
+ beforeEach(async () => {
+ await mockCreatedResponse(createAgentTokenErrorResponse);
+ });
+
+ it('displays the error message', async () => {
+ expect(findAlert().text()).toBe(
+ createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
+ );
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/clusters/agents/components/token_table_spec.js b/spec/frontend/clusters/agents/components/token_table_spec.js
index 47ff944dd84..f6baaf87fa4 100644
--- a/spec/frontend/clusters/agents/components/token_table_spec.js
+++ b/spec/frontend/clusters/agents/components/token_table_spec.js
@@ -1,8 +1,10 @@
-import { GlEmptyState, GlLink, GlTooltip, GlTruncate } from '@gitlab/ui';
+import { GlEmptyState, GlTooltip, GlTruncate } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import TokenTable from '~/clusters/agents/components/token_table.vue';
+import CreateTokenButton from '~/clusters/agents/components/create_token_button.vue';
import { useFakeDate } from 'helpers/fake_date';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { MAX_LIST_COUNT } from '~/clusters/agents/constants';
describe('ClusterAgentTokenTable', () => {
let wrapper;
@@ -28,13 +30,26 @@ describe('ClusterAgentTokenTable', () => {
name: 'token-2',
},
];
+ const clusterAgentId = 'cluster-agent-id';
+ const cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ };
+
+ const provide = {
+ agentName: 'cluster-agent',
+ projectPath: 'path/to/project',
+ canAdminCluster: true,
+ };
const createComponent = (tokens) => {
- wrapper = extendedWrapper(mount(TokenTable, { propsData: { tokens } }));
+ wrapper = extendedWrapper(
+ mount(TokenTable, { propsData: { tokens, clusterAgentId, cursor }, provide }),
+ );
};
- const findEmptyState = () => wrapper.find(GlEmptyState);
- const findLink = () => wrapper.find(GlLink);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findCreateTokenBtn = () => wrapper.findComponent(CreateTokenButton);
beforeEach(() => {
return createComponent(defaultTokens);
@@ -44,11 +59,15 @@ describe('ClusterAgentTokenTable', () => {
wrapper.destroy();
});
- it('displays a learn more link', () => {
- const learnMoreLink = findLink();
+ it('displays the create token button', () => {
+ expect(findCreateTokenBtn().exists()).toBe(true);
+ });
- expect(learnMoreLink.exists()).toBe(true);
- expect(learnMoreLink.text()).toBe(TokenTable.i18n.learnMore);
+ it('passes the correct params to the create token component', () => {
+ expect(findCreateTokenBtn().props()).toMatchObject({
+ clusterAgentId,
+ cursor,
+ });
});
it.each`
@@ -56,7 +75,7 @@ describe('ClusterAgentTokenTable', () => {
${'token-1'} | ${0}
${'token-2'} | ${1}
`('displays token name "$name" for line "$lineNumber"', ({ name, lineNumber }) => {
- const tokens = wrapper.findAll('[data-testid="agent-token-name"]');
+ const tokens = wrapper.findAllByTestId('agent-token-name');
const token = tokens.at(lineNumber);
expect(token.text()).toBe(name);
@@ -83,7 +102,7 @@ describe('ClusterAgentTokenTable', () => {
`(
'displays created information "$createdText" for line "$lineNumber"',
({ createdText, lineNumber }) => {
- const tokens = wrapper.findAll('[data-testid="agent-token-created-time"]');
+ const tokens = wrapper.findAllByTestId('agent-token-created-time');
const token = tokens.at(lineNumber);
expect(token.text()).toBe(createdText);
@@ -97,7 +116,7 @@ describe('ClusterAgentTokenTable', () => {
`(
'displays creator information "$createdBy" for line "$lineNumber"',
({ createdBy, lineNumber }) => {
- const tokens = wrapper.findAll('[data-testid="agent-token-created-user"]');
+ const tokens = wrapper.findAllByTestId('agent-token-created-user');
const token = tokens.at(lineNumber);
expect(token.text()).toBe(createdBy);
@@ -111,7 +130,7 @@ describe('ClusterAgentTokenTable', () => {
`(
'displays description information "$description" for line "$lineNumber"',
({ description, truncatesText, hasTooltip, lineNumber }) => {
- const tokens = wrapper.findAll('[data-testid="agent-token-description"]');
+ const tokens = wrapper.findAllByTestId('agent-token-description');
const token = tokens.at(lineNumber);
expect(token.text()).toContain(description);
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 5577176bcc5..0bec2a5934e 100644
--- a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
+++ b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap
@@ -1,8 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NewCluster renders the cluster component correctly 1`] = `
-"<div>
- <h4>Enter the details for your Kubernetes cluster</h4>
- <p>Please enter access information for your Kubernetes cluster. If you need help, you can read our <b-link-stub href=\\"/some/help/path\\" target=\\"_blank\\" event=\\"click\\" routertag=\\"a\\" class=\\"gl-link\\">documentation</b-link-stub> on Kubernetes</p>
+"<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>
</div>"
`;
diff --git a/spec/frontend/clusters/components/new_cluster_spec.js b/spec/frontend/clusters/components/new_cluster_spec.js
index b73442f6ec3..b62e678154c 100644
--- a/spec/frontend/clusters/components/new_cluster_spec.js
+++ b/spec/frontend/clusters/components/new_cluster_spec.js
@@ -31,9 +31,7 @@ describe('NewCluster', () => {
});
it('renders the correct information text', () => {
- expect(findDescription().text()).toContain(
- 'Please enter access information for your Kubernetes cluster.',
- );
+ expect(findDescription().text()).toContain('Enter details about your cluster.');
});
it('renders a valid help link set by the backend', () => {
diff --git a/spec/frontend/clusters/mock_data.js b/spec/frontend/clusters/mock_data.js
index 75306ca0295..63840486d0d 100644
--- a/spec/frontend/clusters/mock_data.js
+++ b/spec/frontend/clusters/mock_data.js
@@ -163,3 +163,60 @@ export const mockAgentHistoryActivityItems = [
body: 'Event occurred',
},
];
+
+export const clusterAgentToken = {
+ data: {
+ clusterAgentTokenCreate: {
+ errors: [],
+ secret: 'token-secret',
+ token: {
+ createdAt: '2022-03-13T18:42:44Z',
+ createdByUser: {
+ ...user,
+ },
+ description: 'token-description',
+ id: 'token-id',
+ lastUsedAt: null,
+ name: 'token-name',
+ __typename: 'ClusterAgentToken',
+ },
+ __typename: 'ClusterAgentTokenCreatePayload',
+ },
+ },
+};
+
+export const createAgentTokenErrorResponse = {
+ data: {
+ clusterAgentTokenCreate: {
+ token: null,
+ secret: null,
+ errors: ['could not create agent token'],
+ },
+ },
+};
+
+export const getTokenResponse = {
+ data: {
+ project: {
+ id: 'project-1',
+ clusterAgent: {
+ id: 'cluster-agent-id',
+ createdAt: '2022-03-13T18:42:44Z',
+ createdByUser: {
+ ...user,
+ },
+ tokens: {
+ count: 1,
+ nodes: [{ ...clusterAgentToken.token }],
+ pageInfo: {
+ endCursor: '',
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ },
+ },
+ },
+ __typename: 'Project',
+ },
+ },
+};
diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js
index dc7f0ebae74..db723622a51 100644
--- a/spec/frontend/clusters_list/components/agent_table_spec.js
+++ b/spec/frontend/clusters_list/components/agent_table_spec.js
@@ -8,6 +8,9 @@ import { stubComponent } from 'helpers/stub_component';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { clusterAgents, connectedTimeNow, connectedTimeInactive } from './mock_data';
+const defaultConfigHelpUrl =
+ '/help/user/clusters/agent/install/index#create-an-agent-without-configuration-file';
+
const provideData = {
gitlabVersion: '14.8',
};
@@ -31,8 +34,8 @@ describe('AgentTable', () => {
let wrapper;
const findAgentLink = (at) => wrapper.findAllByTestId('cluster-agent-name-link').at(at);
- const findStatusIcon = (at) => wrapper.findAllComponents(GlIcon).at(at);
const findStatusText = (at) => wrapper.findAllByTestId('cluster-agent-connection-status').at(at);
+ const findStatusIcon = (at) => findStatusText(at).find(GlIcon);
const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at);
const findVersionText = (at) => wrapper.findAllByTestId('cluster-agent-version').at(at);
const findConfiguration = (at) =>
@@ -141,16 +144,16 @@ describe('AgentTable', () => {
);
it.each`
- agentPath | hasLink | lineNumber
- ${'.gitlab/agents/agent-1'} | ${true} | ${0}
- ${'.gitlab/agents/agent-2'} | ${false} | ${1}
+ agentConfig | link | lineNumber
+ ${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0}
+ ${'Default configuration'} | ${defaultConfigHelpUrl} | ${1}
`(
'displays config file path as "$agentPath" at line $lineNumber',
- ({ agentPath, hasLink, lineNumber }) => {
+ ({ agentConfig, link, lineNumber }) => {
const findLink = findConfiguration(lineNumber).find(GlLink);
- expect(findLink.exists()).toBe(hasLink);
- expect(findConfiguration(lineNumber).text()).toBe(agentPath);
+ expect(findLink.attributes('href')).toBe(link);
+ expect(findConfiguration(lineNumber).text()).toBe(agentConfig);
},
);
diff --git a/spec/frontend/clusters_list/components/agent_token_spec.js b/spec/frontend/clusters_list/components/agent_token_spec.js
new file mode 100644
index 00000000000..a80c8ffaad4
--- /dev/null
+++ b/spec/frontend/clusters_list/components/agent_token_spec.js
@@ -0,0 +1,76 @@
+import { GlAlert, GlFormInputGroup } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import AgentToken from '~/clusters_list/components/agent_token.vue';
+import { I18N_AGENT_TOKEN, INSTALL_AGENT_MODAL_ID } from '~/clusters_list/constants';
+import { generateAgentRegistrationCommand } from '~/clusters_list/clusters_util';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+
+const kasAddress = 'kas.example.com';
+const agentToken = 'agent-token';
+const modalId = INSTALL_AGENT_MODAL_ID;
+
+describe('InstallAgentModal', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findCodeBlock = () => wrapper.findComponent(CodeBlock);
+ const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
+ const findInput = () => wrapper.findComponent(GlFormInputGroup);
+
+ const createWrapper = () => {
+ const provide = {
+ kasAddress,
+ };
+
+ const propsData = {
+ agentToken,
+ modalId,
+ };
+
+ wrapper = shallowMountExtended(AgentToken, {
+ provide,
+ propsData,
+ });
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('initial state', () => {
+ it('shows basic agent installation instructions', () => {
+ expect(wrapper.text()).toContain(I18N_AGENT_TOKEN.basicInstallTitle);
+ expect(wrapper.text()).toContain(I18N_AGENT_TOKEN.basicInstallBody);
+ });
+
+ it('shows advanced agent installation instructions', () => {
+ expect(wrapper.text()).toContain(I18N_AGENT_TOKEN.advancedInstallTitle);
+ });
+
+ it('shows agent token as an input value', () => {
+ expect(findInput().props('value')).toBe('agent-token');
+ });
+
+ it('renders a copy button', () => {
+ expect(findCopyButton().props()).toMatchObject({
+ title: 'Copy command',
+ text: generateAgentRegistrationCommand(agentToken, kasAddress),
+ modalId,
+ });
+ });
+
+ it('shows warning alert', () => {
+ expect(findAlert().props('title')).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle);
+ });
+
+ it('shows code block with agent installation command', () => {
+ expect(findCodeBlock().props('code')).toContain('--agent-token=agent-token');
+ expect(findCodeBlock().props('code')).toContain('--kas-address=kas.example.com');
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
index bcc1d4e8b9e..eca2b1f5cb1 100644
--- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
+++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
@@ -1,5 +1,5 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue';
import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants';
@@ -9,11 +9,14 @@ describe('AvailableAgentsDropdown', () => {
const i18n = I18N_AVAILABLE_AGENTS_DROPDOWN;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findConfiguredAgentItem = () => findDropdownItems().at(0);
+ const findFirstAgentItem = () => findDropdownItems().at(0);
+ const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
+ const findCreateButton = () => wrapper.findByTestId('create-config-button');
const createWrapper = ({ propsData }) => {
- wrapper = shallowMount(AvailableAgentsDropdown, {
+ wrapper = shallowMountExtended(AvailableAgentsDropdown, {
propsData,
+ stubs: { GlDropdown },
});
};
@@ -23,7 +26,7 @@ describe('AvailableAgentsDropdown', () => {
describe('there are agents available', () => {
const propsData = {
- availableAgents: ['configured-agent'],
+ availableAgents: ['configured-agent', 'search-agent', 'test-agent'],
isRegistering: false,
};
@@ -35,9 +38,38 @@ describe('AvailableAgentsDropdown', () => {
expect(findDropdown().props('text')).toBe(i18n.selectAgent);
});
- describe('click events', () => {
+ describe('search agent', () => {
+ it('renders search button', () => {
+ expect(findSearchInput().exists()).toBe(true);
+ });
+
+ it('renders all agents when search term is empty', () => {
+ expect(findDropdownItems()).toHaveLength(3);
+ });
+
+ it('renders only the agent searched for when the search item exists', async () => {
+ await findSearchInput().vm.$emit('input', 'search-agent');
+
+ expect(findDropdownItems()).toHaveLength(1);
+ expect(findFirstAgentItem().text()).toBe('search-agent');
+ });
+
+ it('renders create button when search started', async () => {
+ await findSearchInput().vm.$emit('input', 'new-agent');
+
+ expect(findCreateButton().exists()).toBe(true);
+ });
+
+ it("doesn't render create button when search item is found", async () => {
+ await findSearchInput().vm.$emit('input', 'search-agent');
+
+ expect(findCreateButton().exists()).toBe(false);
+ });
+ });
+
+ describe('select existing agent configuration', () => {
beforeEach(() => {
- findConfiguredAgentItem().vm.$emit('click');
+ findFirstAgentItem().vm.$emit('click');
});
it('emits agentSelected with the name of the clicked agent', () => {
@@ -46,7 +78,22 @@ describe('AvailableAgentsDropdown', () => {
it('marks the clicked item as selected', () => {
expect(findDropdown().props('text')).toBe('configured-agent');
- expect(findConfiguredAgentItem().props('isChecked')).toBe(true);
+ expect(findFirstAgentItem().props('isChecked')).toBe(true);
+ });
+ });
+
+ describe('create new agent configuration', () => {
+ beforeEach(async () => {
+ await findSearchInput().vm.$emit('input', 'new-agent');
+ findCreateButton().vm.$emit('click');
+ });
+
+ it('emits agentSelected with the name of the clicked agent', () => {
+ expect(wrapper.emitted('agentSelected')).toEqual([['new-agent']]);
+ });
+
+ it('marks the clicked item as selected', () => {
+ expect(findDropdown().props('text')).toBe('new-agent');
});
});
});
diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js
index 331690fc642..312df12ab5f 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 } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlButton } 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';
@@ -14,13 +14,18 @@ describe('ClustersActionsComponent', () => {
newClusterPath,
addClusterPath,
canAddCluster: true,
+ displayClusterAgents: true,
+ certificateBasedClustersEnabled: true,
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdownItemIds = () =>
+ findDropdownItems().wrappers.map((x) => x.attributes('data-testid'));
const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link');
const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link');
const findConnectNewAgentLink = () => wrapper.findByTestId('connect-new-agent-link');
+ const findConnectWithAgentButton = () => wrapper.findComponent(GlButton);
const createWrapper = (provideData = {}) => {
wrapper = shallowMountExtended(ClustersActions, {
@@ -42,43 +47,110 @@ describe('ClustersActionsComponent', () => {
afterEach(() => {
wrapper.destroy();
});
+ describe('when the certificate based clusters are enabled', () => {
+ it('renders actions menu', () => {
+ expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton);
+ });
- it('renders actions menu', () => {
- expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton);
- });
+ it('renders correct href attributes for the links', () => {
+ expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
+ expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath);
+ });
- it('renders a dropdown with 3 actions items', () => {
- expect(findDropdownItems()).toHaveLength(3);
- });
+ describe('when user cannot add clusters', () => {
+ beforeEach(() => {
+ createWrapper({ canAddCluster: false });
+ });
- it('renders correct href attributes for the links', () => {
- expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
- expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath);
- });
+ it('disables dropdown', () => {
+ expect(findDropdown().props('disabled')).toBe(true);
+ });
- it('renders correct modal id for the agent link', () => {
- const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive');
+ it('shows tooltip explaining why dropdown is disabled', () => {
+ const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint);
+ });
- expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
- });
+ 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('renders a dropdown with 3 actions items', () => {
+ expect(findDropdownItemIds()).toEqual([
+ 'connect-new-agent-link',
+ 'new-cluster-link',
+ 'connect-cluster-link',
+ ]);
+ });
+
+ it('renders correct modal id for the agent link', () => {
+ const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive');
+
+ expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
+ });
- it('shows tooltip', () => {
- const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent);
+ it('shows tooltip', () => {
+ const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent);
+ });
+
+ it('shows split button in dropdown', () => {
+ expect(findDropdown().props('split')).toBe(true);
+ });
+
+ it('binds split button with modal id', () => {
+ const binding = getBinding(findDropdown().element, 'gl-modal-directive');
+
+ expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
+ });
+ });
+
+ describe('when on group or admin level', () => {
+ beforeEach(() => {
+ createWrapper({ displayClusterAgents: false });
+ });
+
+ it('renders a dropdown with 2 actions items', () => {
+ expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']);
+ });
+
+ it('shows tooltip', () => {
+ const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster);
+ });
+
+ it('does not show split button in dropdown', () => {
+ expect(findDropdown().props('split')).toBe(false);
+ });
+
+ it('does not bind dropdown button to modal', () => {
+ const binding = getBinding(findDropdown().element, 'gl-modal-directive');
+
+ expect(binding.value).toBe(false);
+ });
+ });
});
- describe('when user cannot add clusters', () => {
+ describe('when the certificate based clusters not enabled', () => {
beforeEach(() => {
- createWrapper({ canAddCluster: false });
+ createWrapper({ certificateBasedClustersEnabled: false });
});
- it('disables dropdown', () => {
- expect(findDropdown().props('disabled')).toBe(true);
+ it('it does not show the the dropdown', () => {
+ expect(findDropdown().exists()).toBe(false);
});
- it('shows tooltip explaining why dropdown is disabled', () => {
- const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint);
+ it('shows the connect with agent button', () => {
+ expect(findConnectWithAgentButton().props()).toMatchObject({
+ disabled: !defaultProvide.canAddCluster,
+ category: 'primary',
+ variant: 'confirm',
+ });
+ expect(findConnectWithAgentButton().text()).toBe(CLUSTERS_ACTIONS.connectWithAgent);
});
});
});
diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
index cf0f6881960..fe2189296a6 100644
--- a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
@@ -4,7 +4,7 @@ import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state.
import ClusterStore from '~/clusters_list/store';
const clustersEmptyStateImage = 'path/to/svg';
-const newClusterPath = '/path/to/connect/cluster';
+const addClusterPath = '/path/to/connect/cluster';
const emptyStateHelpText = 'empty state text';
describe('ClustersEmptyStateComponent', () => {
@@ -12,7 +12,7 @@ describe('ClustersEmptyStateComponent', () => {
const defaultProvideData = {
clustersEmptyStateImage,
- newClusterPath,
+ addClusterPath,
};
const findButton = () => wrapper.findComponent(GlButton);
diff --git a/spec/frontend/clusters_list/components/clusters_main_view_spec.js b/spec/frontend/clusters_list/components/clusters_main_view_spec.js
index 37665bf7abd..218463b9adf 100644
--- a/spec/frontend/clusters_list/components/clusters_main_view_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_main_view_spec.js
@@ -6,7 +6,9 @@ import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vu
import {
AGENT,
CERTIFICATE_BASED,
+ AGENT_TAB,
CLUSTERS_TABS,
+ CERTIFICATE_TAB,
MAX_CLUSTERS_LIST,
MAX_LIST_COUNT,
EVENT_LABEL_TABS,
@@ -23,12 +25,20 @@ describe('ClustersMainViewComponent', () => {
defaultBranchName,
};
- beforeEach(() => {
+ const defaultProvide = {
+ certificateBasedClustersEnabled: true,
+ displayClusterAgents: true,
+ };
+
+ const createWrapper = (extendedProvide = {}) => {
wrapper = shallowMountExtended(ClustersMainView, {
propsData,
+ provide: {
+ ...defaultProvide,
+ ...extendedProvide,
+ },
});
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- });
+ };
afterEach(() => {
wrapper.destroy();
@@ -39,57 +49,110 @@ describe('ClustersMainViewComponent', () => {
const findGlTabAtIndex = (index) => findAllTabs().at(index);
const findComponent = () => wrapper.findByTestId('clusters-tab-component');
const findModal = () => wrapper.findComponent(InstallAgentModal);
+ describe('when the certificate based clusters are enabled', () => {
+ describe('when on project level', () => {
+ beforeEach(() => {
+ createWrapper({ displayClusterAgents: true });
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
- it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', () => {
- expect(findTabs().exists()).toBe(true);
- expect(findTabs().props('syncActiveTabWithQueryParams')).toBe(true);
- });
+ it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', () => {
+ expect(findTabs().exists()).toBe(true);
+ expect(findTabs().props('syncActiveTabWithQueryParams')).toBe(true);
+ });
- it('renders correct number of tabs', () => {
- expect(findAllTabs()).toHaveLength(CLUSTERS_TABS.length);
- });
+ it('renders correct number of tabs', () => {
+ expect(findAllTabs()).toHaveLength(CLUSTERS_TABS.length);
+ });
- it('passes child-component param to the component', () => {
- expect(findComponent().props('defaultBranchName')).toBe(defaultBranchName);
- });
+ describe('tabs', () => {
+ it.each`
+ tabTitle | queryParamValue | lineNumber
+ ${'All'} | ${'all'} | ${0}
+ ${'Agent'} | ${AGENT} | ${1}
+ ${'Certificate'} | ${CERTIFICATE_BASED} | ${2}
+ `(
+ 'renders correct tab title and query param value',
+ ({ tabTitle, queryParamValue, lineNumber }) => {
+ expect(findGlTabAtIndex(lineNumber).attributes('title')).toBe(tabTitle);
+ expect(findGlTabAtIndex(lineNumber).props('queryParamValue')).toBe(queryParamValue);
+ },
+ );
+ });
- it('passes correct max-agents param to the modal', () => {
- expect(findModal().props('maxAgents')).toBe(MAX_CLUSTERS_LIST);
- });
+ describe.each`
+ tab | tabName
+ ${'1'} | ${AGENT}
+ ${'2'} | ${CERTIFICATE_BASED}
+ `(
+ 'when the child component emits the tab change event for $tabName tab',
+ ({ tab, tabName }) => {
+ beforeEach(() => {
+ findComponent().vm.$emit('changeTab', tabName);
+ });
- describe('tabs', () => {
- it.each`
- tabTitle | queryParamValue | lineNumber
- ${'All'} | ${'all'} | ${0}
- ${'Agent'} | ${AGENT} | ${1}
- ${'Certificate'} | ${CERTIFICATE_BASED} | ${2}
- `(
- 'renders correct tab title and query param value',
- ({ tabTitle, queryParamValue, lineNumber }) => {
- expect(findGlTabAtIndex(lineNumber).attributes('title')).toBe(tabTitle);
- expect(findGlTabAtIndex(lineNumber).props('queryParamValue')).toBe(queryParamValue);
- },
- );
- });
+ it(`changes the tab value to ${tab}`, () => {
+ expect(findTabs().attributes('value')).toBe(tab);
+ });
+ },
+ );
- describe('when the child component emits the tab change event', () => {
- beforeEach(() => {
- findComponent().vm.$emit('changeTab', AGENT);
- });
+ describe.each`
+ tab | tabName | maxAgents
+ ${1} | ${AGENT} | ${MAX_LIST_COUNT}
+ ${2} | ${CERTIFICATE_BASED} | ${MAX_CLUSTERS_LIST}
+ `('when the active tab is $tabName', ({ tab, tabName, maxAgents }) => {
+ beforeEach(() => {
+ findTabs().vm.$emit('input', tab);
+ });
+
+ it('passes child-component param to the component', () => {
+ expect(findComponent().props('defaultBranchName')).toBe(defaultBranchName);
+ });
+
+ it(`sets max-agents param to ${maxAgents} and passes it to the modal`, () => {
+ expect(findModal().props('maxAgents')).toBe(maxAgents);
+ });
- it('changes the tab', () => {
- expect(findTabs().attributes('value')).toBe('1');
+ it(`sends the correct tracking event with the property '${tabName}'`, () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_CHANGE, {
+ label: EVENT_LABEL_TABS,
+ property: tabName,
+ });
+ });
+ });
});
- it('passes correct max-agents param to the modal', () => {
- expect(findModal().props('maxAgents')).toBe(MAX_LIST_COUNT);
+ describe('when on group or admin level', () => {
+ beforeEach(() => {
+ createWrapper({ displayClusterAgents: false });
+ });
+
+ it('renders correct number of tabs', () => {
+ expect(findAllTabs()).toHaveLength(1);
+ });
+
+ it('renders correct tab title', () => {
+ expect(findGlTabAtIndex(0).attributes('title')).toBe(CERTIFICATE_TAB.title);
+ });
});
- it('sends the correct tracking event', () => {
- findTabs().vm.$emit('input', 1);
- expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_CHANGE, {
- label: EVENT_LABEL_TABS,
- property: AGENT,
+ describe('when the certificate based clusters not enabled', () => {
+ beforeEach(() => {
+ createWrapper({ certificateBasedClustersEnabled: false });
+ });
+
+ it('it displays only the Agent tab', () => {
+ expect(findAllTabs()).toHaveLength(1);
+ const agentTab = findGlTabAtIndex(0);
+
+ expect(agentTab.props()).toMatchObject({
+ queryParamValue: AGENT_TAB.queryParamValue,
+ titleLinkClass: '',
+ });
+ expect(agentTab.attributes()).toMatchObject({
+ title: AGENT_TAB.title,
+ });
});
});
});
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index 82e667093aa..3f3f5e0daf6 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -2,7 +2,7 @@ import {
GlLoadingIcon,
GlPagination,
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlTable,
+ GlTableLite,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { mount } from '@vue/test-utils';
@@ -23,7 +23,7 @@ describe('Clusters', () => {
const totalClustersNumber = 6;
const clustersEmptyStateImage = 'path/to/svg';
const emptyStateHelpText = null;
- const newClusterPath = '/path/to/new/cluster';
+ const addClusterPath = '/path/to/new/cluster';
const entryData = {
endpoint,
@@ -36,12 +36,12 @@ describe('Clusters', () => {
const provideData = {
clustersEmptyStateImage,
emptyStateHelpText,
- newClusterPath,
+ addClusterPath,
};
const findLoader = () => wrapper.findComponent(GlLoadingIcon);
const findPaginatedButtons = () => wrapper.findComponent(GlPagination);
- const findTable = () => wrapper.findComponent(GlTable);
+ const findTable = () => wrapper.findComponent(GlTableLite);
const findStatuses = () => findTable().findAll('.js-status');
const findEmptyState = () => wrapper.findComponent(ClustersEmptyState);
@@ -51,7 +51,7 @@ describe('Clusters', () => {
const createWrapper = ({ propsData = {} }) => {
store = ClusterStore(entryData);
- wrapper = mount(Clusters, { propsData, provide: provideData, store, stubs: { GlTable } });
+ wrapper = mount(Clusters, { propsData, provide: provideData, store, stubs: { GlTableLite } });
return axios.waitForAll();
};
diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js
index 37432ed0193..38f653509a8 100644
--- a/spec/frontend/clusters_list/components/install_agent_modal_spec.js
+++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js
@@ -6,6 +6,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking } from 'helpers/tracking_helper';
import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue';
import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue';
+import AgentToken from '~/clusters_list/components/agent_token.vue';
import {
I18N_AGENT_MODAL,
MAX_LIST_COUNT,
@@ -21,7 +22,6 @@ import createAgentMutation from '~/clusters_list/graphql/mutations/create_agent.
import createAgentTokenMutation from '~/clusters_list/graphql/mutations/create_agent_token.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import CodeBlock from '~/vue_shared/components/code_block.vue';
import {
createAgentResponse,
createAgentErrorResponse,
@@ -39,6 +39,7 @@ const kasAddress = 'kas.example.com';
const emptyStateImage = 'path/to/image';
const defaultBranchName = 'default';
const maxAgents = MAX_LIST_COUNT;
+const i18n = I18N_AGENT_MODAL;
describe('InstallAgentModal', () => {
let wrapper;
@@ -60,6 +61,7 @@ describe('InstallAgentModal', () => {
const findModal = () => wrapper.findComponent(ModalStub);
const findAgentDropdown = () => findModal().findComponent(AvailableAgentsDropdown);
const findAlert = () => findModal().findComponent(GlAlert);
+ const findAgentInstructions = () => findModal().findComponent(AgentToken);
const findButtonByVariant = (variant) =>
findModal()
.findAll(GlButton)
@@ -67,7 +69,7 @@ describe('InstallAgentModal', () => {
const findActionButton = () => findButtonByVariant('confirm');
const findCancelButton = () => findButtonByVariant('default');
const findPrimaryButton = () => wrapper.findByTestId('agent-primary-button');
- const findImage = () => wrapper.findByRole('img', { alt: I18N_AGENT_MODAL.empty_state.altText });
+ const findImage = () => wrapper.findByRole('img', { alt: i18n.altText });
const expectDisabledAttribute = (element, disabled) => {
if (disabled) {
@@ -140,16 +142,16 @@ describe('InstallAgentModal', () => {
apolloProvider = null;
});
- describe('when agent configurations are present', () => {
- const i18n = I18N_AGENT_MODAL.agent_registration;
-
+ describe('when KAS is enabled', () => {
describe('initial state', () => {
it('renders the dropdown for available agents', () => {
expect(findAgentDropdown().isVisible()).toBe(true);
+ });
+
+ it("doesn't render agent installation instructions", () => {
expect(findModal().text()).not.toContain(i18n.basicInstallTitle);
expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false);
expect(findModal().findComponent(GlAlert).exists()).toBe(false);
- expect(findModal().findComponent(CodeBlock).exists()).toBe(false);
});
it('renders a cancel button', () => {
@@ -220,19 +222,7 @@ describe('InstallAgentModal', () => {
});
it('shows agent instructions', () => {
- const modalText = findModal().text();
- expect(modalText).toContain(i18n.basicInstallTitle);
- expect(modalText).toContain(i18n.basicInstallBody);
-
- const token = findModal().findComponent(GlFormInputGroup);
- expect(token.props('value')).toBe('mock-agent-token');
-
- const alert = findModal().findComponent(GlAlert);
- expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle);
-
- const code = findModal().findComponent(CodeBlock).props('code');
- expect(code).toContain('--agent-token=mock-agent-token');
- expect(code).toContain('--kas-address=kas.example.com');
+ expect(findAgentInstructions().exists()).toBe(true);
});
describe('error creating agent', () => {
@@ -272,44 +262,7 @@ describe('InstallAgentModal', () => {
});
});
- describe('when there are no agent configurations present', () => {
- const i18n = I18N_AGENT_MODAL.empty_state;
- const apolloQueryEmptyResponse = {
- data: {
- project: {
- clusterAgents: { nodes: [] },
- agentConfigurations: { nodes: [] },
- },
- },
- };
-
- beforeEach(() => {
- apolloProvider = createMockApollo([
- [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryEmptyResponse)],
- ]);
- createWrapper();
- });
-
- it('renders empty state image', () => {
- expect(findImage().attributes('src')).toBe(emptyStateImage);
- });
-
- it('renders a primary button', () => {
- expect(findPrimaryButton().isVisible()).toBe(true);
- expect(findPrimaryButton().text()).toBe(i18n.primaryButton);
- });
-
- it('sends the event with the modalType', () => {
- findModal().vm.$emit('show');
- expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, {
- label: EVENT_LABEL_MODAL,
- property: MODAL_TYPE_EMPTY,
- });
- });
- });
-
describe('when KAS is disabled', () => {
- const i18n = I18N_AGENT_MODAL.empty_state;
beforeEach(async () => {
apolloProvider = createMockApollo([
[getAgentConfigurations, jest.fn().mockResolvedValue(kasDisabledErrorResponse)],
@@ -331,11 +284,19 @@ describe('InstallAgentModal', () => {
it('renders a cancel button', () => {
expect(findCancelButton().isVisible()).toBe(true);
- expect(findCancelButton().text()).toBe(i18n.done);
+ expect(findCancelButton().text()).toBe(i18n.close);
});
it("doesn't render a secondary button", () => {
expect(findPrimaryButton().exists()).toBe(false);
});
+
+ it('sends the event with the modalType', () => {
+ findModal().vm.$emit('show');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, {
+ label: EVENT_LABEL_MODAL,
+ property: MODAL_TYPE_EMPTY,
+ });
+ });
});
});
diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js
index 9306c15e676..0d7c0360e9b 100644
--- a/spec/frontend/code_navigation/components/app_spec.js
+++ b/spec/frontend/code_navigation/components/app_spec.js
@@ -5,13 +5,14 @@ import App from '~/code_navigation/components/app.vue';
import Popover from '~/code_navigation/components/popover.vue';
import createState from '~/code_navigation/store/state';
+const setInitialData = jest.fn();
const fetchData = jest.fn();
const showDefinition = jest.fn();
let wrapper;
Vue.use(Vuex);
-function factory(initialState = {}) {
+function factory(initialState = {}, props = {}) {
const store = new Vuex.Store({
state: {
...createState(),
@@ -19,12 +20,13 @@ function factory(initialState = {}) {
definitionPathPrefix: 'https://test.com/blob/main',
},
actions: {
+ setInitialData,
fetchData,
showDefinition,
},
});
- wrapper = shallowMount(App, { store });
+ wrapper = shallowMount(App, { store, propsData: { ...props } });
}
describe('Code navigation app component', () => {
@@ -32,6 +34,19 @@ describe('Code navigation app component', () => {
wrapper.destroy();
});
+ it('sets initial data on mount if the correct props are passed', () => {
+ const codeNavigationPath = 'code/nav/path.js';
+ const path = 'blob/path.js';
+ const definitionPathPrefix = 'path/prefix';
+
+ factory({}, { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix });
+
+ expect(setInitialData).toHaveBeenCalledWith(expect.anything(), {
+ blobs: [{ codeNavigationPath, path }],
+ definitionPathPrefix,
+ });
+ });
+
it('fetches data on mount', () => {
factory();
diff --git a/spec/frontend/code_quality_walkthrough/components/__snapshots__/step_spec.js.snap b/spec/frontend/code_quality_walkthrough/components/__snapshots__/step_spec.js.snap
deleted file mode 100644
index f17d99ad257..00000000000
--- a/spec/frontend/code_quality_walkthrough/components/__snapshots__/step_spec.js.snap
+++ /dev/null
@@ -1,174 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component commit_ci_file step renders a popover 1`] = `
-<div>
- <gl-popover-stub
- container="viewport"
- cssclasses=""
- offset="90"
- placement="right"
- show=""
- target="#js-code-quality-walkthrough"
- triggers="manual"
- >
-
- <gl-sprintf-stub
- message="To begin with code quality, we first need to create a new CI file using our code editor. We added a code quality template in the code editor to help you get started %{emojiStart}wink%{emojiEnd} .%{lineBreak}Take some time to review the template, when you are ready, use the %{strongStart}commit changes%{strongEnd} button at the bottom of the page."
- />
-
- <div
- class="gl-mt-2 gl-text-right"
- >
- <gl-button-stub
- buttontextclasses=""
- category="tertiary"
- href=""
- icon=""
- size="medium"
- variant="link"
- >
-
- Got it
-
- </gl-button-stub>
- </div>
- </gl-popover-stub>
-
- <!---->
-</div>
-`;
-
-exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component failed_pipeline step renders a popover 1`] = `
-<div>
- <gl-popover-stub
- container="viewport"
- cssclasses=""
- offset="98"
- placement="bottom"
- show=""
- target="#js-code-quality-walkthrough"
- triggers="manual"
- >
-
- <gl-sprintf-stub
- message="Your job failed. No worries - this happens. Let's view the logs, and see how we can fix it."
- />
-
- <div
- class="gl-mt-2 gl-text-right"
- >
- <gl-button-stub
- buttontextclasses=""
- category="tertiary"
- href="/group/project/-/jobs/:id?code_quality_walkthrough=true"
- icon=""
- size="medium"
- variant="link"
- >
-
- View the logs
-
- </gl-button-stub>
- </div>
- </gl-popover-stub>
-
- <!---->
-</div>
-`;
-
-exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component running_pipeline step renders a popover 1`] = `
-<div>
- <gl-popover-stub
- container="viewport"
- cssclasses=""
- offset="97"
- placement="bottom"
- show=""
- target="#js-code-quality-walkthrough"
- triggers="manual"
- >
-
- <gl-sprintf-stub
- message="Your pipeline can take a few minutes to run. If you enabled email notifications, you'll receive an email with your pipeline status. In the meantime, why don't you get some coffee? You earned it!"
- />
-
- <div
- class="gl-mt-2 gl-text-right"
- >
- <gl-button-stub
- buttontextclasses=""
- category="tertiary"
- href=""
- icon=""
- size="medium"
- variant="link"
- >
-
- Got it
-
- </gl-button-stub>
- </div>
- </gl-popover-stub>
-
- <!---->
-</div>
-`;
-
-exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component success_pipeline step renders a popover 1`] = `
-<div>
- <gl-popover-stub
- container="viewport"
- cssclasses=""
- offset="98"
- placement="bottom"
- show=""
- target="#js-code-quality-walkthrough"
- triggers="manual"
- >
-
- <gl-sprintf-stub
- message="A code quality job will now run every time you or your team members commit changes to your project. You can view the results of the code quality job in the job logs."
- />
-
- <div
- class="gl-mt-2 gl-text-right"
- >
- <gl-button-stub
- buttontextclasses=""
- category="tertiary"
- href="/group/project/-/jobs/:id?code_quality_walkthrough=true"
- icon=""
- size="medium"
- variant="link"
- >
-
- View the logs
-
- </gl-button-stub>
- </div>
- </gl-popover-stub>
-
- <!---->
-</div>
-`;
-
-exports[`When the code_quality_walkthrough URL parameter is present Code Quality Walkthrough Step component troubleshoot_job step renders an alert 1`] = `
-<div>
- <!---->
-
- <gl-alert-stub
- class="gl-my-5"
- dismissible="true"
- dismisslabel="Dismiss"
- primarybuttontext="Read the documentation"
- secondarybuttonlink=""
- secondarybuttontext=""
- title="Troubleshoot your code quality job"
- variant="tip"
- >
-
- Not sure how to fix your failed job? We have compiled some tips on how to troubleshoot code quality jobs in the documentation.
-
- </gl-alert-stub>
-</div>
-`;
diff --git a/spec/frontend/code_quality_walkthrough/components/step_spec.js b/spec/frontend/code_quality_walkthrough/components/step_spec.js
deleted file mode 100644
index b43629c2f96..00000000000
--- a/spec/frontend/code_quality_walkthrough/components/step_spec.js
+++ /dev/null
@@ -1,156 +0,0 @@
-import { GlButton, GlPopover } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Cookies from 'js-cookie';
-import Step from '~/code_quality_walkthrough/components/step.vue';
-import { EXPERIMENT_NAME, STEPS } from '~/code_quality_walkthrough/constants';
-import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
-import { getParameterByName } from '~/lib/utils/url_utility';
-import Tracking from '~/tracking';
-
-jest.mock('~/lib/utils/url_utility', () => ({
- ...jest.requireActual('~/lib/utils/url_utility'),
- getParameterByName: jest.fn(),
-}));
-
-let wrapper;
-
-function factory({ step, link }) {
- wrapper = shallowMount(Step, {
- propsData: { step, link },
- });
-}
-
-afterEach(() => {
- wrapper.destroy();
-});
-
-const dummyLink = '/group/project/-/jobs/:id?code_quality_walkthrough=true';
-const dummyContext = 'experiment_context';
-
-const findButton = () => wrapper.findComponent(GlButton);
-const findPopover = () => wrapper.findComponent(GlPopover);
-
-describe('When the code_quality_walkthrough URL parameter is missing', () => {
- beforeEach(() => {
- getParameterByName.mockReturnValue(false);
- });
-
- it('does not render the component', () => {
- factory({
- step: STEPS.commitCiFile,
- });
-
- expect(findPopover().exists()).toBe(false);
- });
-});
-
-describe('When the code_quality_walkthrough URL parameter is present', () => {
- beforeEach(() => {
- getParameterByName.mockReturnValue(true);
- Cookies.set(EXPERIMENT_NAME, { data: dummyContext });
- });
-
- afterEach(() => {
- Cookies.remove(EXPERIMENT_NAME);
- });
-
- describe('When mounting the component', () => {
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
-
- factory({
- step: STEPS.commitCiFile,
- });
- });
-
- it('tracks an event', () => {
- expect(Tracking.event).toHaveBeenCalledWith(
- EXPERIMENT_NAME,
- `${STEPS.commitCiFile}_displayed`,
- {
- context: {
- schema: TRACKING_CONTEXT_SCHEMA,
- data: dummyContext,
- },
- },
- );
- });
- });
-
- describe('When updating the component', () => {
- beforeEach(() => {
- factory({
- step: STEPS.runningPipeline,
- });
-
- jest.spyOn(Tracking, 'event');
-
- wrapper.setProps({ step: STEPS.successPipeline });
- });
-
- it('tracks an event', () => {
- expect(Tracking.event).toHaveBeenCalledWith(
- EXPERIMENT_NAME,
- `${STEPS.successPipeline}_displayed`,
- {
- context: {
- schema: TRACKING_CONTEXT_SCHEMA,
- data: dummyContext,
- },
- },
- );
- });
- });
-
- describe('When dismissing a popover', () => {
- beforeEach(() => {
- factory({
- step: STEPS.commitCiFile,
- });
-
- jest.spyOn(Cookies, 'set');
- jest.spyOn(Tracking, 'event');
-
- findButton().vm.$emit('click');
- });
-
- it('sets a cookie', () => {
- expect(Cookies.set).toHaveBeenCalledWith(
- EXPERIMENT_NAME,
- { commit_ci_file: true, data: dummyContext },
- { expires: 365, secure: false },
- );
- });
-
- it('removes the popover', () => {
- expect(findPopover().exists()).toBe(false);
- });
-
- it('tracks an event', () => {
- expect(Tracking.event).toHaveBeenCalledWith(
- EXPERIMENT_NAME,
- `${STEPS.commitCiFile}_dismissed`,
- {
- context: {
- schema: TRACKING_CONTEXT_SCHEMA,
- data: dummyContext,
- },
- },
- );
- });
- });
-
- describe('Code Quality Walkthrough Step component', () => {
- describe.each(Object.values(STEPS))('%s step', (step) => {
- it(`renders ${step === STEPS.troubleshootJob ? 'an alert' : 'a popover'}`, () => {
- const options = { step };
- if ([STEPS.successPipeline, STEPS.failedPipeline].includes(step)) {
- options.link = dummyLink;
- }
- factory(options);
-
- expect(wrapper.element).toMatchSnapshot();
- });
- });
- });
-});
diff --git a/spec/frontend/content_editor/components/content_editor_alert_spec.js b/spec/frontend/content_editor/components/content_editor_alert_spec.js
index 2ddcd8f024e..12484cb13c6 100644
--- a/spec/frontend/content_editor/components/content_editor_alert_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js
@@ -3,20 +3,25 @@ import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
-import { createTestEditor, emitEditorEvent } from '../test_utils';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import { ALERT_EVENT } from '~/content_editor/constants';
+import { createTestEditor } from '../test_utils';
describe('content_editor/components/content_editor_alert', () => {
let wrapper;
let tiptapEditor;
+ let eventHub;
const findErrorAlert = () => wrapper.findComponent(GlAlert);
const createWrapper = async () => {
tiptapEditor = createTestEditor();
+ eventHub = eventHubFactory();
wrapper = shallowMountExtended(ContentEditorAlert, {
provide: {
tiptapEditor,
+ eventHub,
},
stubs: {
EditorStateObserver,
@@ -37,7 +42,9 @@ describe('content_editor/components/content_editor_alert', () => {
async ({ message, variant }) => {
createWrapper();
- await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } });
+ eventHub.$emit(ALERT_EVENT, { message, variant });
+
+ await nextTick();
expect(findErrorAlert().text()).toBe(message);
expect(findErrorAlert().attributes().variant).toBe(variant);
@@ -48,11 +55,9 @@ describe('content_editor/components/content_editor_alert', () => {
const message = 'error message';
createWrapper();
-
- await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } });
-
+ eventHub.$emit(ALERT_EVENT, { message });
+ await nextTick();
findErrorAlert().vm.$emit('dismiss');
-
await nextTick();
expect(findErrorAlert().exists()).toBe(false);
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 9a772c41e52..73fcfeab8bc 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -1,6 +1,4 @@
-import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent } from '@tiptap/vue-2';
-import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
@@ -8,11 +6,7 @@ import ContentEditorProvider from '~/content_editor/components/content_editor_pr
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
-import {
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
-} from '~/content_editor/constants';
+import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
import { emitEditorEvent } from '../test_utils';
jest.mock('~/emoji');
@@ -25,9 +19,6 @@ describe('ContentEditor', () => {
const findEditorElement = () => wrapper.findByTestId('content-editor');
const findEditorContent = () => wrapper.findComponent(EditorContent);
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findBubbleMenu = () => wrapper.findComponent(FormattingBubbleMenu);
-
const createWrapper = (propsData = {}) => {
renderMarkdown = jest.fn();
@@ -117,69 +108,15 @@ describe('ContentEditor', () => {
expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true);
});
- describe('when loading content', () => {
- beforeEach(async () => {
- createWrapper();
-
- contentEditor.emit(LOADING_CONTENT_EVENT);
-
- await nextTick();
- });
-
- it('displays loading indicator', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- });
-
- it('hides EditorContent component', () => {
- expect(findEditorContent().exists()).toBe(false);
- });
-
- it('hides formatting bubble menu', () => {
- expect(findBubbleMenu().exists()).toBe(false);
- });
- });
-
- describe('when loading content succeeds', () => {
- beforeEach(async () => {
- createWrapper();
-
- contentEditor.emit(LOADING_CONTENT_EVENT);
- await nextTick();
- contentEditor.emit(LOADING_SUCCESS_EVENT);
- await nextTick();
- });
-
- it('hides loading indicator', () => {
- expect(findLoadingIcon().exists()).toBe(false);
- });
+ it('renders loading indicator component', () => {
+ createWrapper();
- it('displays EditorContent component', () => {
- expect(findEditorContent().exists()).toBe(true);
- });
+ expect(wrapper.findComponent(LoadingIndicator).exists()).toBe(true);
});
- describe('when loading content fails', () => {
- const error = 'error';
-
- beforeEach(async () => {
- createWrapper();
-
- contentEditor.emit(LOADING_CONTENT_EVENT);
- await nextTick();
- contentEditor.emit(LOADING_ERROR_EVENT, error);
- await nextTick();
- });
-
- it('hides loading indicator', () => {
- expect(findLoadingIcon().exists()).toBe(false);
- });
-
- it('displays EditorContent component', () => {
- expect(findEditorContent().exists()).toBe(true);
- });
+ it('renders formatting bubble menu', () => {
+ createWrapper();
- it('displays formatting bubble menu', () => {
- expect(findBubbleMenu().exists()).toBe(true);
- });
+ expect(wrapper.findComponent(FormattingBubbleMenu).exists()).toBe(true);
});
});
diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js
index 5e4bb348e1f..51a594a606b 100644
--- a/spec/frontend/content_editor/components/editor_state_observer_spec.js
+++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js
@@ -3,6 +3,13 @@ import { each } from 'lodash';
import EditorStateObserver, {
tiptapToComponentMap,
} from '~/content_editor/components/editor_state_observer.vue';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import {
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+ ALERT_EVENT,
+} from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/editor_state_observer', () => {
@@ -11,19 +18,29 @@ describe('content_editor/components/editor_state_observer', () => {
let onDocUpdateListener;
let onSelectionUpdateListener;
let onTransactionListener;
+ let onLoadingContentListener;
+ let onLoadingSuccessListener;
+ let onLoadingErrorListener;
+ let onAlertListener;
+ let eventHub;
const buildEditor = () => {
tiptapEditor = createTestEditor();
+ eventHub = eventHubFactory();
jest.spyOn(tiptapEditor, 'on');
};
const buildWrapper = () => {
wrapper = shallowMount(EditorStateObserver, {
- provide: { tiptapEditor },
+ provide: { tiptapEditor, eventHub },
listeners: {
docUpdate: onDocUpdateListener,
selectionUpdate: onSelectionUpdateListener,
transaction: onTransactionListener,
+ [ALERT_EVENT]: onAlertListener,
+ [LOADING_CONTENT_EVENT]: onLoadingContentListener,
+ [LOADING_SUCCESS_EVENT]: onLoadingSuccessListener,
+ [LOADING_ERROR_EVENT]: onLoadingErrorListener,
},
});
};
@@ -32,8 +49,11 @@ describe('content_editor/components/editor_state_observer', () => {
onDocUpdateListener = jest.fn();
onSelectionUpdateListener = jest.fn();
onTransactionListener = jest.fn();
+ onAlertListener = jest.fn();
+ onLoadingSuccessListener = jest.fn();
+ onLoadingContentListener = jest.fn();
+ onLoadingErrorListener = jest.fn();
buildEditor();
- buildWrapper();
});
afterEach(() => {
@@ -44,6 +64,8 @@ describe('content_editor/components/editor_state_observer', () => {
it('emits update, selectionUpdate, and transaction events', () => {
const content = '<p>My paragraph</p>';
+ buildWrapper();
+
tiptapEditor.commands.insertContent(content);
expect(onDocUpdateListener).toHaveBeenCalledWith(
@@ -58,10 +80,27 @@ describe('content_editor/components/editor_state_observer', () => {
});
});
+ it.each`
+ event | listener
+ ${ALERT_EVENT} | ${() => onAlertListener}
+ ${LOADING_CONTENT_EVENT} | ${() => onLoadingContentListener}
+ ${LOADING_SUCCESS_EVENT} | ${() => onLoadingSuccessListener}
+ ${LOADING_ERROR_EVENT} | ${() => onLoadingErrorListener}
+ `('listens to $event event in the eventBus object', ({ event, listener }) => {
+ const args = {};
+
+ buildWrapper();
+
+ eventHub.$emit(event, args);
+ expect(listener()).toHaveBeenCalledWith(args);
+ });
+
describe('when component is destroyed', () => {
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
jest.spyOn(tiptapEditor, 'off');
+ buildWrapper();
+
wrapper.destroy();
each(tiptapToComponentMap, (_, tiptapEvent) => {
@@ -71,5 +110,25 @@ describe('content_editor/components/editor_state_observer', () => {
);
});
});
+
+ it.each`
+ event
+ ${ALERT_EVENT}
+ ${LOADING_CONTENT_EVENT}
+ ${LOADING_SUCCESS_EVENT}
+ ${LOADING_ERROR_EVENT}
+ `('removes $event event hook from eventHub', ({ event }) => {
+ jest.spyOn(eventHub, '$off');
+ jest.spyOn(eventHub, '$on');
+
+ buildWrapper();
+
+ wrapper.destroy();
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ event,
+ eventHub.$on.mock.calls.find(([eventName]) => eventName === event)[1],
+ );
+ });
});
});
diff --git a/spec/frontend/content_editor/components/loading_indicator_spec.js b/spec/frontend/content_editor/components/loading_indicator_spec.js
new file mode 100644
index 00000000000..e4fb09b70a4
--- /dev/null
+++ b/spec/frontend/content_editor/components/loading_indicator_spec.js
@@ -0,0 +1,71 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
+import {
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+} from '~/content_editor/constants';
+
+describe('content_editor/components/loading_indicator', () => {
+ let wrapper;
+
+ const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const createWrapper = () => {
+ wrapper = shallowMountExtended(LoadingIndicator);
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when loading content', () => {
+ beforeEach(async () => {
+ createWrapper();
+
+ findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
+
+ await nextTick();
+ });
+
+ it('displays loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('when loading content succeeds', () => {
+ beforeEach(async () => {
+ createWrapper();
+
+ findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
+ await nextTick();
+ findEditorStateObserver().vm.$emit(LOADING_SUCCESS_EVENT);
+ await nextTick();
+ });
+
+ it('hides loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('when loading content fails', () => {
+ const error = 'error';
+
+ beforeEach(async () => {
+ createWrapper();
+
+ findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
+ await nextTick();
+ findEditorStateObserver().vm.$emit(LOADING_ERROR_EVENT, error);
+ await nextTick();
+ });
+
+ it('hides loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js
index 60263c46bdd..ce50482302d 100644
--- a/spec/frontend/content_editor/components/toolbar_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_button_spec.js
@@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
+import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_button', () => {
@@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => {
},
provide: {
tiptapEditor,
+ eventHub: eventHubFactory(),
},
propsData: {
contentType: CONTENT_TYPE,
diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js
index 0cf488260bd..fc26a9da471 100644
--- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js
@@ -1,6 +1,7 @@
import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
+import eventHubFactory from '~/helpers/event_hub_factory';
import Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
@@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => {
wrapper = mountExtended(ToolbarLinkButton, {
provide: {
tiptapEditor: editor,
+ eventHub: eventHubFactory(),
},
});
};
diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
index 65c1c8c8310..608be1bd693 100644
--- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
@@ -4,6 +4,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ
import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
import Heading from '~/content_editor/extensions/heading';
+import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_text_style_dropdown', () => {
@@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
},
provide: {
tiptapEditor,
+ eventHub: eventHubFactory(),
},
propsData: {
...propsData,
diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js
index d2d2cd98a78..ec67545cf17 100644
--- a/spec/frontend/content_editor/extensions/attachment_spec.js
+++ b/spec/frontend/content_editor/extensions/attachment_spec.js
@@ -4,7 +4,9 @@ import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image';
import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading';
+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">
@@ -25,6 +27,7 @@ describe('content_editor/extensions/attachment', () => {
let link;
let renderMarkdown;
let mock;
+ let eventHub;
const uploadsPath = '/uploads/';
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
@@ -50,9 +53,15 @@ describe('content_editor/extensions/attachment', () => {
beforeEach(() => {
renderMarkdown = jest.fn();
+ eventHub = eventHubFactory();
tiptapEditor = createTestEditor({
- extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })],
+ extensions: [
+ Loading,
+ Link,
+ Image,
+ Attachment.configure({ renderMarkdown, uploadsPath, eventHub }),
+ ],
});
({
@@ -160,7 +169,8 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: imageFile });
- tiptapEditor.on('alert', ({ message }) => {
+ eventHub.$on('alert', ({ message, variant }) => {
+ expect(variant).toBe(VARIANT_DANGER);
expect(message).toBe('An error occurred while uploading the image. Please try again.');
done();
});
@@ -236,7 +246,8 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
- tiptapEditor.on('alert', ({ message }) => {
+ eventHub.$on('alert', ({ message, variant }) => {
+ expect(variant).toBe(VARIANT_DANGER);
expect(message).toBe('An error occurred while uploading the file. Please try again.');
done();
});
diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
new file mode 100644
index 00000000000..8f734c7dabc
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
@@ -0,0 +1,127 @@
+import PasteMarkdown from '~/content_editor/extensions/paste_markdown';
+import Bold from '~/content_editor/extensions/bold';
+import { VARIANT_DANGER } from '~/flash';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import {
+ ALERT_EVENT,
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+} from '~/content_editor/constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils';
+
+describe('content_editor/extensions/paste_markdown', () => {
+ let tiptapEditor;
+ let doc;
+ let p;
+ let bold;
+ let renderMarkdown;
+ let eventHub;
+ const defaultData = { 'text/plain': '**bold text**' };
+
+ beforeEach(() => {
+ renderMarkdown = jest.fn();
+ eventHub = eventHubFactory();
+
+ jest.spyOn(eventHub, '$emit');
+
+ tiptapEditor = createTestEditor({
+ extensions: [PasteMarkdown.configure({ renderMarkdown, eventHub }), Bold],
+ });
+
+ ({
+ builders: { doc, p, bold },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ Bold: { markType: Bold.name },
+ },
+ }));
+ });
+
+ const buildClipboardEvent = ({ data = {}, types = ['text/plain'] } = {}) => {
+ return Object.assign(new Event('paste'), {
+ clipboardData: { types, getData: jest.fn((type) => data[type] || defaultData[type]) },
+ });
+ };
+
+ const triggerPasteEventHandler = (event) => {
+ let handled = false;
+
+ tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
+ handled = eventHandler(tiptapEditor.view, event);
+ });
+
+ return handled;
+ };
+
+ const triggerPasteEventHandlerAndWaitForTransaction = (event) => {
+ return waitUntilNextDocTransaction({
+ tiptapEditor,
+ action: () => {
+ tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
+ return eventHandler(tiptapEditor.view, event);
+ });
+ },
+ });
+ };
+
+ it.each`
+ types | data | handled | desc
+ ${['text/plain']} | ${{}} | ${true} | ${'handles plain text'}
+ ${['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);
+ });
+
+ describe('when pasting raw markdown source', () => {
+ describe('when rendering markdown succeeds', () => {
+ beforeEach(() => {
+ renderMarkdown.mockResolvedValueOnce('<strong>bold text</strong>');
+ });
+
+ it('transforms pasted text into a prosemirror node', async () => {
+ const expectedDoc = doc(p(bold('bold text')));
+
+ await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+
+ it(`triggers ${LOADING_SUCCESS_EVENT}`, async () => {
+ await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_CONTENT_EVENT);
+ expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_SUCCESS_EVENT);
+ });
+ });
+
+ describe('when rendering markdown fails', () => {
+ beforeEach(() => {
+ renderMarkdown.mockRejectedValueOnce();
+ });
+
+ it(`triggers ${LOADING_ERROR_EVENT} event`, async () => {
+ triggerPasteEventHandler(buildClipboardEvent());
+
+ await waitForPromises();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_ERROR_EVENT);
+ });
+
+ it(`triggers ${ALERT_EVENT} event`, async () => {
+ triggerPasteEventHandler(buildClipboardEvent());
+
+ await waitForPromises();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(ALERT_EVENT, {
+ message: expect.any(String),
+ variant: VARIANT_DANGER,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/markdown_processing_spec_helper.js b/spec/frontend/content_editor/markdown_processing_spec_helper.js
index bb7ec0030a2..41442dd8388 100644
--- a/spec/frontend/content_editor/markdown_processing_spec_helper.js
+++ b/spec/frontend/content_editor/markdown_processing_spec_helper.js
@@ -55,7 +55,7 @@ const testSerializesHtmlToMarkdownForElement = async ({ markdown, html }) => {
// Assert that the markdown we ended up with after sending it through all the ContentEditor
// plumbing matches the original markdown from the YAML.
- expect(serializedContent).toBe(markdown);
+ expect(serializedContent.trim()).toBe(markdown.trim());
};
// describeMarkdownProcesssing
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
index e48687f1548..3bc72b13302 100644
--- a/spec/frontend/content_editor/services/content_editor_spec.js
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -4,19 +4,31 @@ import {
LOADING_ERROR_EVENT,
} from '~/content_editor/constants';
import { ContentEditor } from '~/content_editor/services/content_editor';
-
-import { createTestEditor } from '../test_utils';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/services/content_editor', () => {
let contentEditor;
let serializer;
+ let deserializer;
+ let eventHub;
+ let doc;
+ let p;
beforeEach(() => {
const tiptapEditor = createTestEditor();
jest.spyOn(tiptapEditor, 'destroy');
+ ({
+ builders: { doc, p },
+ } = createDocBuilder({
+ tiptapEditor,
+ }));
+
serializer = { deserialize: jest.fn() };
- contentEditor = new ContentEditor({ tiptapEditor, serializer });
+ deserializer = { deserialize: jest.fn() };
+ eventHub = eventHubFactory();
+ contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub });
});
describe('.dispose', () => {
@@ -30,33 +42,42 @@ describe('content_editor/services/content_editor', () => {
});
describe('when setSerializedContent succeeds', () => {
+ let document;
+
beforeEach(() => {
- serializer.deserialize.mockResolvedValueOnce('');
+ document = doc(p('document'));
+ deserializer.deserialize.mockResolvedValueOnce({ document });
});
- it('emits loadingContent and loadingSuccess event', () => {
+ it('emits loadingContent and loadingSuccess event in the eventHub', () => {
let loadingContentEmitted = false;
- contentEditor.on(LOADING_CONTENT_EVENT, () => {
+ eventHub.$on(LOADING_CONTENT_EVENT, () => {
loadingContentEmitted = true;
});
- contentEditor.on(LOADING_SUCCESS_EVENT, () => {
+ eventHub.$on(LOADING_SUCCESS_EVENT, () => {
expect(loadingContentEmitted).toBe(true);
});
contentEditor.setSerializedContent('**bold text**');
});
+
+ it('sets the deserialized document in the tiptap editor object', async () => {
+ await contentEditor.setSerializedContent('**bold text**');
+
+ expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
+ });
});
describe('when setSerializedContent fails', () => {
const error = 'error';
beforeEach(() => {
- serializer.deserialize.mockRejectedValueOnce(error);
+ deserializer.deserialize.mockRejectedValueOnce(error);
});
it('emits loadingError event', async () => {
- contentEditor.on(LOADING_ERROR_EVENT, (e) => {
+ eventHub.$on(LOADING_ERROR_EVENT, (e) => {
expect(e).toBe('error');
});
diff --git a/spec/frontend/content_editor/services/markdown_deserializer_spec.js b/spec/frontend/content_editor/services/markdown_deserializer_spec.js
new file mode 100644
index 00000000000..bea43a0effc
--- /dev/null
+++ b/spec/frontend/content_editor/services/markdown_deserializer_spec.js
@@ -0,0 +1,62 @@
+import createMarkdownDeserializer from '~/content_editor/services/markdown_deserializer';
+import Bold from '~/content_editor/extensions/bold';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+describe('content_editor/services/markdown_deserializer', () => {
+ let renderMarkdown;
+ let doc;
+ let p;
+ let bold;
+ let tiptapEditor;
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({
+ extensions: [Bold],
+ });
+
+ ({
+ builders: { doc, p, bold },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ bold: { markType: Bold.name },
+ },
+ }));
+ renderMarkdown = jest.fn();
+ });
+
+ describe('when deserializing', () => {
+ let result;
+ const text = 'Bold text';
+
+ beforeEach(async () => {
+ const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
+
+ renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p>`);
+
+ result = await deserializer.deserialize({
+ content: 'content',
+ schema: tiptapEditor.schema,
+ });
+ });
+ it('transforms HTML returned by render function to a ProseMirror document', async () => {
+ const expectedDoc = 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-->`);
+ });
+ });
+
+ describe('when the render function returns an empty value', () => {
+ it('returns an empty object', async () => {
+ const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
+
+ renderMarkdown.mockResolvedValueOnce(null);
+
+ expect(await deserializer.deserialize({ content: 'content' })).toEqual({});
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index 01d4c994e88..2b76dc6c984 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -597,6 +597,7 @@ this is not really json but just trying out whether this case works or not
paragraph('A giant ', italic('owl-like'), ' creature.'),
),
),
+ heading('this is a heading'),
),
).toBe(
`
@@ -612,6 +613,8 @@ A giant _owl-like_ creature.
</dd>
</dl>
+
+# this is a heading
`.trim(),
);
});
@@ -623,6 +626,7 @@ A giant _owl-like_ creature.
detailsContent(paragraph('this is the summary')),
detailsContent(paragraph('this content will be hidden')),
),
+ heading('this is a heading'),
),
).toBe(
`
@@ -630,6 +634,8 @@ A giant _owl-like_ creature.
<summary>this is the summary</summary>
this content will be hidden
</details>
+
+# this is a heading
`.trim(),
);
});
@@ -648,7 +654,7 @@ this content will be hidden
detailsContent(paragraph('this content will be ', italic('hidden'))),
),
details(detailsContent(paragraph('summary 2')), detailsContent(paragraph('content 2'))),
- ),
+ ).trim(),
).toBe(
`
<details>
@@ -669,6 +675,7 @@ console.log(c);
this content will be _hidden_
</details>
+
<details>
<summary>summary 2</summary>
content 2
@@ -694,7 +701,7 @@ content 2
),
),
),
- ),
+ ).trim(),
).toBe(
`
<details>
@@ -709,7 +716,9 @@ content 2
_inception_
</details>
+
</details>
+
</details>
`.trim(),
);
diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
index 6f908f468f6..abd9588daff 100644
--- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
+++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
@@ -2,8 +2,8 @@ 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 markdownSerializer from '~/content_editor/services/markdown_serializer';
-import { getMarkdownSource } from '~/content_editor/services/markdown_sourcemap';
+import markdownDeserializer from '~/content_editor/services/markdown_deserializer';
+import { getMarkdownSource, getFullSource } from '~/content_editor/services/markdown_sourcemap';
import { createTestEditor, createDocBuilder } from '../test_utils';
const BULLET_LIST_MARKDOWN = `+ list item 1
@@ -52,10 +52,29 @@ const {
});
describe('content_editor/services/markdown_sourcemap', () => {
+ describe('getFullSource', () => {
+ it.each`
+ lastChild | expected
+ ${null} | ${[]}
+ ${{ nodeName: 'paragraph' }} | ${[]}
+ ${{ nodeName: '#comment', textContent: null }} | ${[]}
+ ${{ nodeName: '#comment', textContent: '+ list item 1\n+ list item 2' }} | ${['+ list item 1', '+ list item 2']}
+ `('with lastChild=$lastChild, returns $expected', ({ lastChild, expected }) => {
+ const element = {
+ ownerDocument: {
+ body: {
+ lastChild,
+ },
+ },
+ };
+
+ expect(getFullSource(element)).toEqual(expected);
+ });
+ });
+
it('gets markdown source for a rendered HTML element', async () => {
- const deserialized = await markdownSerializer({
+ const { document } = await markdownDeserializer({
render: () => BULLET_LIST_HTML,
- serializerConfig: {},
}).deserialize({
schema: tiptapEditor.schema,
content: BULLET_LIST_MARKDOWN,
@@ -76,6 +95,6 @@ describe('content_editor/services/markdown_sourcemap', () => {
),
);
- expect(deserialized).toEqual(expected.toJSON());
+ expect(document.toJSON()).toEqual(expected.toJSON());
});
});
diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js
index 84eaa3c5f44..dde9d738235 100644
--- a/spec/frontend/content_editor/test_utils.js
+++ b/spec/frontend/content_editor/test_utils.js
@@ -142,3 +142,23 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => {
f(view, selection.from, inputRuleText.length + 1, inputRuleText),
);
};
+
+/**
+ * Executes an action that triggers a transaction in the
+ * tiptap Editor. Returns a promise that resolves
+ * after the transaction completes
+ * @param {*} params.tiptapEditor Tiptap editor
+ * @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 }) => {
+ return new Promise((resolve) => {
+ const handleTransaction = () => {
+ tiptapEditor.off('update', handleTransaction);
+ resolve();
+ };
+
+ tiptapEditor.on('update', handleTransaction);
+ action();
+ });
+};
diff --git a/spec/frontend/contributors/store/getters_spec.js b/spec/frontend/contributors/store/getters_spec.js
index a4202e0ef4b..48218ff60e4 100644
--- a/spec/frontend/contributors/store/getters_spec.js
+++ b/spec/frontend/contributors/store/getters_spec.js
@@ -35,7 +35,7 @@ describe('Contributors Store Getters', () => {
{ author_name: 'Carlson', author_email: 'carlson123@gmail.com', date: '2019-05-05' },
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' },
{ author_name: 'Johan', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' },
- { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' },
+ { author_name: 'John', author_email: 'JAWNNYPOO@gmail.com', date: '2019-03-03' },
];
parsed = getters.parsedData(state);
});
diff --git a/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap
deleted file mode 100644
index e688df8f281..00000000000
--- a/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap
+++ /dev/null
@@ -1,28 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`TotalTimeComponent with a blank object should render -- 1`] = `"<span class=\\"total-time\\"> -- </span>"`;
-
-exports[`TotalTimeComponent with a valid time object with {"days": 3, "mins": 47, "seconds": 3} 1`] = `
-"<span class=\\"total-time\\">
- 3 <span>days</span></span>"
-`;
-
-exports[`TotalTimeComponent with a valid time object with {"hours": 7, "mins": 20, "seconds": 10} 1`] = `
-"<span class=\\"total-time\\">
- 7 <span>hrs</span></span>"
-`;
-
-exports[`TotalTimeComponent with a valid time object with {"hours": 23, "mins": 10} 1`] = `
-"<span class=\\"total-time\\">
- 23 <span>hrs</span></span>"
-`;
-
-exports[`TotalTimeComponent with a valid time object with {"mins": 47, "seconds": 3} 1`] = `
-"<span class=\\"total-time\\">
- 47 <span>mins</span></span>"
-`;
-
-exports[`TotalTimeComponent with a valid time object with {"seconds": 35} 1`] = `
-"<span class=\\"total-time\\">
- 35 <span>s</span></span>"
-`;
diff --git a/spec/frontend/cycle_analytics/__snapshots__/total_time_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/total_time_spec.js.snap
new file mode 100644
index 00000000000..7f211c1028e
--- /dev/null
+++ b/spec/frontend/cycle_analytics/__snapshots__/total_time_spec.js.snap
@@ -0,0 +1,28 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TotalTime with a blank object should render -- 1`] = `"<span class=\\"total-time\\"> -- </span>"`;
+
+exports[`TotalTime with a valid time object with {"days": 3, "mins": 47, "seconds": 3} 1`] = `
+"<span class=\\"total-time\\">
+ 3 <span>days</span></span>"
+`;
+
+exports[`TotalTime with a valid time object with {"hours": 7, "mins": 20, "seconds": 10} 1`] = `
+"<span class=\\"total-time\\">
+ 7 <span>hrs</span></span>"
+`;
+
+exports[`TotalTime with a valid time object with {"hours": 23, "mins": 10} 1`] = `
+"<span class=\\"total-time\\">
+ 23 <span>hrs</span></span>"
+`;
+
+exports[`TotalTime with a valid time object with {"mins": 47, "seconds": 3} 1`] = `
+"<span class=\\"total-time\\">
+ 47 <span>mins</span></span>"
+`;
+
+exports[`TotalTime with a valid time object with {"seconds": 35} 1`] = `
+"<span class=\\"total-time\\">
+ 35 <span>s</span></span>"
+`;
diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js
index 7b1ef71da63..bdf35f904ed 100644
--- a/spec/frontend/cycle_analytics/base_spec.js
+++ b/spec/frontend/cycle_analytics/base_spec.js
@@ -143,9 +143,12 @@ 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/limit_warning_component_spec.js b/spec/frontend/cycle_analytics/limit_warning_component_spec.js
deleted file mode 100644
index 3dac7438909..00000000000
--- a/spec/frontend/cycle_analytics/limit_warning_component_spec.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import LimitWarningComponent from '~/cycle_analytics/components/limit_warning_component.vue';
-import Translate from '~/vue_shared/translate';
-
-Vue.use(Translate);
-
-const createComponent = (props) =>
- shallowMount(LimitWarningComponent, {
- propsData: {
- ...props,
- },
- });
-
-describe('Limit warning component', () => {
- let component;
-
- beforeEach(() => {
- component = null;
- });
-
- afterEach(() => {
- component.destroy();
- });
-
- it('should not render if count is not exactly than 50', () => {
- component = createComponent({ count: 5 });
-
- expect(component.text().trim()).toBe('');
-
- component = createComponent({ count: 55 });
-
- expect(component.text().trim()).toBe('');
- });
-
- it('should render if count is exactly 50', () => {
- component = createComponent({ count: 50 });
-
- expect(component.text().trim()).toBe('Showing 50 events');
- });
-});
diff --git a/spec/frontend/cycle_analytics/total_time_component_spec.js b/spec/frontend/cycle_analytics/total_time_spec.js
index 9003c0330c0..8cf9feab6e9 100644
--- a/spec/frontend/cycle_analytics/total_time_component_spec.js
+++ b/spec/frontend/cycle_analytics/total_time_spec.js
@@ -1,11 +1,11 @@
import { mount } from '@vue/test-utils';
-import TotalTimeComponent from '~/cycle_analytics/components/total_time_component.vue';
+import TotalTime from '~/cycle_analytics/components/total_time.vue';
-describe('TotalTimeComponent', () => {
+describe('TotalTime', () => {
let wrapper = null;
const createComponent = (propsData) => {
- return mount(TotalTimeComponent, {
+ return mount(TotalTime, {
propsData,
});
};
diff --git a/spec/frontend/cycle_analytics/value_stream_filters_spec.js b/spec/frontend/cycle_analytics/value_stream_filters_spec.js
index 6e96a6d756a..5a0b046393a 100644
--- a/spec/frontend/cycle_analytics/value_stream_filters_spec.js
+++ b/spec/frontend/cycle_analytics/value_stream_filters_spec.js
@@ -1,4 +1,5 @@
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';
@@ -29,6 +30,7 @@ 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();
@@ -57,6 +59,10 @@ 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');
@@ -88,4 +94,52 @@ 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 7a539b262fc..6199e61df0c 100644
--- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
+++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
@@ -109,7 +109,7 @@ describe('ValueStreamMetrics', () => {
});
describe('filterFn', () => {
- const transferedMetricsData = prepareTimeMetricsData(metricsData, METRICS_POPOVER_CONTENT);
+ const transferredMetricsData = prepareTimeMetricsData(metricsData, METRICS_POPOVER_CONTENT);
it('with a filter function, will call the function with the metrics data', async () => {
const filteredData = [
@@ -123,7 +123,7 @@ describe('ValueStreamMetrics', () => {
await waitForPromises();
- expect(mockFilterFn).toHaveBeenCalledWith(transferedMetricsData);
+ expect(mockFilterFn).toHaveBeenCalledWith(transferredMetricsData);
expect(wrapper.vm.metrics).toEqual(filteredData);
});
@@ -133,7 +133,7 @@ describe('ValueStreamMetrics', () => {
await waitForPromises();
expect(mockFilterFn).not.toHaveBeenCalled();
- expect(wrapper.vm.metrics).toEqual(transferedMetricsData);
+ expect(wrapper.vm.metrics).toEqual(transferredMetricsData);
});
});
diff --git a/spec/frontend/deploy_tokens/components/revoke_button_spec.js b/spec/frontend/deploy_tokens/components/revoke_button_spec.js
index e70dfe4d2e6..fa2a7d9b155 100644
--- a/spec/frontend/deploy_tokens/components/revoke_button_spec.js
+++ b/spec/frontend/deploy_tokens/components/revoke_button_spec.js
@@ -70,11 +70,6 @@ describe('RevokeButton', () => {
expect(findRevokeButton().exists()).toBe(true);
});
- it('passes the buttonClass to the button', () => {
- wrapper = createComponent({ buttonClass: 'my-revoke-button' });
- expect(findRevokeButton().classes()).toContain('my-revoke-button');
- });
-
it('opens the modal', () => {
findRevokeButton().trigger('click');
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.modalId);
diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js
index 9b8f0421b7c..f982749d1de 100644
--- a/spec/frontend/diffs/components/diff_view_spec.js
+++ b/spec/frontend/diffs/components/diff_view_spec.js
@@ -51,10 +51,18 @@ describe('DiffView', () => {
it('renders a match line', () => {
const wrapper = createWrapper({
- diffLines: [{ isMatchLineLeft: true, left: { rich_text: 'matched text', lineDraft: {} } }],
+ diffLines: [
+ {
+ isMatchLineLeft: true,
+ left: {
+ rich_text: '@@ -4,12 +4,12 @@ import createFlash from &#39;~/flash&#39;;',
+ lineDraft: {},
+ },
+ },
+ ],
});
expect(wrapper.find(DiffExpansionCell).exists()).toBe(true);
- expect(wrapper.text()).toContain('matched text');
+ expect(wrapper.text()).toContain("@@ -4,12 +4,12 @@ import createFlash from '~/flash';");
});
it.each`
diff --git a/spec/frontend/diffs/components/hidden_files_warning_spec.js b/spec/frontend/diffs/components/hidden_files_warning_spec.js
index 3f1f23a40f5..bbd4f5faeec 100644
--- a/spec/frontend/diffs/components/hidden_files_warning_spec.js
+++ b/spec/frontend/diffs/components/hidden_files_warning_spec.js
@@ -1,4 +1,6 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
const propsData = {
@@ -12,7 +14,7 @@ describe('HiddenFilesWarning', () => {
let wrapper;
const createComponent = () => {
- wrapper = shallowMount(HiddenFilesWarning, {
+ wrapper = mount(HiddenFilesWarning, {
propsData,
});
};
@@ -26,22 +28,20 @@ describe('HiddenFilesWarning', () => {
});
it('has a correct plain diff URL', () => {
- const plainDiffLink = wrapper.findAll('a').wrappers.filter((x) => x.text() === 'Plain diff')[0];
+ const plainDiffLink = wrapper.findAllComponents(GlButton).at(0);
expect(plainDiffLink.attributes('href')).toBe(propsData.plainDiffPath);
});
it('has a correct email patch URL', () => {
- const emailPatchLink = wrapper
- .findAll('a')
- .wrappers.filter((x) => x.text() === 'Email patch')[0];
+ const emailPatchLink = wrapper.findAllComponents(GlButton).at(1);
expect(emailPatchLink.attributes('href')).toBe(propsData.emailPatchPath);
});
it('has a correct visible/total files text', () => {
- const filesText = wrapper.find('strong');
-
- expect(filesText.text()).toBe('5 of 10');
+ expect(wrapper.text()).toContain(
+ __('To preserve performance only 5 of 10 files are displayed.'),
+ );
});
});
diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
index 6ea8f691c3c..49f8e22e01c 100644
--- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
+++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
@@ -79,27 +79,21 @@ describe('Compare diff version dropdowns', () => {
};
};
- const assertVersions = (targetVersions) => {
- // base and head should be the last two versions in that order
- const targetBaseVersion = targetVersions[targetVersions.length - 2];
- const targetHeadVersion = targetVersions[targetVersions.length - 1];
+ const assertVersions = (targetVersions, checkBaseVersion) => {
+ const targetLatestVersion = targetVersions[targetVersions.length - 1];
expect(targetVersions[0]).toEqual(expectedFirstVersion);
- expect(targetBaseVersion).toEqual(expectedBaseVersion);
- expect(targetHeadVersion).toEqual(expectedHeadVersion);
+
+ if (checkBaseVersion) {
+ expect(targetLatestVersion).toEqual(expectedBaseVersion);
+ } else {
+ expect(targetLatestVersion).toEqual(expectedHeadVersion);
+ }
};
afterEach(() => {
setWindowLocation(originalLocation);
});
- it('base version selected', () => {
- setupTest();
- expectedBaseVersion.selected = true;
-
- const targetVersions = getters.diffCompareDropdownTargetVersions(localState, getters);
- assertVersions(targetVersions);
- });
-
it('head version selected', () => {
setupTest(true);
@@ -126,6 +120,21 @@ describe('Compare diff version dropdowns', () => {
});
assertVersions(targetVersions);
});
+
+ describe('when state.mergeRequestDiff.head_version_path is null', () => {
+ beforeEach(() => {
+ localState.mergeRequestDiff.head_version_path = null;
+ });
+
+ it('base version selected', () => {
+ setupTest(true);
+
+ expectedBaseVersion.selected = true;
+
+ const targetVersions = getters.diffCompareDropdownTargetVersions(localState, getters);
+ assertVersions(targetVersions, true);
+ });
+ });
});
it('diffCompareDropdownSourceVersions', () => {
diff --git a/spec/frontend/dirty_submit/dirty_submit_form_spec.js b/spec/frontend/dirty_submit/dirty_submit_form_spec.js
index cfcf1be609e..bcbe824bd9f 100644
--- a/spec/frontend/dirty_submit/dirty_submit_form_spec.js
+++ b/spec/frontend/dirty_submit/dirty_submit_form_spec.js
@@ -93,5 +93,38 @@ describe('DirtySubmitForm', () => {
expect(updateDirtyInputSpy).toHaveBeenCalledTimes(range.length);
});
+
+ describe('when inputs listener is added', () => {
+ it('calls listener when changes are made to an input', () => {
+ const { form, input } = createForm();
+ const inputsListener = jest.fn();
+
+ const dirtySubmitForm = new DirtySubmitForm(form);
+ dirtySubmitForm.addInputsListener(inputsListener);
+
+ setInputValue(input, 'new value');
+
+ jest.runOnlyPendingTimers();
+
+ expect(inputsListener).toHaveBeenCalledTimes(1);
+ });
+
+ describe('when inputs listener is removed', () => {
+ it('does not call listener when changes are made to an input', () => {
+ const { form, input } = createForm();
+ const inputsListener = jest.fn();
+
+ const dirtySubmitForm = new DirtySubmitForm(form);
+ dirtySubmitForm.addInputsListener(inputsListener);
+ dirtySubmitForm.removeInputsListener(inputsListener);
+
+ setInputValue(input, 'new value');
+
+ jest.runOnlyPendingTimers();
+
+ expect(inputsListener).not.toHaveBeenCalled();
+ });
+ });
+ });
});
});
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 5eaac9e9ef9..2f6d277ca75 100644
--- a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
+++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
@@ -4,10 +4,14 @@ import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_e
import ciSchemaPath from '~/editor/schema/ci.json';
import SourceEditor from '~/editor/source_editor';
+// Webpack is configured to use file-loader for the CI schema; mimic that here
+jest.mock('~/editor/schema/ci.json', () => '/assets/ci.json');
+
const mockRef = 'AABBCCDD';
describe('~/editor/editor_ci_config_ext', () => {
const defaultBlobPath = '.gitlab-ci.yml';
+ const expectedSchemaUri = `${TEST_HOST}${ciSchemaPath}`;
let editor;
let instance;
@@ -84,14 +88,13 @@ describe('~/editor/editor_ci_config_ext', () => {
});
expect(getConfiguredYmlSchema()).toEqual({
- uri: `${TEST_HOST}${ciSchemaPath}`,
+ uri: expectedSchemaUri,
fileMatch: [defaultBlobPath],
});
});
it('with an alternative file name match', () => {
createMockEditor({ blobPath: 'dir1/dir2/another-ci-filename.yml' });
-
instance.registerCiSchema({
projectNamespace: mockProjectNamespace,
projectPath: mockProjectPath,
@@ -99,7 +102,7 @@ describe('~/editor/editor_ci_config_ext', () => {
});
expect(getConfiguredYmlSchema()).toEqual({
- uri: `${TEST_HOST}${ciSchemaPath}`,
+ uri: expectedSchemaUri,
fileMatch: ['another-ci-filename.yml'],
});
});
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
index d7acf75fc95..8465b57c660 100644
--- a/spec/frontend/environment.js
+++ b/spec/frontend/environment.js
@@ -123,6 +123,7 @@ class CustomEnvironment extends JSDOMEnvironment {
// Reset `Date` so that Jest can report timing accurately *roll eyes*...
setGlobalDateToRealDate();
+ // eslint-disable-next-line no-restricted-syntax
await new Promise(setImmediate);
if (this.rejectedPromises.length > 0) {
diff --git a/spec/frontend/environments/delete_environment_modal_spec.js b/spec/frontend/environments/delete_environment_modal_spec.js
index 50c4ca00009..48e4f661c1d 100644
--- a/spec/frontend/environments/delete_environment_modal_spec.js
+++ b/spec/frontend/environments/delete_environment_modal_spec.js
@@ -5,8 +5,11 @@ import VueApollo from 'vue-apollo';
import { s__, sprintf } from '~/locale';
import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
import { resolvedEnvironment } from './graphql/mock_data';
+jest.mock('~/flash');
Vue.use(VueApollo);
describe('~/environments/components/delete_environment_modal.vue', () => {
@@ -54,6 +57,34 @@ describe('~/environments/components/delete_environment_modal.vue', () => {
await nextTick();
+ expect(createFlash).not.toHaveBeenCalled();
+
+ expect(deleteResolver).toHaveBeenCalledWith(
+ expect.anything(),
+ { environment: resolvedEnvironment },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('should flash a message on error', async () => {
+ createComponent({ apolloProvider: mockApollo });
+
+ deleteResolver.mockRejectedValue();
+
+ wrapper.findComponent(GlModal).vm.$emit('primary');
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: s__(
+ 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.',
+ ),
+ captureError: true,
+ }),
+ );
+
expect(deleteResolver).toHaveBeenCalledWith(
expect.anything(),
{ environment: resolvedEnvironment },
diff --git a/spec/frontend/environments/enable_review_app_modal_spec.js b/spec/frontend/environments/enable_review_app_modal_spec.js
index 17ae10a2884..b6dac811ea6 100644
--- a/spec/frontend/environments/enable_review_app_modal_spec.js
+++ b/spec/frontend/environments/enable_review_app_modal_spec.js
@@ -4,10 +4,17 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import EnableReviewAppButton from '~/environments/components/enable_review_app_modal.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+// hardcode uniqueId for determinism
+jest.mock('lodash/uniqueId', () => (x) => `${x}77`);
+
+const EXPECTED_COPY_PRE_ID = 'enable-review-app-copy-string-77';
+
describe('Enable Review App Button', () => {
let wrapper;
let modal;
+ const findCopyString = () => wrapper.find(`#${EXPECTED_COPY_PRE_ID}`);
+
afterEach(() => {
wrapper.destroy();
});
@@ -30,12 +37,15 @@ describe('Enable Review App Button', () => {
});
it('renders the defaultBranchName copy', () => {
- const findCopyString = () => wrapper.findByTestId('enable-review-app-copy-string');
expect(findCopyString().text()).toContain('- main');
});
it('renders the copyToClipboard button', () => {
- expect(wrapper.findComponent(ModalCopyButton).exists()).toBe(true);
+ expect(wrapper.findComponent(ModalCopyButton).props()).toMatchObject({
+ modalId: 'fake-id',
+ target: `#${EXPECTED_COPY_PRE_ID}`,
+ title: 'Copy snippet text',
+ });
});
it('emits change events from the modal up', () => {
diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js
index 336c207428e..ada79e2d415 100644
--- a/spec/frontend/environments/environment_actions_spec.js
+++ b/spec/frontend/environments/environment_actions_spec.js
@@ -7,8 +7,15 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import EnvironmentActions from '~/environments/components/environment_actions.vue';
import eventHub from '~/environments/event_hub';
import actionMutation from '~/environments/graphql/mutations/action.mutation.graphql';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import createMockApollo from 'helpers/mock_apollo_helper';
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => {
+ return {
+ confirmAction: jest.fn(),
+ };
+});
+
const scheduledJobAction = {
name: 'scheduled action',
playPath: `${TEST_HOST}/scheduled/job/action`,
@@ -50,7 +57,7 @@ describe('EnvironmentActions Component', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
+ confirmAction.mockReset();
});
it('should render a dropdown button with 2 icons', () => {
@@ -105,7 +112,7 @@ describe('EnvironmentActions Component', () => {
let emitSpy;
const clickAndConfirm = async ({ confirm = true } = {}) => {
- jest.spyOn(window, 'confirm').mockImplementation(() => confirm);
+ confirmAction.mockResolvedValueOnce(confirm);
findDropdownItem(scheduledJobAction).vm.$emit('click');
await nextTick();
@@ -124,7 +131,7 @@ describe('EnvironmentActions Component', () => {
});
it('emits postAction event', () => {
- expect(window.confirm).toHaveBeenCalled();
+ expect(confirmAction).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath });
});
@@ -134,13 +141,13 @@ describe('EnvironmentActions Component', () => {
});
describe('when postAction event is denied', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponentWithScheduledJobs({ mountFn: mount });
clickAndConfirm({ confirm: false });
});
it('does not emit postAction event if confirmation is cancelled', () => {
- expect(window.confirm).toHaveBeenCalled();
+ expect(confirmAction).toHaveBeenCalled();
expect(emitSpy).not.toHaveBeenCalled();
});
});
diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js
new file mode 100644
index 00000000000..f2027252f05
--- /dev/null
+++ b/spec/frontend/environments/environment_folder_spec.js
@@ -0,0 +1,132 @@
+import VueApollo from 'vue-apollo';
+import Vue, { nextTick } from 'vue';
+import { GlCollapse, GlIcon } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { stubTransition } from 'helpers/stub_transition';
+import { __, s__ } from '~/locale';
+import EnvironmentsFolder from '~/environments/components/environment_folder.vue';
+import EnvironmentItem from '~/environments/components/new_environment_item.vue';
+import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('~/environments/components/environments_folder.vue', () => {
+ let wrapper;
+ let environmentFolderMock;
+ let nestedEnvironment;
+
+ const findLink = () => wrapper.findByRole('link', { name: s__('Environments|Show all') });
+
+ const createApolloProvider = () => {
+ const mockResolvers = { Query: { folder: environmentFolderMock } };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (propsData, apolloProvider) =>
+ mountExtended(EnvironmentsFolder, {
+ apolloProvider,
+ propsData: {
+ scope: 'available',
+ ...propsData,
+ },
+ stubs: { transition: stubTransition() },
+ provide: { helpPagePath: '/help' },
+ });
+
+ beforeEach(async () => {
+ environmentFolderMock = jest.fn();
+ [nestedEnvironment] = resolvedEnvironmentsApp.environments;
+ environmentFolderMock.mockReturnValue(resolvedFolder);
+ });
+
+ afterEach(() => {
+ wrapper?.destroy();
+ });
+
+ describe('default', () => {
+ let folderName;
+ let button;
+
+ beforeEach(async () => {
+ wrapper = createWrapper({ nestedEnvironment }, createApolloProvider());
+
+ await nextTick();
+ await waitForPromises();
+ folderName = wrapper.findByText(nestedEnvironment.name);
+ button = wrapper.findByRole('button', { name: __('Expand') });
+ });
+
+ it('displays the name of the folder', () => {
+ expect(folderName.text()).toBe(nestedEnvironment.name);
+ });
+
+ describe('collapse', () => {
+ let icons;
+ let collapse;
+
+ beforeEach(() => {
+ collapse = wrapper.findComponent(GlCollapse);
+ icons = wrapper.findAllComponents(GlIcon);
+ });
+
+ it('is collapsed by default', () => {
+ const link = findLink();
+
+ expect(collapse.attributes('visible')).toBeUndefined();
+ const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2);
+ expect(iconNames).toEqual(['angle-right', 'folder-o']);
+ expect(folderName.classes('gl-font-weight-bold')).toBe(false);
+ expect(link.exists()).toBe(false);
+ });
+
+ it('opens on click', async () => {
+ await button.trigger('click');
+
+ const link = findLink();
+
+ expect(button.attributes('aria-label')).toBe(__('Collapse'));
+ expect(collapse.attributes('visible')).toBe('visible');
+ const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2);
+ expect(iconNames).toEqual(['angle-down', 'folder-open']);
+ expect(folderName.classes('gl-font-weight-bold')).toBe(true);
+ expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath);
+ });
+
+ it('displays all environments when opened', async () => {
+ await button.trigger('click');
+
+ const names = resolvedFolder.environments.map((e) =>
+ expect.stringMatching(e.nameWithoutType),
+ );
+ const environments = wrapper
+ .findAllComponents(EnvironmentItem)
+ .wrappers.map((w) => w.text());
+ expect(environments).toEqual(expect.arrayContaining(names));
+ });
+ });
+ });
+
+ it.each(['available', 'stopped'])(
+ 'with scope=%s, fetches environments with scope',
+ async (scope) => {
+ wrapper = createWrapper({ nestedEnvironment, scope }, createApolloProvider());
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentFolderMock).toHaveBeenCalledTimes(1);
+ expect(environmentFolderMock).toHaveBeenCalledWith(
+ {},
+ {
+ environment: nestedEnvironment.latest,
+ scope,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
+ },
+ );
+});
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
index b930259149f..0b36d2a940d 100644
--- a/spec/frontend/environments/environment_item_spec.js
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -79,7 +79,7 @@ describe('Environment item', () => {
describe('With user information', () => {
it('should render user avatar with link to profile', () => {
- expect(wrapper.find('.js-deploy-user-container').attributes('href')).toEqual(
+ expect(wrapper.find('.js-deploy-user-container').props('linkHref')).toEqual(
environment.last_deployment.user.web_url,
);
});
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index 92d1820681c..91b75c850bd 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -1,282 +1,355 @@
-import { GlTabs } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import Container from '~/environments/components/container.vue';
-import DeployBoard from '~/environments/components/deploy_board.vue';
-import EmptyState from '~/environments/components/empty_state.vue';
-import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlPagination } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { sprintf, __, s__ } from '~/locale';
import EnvironmentsApp from '~/environments/components/environments_app.vue';
-import axios from '~/lib/utils/axios_utils';
-import * as urlUtils from '~/lib/utils/url_utility';
-import { environment, folder } from './mock_data';
+import EnvironmentsFolder from '~/environments/components/environment_folder.vue';
+import EnvironmentsItem from '~/environments/components/new_environment_item.vue';
+import EmptyState from '~/environments/components/empty_state.vue';
+import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue';
+import CanaryUpdateModal from '~/environments/components/canary_update_modal.vue';
+import { resolvedEnvironmentsApp, resolvedFolder, resolvedEnvironment } from './graphql/mock_data';
-describe('Environment', () => {
- let mock;
- let wrapper;
+Vue.use(VueApollo);
- const mockData = {
- endpoint: 'environments.json',
- canCreateEnvironment: true,
- newEnvironmentPath: 'environments/new',
- helpPagePath: 'help',
- userCalloutsPath: '/callouts',
- lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
- helpCanaryDeploymentsPath: 'help/canary-deployments',
+describe('~/environments/components/environments_app.vue', () => {
+ let wrapper;
+ let environmentAppMock;
+ let environmentFolderMock;
+ let paginationMock;
+ let environmentToStopMock;
+ let environmentToChangeCanaryMock;
+ let weightMock;
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ environmentApp: environmentAppMock,
+ folder: environmentFolderMock,
+ pageInfo: paginationMock,
+ environmentToStop: environmentToStopMock,
+ environmentToDelete: jest.fn().mockResolvedValue(resolvedEnvironment),
+ environmentToRollback: jest.fn().mockResolvedValue(resolvedEnvironment),
+ environmentToChangeCanary: environmentToChangeCanaryMock,
+ weight: weightMock,
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
};
- const mockRequest = (response, body) => {
- mock.onGet(mockData.endpoint).reply(response, body, {
- 'X-nExt-pAge': '2',
- 'x-page': '1',
- 'X-Per-Page': '1',
- 'X-Prev-Page': '',
- 'X-TOTAL': '37',
- 'X-Total-Pages': '2',
+ const createWrapper = ({ provide = {}, apolloProvider } = {}) =>
+ mountExtended(EnvironmentsApp, {
+ provide: {
+ newEnvironmentPath: '/environments/new',
+ canCreateEnvironment: true,
+ defaultBranchName: 'main',
+ helpPagePath: '/help',
+ projectId: '1',
+ ...provide,
+ },
+ apolloProvider,
});
- };
- const createWrapper = (shallow = false, props = {}) => {
- const fn = shallow ? shallowMount : mount;
- wrapper = extendedWrapper(fn(EnvironmentsApp, { propsData: { ...mockData, ...props } }));
- return axios.waitForAll();
+ const createWrapperWithMocked = async ({
+ provide = {},
+ environmentsApp,
+ folder,
+ environmentToStop = {},
+ environmentToChangeCanary = {},
+ weight = 0,
+ pageInfo = {
+ total: 20,
+ perPage: 5,
+ nextPage: 3,
+ page: 2,
+ previousPage: 1,
+ __typename: 'LocalPageInfo',
+ },
+ location = '?scope=available&page=2',
+ }) => {
+ setWindowLocation(location);
+ environmentAppMock.mockReturnValue(environmentsApp);
+ environmentFolderMock.mockReturnValue(folder);
+ paginationMock.mockReturnValue(pageInfo);
+ environmentToStopMock.mockReturnValue(environmentToStop);
+ environmentToChangeCanaryMock.mockReturnValue(environmentToChangeCanary);
+ weightMock.mockReturnValue(weight);
+ const apolloProvider = createApolloProvider();
+ wrapper = createWrapper({ apolloProvider, provide });
+
+ await waitForPromises();
+ await nextTick();
};
- const findEnableReviewAppButton = () => wrapper.findByTestId('enable-review-app');
- const findEnableReviewAppModal = () => wrapper.findAll(EnableReviewAppModal);
- const findNewEnvironmentButton = () => wrapper.findByTestId('new-environment');
- const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a');
- const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a');
-
beforeEach(() => {
- mock = new MockAdapter(axios);
+ environmentAppMock = jest.fn();
+ environmentFolderMock = jest.fn();
+ environmentToStopMock = jest.fn();
+ environmentToChangeCanaryMock = jest.fn();
+ weightMock = jest.fn();
+ paginationMock = jest.fn();
});
afterEach(() => {
wrapper.destroy();
- mock.restore();
});
- describe('successful request', () => {
- describe('without environments', () => {
- beforeEach(() => {
- mockRequest(200, { environments: [] });
- return createWrapper();
- });
+ it('should request available environments if the scope is invalid', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ location: '?scope=bad&page=2',
+ });
- it('should render the empty state', () => {
- expect(wrapper.find(EmptyState).exists()).toBe(true);
- });
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ scope: 'available', page: 2 }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('should show all the folders that are fetched', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
});
- describe('with paginated environments', () => {
- const environmentList = [environment];
+ const text = wrapper.findAllComponents(EnvironmentsFolder).wrappers.map((w) => w.text());
- beforeEach(() => {
- mockRequest(200, {
- environments: environmentList,
- stopped_count: 1,
- available_count: 0,
- });
- return createWrapper();
- });
+ expect(text).toContainEqual(expect.stringMatching('review'));
+ expect(text).not.toContainEqual(expect.stringMatching('production'));
+ });
- it('should render a container table with environments', () => {
- const containerTable = wrapper.find(Container);
+ it('should show all the environments that are fetched', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
- expect(containerTable.exists()).toBe(true);
- expect(containerTable.props('environments').length).toEqual(environmentList.length);
- expect(containerTable.find('.environment-name').text()).toEqual(environmentList[0].name);
- });
+ const text = wrapper.findAllComponents(EnvironmentsItem).wrappers.map((w) => w.text());
- describe('pagination', () => {
- it('should render pagination', () => {
- expect(wrapper.findAll('.gl-pagination li').length).toEqual(9);
- });
-
- it('should make an API request when page is clicked', () => {
- jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
-
- wrapper.find('.gl-pagination li:nth-child(3) .page-link').trigger('click');
- expect(wrapper.vm.updateContent).toHaveBeenCalledWith({
- scope: 'available',
- page: '2',
- nested: true,
- });
- });
-
- it('should make an API request when using tabs', () => {
- jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
- findEnvironmentsTabStopped().trigger('click');
- expect(wrapper.vm.updateContent).toHaveBeenCalledWith({
- scope: 'stopped',
- page: '1',
- nested: true,
- });
- });
-
- it('should not make the same API request when clicking on the current scope tab', () => {
- // component starts at available
- jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
- findEnvironmentsTabAvailable().trigger('click');
- expect(wrapper.vm.updateContent).toHaveBeenCalledTimes(0);
- });
- });
+ expect(text).not.toContainEqual(expect.stringMatching('review'));
+ expect(text).toContainEqual(expect.stringMatching('production'));
+ });
- describe('deploy boards', () => {
- beforeEach(() => {
- const deployEnvironment = {
- ...environment,
- rollout_status: {
- status: 'found',
- },
- };
-
- mockRequest(200, {
- environments: [deployEnvironment],
- stopped_count: 1,
- available_count: 0,
- });
-
- return createWrapper();
- });
-
- it('should render deploy boards', () => {
- expect(wrapper.find(DeployBoard).exists()).toBe(true);
- });
-
- it('should render arrow to open deploy boards', () => {
- expect(
- wrapper.find('.deploy-board-icon [data-testid="chevron-down-icon"]').exists(),
- ).toBe(true);
- });
- });
+ it('should show an empty state with no environments', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: { ...resolvedEnvironmentsApp, environments: [] },
});
+
+ expect(wrapper.findComponent(EmptyState).exists()).toBe(true);
});
- describe('unsuccessful request', () => {
- beforeEach(() => {
- mockRequest(500, {});
- return createWrapper();
+ it('should show a button to create a new environment', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
});
- it('should render empty state', () => {
- expect(wrapper.find(EmptyState).exists()).toBe(true);
- });
+ const button = wrapper.findByRole('link', { name: s__('Environments|New environment') });
+ expect(button.attributes('href')).toBe('/environments/new');
});
- describe('expandable folders', () => {
- beforeEach(() => {
- mockRequest(200, {
- environments: [folder],
- stopped_count: 1,
- available_count: 0,
- });
+ it('should not show a button to create a new environment if the user has no permissions', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ provide: { canCreateEnvironment: false, newEnvironmentPath: '' },
+ });
- mock.onGet(environment.folder_path).reply(200, { environments: [environment] });
+ const button = wrapper.findByRole('link', { name: s__('Environments|New environment') });
+ expect(button.exists()).toBe(false);
+ });
- return createWrapper().then(() => {
- // open folder
- wrapper.find('.folder-name').trigger('click');
- return axios.waitForAll();
- });
+ it('should show a button to open the review app modal', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
});
- it('should open a closed folder', () => {
- expect(wrapper.find('.folder-icon[data-testid="chevron-right-icon"]').exists()).toBe(false);
- });
+ const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
+ button.trigger('click');
- it('should close an opened folder', async () => {
- expect(wrapper.find('.folder-icon[data-testid="chevron-down-icon"]').exists()).toBe(true);
+ await nextTick();
- // close folder
- wrapper.find('.folder-name').trigger('click');
- await nextTick();
- expect(wrapper.find('.folder-icon[data-testid="chevron-down-icon"]').exists()).toBe(false);
- });
+ expect(wrapper.findByText(s__('ReviewApp|Enable Review App')).exists()).toBe(true);
+ });
- it('should show children environments', () => {
- expect(wrapper.findAll('.js-child-row').length).toEqual(1);
+ it('should not show a button to open the review app modal if review apps are configured', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: {
+ ...resolvedEnvironmentsApp,
+ reviewApp: { canSetupReviewApp: false },
+ },
+ folder: resolvedFolder,
});
- it('should show a button to show all environments', () => {
- expect(wrapper.find('.text-center > a.btn').text()).toContain('Show all');
- });
+ const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
+ expect(button.exists()).toBe(false);
});
- describe('environment button', () => {
- describe('when user can create environment', () => {
- beforeEach(() => {
- mockRequest(200, { environments: [] });
- return createWrapper(true);
+ describe('tabs', () => {
+ it('should show tabs for available and stopped environmets', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
});
- it('should render', () => {
- expect(findNewEnvironmentButton().exists()).toBe(true);
+ const [available, stopped] = wrapper.findAllByRole('tab').wrappers;
+
+ expect(available.text()).toContain(__('Available'));
+ expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount);
+ expect(stopped.text()).toContain(__('Stopped'));
+ expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount);
+ });
+
+ it('should change the requested scope on tab change', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+ const stopped = wrapper.findByRole('tab', {
+ name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
});
+
+ stopped.trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ scope: 'stopped', page: 1 }),
+ expect.anything(),
+ expect.anything(),
+ );
});
+ });
- describe('when user can not create environment', () => {
- beforeEach(() => {
- mockRequest(200, { environments: [] });
- return createWrapper(true, { ...mockData, canCreateEnvironment: false });
+ describe('modals', () => {
+ it('should pass the environment to stop to the stop environment modal', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ environmentToStop: resolvedEnvironment,
});
- it('should not render', () => {
- expect(findNewEnvironmentButton().exists()).toBe(false);
+ const modal = wrapper.findComponent(StopEnvironmentModal);
+
+ expect(modal.props('environment')).toMatchObject(resolvedEnvironment);
+ });
+
+ it('should pass the environment to change canary to the canary update modal', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ environmentToChangeCanary: resolvedEnvironment,
+ weight: 10,
});
+
+ const modal = wrapper.findComponent(CanaryUpdateModal);
+
+ expect(modal.props('environment')).toMatchObject(resolvedEnvironment);
});
});
- describe('review app modal', () => {
- describe('when it is not possible to enable a review app', () => {
- beforeEach(() => {
- mockRequest(200, { environments: [] });
- return createWrapper(true);
+ describe('pagination', () => {
+ it('should sync page from query params on load', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
});
- it('should not render the enable review app button', () => {
- expect(findEnableReviewAppButton().exists()).toBe(false);
- });
+ expect(wrapper.findComponent(GlPagination).props('value')).toBe(2);
+ });
- it('should not render a review app modal', () => {
- const modal = findEnableReviewAppModal();
- expect(modal).toHaveLength(0);
- expect(modal.exists()).toBe(false);
+ it('should change the requested page on next page click', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+ const next = wrapper.findByRole('link', {
+ name: __('Go to next page'),
});
+
+ next.trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ page: 3 }),
+ expect.anything(),
+ expect.anything(),
+ );
});
- describe('when it is possible to enable a review app', () => {
- beforeEach(() => {
- mockRequest(200, { environments: [], review_app: { can_setup_review_app: true } });
- return createWrapper(true);
+ it('should change the requested page on previous page click', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+ const prev = wrapper.findByRole('link', {
+ name: __('Go to previous page'),
});
- it('should render the enable review app button', () => {
- expect(findEnableReviewAppButton().exists()).toBe(true);
- expect(findEnableReviewAppButton().text()).toContain('Enable review app');
+ prev.trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ page: 1 }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('should change the requested page on specific page click', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
});
- it('should render only one review app modal', () => {
- const modal = findEnableReviewAppModal();
- expect(modal).toHaveLength(1);
- expect(modal.at(0).exists()).toBe(true);
+ const page = 1;
+ const pageButton = wrapper.findByRole('link', {
+ name: sprintf(__('Go to page %{page}'), { page }),
});
- });
- });
- describe('tabs', () => {
- beforeEach(() => {
- mockRequest(200, { environments: [] });
- jest
- .spyOn(urlUtils, 'getParameterByName')
- .mockImplementation((param) => (param === 'scope' ? 'stopped' : null));
- return createWrapper(true);
+ pageButton.trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ page }),
+ expect.anything(),
+ expect.anything(),
+ );
});
- it('selects the tab for the parameter', () => {
- expect(wrapper.findComponent(GlTabs).attributes('value')).toBe('1');
+ it('should sync the query params to the new page', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+ const next = wrapper.findByRole('link', {
+ name: __('Go to next page'),
+ });
+
+ next.trigger('click');
+
+ await nextTick();
+ expect(window.location.search).toBe('?scope=available&page=3');
});
});
});
diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js
index 21d7e09bad5..26f0659204a 100644
--- a/spec/frontend/environments/graphql/resolvers_spec.js
+++ b/spec/frontend/environments/graphql/resolvers_spec.js
@@ -7,6 +7,7 @@ import environmentToDelete from '~/environments/graphql/queries/environment_to_d
import environmentToStopQuery from '~/environments/graphql/queries/environment_to_stop.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql';
+import isEnvironmentStoppingQuery from '~/environments/graphql/queries/is_environment_stopping.query.graphql';
import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql';
import { TEST_HOST } from 'helpers/test_constants';
import {
@@ -123,10 +124,11 @@ describe('~/frontend/environments/graphql/resolvers', () => {
});
describe('folder', () => {
it('should fetch the folder url passed to it', async () => {
- mock.onGet(ENDPOINT, { params: { per_page: 3 } }).reply(200, folder);
+ mock.onGet(ENDPOINT, { params: { per_page: 3, scope: 'available' } }).reply(200, folder);
const environmentFolder = await mockResolvers.Query.folder(null, {
environment: { folderPath: ENDPOINT },
+ scope: 'available',
});
expect(environmentFolder).toEqual(resolvedFolder);
@@ -136,11 +138,36 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should post to the stop environment path', async () => {
mock.onPost(ENDPOINT).reply(200);
- await mockResolvers.Mutation.stopEnvironment(null, { environment: { stopPath: ENDPOINT } });
+ const client = { writeQuery: jest.fn() };
+ const environment = { stopPath: ENDPOINT };
+ await mockResolvers.Mutation.stopEnvironment(null, { environment }, { client });
expect(mock.history.post).toContainEqual(
expect.objectContaining({ url: ENDPOINT, method: 'post' }),
);
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: isEnvironmentStoppingQuery,
+ variables: { environment },
+ data: { isEnvironmentStopping: true },
+ });
+ });
+ it('should set is stopping to false if stop fails', async () => {
+ mock.onPost(ENDPOINT).reply(500);
+
+ const client = { writeQuery: jest.fn() };
+ const environment = { stopPath: ENDPOINT };
+ await mockResolvers.Mutation.stopEnvironment(null, { environment }, { client });
+
+ expect(mock.history.post).toContainEqual(
+ expect.objectContaining({ url: ENDPOINT, method: 'post' }),
+ );
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: isEnvironmentStoppingQuery,
+ variables: { environment },
+ data: { isEnvironmentStopping: false },
+ });
});
});
describe('rollbackEnvironment', () => {
diff --git a/spec/frontend/environments/new_environment_folder_spec.js b/spec/frontend/environments/new_environment_folder_spec.js
deleted file mode 100644
index 460263587be..00000000000
--- a/spec/frontend/environments/new_environment_folder_spec.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import VueApollo from 'vue-apollo';
-import Vue, { nextTick } from 'vue';
-import { GlCollapse, GlIcon } from '@gitlab/ui';
-import waitForPromises from 'helpers/wait_for_promises';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { stubTransition } from 'helpers/stub_transition';
-import { __, s__ } from '~/locale';
-import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue';
-import EnvironmentItem from '~/environments/components/new_environment_item.vue';
-import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data';
-
-Vue.use(VueApollo);
-
-describe('~/environments/components/new_environments_folder.vue', () => {
- let wrapper;
- let environmentFolderMock;
- let nestedEnvironment;
- let folderName;
- let button;
-
- const findLink = () => wrapper.findByRole('link', { name: s__('Environments|Show all') });
-
- const createApolloProvider = () => {
- const mockResolvers = { Query: { folder: environmentFolderMock } };
-
- return createMockApollo([], mockResolvers);
- };
-
- const createWrapper = (propsData, apolloProvider) =>
- mountExtended(EnvironmentsFolder, {
- apolloProvider,
- propsData,
- stubs: { transition: stubTransition() },
- provide: { helpPagePath: '/help' },
- });
-
- beforeEach(async () => {
- environmentFolderMock = jest.fn();
- [nestedEnvironment] = resolvedEnvironmentsApp.environments;
- environmentFolderMock.mockReturnValue(resolvedFolder);
- wrapper = createWrapper({ nestedEnvironment }, createApolloProvider());
-
- await nextTick();
- await waitForPromises();
- folderName = wrapper.findByText(nestedEnvironment.name);
- button = wrapper.findByRole('button', { name: __('Expand') });
- });
-
- afterEach(() => {
- wrapper?.destroy();
- });
-
- it('displays the name of the folder', () => {
- expect(folderName.text()).toBe(nestedEnvironment.name);
- });
-
- describe('collapse', () => {
- let icons;
- let collapse;
-
- beforeEach(() => {
- collapse = wrapper.findComponent(GlCollapse);
- icons = wrapper.findAllComponents(GlIcon);
- });
-
- it('is collapsed by default', () => {
- const link = findLink();
-
- expect(collapse.attributes('visible')).toBeUndefined();
- const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2);
- expect(iconNames).toEqual(['angle-right', 'folder-o']);
- expect(folderName.classes('gl-font-weight-bold')).toBe(false);
- expect(link.exists()).toBe(false);
- });
-
- it('opens on click', async () => {
- await button.trigger('click');
-
- const link = findLink();
-
- expect(button.attributes('aria-label')).toBe(__('Collapse'));
- expect(collapse.attributes('visible')).toBe('visible');
- const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2);
- expect(iconNames).toEqual(['angle-down', 'folder-open']);
- expect(folderName.classes('gl-font-weight-bold')).toBe(true);
- expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath);
- });
-
- it('displays all environments when opened', async () => {
- await button.trigger('click');
-
- const names = resolvedFolder.environments.map((e) =>
- expect.stringMatching(e.nameWithoutType),
- );
- const environments = wrapper.findAllComponents(EnvironmentItem).wrappers.map((w) => w.text());
- expect(environments).toEqual(expect.arrayContaining(names));
- });
- });
-});
diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js
index db596688dad..1d7a33fb95b 100644
--- a/spec/frontend/environments/new_environment_item_spec.js
+++ b/spec/frontend/environments/new_environment_item_spec.js
@@ -24,7 +24,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
mountExtended(EnvironmentItem, {
apolloProvider,
propsData: { environment: resolvedEnvironment, ...propsData },
- provide: { helpPagePath: '/help' },
+ provide: { helpPagePath: '/help', projectId: '1' },
stubs: { transition: stubTransition() },
});
diff --git a/spec/frontend/environments/new_environments_app_spec.js b/spec/frontend/environments/new_environments_app_spec.js
deleted file mode 100644
index 42e3608109b..00000000000
--- a/spec/frontend/environments/new_environments_app_spec.js
+++ /dev/null
@@ -1,329 +0,0 @@
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
-import { GlPagination } from '@gitlab/ui';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import setWindowLocation from 'helpers/set_window_location_helper';
-import { sprintf, __, s__ } from '~/locale';
-import EnvironmentsApp from '~/environments/components/new_environments_app.vue';
-import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue';
-import EnvironmentsItem from '~/environments/components/new_environment_item.vue';
-import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue';
-import CanaryUpdateModal from '~/environments/components/canary_update_modal.vue';
-import { resolvedEnvironmentsApp, resolvedFolder, resolvedEnvironment } from './graphql/mock_data';
-
-Vue.use(VueApollo);
-
-describe('~/environments/components/new_environments_app.vue', () => {
- let wrapper;
- let environmentAppMock;
- let environmentFolderMock;
- let paginationMock;
- let environmentToStopMock;
- let environmentToChangeCanaryMock;
- let weightMock;
-
- const createApolloProvider = () => {
- const mockResolvers = {
- Query: {
- environmentApp: environmentAppMock,
- folder: environmentFolderMock,
- pageInfo: paginationMock,
- environmentToStop: environmentToStopMock,
- environmentToDelete: jest.fn().mockResolvedValue(resolvedEnvironment),
- environmentToRollback: jest.fn().mockResolvedValue(resolvedEnvironment),
- environmentToChangeCanary: environmentToChangeCanaryMock,
- weight: weightMock,
- },
- };
-
- return createMockApollo([], mockResolvers);
- };
-
- const createWrapper = ({ provide = {}, apolloProvider } = {}) =>
- mountExtended(EnvironmentsApp, {
- provide: {
- newEnvironmentPath: '/environments/new',
- canCreateEnvironment: true,
- defaultBranchName: 'main',
- helpPagePath: '/help',
- ...provide,
- },
- apolloProvider,
- });
-
- const createWrapperWithMocked = async ({
- provide = {},
- environmentsApp,
- folder,
- environmentToStop = {},
- environmentToChangeCanary = {},
- weight = 0,
- pageInfo = {
- total: 20,
- perPage: 5,
- nextPage: 3,
- page: 2,
- previousPage: 1,
- __typename: 'LocalPageInfo',
- },
- }) => {
- setWindowLocation('?scope=available&page=2');
- environmentAppMock.mockReturnValue(environmentsApp);
- environmentFolderMock.mockReturnValue(folder);
- paginationMock.mockReturnValue(pageInfo);
- environmentToStopMock.mockReturnValue(environmentToStop);
- environmentToChangeCanaryMock.mockReturnValue(environmentToChangeCanary);
- weightMock.mockReturnValue(weight);
- const apolloProvider = createApolloProvider();
- wrapper = createWrapper({ apolloProvider, provide });
-
- await waitForPromises();
- await nextTick();
- };
-
- beforeEach(() => {
- environmentAppMock = jest.fn();
- environmentFolderMock = jest.fn();
- environmentToStopMock = jest.fn();
- environmentToChangeCanaryMock = jest.fn();
- weightMock = jest.fn();
- paginationMock = jest.fn();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('should show all the folders that are fetched', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
-
- const text = wrapper.findAllComponents(EnvironmentsFolder).wrappers.map((w) => w.text());
-
- expect(text).toContainEqual(expect.stringMatching('review'));
- expect(text).not.toContainEqual(expect.stringMatching('production'));
- });
-
- it('should show all the environments that are fetched', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
-
- const text = wrapper.findAllComponents(EnvironmentsItem).wrappers.map((w) => w.text());
-
- expect(text).not.toContainEqual(expect.stringMatching('review'));
- expect(text).toContainEqual(expect.stringMatching('production'));
- });
-
- it('should show a button to create a new environment', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
-
- const button = wrapper.findByRole('link', { name: s__('Environments|New environment') });
- expect(button.attributes('href')).toBe('/environments/new');
- });
-
- it('should not show a button to create a new environment if the user has no permissions', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- provide: { canCreateEnvironment: false, newEnvironmentPath: '' },
- });
-
- const button = wrapper.findByRole('link', { name: s__('Environments|New environment') });
- expect(button.exists()).toBe(false);
- });
-
- it('should show a button to open the review app modal', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
-
- const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
- button.trigger('click');
-
- await nextTick();
-
- expect(wrapper.findByText(s__('ReviewApp|Enable Review App')).exists()).toBe(true);
- });
-
- it('should not show a button to open the review app modal if review apps are configured', async () => {
- await createWrapperWithMocked({
- environmentsApp: {
- ...resolvedEnvironmentsApp,
- reviewApp: { canSetupReviewApp: false },
- },
- folder: resolvedFolder,
- });
-
- const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
- expect(button.exists()).toBe(false);
- });
-
- describe('tabs', () => {
- it('should show tabs for available and stopped environmets', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
-
- const [available, stopped] = wrapper.findAllByRole('tab').wrappers;
-
- expect(available.text()).toContain(__('Available'));
- expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount);
- expect(stopped.text()).toContain(__('Stopped'));
- expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount);
- });
-
- it('should change the requested scope on tab change', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
- const stopped = wrapper.findByRole('tab', {
- name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
- });
-
- stopped.trigger('click');
-
- await nextTick();
- await waitForPromises();
-
- expect(environmentAppMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({ scope: 'stopped', page: 1 }),
- expect.anything(),
- expect.anything(),
- );
- });
- });
-
- describe('modals', () => {
- it('should pass the environment to stop to the stop environment modal', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- environmentToStop: resolvedEnvironment,
- });
-
- const modal = wrapper.findComponent(StopEnvironmentModal);
-
- expect(modal.props('environment')).toMatchObject(resolvedEnvironment);
- });
-
- it('should pass the environment to change canary to the canary update modal', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- environmentToChangeCanary: resolvedEnvironment,
- weight: 10,
- });
-
- const modal = wrapper.findComponent(CanaryUpdateModal);
-
- expect(modal.props('environment')).toMatchObject(resolvedEnvironment);
- });
- });
-
- describe('pagination', () => {
- it('should sync page from query params on load', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
-
- expect(wrapper.findComponent(GlPagination).props('value')).toBe(2);
- });
-
- it('should change the requested page on next page click', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
- const next = wrapper.findByRole('link', {
- name: __('Go to next page'),
- });
-
- next.trigger('click');
-
- await nextTick();
- await waitForPromises();
-
- expect(environmentAppMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({ page: 3 }),
- expect.anything(),
- expect.anything(),
- );
- });
-
- it('should change the requested page on previous page click', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
- const prev = wrapper.findByRole('link', {
- name: __('Go to previous page'),
- });
-
- prev.trigger('click');
-
- await nextTick();
- await waitForPromises();
-
- expect(environmentAppMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({ page: 1 }),
- expect.anything(),
- expect.anything(),
- );
- });
-
- it('should change the requested page on specific page click', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
-
- const page = 1;
- const pageButton = wrapper.findByRole('link', {
- name: sprintf(__('Go to page %{page}'), { page }),
- });
-
- pageButton.trigger('click');
-
- await nextTick();
- await waitForPromises();
-
- expect(environmentAppMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({ page }),
- expect.anything(),
- expect.anything(),
- );
- });
-
- it('should sync the query params to the new page', async () => {
- await createWrapperWithMocked({
- environmentsApp: resolvedEnvironmentsApp,
- folder: resolvedFolder,
- });
- const next = wrapper.findByRole('link', {
- name: __('Go to next page'),
- });
-
- next.trigger('click');
-
- await nextTick();
- expect(window.location.search).toBe('?scope=available&page=3');
- });
- });
-});
diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js
index 03ae437a89e..4273da6c735 100644
--- a/spec/frontend/error_tracking/components/error_details_spec.js
+++ b/spec/frontend/error_tracking/components/error_details_spec.js
@@ -10,11 +10,7 @@ import {
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-import {
- severityLevel,
- severityLevelVariant,
- errorStatus,
-} from '~/error_tracking/components/constants';
+import { severityLevel, severityLevelVariant, errorStatus } from '~/error_tracking/constants';
import ErrorDetails from '~/error_tracking/components/error_details.vue';
import Stacktrace from '~/error_tracking/components/stacktrace.vue';
import {
diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
index 59671c175e7..5e0f0ca9bef 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -7,6 +7,7 @@ import ErrorTrackingActions from '~/error_tracking/components/error_tracking_act
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '~/error_tracking/utils';
import Tracking from '~/tracking';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import errorsList from './list_mock.json';
Vue.use(Vuex);
@@ -25,28 +26,33 @@ describe('ErrorTrackingList', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findPagination = () => wrapper.find(GlPagination);
const findErrorActions = () => wrapper.find(ErrorTrackingActions);
+ const findIntegratedDisabledAlert = () => wrapper.findByTestId('integrated-disabled-alert');
function mountComponent({
errorTrackingEnabled = true,
userCanEnableErrorTracking = true,
+ showIntegratedTrackingDisabledAlert = false,
stubs = {},
} = {}) {
- wrapper = mount(ErrorTrackingList, {
- store,
- propsData: {
- indexPath: '/path',
- listPath: '/error_tracking',
- projectPath: 'project/test',
- enableErrorTrackingLink: '/link',
- userCanEnableErrorTracking,
- errorTrackingEnabled,
- illustrationPath: 'illustration/path',
- },
- stubs: {
- ...stubChildren(ErrorTrackingList),
- ...stubs,
- },
- });
+ wrapper = extendedWrapper(
+ mount(ErrorTrackingList, {
+ store,
+ propsData: {
+ indexPath: '/path',
+ listPath: '/error_tracking',
+ projectPath: 'project/test',
+ enableErrorTrackingLink: '/link',
+ userCanEnableErrorTracking,
+ errorTrackingEnabled,
+ showIntegratedTrackingDisabledAlert,
+ illustrationPath: 'illustration/path',
+ },
+ stubs: {
+ ...stubChildren(ErrorTrackingList),
+ ...stubs,
+ },
+ }),
+ );
}
beforeEach(() => {
@@ -223,6 +229,31 @@ describe('ErrorTrackingList', () => {
});
});
+ describe('When the integrated tracking diabled alert should be shown', () => {
+ beforeEach(() => {
+ mountComponent({
+ showIntegratedTrackingDisabledAlert: true,
+ stubs: {
+ GlAlert: false,
+ },
+ });
+ });
+
+ it('shows the alert box', () => {
+ expect(findIntegratedDisabledAlert().exists()).toBe(true);
+ });
+
+ describe('when alert is dismissed', () => {
+ it('hides the alert box', async () => {
+ findIntegratedDisabledAlert().vm.$emit('dismiss');
+
+ await nextTick();
+
+ expect(findIntegratedDisabledAlert().exists()).toBe(false);
+ });
+ });
+ });
+
describe('When the ignore button on an error is clicked', () => {
beforeEach(() => {
store.state.list.loading = false;
@@ -367,21 +398,6 @@ describe('ErrorTrackingList', () => {
});
describe('When pagination is required', () => {
- describe('and the user is on the first page', () => {
- beforeEach(() => {
- store.state.list.loading = false;
- mountComponent({
- stubs: {
- GlPagination: false,
- },
- });
- });
-
- it('shows a disabled Prev button', () => {
- expect(wrapper.find('.prev-page-item').attributes('aria-disabled')).toBe('true');
- });
- });
-
describe('and the user is not on the first page', () => {
describe('and the previous button is clicked', () => {
beforeEach(async () => {
diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js
index 4d19ec047ef..4a0bbb1acbe 100644
--- a/spec/frontend/error_tracking_settings/components/app_spec.js
+++ b/spec/frontend/error_tracking_settings/components/app_spec.js
@@ -18,19 +18,27 @@ describe('error tracking settings app', () => {
let store;
let wrapper;
- function mountComponent() {
+ const defaultProps = {
+ initialEnabled: 'true',
+ initialIntegrated: 'false',
+ initialApiHost: TEST_HOST,
+ initialToken: 'someToken',
+ initialProject: null,
+ listProjectsEndpoint: TEST_HOST,
+ operationsSettingsEndpoint: TEST_HOST,
+ gitlabDsn: TEST_GITLAB_DSN,
+ };
+
+ function mountComponent({
+ glFeatures = { integratedErrorTracking: false },
+ props = defaultProps,
+ } = {}) {
wrapper = extendedWrapper(
shallowMount(ErrorTrackingSettings, {
store, // Override the imported store
- propsData: {
- initialEnabled: 'true',
- initialIntegrated: 'false',
- initialApiHost: TEST_HOST,
- initialToken: 'someToken',
- initialProject: null,
- listProjectsEndpoint: TEST_HOST,
- operationsSettingsEndpoint: TEST_HOST,
- gitlabDsn: TEST_GITLAB_DSN,
+ propsData: { ...props },
+ provide: {
+ glFeatures,
},
stubs: {
GlFormInputGroup, // we need this non-shallow to query for a component within a slot
@@ -47,6 +55,7 @@ describe('error tracking settings app', () => {
const findElementWithText = (wrappers, text) => wrappers.filter((item) => item.text() === text);
const findSentrySettings = () => wrapper.findByTestId('sentry-setting-form');
const findDsnSettings = () => wrapper.findByTestId('gitlab-dsn-setting-form');
+ const findEnabledCheckbox = () => wrapper.findByTestId('error-tracking-enabled');
const enableGitLabErrorTracking = async () => {
findBackendSettingsRadioGroup().vm.$emit('change', true);
@@ -88,62 +97,104 @@ describe('error tracking settings app', () => {
});
describe('tracking-backend settings', () => {
- it('contains a form-group with the correct label', () => {
- expect(findBackendSettingsSection().attributes('label')).toBe('Error tracking backend');
+ it('does not contain backend settings section', () => {
+ expect(findBackendSettingsSection().exists()).toBe(false);
});
- it('contains a radio group', () => {
- expect(findBackendSettingsRadioGroup().exists()).toBe(true);
+ it('shows the sentry form', () => {
+ expect(findSentrySettings().exists()).toBe(true);
});
- it('contains the correct radio buttons', () => {
- expect(findBackendSettingsRadioButtons()).toHaveLength(2);
+ describe('enabled setting is true', () => {
+ describe('integrated setting is true', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { ...defaultProps, initialEnabled: 'true', initialIntegrated: 'true' },
+ });
+ });
+
+ it('displays enabled as false', () => {
+ expect(findEnabledCheckbox().attributes('checked')).toBeUndefined();
+ });
+ });
+
+ describe('integrated setting is false', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { ...defaultProps, initialEnabled: 'true', initialIntegrated: 'false' },
+ });
+ });
- expect(findElementWithText(findBackendSettingsRadioButtons(), 'Sentry')).toHaveLength(1);
- expect(findElementWithText(findBackendSettingsRadioButtons(), 'GitLab')).toHaveLength(1);
+ it('displays enabled as true', () => {
+ expect(findEnabledCheckbox().attributes('checked')).toBe('true');
+ });
+ });
});
- it('hides the Sentry settings when GitLab is selected as a tracking-backend', async () => {
- expect(findSentrySettings().exists()).toBe(true);
+ describe('integrated_error_tracking feature flag enabled', () => {
+ beforeEach(() => {
+ mountComponent({
+ glFeatures: { integratedErrorTracking: true },
+ });
+ });
- await enableGitLabErrorTracking();
+ it('contains a form-group with the correct label', () => {
+ expect(findBackendSettingsSection().attributes('label')).toBe('Error tracking backend');
+ });
- expect(findSentrySettings().exists()).toBe(false);
- });
+ it('contains a radio group', () => {
+ expect(findBackendSettingsRadioGroup().exists()).toBe(true);
+ });
- describe('GitLab DSN section', () => {
- it('is visible when GitLab is selected as a tracking-backend and DSN is present', async () => {
- expect(findDsnSettings().exists()).toBe(false);
+ it('contains the correct radio buttons', () => {
+ expect(findBackendSettingsRadioButtons()).toHaveLength(2);
+
+ expect(findElementWithText(findBackendSettingsRadioButtons(), 'Sentry')).toHaveLength(1);
+ expect(findElementWithText(findBackendSettingsRadioButtons(), 'GitLab')).toHaveLength(1);
+ });
+
+ it('hides the Sentry settings when GitLab is selected as a tracking-backend', async () => {
+ expect(findSentrySettings().exists()).toBe(true);
await enableGitLabErrorTracking();
- expect(findDsnSettings().exists()).toBe(true);
+ expect(findSentrySettings().exists()).toBe(false);
});
- it('contains copy-to-clipboard functionality for the GitLab DSN string', async () => {
- await enableGitLabErrorTracking();
+ describe('GitLab DSN section', () => {
+ it('is visible when GitLab is selected as a tracking-backend and DSN is present', async () => {
+ expect(findDsnSettings().exists()).toBe(false);
+
+ await enableGitLabErrorTracking();
+
+ expect(findDsnSettings().exists()).toBe(true);
+ });
- const clipBoardInput = findDsnSettings().findComponent(GlFormInputGroup);
- const clipBoardButton = findDsnSettings().findComponent(ClipboardButton);
+ it('contains copy-to-clipboard functionality for the GitLab DSN string', async () => {
+ await enableGitLabErrorTracking();
- expect(clipBoardInput.props('value')).toBe(TEST_GITLAB_DSN);
- expect(clipBoardInput.attributes('readonly')).toBeTruthy();
- expect(clipBoardButton.props('text')).toBe(TEST_GITLAB_DSN);
+ const clipBoardInput = findDsnSettings().findComponent(GlFormInputGroup);
+ const clipBoardButton = findDsnSettings().findComponent(ClipboardButton);
+
+ expect(clipBoardInput.props('value')).toBe(TEST_GITLAB_DSN);
+ expect(clipBoardInput.attributes('readonly')).toBeTruthy();
+ expect(clipBoardButton.props('text')).toBe(TEST_GITLAB_DSN);
+ });
});
- });
- it.each([true, false])(
- 'calls the `updateIntegrated` action when the setting changes to `%s`',
- (integrated) => {
- jest.spyOn(store, 'dispatch').mockImplementation();
+ it.each([true, false])(
+ 'calls the `updateIntegrated` action when the setting changes to `%s`',
+ (integrated) => {
+ jest.spyOn(store, 'dispatch').mockImplementation();
- expect(store.dispatch).toHaveBeenCalledTimes(0);
+ expect(store.dispatch).toHaveBeenCalledTimes(0);
- findBackendSettingsRadioGroup().vm.$emit('change', integrated);
+ findBackendSettingsRadioGroup().vm.$emit('change', integrated);
- expect(store.dispatch).toHaveBeenCalledTimes(1);
- expect(store.dispatch).toHaveBeenCalledWith('updateIntegrated', integrated);
- },
- );
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+ expect(store.dispatch).toHaveBeenCalledWith('updateIntegrated', integrated);
+ },
+ );
+ });
});
});
diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb
index 1eb48c0ce2c..cb4eb43b88d 100644
--- a/spec/frontend/fixtures/merge_requests.rb
+++ b/spec/frontend/fixtures/merge_requests.rb
@@ -130,6 +130,25 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type:
expect(response).to be_successful
end
+ describe GraphQL::Query, type: :request do
+ include ApiHelpers
+ include GraphqlHelpers
+
+ context 'merge request in state readyToMerge query' do
+ base_input_path = 'vue_merge_request_widget/queries/states/'
+ base_output_path = 'graphql/merge_requests/states/'
+ query_name = 'ready_to_merge.query.graphql'
+
+ it "#{base_output_path}#{query_name}.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}", ee: Gitlab.ee?)
+
+ post_graphql(query, current_user: user, variables: { projectPath: project.full_path, iid: merge_request.iid.to_s })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+ end
+
private
def render_discussions_json(merge_request)
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index cdb4c3fd8ba..25049ee4722 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -33,19 +33,19 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- get_runners_query_name = 'get_runners.query.graphql'
+ admin_runners_query = 'list/admin_runners.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{get_runners_query_name}")
+ get_graphql_query_as_string("#{query_path}#{admin_runners_query}")
end
- it "#{fixtures_path}#{get_runners_query_name}.json" do
+ it "#{fixtures_path}#{admin_runners_query}.json" do
post_graphql(query, current_user: admin, variables: {})
expect_graphql_errors_to_be_empty
end
- it "#{fixtures_path}#{get_runners_query_name}.paginated.json" do
+ it "#{fixtures_path}#{admin_runners_query}.paginated.json" do
post_graphql(query, current_user: admin, variables: { first: 2 })
expect_graphql_errors_to_be_empty
@@ -53,13 +53,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- get_runners_count_query_name = 'get_runners_count.query.graphql'
+ admin_runners_count_query = 'list/admin_runners_count.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{get_runners_count_query_name}")
+ get_graphql_query_as_string("#{query_path}#{admin_runners_count_query}")
end
- it "#{fixtures_path}#{get_runners_count_query_name}.json" do
+ it "#{fixtures_path}#{admin_runners_count_query}.json" do
post_graphql(query, current_user: admin, variables: {})
expect_graphql_errors_to_be_empty
@@ -67,13 +67,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- get_runner_query_name = 'get_runner.query.graphql'
+ runner_query = 'details/runner.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{get_runner_query_name}")
+ get_graphql_query_as_string("#{query_path}#{runner_query}")
end
- it "#{fixtures_path}#{get_runner_query_name}.json" do
+ it "#{fixtures_path}#{runner_query}.json" do
post_graphql(query, current_user: admin, variables: {
id: instance_runner.to_global_id.to_s
})
@@ -81,7 +81,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty
end
- it "#{fixtures_path}#{get_runner_query_name}.with_group.json" do
+ it "#{fixtures_path}#{runner_query}.with_group.json" do
post_graphql(query, current_user: admin, variables: {
id: group_runner.to_global_id.to_s
})
@@ -91,13 +91,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- get_runner_projects_query_name = 'get_runner_projects.query.graphql'
+ runner_projects_query = 'details/runner_projects.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{get_runner_projects_query_name}")
+ get_graphql_query_as_string("#{query_path}#{runner_projects_query}")
end
- it "#{fixtures_path}#{get_runner_projects_query_name}.json" do
+ it "#{fixtures_path}#{runner_projects_query}.json" do
post_graphql(query, current_user: admin, variables: {
id: project_runner.to_global_id.to_s
})
@@ -107,13 +107,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- get_runner_jobs_query_name = 'get_runner_jobs.query.graphql'
+ runner_jobs_query = 'details/runner_jobs.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{get_runner_jobs_query_name}")
+ get_graphql_query_as_string("#{query_path}#{runner_jobs_query}")
end
- it "#{fixtures_path}#{get_runner_jobs_query_name}.json" do
+ it "#{fixtures_path}#{runner_jobs_query}.json" do
post_graphql(query, current_user: admin, variables: {
id: instance_runner.to_global_id.to_s
})
@@ -131,13 +131,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- get_group_runners_query_name = 'get_group_runners.query.graphql'
+ group_runners_query = 'list/group_runners.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}")
+ get_graphql_query_as_string("#{query_path}#{group_runners_query}")
end
- it "#{fixtures_path}#{get_group_runners_query_name}.json" do
+ it "#{fixtures_path}#{group_runners_query}.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path
})
@@ -145,7 +145,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty
end
- it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do
+ it "#{fixtures_path}#{group_runners_query}.paginated.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path,
first: 1
@@ -156,13 +156,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- get_group_runners_count_query_name = 'get_group_runners_count.query.graphql'
+ group_runners_count_query = 'list/group_runners_count.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{get_group_runners_count_query_name}")
+ get_graphql_query_as_string("#{query_path}#{group_runners_count_query}")
end
- it "#{fixtures_path}#{get_group_runners_count_query_name}.json" do
+ it "#{fixtures_path}#{group_runners_count_query}.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path
})
diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js
index 5ddc0ffa50f..50b05fb30e0 100644
--- a/spec/frontend/google_cloud/components/app_spec.js
+++ b/spec/frontend/google_cloud/components/app_spec.js
@@ -17,15 +17,18 @@ const SCREEN_COMPONENTS = {
};
const SERVICE_ACCOUNTS_FORM_PROPS = {
gcpProjects: [1, 2, 3],
- environments: [4, 5, 6],
+ refs: [4, 5, 6],
cancelPath: '',
};
const HOME_PROPS = {
serviceAccounts: [{}, {}],
+ gcpRegions: [{}, {}],
createServiceAccountUrl: '#url-create-service-account',
+ configureGcpRegionsUrl: '#url-configure-gcp-regions',
emptyIllustrationUrl: '#url-empty-illustration',
enableCloudRunUrl: '#url-enable-cloud-run',
enableCloudStorageUrl: '#enableCloudStorageUrl',
+ revokeOauthUrl: '#revokeOauthUrl',
};
describe('google_cloud App component', () => {
diff --git a/spec/frontend/google_cloud/components/gcp_regions_form_spec.js b/spec/frontend/google_cloud/components/gcp_regions_form_spec.js
new file mode 100644
index 00000000000..a8b7593e7c8
--- /dev/null
+++ b/spec/frontend/google_cloud/components/gcp_regions_form_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import GcpRegionsForm from '~/google_cloud/components/gcp_regions_form.vue';
+
+describe('GcpRegionsForm component', () => {
+ let wrapper;
+
+ const findHeader = () => wrapper.find('header');
+ const findAllFormGroups = () => wrapper.findAllComponents(GlFormGroup);
+ const findAllFormSelects = () => wrapper.findAllComponents(GlFormSelect);
+ const findAllButtons = () => wrapper.findAllComponents(GlButton);
+
+ const propsData = { availableRegions: [], refs: [], cancelPath: '#cancel-url' };
+
+ beforeEach(() => {
+ wrapper = shallowMount(GcpRegionsForm, { propsData });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains header', () => {
+ expect(findHeader().exists()).toBe(true);
+ });
+
+ it('contains Regions form group', () => {
+ const formGroup = findAllFormGroups().at(0);
+ expect(formGroup.exists()).toBe(true);
+ });
+
+ it('contains Regions dropdown', () => {
+ const select = findAllFormSelects().at(0);
+ expect(select.exists()).toBe(true);
+ });
+
+ it('contains Refs form group', () => {
+ const formGroup = findAllFormGroups().at(1);
+ expect(formGroup.exists()).toBe(true);
+ });
+
+ it('contains Refs dropdown', () => {
+ const select = findAllFormSelects().at(1);
+ expect(select.exists()).toBe(true);
+ });
+
+ it('contains Submit button', () => {
+ const button = findAllButtons().at(0);
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe(GcpRegionsForm.i18n.submitLabel);
+ });
+
+ it('contains Cancel button', () => {
+ const button = findAllButtons().at(1);
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe(GcpRegionsForm.i18n.cancelLabel);
+ expect(button.attributes('href')).toBe('#cancel-url');
+ });
+});
diff --git a/spec/frontend/google_cloud/components/gcp_regions_list_spec.js b/spec/frontend/google_cloud/components/gcp_regions_list_spec.js
new file mode 100644
index 00000000000..ab0c17451e8
--- /dev/null
+++ b/spec/frontend/google_cloud/components/gcp_regions_list_spec.js
@@ -0,0 +1,79 @@
+import { mount } from '@vue/test-utils';
+import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
+import GcpRegionsList from '~/google_cloud/components/gcp_regions_list.vue';
+
+describe('GcpRegions component', () => {
+ describe('when the project does not have any configured regions', () => {
+ let wrapper;
+
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findButtonInEmptyState = () => findEmptyState().findComponent(GlButton);
+
+ beforeEach(() => {
+ const propsData = {
+ list: [],
+ createUrl: '#create-url',
+ emptyIllustrationUrl: '#empty-illustration-url',
+ };
+ wrapper = mount(GcpRegionsList, { propsData });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows the empty state component', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ });
+ it('shows the link to create new service accounts', () => {
+ const button = findButtonInEmptyState();
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe('Configure regions');
+ expect(button.attributes('href')).toBe('#create-url');
+ });
+ });
+
+ describe('when three gcp regions are passed via props', () => {
+ let wrapper;
+
+ const findTitle = () => wrapper.find('h2');
+ const findDescription = () => wrapper.find('p');
+ const findTable = () => wrapper.findComponent(GlTable);
+ const findRows = () => findTable().findAll('tr');
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ beforeEach(() => {
+ const propsData = {
+ list: [{}, {}, {}],
+ createUrl: '#create-url',
+ emptyIllustrationUrl: '#empty-illustration-url',
+ };
+ wrapper = mount(GcpRegionsList, { propsData });
+ });
+
+ it('shows the title', () => {
+ expect(findTitle().text()).toBe('Regions');
+ });
+
+ it('shows the description', () => {
+ expect(findDescription().text()).toBe(
+ 'Configure your environments to be deployed to specific geographical regions',
+ );
+ });
+
+ it('shows the table', () => {
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it('table must have three rows + header row', () => {
+ expect(findRows()).toHaveLength(4);
+ });
+
+ it('shows the link to create new service accounts', () => {
+ const button = findButton();
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe('Configure regions');
+ expect(button.attributes('href')).toBe('#create-url');
+ });
+ });
+});
diff --git a/spec/frontend/google_cloud/components/home_spec.js b/spec/frontend/google_cloud/components/home_spec.js
index 57cf831b19b..42e3d72577d 100644
--- a/spec/frontend/google_cloud/components/home_spec.js
+++ b/spec/frontend/google_cloud/components/home_spec.js
@@ -18,10 +18,13 @@ describe('google_cloud Home component', () => {
const TEST_HOME_PROPS = {
serviceAccounts: [{}, {}],
+ gcpRegions: [{}, {}],
createServiceAccountUrl: '#url-create-service-account',
+ configureGcpRegionsUrl: '#url-configure-gcp-regions',
emptyIllustrationUrl: '#url-empty-illustration',
enableCloudRunUrl: '#url-enable-cloud-run',
enableCloudStorageUrl: '#enableCloudStorageUrl',
+ revokeOauthUrl: '#revokeOauthUrl',
};
beforeEach(() => {
diff --git a/spec/frontend/google_cloud/components/revoke_oauth_spec.js b/spec/frontend/google_cloud/components/revoke_oauth_spec.js
new file mode 100644
index 00000000000..87580dbf6de
--- /dev/null
+++ b/spec/frontend/google_cloud/components/revoke_oauth_spec.js
@@ -0,0 +1,47 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlForm } from '@gitlab/ui';
+import RevokeOauth, {
+ GOOGLE_CLOUD_REVOKE_TITLE,
+ GOOGLE_CLOUD_REVOKE_DESCRIPTION,
+} from '~/google_cloud/components/revoke_oauth.vue';
+
+describe('RevokeOauth component', () => {
+ let wrapper;
+
+ const findTitle = () => wrapper.find('h2');
+ const findDescription = () => wrapper.find('p');
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findButton = () => wrapper.findComponent(GlButton);
+ const propsData = {
+ url: 'url_general_feedback',
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(RevokeOauth, { propsData });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains title', () => {
+ const title = findTitle();
+ expect(title.text()).toContain('Revoke authorizations');
+ });
+
+ it('contains description', () => {
+ const description = findDescription();
+ expect(description.text()).toContain(GOOGLE_CLOUD_REVOKE_DESCRIPTION);
+ });
+
+ it('contains form', () => {
+ const form = findForm();
+ expect(form.attributes('action')).toBe(propsData.url);
+ expect(form.attributes('method')).toBe('post');
+ });
+
+ it('contains button', () => {
+ const button = findButton();
+ expect(button.text()).toContain(GOOGLE_CLOUD_REVOKE_TITLE);
+ });
+});
diff --git a/spec/frontend/google_cloud/components/service_accounts_form_spec.js b/spec/frontend/google_cloud/components/service_accounts_form_spec.js
index 7262e12c84d..38602d4e8cc 100644
--- a/spec/frontend/google_cloud/components/service_accounts_form_spec.js
+++ b/spec/frontend/google_cloud/components/service_accounts_form_spec.js
@@ -11,7 +11,7 @@ describe('ServiceAccountsForm component', () => {
const findAllButtons = () => wrapper.findAllComponents(GlButton);
const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
- const propsData = { gcpProjects: [], environments: [], cancelPath: '#cancel-url' };
+ const propsData = { gcpProjects: [], refs: [], cancelPath: '#cancel-url' };
beforeEach(() => {
wrapper = shallowMount(ServiceAccountsForm, { propsData, stubs: { GlFormCheckbox } });
diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js
index 9112b0e17e7..de4a57a7319 100644
--- a/spec/frontend/google_tag_manager/index_spec.js
+++ b/spec/frontend/google_tag_manager/index_spec.js
@@ -1,16 +1,18 @@
import { merge } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import {
+ trackCombinedGroupProjectForm,
trackFreeTrialAccountSubmissions,
+ trackProjectImport,
trackNewRegistrations,
trackSaasTrialSubmit,
trackSaasTrialSkip,
trackSaasTrialGroup,
trackSaasTrialProject,
- trackSaasTrialProjectImport,
trackSaasTrialGetStarted,
trackCheckout,
trackTransaction,
+ trackAddToCartUsageTab,
} from '~/google_tag_manager';
import { setHTMLFixture } from 'helpers/fixtures';
import { logError } from '~/lib/logger';
@@ -148,20 +150,20 @@ describe('~/google_tag_manager/index', () => {
createTestCase(trackSaasTrialProject, {
forms: [{ id: 'new_project', expectation: { event: 'saasTrialProject' } }],
}),
- createTestCase(trackSaasTrialProjectImport, {
+ createTestCase(trackProjectImport, {
links: [
{
id: 'js-test-btn-0',
cls: 'js-import-project-btn',
attributes: { 'data-platform': 'bitbucket' },
- expectation: { event: 'saasTrialProjectImport', saasProjectImport: 'bitbucket' },
+ expectation: { event: 'projectImport', platform: 'bitbucket' },
},
{
// id is neeeded so we trigger the right element in the test
id: 'js-test-btn-1',
cls: 'js-import-project-btn',
attributes: { 'data-platform': 'github' },
- expectation: { event: 'saasTrialProjectImport', saasProjectImport: 'github' },
+ expectation: { event: 'projectImport', platform: 'github' },
},
],
}),
@@ -173,6 +175,40 @@ describe('~/google_tag_manager/index', () => {
},
],
}),
+ createTestCase(trackAddToCartUsageTab, {
+ links: [
+ {
+ cls: 'js-buy-additional-minutes',
+ expectation: {
+ event: 'EECproductAddToCart',
+ ecommerce: {
+ currencyCode: 'USD',
+ add: {
+ products: [
+ {
+ name: 'CI/CD Minutes',
+ id: '0003',
+ price: '10',
+ brand: 'GitLab',
+ category: 'DevOps',
+ variant: 'add-on',
+ quantity: 1,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ }),
+ createTestCase(trackCombinedGroupProjectForm, {
+ forms: [
+ {
+ cls: 'js-groups-projects-form',
+ expectation: { event: 'combinedGroupProjectFormSubmit' },
+ },
+ ],
+ }),
])('%p', (subject, { links = [], forms = [], expectedEvents }) => {
beforeEach(() => {
setHTMLFixture(createHTML({ links, forms }));
diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js
index 9f478eedbfb..bf899e47d1c 100644
--- a/spec/frontend/graphql_shared/utils_spec.js
+++ b/spec/frontend/graphql_shared/utils_spec.js
@@ -95,7 +95,7 @@ describe('convertToGraphQLIds', () => {
it.each`
type | ids | message
- ${mockType} | ${null} | ${"Cannot read property 'map' of null"}
+ ${mockType} | ${null} | ${"Cannot read properties of null (reading 'map')"}
${mockType} | ${[mockId, null]} | ${'id must be a number or string; got object'}
${null} | ${[mockId]} | ${'type must be a string; got object'}
`('throws TypeError with "$message" if a param is missing', ({ type, ids, message }) => {
diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
index 502f10ff771..f427482be46 100644
--- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem, GlLoadingIcon, GlAvatar } from '@gitlab/ui';
+import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
@@ -46,6 +46,7 @@ describe('HeaderSearchAutocompleteItems', () => {
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findGlAvatar = () => wrapper.findComponent(GlAvatar);
+ const findGlAlert = () => wrapper.findComponent(GlAlert);
describe('template', () => {
describe('when loading is true', () => {
@@ -62,6 +63,15 @@ describe('HeaderSearchAutocompleteItems', () => {
});
});
+ describe('when api returns error', () => {
+ beforeEach(() => {
+ createComponent({ autocompleteError: true });
+ });
+
+ it('renders Alert', () => {
+ expect(findGlAlert().exists()).toBe(true);
+ });
+ });
describe('when loading is false', () => {
beforeEach(() => {
createComponent({ loading: false });
@@ -86,6 +96,7 @@ describe('HeaderSearchAutocompleteItems', () => {
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
});
});
+
describe.each`
item | showAvatar | avatarSize
${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)}
diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js
index 6599115f017..1748d89a6d3 100644
--- a/spec/frontend/header_search/store/actions_spec.js
+++ b/spec/frontend/header_search/store/actions_spec.js
@@ -1,6 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
-import createFlash from '~/flash';
import * as actions from '~/header_search/store/actions';
import * as types from '~/header_search/store/mutation_types';
import createState from '~/header_search/store/state';
@@ -13,11 +12,6 @@ describe('Header Search Store Actions', () => {
let state;
let mock;
- const flashCallback = (callCount) => {
- expect(createFlash).toHaveBeenCalledTimes(callCount);
- createFlash.mockClear();
- };
-
beforeEach(() => {
state = createState({});
mock = new MockAdapter(axios);
@@ -29,10 +23,10 @@ describe('Header Search Store Actions', () => {
});
describe.each`
- axiosMock | type | expectedMutations | flashCallCount
- ${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS_RES }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }]} | ${0}
- ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} | ${1}
- `('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations, flashCallCount }) => {
+ axiosMock | type | expectedMutations
+ ${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS_RES }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }]}
+ ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]}
+ `('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations }) => {
describe(`on ${type}`, () => {
beforeEach(() => {
mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res);
@@ -42,7 +36,7 @@ describe('Header Search Store Actions', () => {
action: actions.fetchAutocompleteOptions,
state,
expectedMutations,
- }).then(() => flashCallback(flashCallCount));
+ });
});
});
});
diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js
index 35d1bf350d7..d3510de1439 100644
--- a/spec/frontend/header_search/store/getters_spec.js
+++ b/spec/frontend/header_search/store/getters_spec.js
@@ -37,20 +37,29 @@ describe('Header Search Store Getters', () => {
});
describe.each`
- group | project | scope | expectedPath
- ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
- ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
- `('searchQuery', ({ group, project, scope, expectedPath }) => {
- describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => {
+ group | project | scope | forSnippets | codeSearch | ref | expectedPath
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
+ ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
+ `('searchQuery', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
scope,
+ for_snippets: forSnippets,
+ code_search: codeSearch,
+ ref,
},
});
state.search = MOCK_SEARCH;
@@ -135,20 +144,29 @@ describe('Header Search Store Getters', () => {
});
describe.each`
- group | project | scope | expectedPath
- ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
- ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
- `('projectUrl', ({ group, project, scope, expectedPath }) => {
- describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => {
+ group | project | scope | forSnippets | codeSearch | ref | expectedPath
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
+ ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
+ `('projectUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
scope,
+ for_snippets: forSnippets,
+ code_search: codeSearch,
+ ref,
},
});
state.search = MOCK_SEARCH;
@@ -161,20 +179,29 @@ describe('Header Search Store Getters', () => {
});
describe.each`
- group | project | scope | expectedPath
- ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
- ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
- `('groupUrl', ({ group, project, scope, expectedPath }) => {
- describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => {
+ group | project | scope | forSnippets | codeSearch | ref | expectedPath
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
+ ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
+ `('groupUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
scope,
+ for_snippets: forSnippets,
+ code_search: codeSearch,
+ ref,
},
});
state.search = MOCK_SEARCH;
@@ -187,20 +214,29 @@ describe('Header Search Store Getters', () => {
});
describe.each`
- group | project | scope | expectedPath
- ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`}
- `('allUrl', ({ group, project, scope, expectedPath }) => {
- describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => {
+ group | project | scope | forSnippets | codeSearch | ref | expectedPath
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
+ ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
+ ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true&search_code=true`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
+ `('allUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
scope,
+ for_snippets: forSnippets,
+ code_search: codeSearch,
+ ref,
},
});
state.search = MOCK_SEARCH;
diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js
index 7bcf8e49118..e3c15ded948 100644
--- a/spec/frontend/header_search/store/mutations_spec.js
+++ b/spec/frontend/header_search/store/mutations_spec.js
@@ -20,6 +20,7 @@ describe('Header Search Store Mutations', () => {
expect(state.loading).toBe(true);
expect(state.autocompleteOptions).toStrictEqual([]);
+ expect(state.autocompleteError).toBe(false);
});
});
@@ -29,6 +30,7 @@ describe('Header Search Store Mutations', () => {
expect(state.loading).toBe(false);
expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS);
+ expect(state.autocompleteError).toBe(false);
});
});
@@ -38,6 +40,7 @@ describe('Header Search Store Mutations', () => {
expect(state.loading).toBe(false);
expect(state.autocompleteOptions).toStrictEqual([]);
+ expect(state.autocompleteError).toBe(true);
});
});
@@ -46,6 +49,7 @@ describe('Header Search Store Mutations', () => {
mutations[types.CLEAR_AUTOCOMPLETE](state);
expect(state.autocompleteOptions).toStrictEqual([]);
+ expect(state.autocompleteError).toBe(false);
});
});
diff --git a/spec/frontend/ide/components/file_templates/bar_spec.js b/spec/frontend/ide/components/file_templates/bar_spec.js
index e8ebfa78fe9..aaf9c17ccbf 100644
--- a/spec/frontend/ide/components/file_templates/bar_spec.js
+++ b/spec/frontend/ide/components/file_templates/bar_spec.js
@@ -36,7 +36,7 @@ describe('IDE file templates bar component', () => {
it('calls setSelectedTemplateType when clicking item', () => {
jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation();
- vm.$el.querySelector('.dropdown-content button').click();
+ vm.$el.querySelector('.dropdown-menu button').click();
expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
name: '.gitlab-ci.yml',
@@ -64,10 +64,10 @@ describe('IDE file templates bar component', () => {
expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template');
});
- it('calls fetchTemplate on click', () => {
+ it('calls fetchTemplate on dropdown open', () => {
jest.spyOn(vm, 'fetchTemplate').mockImplementation();
- vm.$el.querySelectorAll('.dropdown-content')[1].querySelector('button').click();
+ vm.$el.querySelectorAll('.dropdown-menu')[1].querySelector('button').click();
expect(vm.fetchTemplate).toHaveBeenCalledWith({
name: 'test',
@@ -85,7 +85,7 @@ describe('IDE file templates bar component', () => {
it('calls undoFileTemplate when clicking undo button', () => {
jest.spyOn(vm, 'undoFileTemplate').mockImplementation();
- vm.$el.querySelector('.btn-default').click();
+ vm.$el.querySelector('.btn-default-secondary').click();
expect(vm.undoFileTemplate).toHaveBeenCalled();
});
diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js
index 8134248bbf4..e8635444801 100644
--- a/spec/frontend/ide/components/new_dropdown/modal_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js
@@ -38,7 +38,7 @@ describe('new file modal component', () => {
});
it(`sets button label as ${entryType}`, () => {
- expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle);
+ expect(document.querySelector('.btn-confirm').textContent.trim()).toBe(btnTitle);
});
it(`sets form label as ${entryType}`, () => {
@@ -77,7 +77,7 @@ describe('new file modal component', () => {
await nextTick();
expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
- expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle);
+ expect(document.querySelector('.btn-confirm').textContent.trim()).toBe(btnTitle);
},
);
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 96c9baeb328..9a30fd5f5c3 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -169,12 +169,11 @@ describe('RepoEditor', () => {
expect(findEditor().isVisible()).toBe(true);
});
- it('renders only an edit tab', async () => {
+ it('renders no tabs', async () => {
await createComponent();
const tabs = findTabs();
- expect(tabs).toHaveLength(1);
- expect(tabs.at(0).text()).toBe('Edit');
+ expect(tabs).toHaveLength(0);
});
});
@@ -196,25 +195,48 @@ describe('RepoEditor', () => {
mock.restore();
});
- it('renders an Edit and a Preview Tab', async () => {
- await createComponent({ activeFile });
- const tabs = findTabs();
+ describe('when files is markdown', () => {
+ let layoutSpy;
- expect(tabs).toHaveLength(2);
- expect(tabs.at(0).text()).toBe('Edit');
- expect(tabs.at(1).text()).toBe('Preview Markdown');
- });
+ beforeEach(async () => {
+ await createComponent({ activeFile });
+ layoutSpy = jest.spyOn(wrapper.vm.editor, 'layout');
+ });
- it('renders markdown for tempFile', async () => {
- // by default files created in the spec are temp: no need for explicitly sending the param
- await createComponent({ activeFile });
+ it('renders an Edit and a Preview Tab', () => {
+ const tabs = findTabs();
- findPreviewTab().trigger('click');
- await waitForPromises();
- expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content);
+ expect(tabs).toHaveLength(2);
+ expect(tabs.at(0).text()).toBe('Edit');
+ expect(tabs.at(1).text()).toBe('Preview Markdown');
+ });
+
+ it('renders markdown for tempFile', async () => {
+ findPreviewTab().trigger('click');
+ await waitForPromises();
+ expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content);
+ });
+
+ it('should not trigger layout', async () => {
+ expect(layoutSpy).not.toHaveBeenCalled();
+ });
+
+ describe('when file changes to non-markdown file', () => {
+ beforeEach(async () => {
+ wrapper.setProps({ file: dummyFile.empty });
+ });
+
+ it('should hide tabs', () => {
+ expect(findTabs()).toHaveLength(0);
+ });
+
+ it('should trigger refresh dimensions', async () => {
+ expect(layoutSpy).toHaveBeenCalledTimes(1);
+ });
+ });
});
- it('shows no tabs when not in Edit mode', async () => {
+ it('when not in edit mode, shows no tabs', async () => {
await createComponent({
state: {
currentActivityView: leftSidebarViews.review.name,
@@ -405,7 +427,7 @@ describe('RepoEditor', () => {
it.each`
mode | isVisible
- ${'edit'} | ${true}
+ ${'edit'} | ${false}
${'review'} | ${false}
${'commit'} | ${false}
`('tabs in $mode are $isVisible', async ({ mode, isVisible } = {}) => {
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 0b12df83cd1..16adf88700f 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
@@ -16,7 +16,7 @@ describe('ImportProjectsTable', () => {
const findFilterField = () =>
wrapper
.findAllComponents(GlFormInput)
- .wrappers.find((w) => w.attributes('placeholder') === 'Filter your repositories by name');
+ .wrappers.find((w) => w.attributes('placeholder') === 'Filter by name');
const providerTitle = 'THE PROVIDER';
const providerRepo = {
diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js
index 1be6007d844..9ed0294e876 100644
--- a/spec/frontend/incidents/components/incidents_list_spec.js
+++ b/spec/frontend/incidents/components/incidents_list_spec.js
@@ -1,6 +1,7 @@
import { GlAlert, GlLoadingIcon, GlTable, GlAvatar, GlEmptyState } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import IncidentsList from '~/incidents/components/incidents_list.vue';
import {
I18N,
@@ -19,7 +20,7 @@ import mockIncidents from '../mocks/incidents.json';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
- joinPaths: jest.fn(),
+ joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
mergeUrlParams: jest.fn(),
setUrlParams: jest.fn(),
updateHistory: jest.fn(),
@@ -48,47 +49,52 @@ describe('Incidents List', () => {
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
const findEmptyState = () => wrapper.find(GlEmptyState);
const findSeverity = () => wrapper.findAll(SeverityToken);
+ const findEscalationStatus = () => wrapper.findAll('[data-testid="incident-escalation-status"]');
+ const findIncidentLink = () => wrapper.findByTestId('incident-link');
function mountComponent({ data = {}, loading = false, provide = {} } = {}) {
- wrapper = mount(IncidentsList, {
- data() {
- return {
- incidents: [],
- incidentsCount: {},
- ...data,
- };
- },
- mocks: {
- $apollo: {
- queries: {
- incidents: {
- loading,
+ wrapper = extendedWrapper(
+ mount(IncidentsList, {
+ data() {
+ return {
+ incidents: [],
+ incidentsCount: {},
+ ...data,
+ };
+ },
+ mocks: {
+ $apollo: {
+ queries: {
+ incidents: {
+ loading,
+ },
},
},
},
- },
- provide: {
- projectPath: '/project/path',
- newIssuePath,
- incidentTemplateName,
- incidentType,
- issuePath: '/project/issues',
- publishedAvailable: true,
- emptyListSvgPath,
- textQuery: '',
- authorUsernameQuery: '',
- assigneeUsernameQuery: '',
- slaFeatureAvailable: true,
- canCreateIncident: true,
- ...provide,
- },
- stubs: {
- GlButton: true,
- GlAvatar: true,
- GlEmptyState: true,
- ServiceLevelAgreementCell: true,
- },
- });
+ provide: {
+ projectPath: '/project/path',
+ newIssuePath,
+ incidentTemplateName,
+ incidentType,
+ issuePath: '/project/issues',
+ publishedAvailable: true,
+ emptyListSvgPath,
+ textQuery: '',
+ authorUsernameQuery: '',
+ assigneeUsernameQuery: '',
+ slaFeatureAvailable: true,
+ canCreateIncident: true,
+ incidentEscalationsAvailable: true,
+ ...provide,
+ },
+ stubs: {
+ GlButton: true,
+ GlAvatar: true,
+ GlEmptyState: true,
+ ServiceLevelAgreementCell: true,
+ },
+ }),
+ );
}
afterEach(() => {
@@ -158,6 +164,14 @@ describe('Incidents List', () => {
expect(findTimeAgo().length).toBe(mockIncidents.length);
});
+ it('renders a link to the incident as the incident title', () => {
+ const { title, iid } = mockIncidents[0];
+ const link = findIncidentLink();
+
+ expect(link.text()).toBe(title);
+ expect(link.attributes('href')).toContain(`issues/incident/${iid}`);
+ });
+
describe('Assignees', () => {
it('shows Unassigned when there are no assignees', () => {
expect(findAssignees().at(0).text()).toBe(I18N.unassigned);
@@ -184,6 +198,34 @@ describe('Incidents List', () => {
expect(findSeverity().length).toBe(mockIncidents.length);
});
+ describe('Escalation status', () => {
+ it('renders escalation status per row', () => {
+ expect(findEscalationStatus().length).toBe(mockIncidents.length);
+
+ const actualStatuses = findEscalationStatus().wrappers.map((status) => status.text());
+ expect(actualStatuses).toEqual([
+ 'Triggered',
+ 'Acknowledged',
+ 'Resolved',
+ I18N.noEscalationStatus,
+ ]);
+ });
+
+ describe('when feature is disabled', () => {
+ beforeEach(() => {
+ mountComponent({
+ data: { incidents: { list: mockIncidents }, incidentsCount },
+ provide: { incidentEscalationsAvailable: false },
+ loading: false,
+ });
+ });
+
+ it('is absent if feature flag is disabled', () => {
+ expect(findEscalationStatus().length).toBe(0);
+ });
+ });
+ });
+
it('contains a link to the incident details page', async () => {
findTableRows().at(0).trigger('click');
expect(visitUrl).toHaveBeenCalledWith(
diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json
index 357b94e5b6c..479b0809de3 100644
--- a/spec/frontend/incidents/mocks/incidents.json
+++ b/spec/frontend/incidents/mocks/incidents.json
@@ -7,6 +7,7 @@
"assignees": {},
"state": "opened",
"severity": "CRITICAL",
+ "escalationStatus": "TRIGGERED",
"slaDueAt": "2020-06-04T12:46:08Z"
},
{
@@ -26,6 +27,7 @@
},
"state": "opened",
"severity": "HIGH",
+ "escalationStatus": "ACKNOWLEDGED",
"slaDueAt": null
},
{
@@ -35,7 +37,8 @@
"createdAt": "2020-05-19T08:53:55Z",
"assignees": {},
"state": "closed",
- "severity": "LOW"
+ "severity": "LOW",
+ "escalationStatus": "RESOLVED"
},
{
"id": 4,
@@ -44,6 +47,7 @@
"createdAt": "2020-05-18T17:13:35Z",
"assignees": {},
"state": "closed",
- "severity": "MEDIUM"
+ "severity": "MEDIUM",
+ "escalationStatus": null
}
]
diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
index c335b593f7d..633389578a0 100644
--- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js
+++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
@@ -35,6 +35,15 @@ describe('ActiveCheckbox', () => {
});
});
+ describe('when activateDisabled is true', () => {
+ it('renders GlFormCheckbox as disabled', () => {
+ createComponent({ activateDisabled: true });
+
+ expect(findGlFormCheckbox().exists()).toBe(true);
+ expect(findInputInCheckbox().attributes('disabled')).toBe('disabled');
+ });
+ });
+
describe('initialActivated is `false`', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index 7e01b79383a..c4569070d09 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -14,6 +14,8 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
+import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue';
+
import {
integrationLevels,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
@@ -22,7 +24,7 @@ import {
import { createStore } from '~/integrations/edit/store';
import httpStatus from '~/lib/utils/http_status';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
-import { mockIntegrationProps, mockField } from '../mock_data';
+import { mockIntegrationProps, mockField, mockSectionConnection } from '../mock_data';
jest.mock('@sentry/browser');
jest.mock('~/lib/utils/url_utility');
@@ -37,7 +39,7 @@ describe('IntegrationForm', () => {
const createComponent = ({
customStateProps = {},
initialState = {},
- props = {},
+ provide = {},
mountFn = shallowMountExtended,
} = {}) => {
const store = createStore({
@@ -47,7 +49,7 @@ describe('IntegrationForm', () => {
dispatch = jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = mountFn(IntegrationForm, {
- propsData: { ...props },
+ provide,
store,
stubs: {
OverrideDropdown,
@@ -78,6 +80,11 @@ describe('IntegrationForm', () => {
const findGlForm = () => wrapper.findComponent(GlForm);
const findRedirectToField = () => wrapper.findByTestId('redirect-to-field');
const findDynamicField = () => wrapper.findComponent(DynamicField);
+ const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField);
+ const findAllSections = () => wrapper.findAllByTestId('integration-section');
+ const findConnectionSection = () => findAllSections().at(0);
+ const findConnectionSectionComponent = () =>
+ findConnectionSection().findComponent(IntegrationSectionConnection);
beforeEach(() => {
mockAxios = new MockAdapter(axios);
@@ -253,23 +260,32 @@ describe('IntegrationForm', () => {
});
describe('fields is present', () => {
- it('renders DynamicField for each field', () => {
- const fields = [
- { name: 'username', type: 'text' },
- { name: 'API token', type: 'password' },
+ it('renders DynamicField for each field without a section', () => {
+ const sectionFields = [
+ { name: 'username', type: 'text', section: mockSectionConnection.type },
+ { name: 'API token', type: 'password', section: mockSectionConnection.type },
+ ];
+
+ const nonSectionFields = [
+ { name: 'branch', type: 'text' },
+ { name: 'labels', type: 'select' },
];
createComponent({
+ provide: {
+ glFeatures: { integrationFormSections: true },
+ },
customStateProps: {
- fields,
+ sections: [mockSectionConnection],
+ fields: [...sectionFields, ...nonSectionFields],
},
});
- const dynamicFields = wrapper.findAll(DynamicField);
+ const dynamicFields = findAllDynamicFields();
expect(dynamicFields).toHaveLength(2);
dynamicFields.wrappers.forEach((field, index) => {
- expect(field.props()).toMatchObject(fields[index]);
+ expect(field.props()).toMatchObject(nonSectionFields[index]);
});
});
});
@@ -300,7 +316,7 @@ describe('IntegrationForm', () => {
});
});
- describe('with `helpHtml` prop', () => {
+ describe('with `helpHtml` provided', () => {
const mockTestId = 'jest-help-html-test';
setHTMLFixture(`
@@ -316,7 +332,7 @@ describe('IntegrationForm', () => {
const mockHelpHtml = document.querySelector(`[data-testid="${mockTestId}"]`);
createComponent({
- props: {
+ provide: {
helpHtml: mockHelpHtml.outerHTML,
},
});
@@ -344,6 +360,106 @@ describe('IntegrationForm', () => {
});
});
+ describe('when integration has sections', () => {
+ beforeEach(() => {
+ createComponent({
+ provide: {
+ glFeatures: { integrationFormSections: true },
+ },
+ customStateProps: {
+ sections: [mockSectionConnection],
+ },
+ });
+ });
+
+ it('renders the expected number of sections', () => {
+ expect(findAllSections().length).toBe(1);
+ });
+
+ it('renders title, description and the correct dynamic component', () => {
+ const connectionSection = findConnectionSection();
+
+ expect(connectionSection.find('h4').text()).toBe(mockSectionConnection.title);
+ expect(connectionSection.find('p').text()).toBe(mockSectionConnection.description);
+ expect(findConnectionSectionComponent().exists()).toBe(true);
+ });
+
+ it('passes only fields with section type', () => {
+ const sectionFields = [
+ { name: 'username', type: 'text', section: mockSectionConnection.type },
+ { name: 'API token', type: 'password', section: mockSectionConnection.type },
+ ];
+
+ const nonSectionFields = [
+ { name: 'branch', type: 'text' },
+ { name: 'labels', type: 'select' },
+ ];
+
+ createComponent({
+ provide: {
+ glFeatures: { integrationFormSections: true },
+ },
+ customStateProps: {
+ sections: [mockSectionConnection],
+ fields: [...sectionFields, ...nonSectionFields],
+ },
+ });
+
+ expect(findConnectionSectionComponent().props('fields')).toEqual(sectionFields);
+ });
+
+ describe.each`
+ formActive | novalidate
+ ${true} | ${undefined}
+ ${false} | ${'true'}
+ `(
+ 'when `toggle-integration-active` is emitted with $formActive',
+ ({ formActive, novalidate }) => {
+ beforeEach(() => {
+ createComponent({
+ provide: {
+ glFeatures: { integrationFormSections: true },
+ },
+ customStateProps: {
+ sections: [mockSectionConnection],
+ showActive: true,
+ initialActivated: false,
+ },
+ });
+
+ findConnectionSectionComponent().vm.$emit('toggle-integration-active', formActive);
+ });
+
+ it(`sets noValidate to ${novalidate}`, () => {
+ expect(findGlForm().attributes('novalidate')).toBe(novalidate);
+ });
+ },
+ );
+
+ describe('when IntegrationSectionConnection emits `request-jira-issue-types` event', () => {
+ beforeEach(() => {
+ jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form'));
+
+ createComponent({
+ provide: {
+ glFeatures: { integrationFormSections: true },
+ },
+ customStateProps: {
+ sections: [mockSectionConnection],
+ testPath: '/test',
+ },
+ mountFn: mountExtended,
+ });
+
+ findConnectionSectionComponent().vm.$emit('request-jira-issue-types');
+ });
+
+ it('dispatches `requestJiraIssueTypes` action', () => {
+ expect(dispatch).toHaveBeenCalledWith('requestJiraIssueTypes', expect.any(FormData));
+ });
+ });
+ });
+
describe('ActiveCheckbox', () => {
describe.each`
showActive
@@ -368,7 +484,7 @@ describe('IntegrationForm', () => {
`(
'when `toggle-integration-active` is emitted with $formActive',
({ formActive, novalidate }) => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
customStateProps: {
showActive: true,
@@ -376,7 +492,7 @@ describe('IntegrationForm', () => {
},
});
- await findActiveCheckbox().vm.$emit('toggle-integration-active', formActive);
+ findActiveCheckbox().vm.$emit('toggle-integration-active', formActive);
});
it(`sets noValidate to ${novalidate}`, () => {
diff --git a/spec/frontend/integrations/edit/components/sections/connection_spec.js b/spec/frontend/integrations/edit/components/sections/connection_spec.js
new file mode 100644
index 00000000000..1eb92e80723
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/sections/connection_spec.js
@@ -0,0 +1,77 @@
+import { shallowMount } from '@vue/test-utils';
+
+import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue';
+import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
+import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
+import { createStore } from '~/integrations/edit/store';
+
+import { mockIntegrationProps } from '../../mock_data';
+
+describe('IntegrationSectionConnection', () => {
+ let wrapper;
+
+ const createComponent = ({ customStateProps = {}, props = {} } = {}) => {
+ const store = createStore({
+ customState: { ...mockIntegrationProps, ...customStateProps },
+ });
+ wrapper = shallowMount(IntegrationSectionConnection, {
+ propsData: { ...props },
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox);
+ const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField);
+
+ describe('template', () => {
+ describe('ActiveCheckbox', () => {
+ describe.each`
+ showActive
+ ${true}
+ ${false}
+ `('when `showActive` is $showActive', ({ showActive }) => {
+ it(`${showActive ? 'renders' : 'does not render'} ActiveCheckbox`, () => {
+ createComponent({
+ customStateProps: {
+ showActive,
+ },
+ });
+
+ expect(findActiveCheckbox().exists()).toBe(showActive);
+ });
+ });
+ });
+
+ describe('DynamicField', () => {
+ it('renders DynamicField for each field', () => {
+ const fields = [
+ { name: 'username', type: 'text' },
+ { name: 'API token', type: 'password' },
+ ];
+
+ createComponent({
+ props: {
+ fields,
+ },
+ });
+
+ const dynamicFields = findAllDynamicFields();
+
+ expect(dynamicFields).toHaveLength(2);
+ dynamicFields.wrappers.forEach((field, index) => {
+ expect(field.props()).toMatchObject(fields[index]);
+ });
+ });
+
+ it('does not render DynamicField when field is empty', () => {
+ createComponent();
+
+ expect(findAllDynamicFields()).toHaveLength(0);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/sections/jira_issues_spec.js b/spec/frontend/integrations/edit/components/sections/jira_issues_spec.js
new file mode 100644
index 00000000000..a7c1cc2a03f
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/sections/jira_issues_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+
+import IntegrationSectionJiraIssue from '~/integrations/edit/components/sections/jira_issues.vue';
+import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
+import { createStore } from '~/integrations/edit/store';
+
+import { mockIntegrationProps } from '../../mock_data';
+
+describe('IntegrationSectionJiraIssue', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ const store = createStore({
+ customState: { ...mockIntegrationProps },
+ });
+ wrapper = shallowMount(IntegrationSectionJiraIssue, {
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields);
+
+ describe('template', () => {
+ it('renders JiraIssuesFields', () => {
+ createComponent();
+
+ expect(findJiraIssuesFields().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js b/spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js
new file mode 100644
index 00000000000..d4ab9864fab
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/sections/jira_trigger_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+
+import IntegrationSectionJiraTrigger from '~/integrations/edit/components/sections/jira_trigger.vue';
+import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
+import { createStore } from '~/integrations/edit/store';
+
+import { mockIntegrationProps } from '../../mock_data';
+
+describe('IntegrationSectionJiraTrigger', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ const store = createStore({
+ customState: { ...mockIntegrationProps },
+ });
+ wrapper = shallowMount(IntegrationSectionJiraTrigger, {
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields);
+
+ describe('template', () => {
+ it('renders JiraTriggerFields', () => {
+ createComponent();
+
+ expect(findJiraTriggerFields().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
index a0816682741..8ee55928926 100644
--- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
@@ -40,13 +40,13 @@ describe('TriggerFields', () => {
describe('events without field property', () => {
const events = [
{
- title: 'push',
+ title: 'Push',
name: 'push_event',
description: 'Event on push',
value: true,
},
{
- title: 'merge_request',
+ title: 'Merge request',
name: 'merge_requests_event',
description: 'Event on merge_request',
value: false,
@@ -81,7 +81,7 @@ describe('TriggerFields', () => {
const checkboxes = findAllGlFormGroups();
const expectedResults = [
{ labelText: 'Push', inputName: 'service[push_event]' },
- { labelText: 'Merge Request', inputName: 'service[merge_requests_event]' },
+ { labelText: 'Merge request', inputName: 'service[merge_requests_event]' },
];
expect(checkboxes).toHaveLength(2);
diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js
index 39e5f8521e8..36850a0a33a 100644
--- a/spec/frontend/integrations/edit/mock_data.js
+++ b/spec/frontend/integrations/edit/mock_data.js
@@ -10,9 +10,11 @@ export const mockIntegrationProps = {
},
jiraIssuesProps: {},
triggerEvents: [],
+ sections: [],
fields: [],
type: '',
inheritFromId: 25,
+ integrationLevel: 'project',
};
export const mockJiraIssueTypes = [
@@ -29,3 +31,9 @@ export const mockField = {
type: 'text',
value: '1',
};
+
+export const mockSectionConnection = {
+ type: 'connection',
+ title: 'Connection details',
+ description: 'Learn more on how to configure this integration.',
+};
diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js
index 3353e0c84cc..4680c4b24cc 100644
--- a/spec/frontend/integrations/edit/store/getters_spec.js
+++ b/spec/frontend/integrations/edit/store/getters_spec.js
@@ -1,5 +1,12 @@
-import { currentKey, isInheriting, propsSource } from '~/integrations/edit/store/getters';
+import {
+ currentKey,
+ isInheriting,
+ isProjectLevel,
+ propsSource,
+} from '~/integrations/edit/store/getters';
+
import createState from '~/integrations/edit/store/state';
+import { integrationLevels } from '~/integrations/constants';
import { mockIntegrationProps } from '../mock_data';
describe('Integration form store getters', () => {
@@ -45,6 +52,18 @@ describe('Integration form store getters', () => {
});
});
+ describe('isProjectLevel', () => {
+ it.each`
+ integrationLevel | expected
+ ${integrationLevels.PROJECT} | ${true}
+ ${integrationLevels.GROUP} | ${false}
+ ${integrationLevels.INSTANCE} | ${false}
+ `('when integrationLevel is `$integrationLevel`', ({ integrationLevel, expected }) => {
+ state.customState.integrationLevel = integrationLevel;
+ expect(isProjectLevel(state)).toBe(expected);
+ });
+ });
+
describe('propsSource', () => {
beforeEach(() => {
state.defaultState = defaultState;
diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js
index 49c55d56080..8085f48f6e2 100644
--- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js
@@ -4,6 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Api from '~/api';
import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue';
import InviteModalBase from '~/invite_members/components/invite_modal_base.vue';
+import ContentTransition from '~/vue_shared/components/content_transition.vue';
import GroupSelect from '~/invite_members/components/group_select.vue';
import { stubComponent } from 'helpers/stub_component';
import { propsData, sharedGroup } from '../mock_data/group_modal';
@@ -19,6 +20,7 @@ describe('InviteGroupsModal', () => {
},
stubs: {
InviteModalBase,
+ ContentTransition,
GlSprintf,
GlModal: stubComponent(GlModal, {
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
@@ -50,6 +52,8 @@ describe('InviteGroupsModal', () => {
const clickInviteButton = () => findInviteButton().vm.$emit('click');
const clickCancelButton = () => findCancelButton().vm.$emit('click');
const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val);
+ const findBase = () => wrapper.findComponent(InviteModalBase);
+ const hideModal = () => wrapper.findComponent(GlModal).vm.$emit('hide');
describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
@@ -70,26 +74,50 @@ describe('InviteGroupsModal', () => {
});
describe('submitting the invite form', () => {
- describe('when sharing the group is successful', () => {
- const groupPostData = {
- group_id: sharedGroup.id,
- group_access: propsData.defaultAccessLevel,
- expires_at: undefined,
- format: 'json',
- };
+ let apiResolve;
+ let apiReject;
+ const groupPostData = {
+ group_id: sharedGroup.id,
+ group_access: propsData.defaultAccessLevel,
+ expires_at: undefined,
+ format: 'json',
+ };
+
+ beforeEach(() => {
+ createComponent();
+ triggerGroupSelect(sharedGroup);
+
+ wrapper.vm.$toast = { show: jest.fn() };
+ jest.spyOn(Api, 'groupShareWithGroup').mockImplementation(
+ () =>
+ new Promise((resolve, reject) => {
+ apiResolve = resolve;
+ apiReject = reject;
+ }),
+ );
+
+ clickInviteButton();
+ });
- beforeEach(() => {
- createComponent();
- triggerGroupSelect(sharedGroup);
+ it('shows loading', () => {
+ expect(findBase().props('isLoading')).toBe(true);
+ });
+
+ it('calls Api groupShareWithGroup with the correct params', () => {
+ expect(Api.groupShareWithGroup).toHaveBeenCalledWith(propsData.id, groupPostData);
+ });
- wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
+ describe('when succeeds', () => {
+ beforeEach(() => {
+ apiResolve({ data: groupPostData });
+ });
- clickInviteButton();
+ it('hides loading', () => {
+ expect(findBase().props('isLoading')).toBe(false);
});
- it('calls Api groupShareWithGroup with the correct params', () => {
- expect(Api.groupShareWithGroup).toHaveBeenCalledWith(propsData.id, groupPostData);
+ it('has no error message', () => {
+ expect(findBase().props('invalidFeedbackMessage')).toBe('');
});
it('displays the successful toastMessage', () => {
@@ -99,18 +127,9 @@ describe('InviteGroupsModal', () => {
});
});
- describe('when sharing the group fails', () => {
+ describe('when fails', () => {
beforeEach(() => {
- createInviteGroupToGroupWrapper();
- triggerGroupSelect(sharedGroup);
-
- wrapper.vm.$toast = { show: jest.fn() };
-
- jest
- .spyOn(Api, 'groupShareWithGroup')
- .mockRejectedValue({ response: { data: { success: false } } });
-
- clickInviteButton();
+ apiReject({ response: { data: { success: false } } });
});
it('does not show the toast message on failure', () => {
@@ -121,22 +140,18 @@ describe('InviteGroupsModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
});
- describe('clearing the invalid state and message', () => {
- it('clears the error when the cancel button is clicked', async () => {
- clickCancelButton();
-
- await nextTick();
+ it.each`
+ desc | act
+ ${'when the cancel button is clicked'} | ${clickCancelButton}
+ ${'when the modal is hidden'} | ${hideModal}
+ ${'when invite button is clicked'} | ${clickInviteButton}
+ ${'when group input changes'} | ${() => triggerGroupSelect(sharedGroup)}
+ `('clears the error, $desc', async ({ act }) => {
+ act();
- expect(membersFormGroupInvalidFeedback()).toBe('');
- });
-
- it('clears the error when the modal is hidden', async () => {
- wrapper.findComponent(GlModal).vm.$emit('hide');
+ await nextTick();
- await nextTick();
-
- expect(membersFormGroupInvalidFeedback()).toBe('');
- });
+ expect(membersFormGroupInvalidFeedback()).toBe('');
});
});
});
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 15a366474e4..dd16bb48cb8 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -19,6 +19,7 @@ import {
LEARN_GITLAB,
} from '~/invite_members/constants';
import eventHub from '~/invite_members/event_hub';
+import ContentTransition from '~/vue_shared/components/content_transition.vue';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { getParameterValues } from '~/lib/utils/url_utility';
@@ -55,6 +56,7 @@ describe('InviteMembersModal', () => {
},
stubs: {
InviteModalBase,
+ ContentTransition,
GlSprintf,
GlModal: stubComponent(GlModal, {
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
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 4b183bfd670..9e17112fb15 100644
--- a/spec/frontend/invite_members/components/invite_modal_base_spec.js
+++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js
@@ -10,22 +10,21 @@ import {
import { stubComponent } from 'helpers/stub_component';
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';
describe('InviteModalBase', () => {
let wrapper;
- const createComponent = (data = {}, props = {}) => {
+ const createComponent = (props = {}) => {
wrapper = shallowMountExtended(InviteModalBase, {
propsData: {
...propsData,
...props,
},
- data() {
- return data;
- },
stubs: {
+ ContentTransition,
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
@@ -52,6 +51,7 @@ describe('InviteModalBase', () => {
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
+ const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
describe('rendering the modal', () => {
beforeEach(() => {
@@ -99,5 +99,33 @@ describe('InviteModalBase', () => {
expect(findDatepicker().exists()).toBe(true);
});
});
+
+ it('renders the members form group', () => {
+ expect(findMembersFormGroup().props()).toEqual({
+ description: propsData.formGroupDescription,
+ invalidFeedback: '',
+ state: null,
+ });
+ });
+ });
+
+ it('with isLoading, shows loading for invite button', () => {
+ createComponent({
+ isLoading: true,
+ });
+
+ expect(findInviteButton().props('loading')).toBe(true);
+ });
+
+ it('with invalidFeedbackMessage, set members form group validation state', () => {
+ createComponent({
+ invalidFeedbackMessage: 'invalid message!',
+ });
+
+ expect(findMembersFormGroup().props()).toEqual({
+ description: propsData.formGroupDescription,
+ invalidFeedback: 'invalid message!',
+ state: false,
+ });
});
});
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
index c7925034eb0..7a350df0ba6 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -6,6 +6,7 @@ import {
issuable3,
} from 'jest/issuable/components/related_issuable_mock_data';
import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue';
+import AddIssuableForm from '~/related_issues/components/add_issuable_form.vue';
import {
issuableTypesMap,
linkedIssueTypesMap,
@@ -139,6 +140,7 @@ describe('RelatedIssuesBlock', () => {
pathIdSeparator: PathIdSeparator.Issue,
isFormVisible: true,
issuableType: 'issue',
+ autoCompleteEpics: false,
},
});
});
@@ -146,6 +148,10 @@ describe('RelatedIssuesBlock', () => {
it('shows add related issues form', () => {
expect(wrapper.find('.js-add-related-issues-form-area').exists()).toBe(true);
});
+
+ it('sets `autoCompleteEpics` to false for add-issuable-form', () => {
+ expect(wrapper.find(AddIssuableForm).props('autoCompleteEpics')).toBe(false);
+ });
});
describe('showCategorizedIssues prop', () => {
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
index c7df3755e88..fd623ad9a5f 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
@@ -28,11 +28,16 @@ describe('RelatedIssuesList', () => {
propsData: {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
+ listLinkType: 'relates_to',
heading,
},
});
});
+ it('assigns value of listLinkType prop to data attribute', () => {
+ expect(wrapper.attributes('data-link-type')).toBe('relates_to');
+ });
+
it('shows a heading', () => {
expect(wrapper.find('h4').text()).toContain(heading);
});
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 88652ddc3cc..33c7ccac180 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -294,6 +294,28 @@ describe('CE IssuesListApp component', () => {
});
describe('initial url params', () => {
+ 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',
+ });
+ });
+
+ it('page_before is set from the url params', () => {
+ setWindowLocation('?page_before=anotherRandomCursorString');
+
+ wrapper = mountComponent();
+
+ expect(findIssuableList().props('urlParams')).toMatchObject({
+ page_before: 'anotherRandomCursorString',
+ });
+ });
+ });
+
describe('search', () => {
it('is set from the url params', () => {
setWindowLocation(locationSearch);
@@ -881,7 +903,12 @@ describe('CE IssuesListApp component', () => {
});
it('does not update IssuableList with url params ', async () => {
- const defaultParams = { sort: 'created_date', state: 'opened' };
+ const defaultParams = {
+ page_after: null,
+ page_before: null,
+ sort: 'created_date',
+ state: 'opened',
+ };
expect(findIssuableList().props('urlParams')).toEqual(defaultParams);
});
diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js
index 1d3e94df897..a60350d91c5 100644
--- a/spec/frontend/issues/list/utils_spec.js
+++ b/spec/frontend/issues/list/utils_spec.js
@@ -9,8 +9,8 @@ import {
urlParamsWithSpecialValues,
} from 'jest/issues/list/mock_data';
import {
- defaultPageSizeParams,
- largePageSizeParams,
+ PAGE_SIZE,
+ PAGE_SIZE_MANUAL,
RELATIVE_POSITION_ASC,
urlSortParams,
} from '~/issues/list/constants';
@@ -29,10 +29,37 @@ describe('getInitialPageParams', () => {
it.each(Object.keys(urlSortParams))(
'returns the correct page params for sort key %s',
(sortKey) => {
- const expectedPageParams =
- sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams;
+ const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
- expect(getInitialPageParams(sortKey)).toBe(expectedPageParams);
+ expect(getInitialPageParams(sortKey)).toEqual({ firstPageSize });
+ },
+ );
+
+ it.each(Object.keys(urlSortParams))(
+ 'returns the correct page params for sort key %s with afterCursor',
+ (sortKey) => {
+ const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
+ const afterCursor = 'randomCursorString';
+ const beforeCursor = undefined;
+
+ expect(getInitialPageParams(sortKey, afterCursor, beforeCursor)).toEqual({
+ firstPageSize,
+ afterCursor,
+ });
+ },
+ );
+
+ it.each(Object.keys(urlSortParams))(
+ 'returns the correct page params for sort key %s with beforeCursor',
+ (sortKey) => {
+ const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
+ const afterCursor = undefined;
+ const beforeCursor = 'anotherRandomCursorString';
+
+ expect(getInitialPageParams(sortKey, afterCursor, beforeCursor)).toEqual({
+ firstPageSize,
+ beforeCursor,
+ });
},
);
});
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index 3890fc7a353..08f8996de6f 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -2,17 +2,21 @@ import $ from 'jquery';
import { nextTick } from 'vue';
import '~/behaviors/markdown/render_gfm';
import { GlPopover, GlModal } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
+import { mockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createFlash from '~/flash';
import Description from '~/issues/show/components/description.vue';
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';
import {
descriptionProps as initialProps,
descriptionHtmlWithCheckboxes,
} from '../mock_data/mock_data';
+jest.mock('~/flash');
jest.mock('~/task_list');
const showModal = jest.fn();
@@ -30,9 +34,10 @@ describe('Description component', () => {
const findPopovers = () => wrapper.findAllComponents(GlPopover);
const findModal = () => wrapper.findComponent(GlModal);
const findCreateWorkItem = () => wrapper.findComponent(CreateWorkItem);
+ const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
function createComponent({ props = {}, provide = {} } = {}) {
- wrapper = shallowMount(Description, {
+ wrapper = shallowMountExtended(Description, {
propsData: {
...initialProps,
...props,
@@ -210,7 +215,7 @@ describe('Description component', () => {
describe('with work items feature flag is enabled', () => {
describe('empty description', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
props: {
descriptionHtml: '',
@@ -221,7 +226,7 @@ describe('Description component', () => {
},
},
});
- await nextTick();
+ return nextTick();
});
it('renders without error', () => {
@@ -230,7 +235,7 @@ describe('Description component', () => {
});
describe('description with checkboxes', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent({
props: {
descriptionHtml: descriptionHtmlWithCheckboxes,
@@ -241,7 +246,7 @@ describe('Description component', () => {
},
},
});
- await nextTick();
+ return nextTick();
});
it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => {
@@ -275,7 +280,7 @@ describe('Description component', () => {
it('updates description HTML on `onCreate` event', async () => {
const newTitle = 'New title';
findConvertToTaskButton().vm.$emit('click');
- findCreateWorkItem().vm.$emit('onCreate', newTitle);
+ findCreateWorkItem().vm.$emit('onCreate', { title: newTitle });
expect(hideModal).toHaveBeenCalled();
await nextTick();
@@ -283,5 +288,69 @@ describe('Description component', () => {
expect(wrapper.text()).toContain(newTitle);
});
});
+
+ describe('work items detail', () => {
+ const id = '1';
+ const title = 'my first task';
+ const type = 'task';
+
+ const createThenClickOnTask = () => {
+ findConvertToTaskButton().vm.$emit('click');
+ findCreateWorkItem().vm.$emit('onCreate', { id, title, type });
+ return wrapper.findByRole('button', { name: title }).trigger('click');
+ };
+
+ beforeEach(() => {
+ createComponent({
+ props: {
+ descriptionHtml: descriptionHtmlWithCheckboxes,
+ },
+ provide: {
+ glFeatures: { workItems: true },
+ },
+ });
+ return nextTick();
+ });
+
+ it('opens when task button is clicked', async () => {
+ expect(findWorkItemDetailModal().props('visible')).toBe(false);
+
+ await createThenClickOnTask();
+
+ expect(findWorkItemDetailModal().props('visible')).toBe(true);
+ });
+
+ it('closes from an open state', async () => {
+ await createThenClickOnTask();
+
+ expect(findWorkItemDetailModal().props('visible')).toBe(true);
+
+ findWorkItemDetailModal().vm.$emit('close');
+ await nextTick();
+
+ expect(findWorkItemDetailModal().props('visible')).toBe(false);
+ });
+
+ it('shows error on error', async () => {
+ const message = 'I am error';
+
+ await createThenClickOnTask();
+ findWorkItemDetailModal().vm.$emit('error', message);
+
+ expect(createFlash).toHaveBeenCalledWith({ message });
+ });
+
+ it('tracks when opened', async () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ await createThenClickOnTask();
+
+ expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'viewed_work_item_from_modal', {
+ category: 'workItems:show',
+ label: 'work_item_view',
+ property: 'type_task',
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js
index 4a557a60b94..329c4234f30 100644
--- a/spec/frontend/issues/show/components/header_actions_spec.js
+++ b/spec/frontend/issues/show/components/header_actions_spec.js
@@ -1,5 +1,5 @@
-import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
+import { GlButton, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mockTracking } from 'helpers/tracking_helper';
@@ -65,12 +65,17 @@ describe('HeaderActions component', () => {
},
};
- const findToggleIssueStateButton = () => wrapper.findComponent(GlButton);
- const findDropdownAt = (index) => wrapper.findAllComponents(GlDropdown).at(index);
- const findMobileDropdownItems = () => findDropdownAt(0).findAllComponents(GlDropdownItem);
- const findDesktopDropdownItems = () => findDropdownAt(1).findAllComponents(GlDropdownItem);
- const findModal = () => wrapper.findComponent(GlModal);
- const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index);
+ const findToggleIssueStateButton = () => wrapper.find(GlButton);
+
+ const findDropdownBy = (dataTestId) => wrapper.find(`[data-testid="${dataTestId}"]`);
+ const findMobileDropdown = () => findDropdownBy('mobile-dropdown');
+ const findDesktopDropdown = () => findDropdownBy('desktop-dropdown');
+ const findMobileDropdownItems = () => findMobileDropdown().findAll(GlDropdownItem);
+ const findDesktopDropdownItems = () => findDesktopDropdown().findAll(GlDropdownItem);
+
+ const findModal = () => wrapper.find(GlModal);
+
+ const findModalLinkAt = (index) => findModal().findAll(GlLink).at(index);
const mountComponent = ({
props = {},
@@ -161,24 +166,24 @@ describe('HeaderActions component', () => {
});
describe.each`
- description | isCloseIssueItemVisible | findDropdownItems
- ${'mobile dropdown'} | ${true} | ${findMobileDropdownItems}
- ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems}
- `('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => {
+ description | isCloseIssueItemVisible | findDropdownItems | findDropdown
+ ${'mobile dropdown'} | ${true} | ${findMobileDropdownItems} | ${findMobileDropdown}
+ ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} | ${findDesktopDropdown}
+ `('$description', ({ isCloseIssueItemVisible, findDropdownItems, findDropdown }) => {
describe.each`
- description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue
- ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true}
- ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true}
- ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true}
- ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
- ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false}
+ description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue
+ ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user can create ${issueType}`} | ${`New related ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot create ${issueType}`} | ${`New related ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true}
+ ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true}
+ ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true}
+ ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
+ ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false}
`(
'$description',
({
@@ -214,6 +219,24 @@ describe('HeaderActions component', () => {
});
},
);
+
+ describe(`when user can update but not create ${issueType}`, () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ canUpdateIssue: true,
+ canCreateIssue: false,
+ isIssueAuthor: true,
+ issueType,
+ canReportSpam: false,
+ canPromoteToEpic: false,
+ },
+ });
+ });
+ it(`${isCloseIssueItemVisible ? 'shows' : 'hides'} the dropdown button`, () => {
+ expect(findDropdown().exists()).toBe(isCloseIssueItemVisible);
+ });
+ });
});
});
diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
index 9bf0e106194..20c6cda33d4 100644
--- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
+++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
@@ -1,7 +1,6 @@
import { GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import merge from 'lodash/merge';
-import waitForPromises from 'helpers/wait_for_promises';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
import DescriptionComponent from '~/issues/show/components/description.vue';
import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue';
@@ -36,6 +35,7 @@ describe('Incident Tabs component', () => {
fullPath: '',
iid: '',
uploadMetricsFeatureAvailable: true,
+ glFeatures: { incidentTimelineEventTab: true, incidentTimelineEvents: true },
},
data() {
return { alert: mockAlert, ...data };
@@ -112,19 +112,15 @@ describe('Incident Tabs component', () => {
});
describe('upload metrics feature available', () => {
- it('shows the metric tab when metrics are available', async () => {
+ it('shows the metric tab when metrics are available', () => {
mountComponent({}, { provide: { uploadMetricsFeatureAvailable: true } });
- await waitForPromises();
-
expect(findMetricsTab().exists()).toBe(true);
});
- it('hides the tab when metrics are not available', async () => {
+ it('hides the tab when metrics are not available', () => {
mountComponent({}, { provide: { uploadMetricsFeatureAvailable: false } });
- await waitForPromises();
-
expect(findMetricsTab().exists()).toBe(false);
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
index aa0f1440b20..6b3ca7ffd65 100644
--- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
@@ -8,6 +8,7 @@ import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.
import UserLink from '~/jira_connect/subscriptions/components/user_link.vue';
import createStore from '~/jira_connect/subscriptions/store';
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 { mockSubscription } from '../mock_data';
@@ -24,6 +25,7 @@ describe('JiraConnectApp', () => {
const findAlertLink = () => findAlert().findComponent(GlLink);
const findSignInPage = () => wrapper.findComponent(SignInPage);
const findSubscriptionsPage = () => wrapper.findComponent(SubscriptionsPage);
+ const findUserLink = () => wrapper.findComponent(UserLink);
const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => {
store = createStore();
@@ -78,10 +80,11 @@ describe('JiraConnectApp', () => {
},
});
- const userLink = wrapper.findComponent(UserLink);
+ const userLink = findUserLink();
expect(userLink.exists()).toBe(true);
expect(userLink.props()).toEqual({
hasSubscriptions: false,
+ user: null,
userSignedIn: false,
});
});
@@ -153,4 +156,55 @@ 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');
+
+ await nextTick();
+ });
+
+ it('displays alert', () => {
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(true);
+ expect(alert.html()).toContain(I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE);
+ expect(alert.props('variant')).toBe('danger');
+ });
+ });
+ });
});
diff --git a/spec/frontend/jira_connect/subscriptions/components/sign_in_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/sign_in_legacy_button_spec.js
index 94dcf9decec..4ebfaed261e 100644
--- a/spec/frontend/jira_connect/subscriptions/components/sign_in_button_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/sign_in_legacy_button_spec.js
@@ -1,18 +1,18 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
-import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue';
+import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue';
import waitForPromises from 'helpers/wait_for_promises';
const MOCK_USERS_PATH = '/user';
jest.mock('~/jira_connect/subscriptions/utils');
-describe('SignInButton', () => {
+describe('SignInLegacyButton', () => {
let wrapper;
const createComponent = ({ slots } = {}) => {
- wrapper = shallowMount(SignInButton, {
+ wrapper = shallowMount(SignInLegacyButton, {
propsData: {
usersPath: MOCK_USERS_PATH,
},
@@ -30,7 +30,7 @@ describe('SignInButton', () => {
createComponent();
expect(findButton().exists()).toBe(true);
- expect(findButton().text()).toBe(SignInButton.i18n.defaultButtonText);
+ expect(findButton().text()).toBe(SignInLegacyButton.i18n.defaultButtonText);
});
describe.each`
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
new file mode 100644
index 00000000000..18274cd4362
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js
@@ -0,0 +1,204 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { nextTick } from 'vue';
+import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
+import {
+ I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
+ OAUTH_WINDOW_OPTIONS,
+} from '~/jira_connect/subscriptions/constants';
+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';
+
+jest.mock('~/lib/utils/accessor');
+jest.mock('~/jira_connect/subscriptions/utils');
+jest.mock('~/jira_connect/subscriptions/pkce', () => ({
+ createCodeVerifier: jest.fn().mockReturnValue('mock-verifier'),
+ createCodeChallenge: jest.fn().mockResolvedValue('mock-challenge'),
+}));
+
+const mockOauthMetadata = {
+ oauth_authorize_url: 'https://gitlab.com/mockOauth',
+ oauth_token_url: 'https://gitlab.com/mockOauthToken',
+ state: 'good-state',
+};
+
+describe('SignInOauthButton', () => {
+ let wrapper;
+ let mockAxios;
+
+ const createComponent = ({ slots } = {}) => {
+ wrapper = shallowMount(SignInOauthButton, {
+ slots,
+ provide: {
+ oauthMetadata: mockOauthMetadata,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mockAxios.restore();
+ });
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ it('displays a button', () => {
+ createComponent();
+
+ expect(findButton().exists()).toBe(true);
+ expect(findButton().text()).toBe(I18N_DEFAULT_SIGN_IN_BUTTON_TEXT);
+ });
+
+ it.each`
+ scenario | cryptoAvailable
+ ${'when crypto API is available'} | ${true}
+ ${'when crypto API is unavailable'} | ${false}
+ `('$scenario when canUseCrypto returns $cryptoAvailable', ({ cryptoAvailable }) => {
+ AccessorUtilities.canUseCrypto = jest.fn().mockReturnValue(cryptoAvailable);
+ createComponent();
+
+ expect(findButton().props('disabled')).toBe(!cryptoAvailable);
+ });
+
+ describe('on click', () => {
+ beforeEach(async () => {
+ jest.spyOn(window, 'open').mockReturnValue();
+ createComponent();
+
+ findButton().vm.$emit('click');
+
+ await nextTick();
+ });
+
+ it('sets `loading` prop of button to `true`', () => {
+ expect(findButton().props('loading')).toBe(true);
+ });
+
+ it('calls `window.open` with correct arguments', () => {
+ expect(window.open).toHaveBeenCalledWith(
+ `${mockOauthMetadata.oauth_authorize_url}?code_challenge=mock-challenge&code_challenge_method=S256`,
+ I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
+ OAUTH_WINDOW_OPTIONS,
+ );
+ });
+
+ it('sets the `codeVerifier` internal state', () => {
+ expect(wrapper.vm.codeVerifier).toBe('mock-verifier');
+ });
+
+ describe('on window message event', () => {
+ describe('when window message properties are corrupted', () => {
+ describe.each`
+ origin | state | messageOrigin | messageState
+ ${window.origin} | ${mockOauthMetadata.state} | ${'bad-origin'} | ${mockOauthMetadata.state}
+ ${window.origin} | ${mockOauthMetadata.state} | ${window.origin} | ${'bad-state'}
+ `(
+ 'when message is [state=$messageState, origin=$messageOrigin]',
+ ({ messageOrigin, messageState }) => {
+ beforeEach(async () => {
+ const mockEvent = {
+ origin: messageOrigin,
+ data: {
+ state: messageState,
+ code: '1234',
+ },
+ };
+ window.dispatchEvent(new MessageEvent('message', mockEvent));
+ await waitForPromises();
+ });
+
+ it('emits `error` event', () => {
+ expect(wrapper.emitted('error')).toBeTruthy();
+ });
+
+ it('does not emit `sign-in` event', () => {
+ expect(wrapper.emitted('sign-in')).toBeFalsy();
+ });
+
+ it('sets `loading` prop of button to `false`', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+ },
+ );
+ });
+
+ describe('when window message properties are valid', () => {
+ const mockAccessToken = '5678';
+ const mockUser = { name: 'test user' };
+ const mockEvent = {
+ origin: window.origin,
+ data: {
+ state: mockOauthMetadata.state,
+ code: '1234',
+ },
+ };
+
+ describe('when API requests succeed', () => {
+ beforeEach(async () => {
+ jest.spyOn(axios, 'post');
+ jest.spyOn(axios, 'get');
+ mockAxios
+ .onPost(mockOauthMetadata.oauth_token_url)
+ .replyOnce(httpStatus.OK, { access_token: mockAccessToken });
+ mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.OK, mockUser);
+
+ window.dispatchEvent(new MessageEvent('message', mockEvent));
+
+ await waitForPromises();
+ });
+
+ it('executes POST request to Oauth token endpoint', () => {
+ expect(axios.post).toHaveBeenCalledWith(mockOauthMetadata.oauth_token_url, {
+ code: '1234',
+ code_verifier: 'mock-verifier',
+ });
+ });
+
+ it('executes GET request to fetch user data', () => {
+ expect(axios.get).toHaveBeenCalledWith('/api/v4/user', {
+ headers: { Authorization: `Bearer ${mockAccessToken}` },
+ });
+ });
+
+ it('emits `sign-in` event with user data', () => {
+ expect(wrapper.emitted('sign-in')[0]).toEqual([mockUser]);
+ });
+ });
+
+ 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);
+
+ window.dispatchEvent(new MessageEvent('message', mockEvent));
+
+ await waitForPromises();
+ });
+
+ it('emits `error` event', () => {
+ expect(wrapper.emitted('error')).toBeTruthy();
+ });
+
+ it('does not emit `sign-in` event', () => {
+ expect(wrapper.emitted('sign-in')).toBeFalsy();
+ });
+
+ it('sets `loading` prop of button to `false`', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js
index b98a36269a3..2f5e47d1ae4 100644
--- a/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js
@@ -7,7 +7,7 @@ jest.mock('~/jira_connect/subscriptions/utils', () => ({
getGitlabSignInURL: jest.fn().mockImplementation((path) => Promise.resolve(path)),
}));
-describe('SubscriptionsList', () => {
+describe('UserLink', () => {
let wrapper;
const createComponent = (propsData = {}, { provide } = {}) => {
@@ -68,24 +68,35 @@ describe('SubscriptionsList', () => {
});
describe('gitlab user link', () => {
- window.gon = { current_username: 'root' };
+ describe.each`
+ current_username | gitlabUserPath | user | expectedUserHandle | expectedUserLink
+ ${'root'} | ${'/root'} | ${{ username: 'test-user' }} | ${'@root'} | ${'/root'}
+ ${'root'} | ${'/root'} | ${undefined} | ${'@root'} | ${'/root'}
+ ${undefined} | ${undefined} | ${{ username: 'test-user' }} | ${'@test-user'} | ${'/test-user'}
+ `(
+ 'when current_username=$current_username, gitlabUserPath=$gitlabUserPath and user=$user',
+ ({ current_username, gitlabUserPath, user, expectedUserHandle, expectedUserLink }) => {
+ beforeEach(() => {
+ window.gon = { current_username, relative_root_url: '' };
- beforeEach(() => {
- createComponent(
- {
- userSignedIn: true,
- hasSubscriptions: true,
- },
- { provide: { gitlabUserPath: '/root' } },
- );
- });
+ createComponent(
+ {
+ userSignedIn: true,
+ hasSubscriptions: true,
+ user,
+ },
+ { provide: { gitlabUserPath } },
+ );
+ });
- it('renders with correct href', () => {
- expect(findGitlabUserLink().attributes('href')).toBe('/root');
- });
+ it(`sets href to ${expectedUserLink}`, () => {
+ expect(findGitlabUserLink().attributes('href')).toBe(expectedUserLink);
+ });
- it('contains GitLab user handle', () => {
- expect(findGitlabUserLink().text()).toBe('@root');
- });
+ it(`renders ${expectedUserHandle} as text`, () => {
+ expect(findGitlabUserLink().text()).toBe(expectedUserHandle);
+ });
+ },
+ );
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js
index 4e3297506f1..175896c4ab0 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js
@@ -1,26 +1,44 @@
-import { mount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue';
-import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.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';
import createStore from '~/jira_connect/subscriptions/store';
+import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '../../../../../app/assets/javascripts/jira_connect/subscriptions/constants';
jest.mock('~/jira_connect/subscriptions/utils');
+const mockUsersPath = '/test';
+const defaultProvide = {
+ oauthMetadata: {},
+ usersPath: mockUsersPath,
+};
+
describe('SignInPage', () => {
let wrapper;
let store;
- const findSignInButton = () => wrapper.findComponent(SignInButton);
+ const findSignInLegacyButton = () => wrapper.findComponent(SignInLegacyButton);
+ const findSignInOauthButton = () => wrapper.findComponent(SignInOauthButton);
const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList);
- const createComponent = ({ provide, props } = {}) => {
+ const createComponent = ({ props, jiraConnectOauthEnabled } = {}) => {
store = createStore();
- wrapper = mount(SignInPage, {
+ wrapper = shallowMount(SignInPage, {
store,
- provide,
+ provide: {
+ ...defaultProvide,
+ glFeatures: {
+ jiraConnectOauth: jiraConnectOauthEnabled,
+ },
+ },
propsData: props,
+ stubs: {
+ SignInLegacyButton,
+ SignInOauthButton,
+ },
});
};
@@ -29,33 +47,74 @@ describe('SignInPage', () => {
});
describe('template', () => {
- const mockUsersPath = '/test';
describe.each`
- scenario | expectSubscriptionsList | signInButtonText
- ${'with subscriptions'} | ${true} | ${SignInPage.i18n.signinButtonTextWithSubscriptions}
- ${'without subscriptions'} | ${false} | ${SignInButton.i18n.defaultButtonText}
- `('$scenario', ({ expectSubscriptionsList, signInButtonText }) => {
- beforeEach(() => {
- createComponent({
- provide: {
- usersPath: mockUsersPath,
- },
- props: {
- hasSubscriptions: expectSubscriptionsList,
- },
+ scenario | hasSubscriptions | signInButtonText
+ ${'with subscriptions'} | ${true} | ${SignInPage.i18n.signInButtonTextWithSubscriptions}
+ ${'without subscriptions'} | ${false} | ${I18N_DEFAULT_SIGN_IN_BUTTON_TEXT}
+ `('$scenario', ({ hasSubscriptions, signInButtonText }) => {
+ describe('when `jiraConnectOauthEnabled` feature flag is disabled', () => {
+ beforeEach(() => {
+ createComponent({
+ jiraConnectOauthEnabled: false,
+ props: {
+ hasSubscriptions,
+ },
+ });
});
- });
- it(`renders sign in button with text ${signInButtonText}`, () => {
- expect(findSignInButton().text()).toMatchInterpolatedText(signInButtonText);
+ it('renders legacy sign in button', () => {
+ const button = findSignInLegacyButton();
+ expect(button.props('usersPath')).toBe(mockUsersPath);
+ expect(button.text()).toMatchInterpolatedText(signInButtonText);
+ });
});
- it('renders sign in button with `usersPath` prop', () => {
- expect(findSignInButton().props('usersPath')).toBe(mockUsersPath);
+ describe('when `jiraConnectOauthEnabled` feature flag is enabled', () => {
+ beforeEach(() => {
+ createComponent({
+ jiraConnectOauthEnabled: true,
+ props: {
+ hasSubscriptions,
+ },
+ });
+ });
+
+ describe('oauth sign in button', () => {
+ it('renders oauth sign in button', () => {
+ const button = findSignInOauthButton();
+ expect(button.text()).toMatchInterpolatedText(signInButtonText);
+ });
+
+ 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(`${expectSubscriptionsList ? 'renders' : 'does not render'} subscriptions list`, () => {
- expect(findSubscriptionsList().exists()).toBe(expectSubscriptionsList);
+ it(`${hasSubscriptions ? 'renders' : 'does not render'} subscriptions list`, () => {
+ createComponent({
+ props: {
+ hasSubscriptions,
+ },
+ });
+
+ expect(findSubscriptionsList().exists()).toBe(hasSubscriptions);
});
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/pkce_spec.js b/spec/frontend/jira_connect/subscriptions/pkce_spec.js
new file mode 100644
index 00000000000..4ee88059b7a
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/pkce_spec.js
@@ -0,0 +1,48 @@
+import crypto from 'crypto';
+import { TextEncoder, TextDecoder } from 'util';
+
+import { createCodeVerifier, createCodeChallenge } from '~/jira_connect/subscriptions/pkce';
+
+global.TextEncoder = TextEncoder;
+global.TextDecoder = TextDecoder;
+
+describe('pkce', () => {
+ beforeAll(() => {
+ Object.defineProperty(global.self, 'crypto', {
+ value: {
+ getRandomValues: (arr) => crypto.randomBytes(arr.length),
+ subtle: {
+ digest: jest.fn().mockResolvedValue(new ArrayBuffer(1)),
+ },
+ },
+ });
+ });
+
+ describe('createCodeVerifier', () => {
+ it('calls `window.crypto.getRandomValues`', () => {
+ window.crypto.getRandomValues = jest.fn();
+ createCodeVerifier();
+
+ expect(window.crypto.getRandomValues).toHaveBeenCalled();
+ });
+
+ it(`returns a string with 128 characters`, () => {
+ const codeVerifier = createCodeVerifier();
+ expect(codeVerifier).toHaveLength(128);
+ });
+ });
+
+ describe('createCodeChallenge', () => {
+ it('calls `window.crypto.subtle.digest` with correct arguments', async () => {
+ await createCodeChallenge('1234');
+
+ expect(window.crypto.subtle.digest).toHaveBeenCalledWith('SHA-256', expect.anything());
+ });
+
+ it('returns base64 URL-encoded string', async () => {
+ const codeChallenge = await createCodeChallenge('1234');
+
+ expect(codeChallenge).toBe('AA');
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js
index d4e1e711777..06ebcd7f134 100644
--- a/spec/frontend/jobs/components/job_app_spec.js
+++ b/spec/frontend/jobs/components/job_app_spec.js
@@ -34,7 +34,6 @@ describe('Job App', () => {
const props = {
artifactHelpUrl: 'help/artifact',
deploymentHelpUrl: 'help/deployment',
- codeQualityHelpPath: '/help/code_quality',
runnerSettingsUrl: 'settings/ci-cd/runners',
terminalPath: 'jobs/123/terminal',
projectPath: 'user-name/project-name',
diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js
index 226322a2951..cd3ee734466 100644
--- a/spec/frontend/jobs/components/job_log_controllers_spec.js
+++ b/spec/frontend/jobs/components/job_log_controllers_spec.js
@@ -8,7 +8,6 @@ describe('Job log controllers', () => {
afterEach(() => {
if (wrapper?.destroy) {
wrapper.destroy();
- wrapper = null;
}
});
@@ -34,7 +33,6 @@ describe('Job log controllers', () => {
const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]');
const findRawLink = () => wrapper.find('[data-testid="raw-link"]');
const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]');
- const findEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]');
const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]');
const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]');
@@ -76,28 +74,6 @@ describe('Job log controllers', () => {
expect(findRawLinkController().exists()).toBe(false);
});
});
-
- describe('when is erasable', () => {
- beforeEach(() => {
- createWrapper();
- });
-
- it('renders erase job link', () => {
- expect(findEraseLink().exists()).toBe(true);
- });
- });
-
- describe('when it is not erasable', () => {
- beforeEach(() => {
- createWrapper({
- erasePath: null,
- });
- });
-
- it('does not render erase button', () => {
- expect(findEraseLink().exists()).toBe(false);
- });
- });
});
describe('scroll buttons', () => {
diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js
index 6914b8d4fa1..ad72b9be261 100644
--- a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js
+++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js
@@ -1,5 +1,4 @@
-import { GlButton, GlLink } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue';
import createStore from '~/jobs/store';
import job from '../mock_data';
@@ -9,12 +8,12 @@ describe('Job Sidebar Retry Button', () => {
let wrapper;
const forwardDeploymentFailure = 'forward_deployment_failure';
- const findRetryButton = () => wrapper.find(GlButton);
- const findRetryLink = () => wrapper.find(GlLink);
+ const findRetryButton = () => wrapper.findByTestId('retry-job-button');
+ const findRetryLink = () => wrapper.findByTestId('retry-job-link');
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
- wrapper = shallowMount(JobsSidebarRetryButton, {
+ wrapper = shallowMountExtended(JobsSidebarRetryButton, {
propsData: {
href: job.retry_path,
modalId: 'modal-id',
@@ -27,7 +26,6 @@ describe('Job Sidebar Retry Button', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
- wrapper = null;
}
});
@@ -44,7 +42,6 @@ describe('Job Sidebar Retry Button', () => {
expect(findRetryButton().exists()).toBe(buttonExists);
expect(findRetryLink().exists()).toBe(linkExists);
- expect(wrapper.text()).toMatch('Retry');
},
);
@@ -55,6 +52,7 @@ describe('Job Sidebar Retry Button', () => {
expect(findRetryButton().attributes()).toMatchObject({
category: 'primary',
variant: 'confirm',
+ icon: 'retry',
});
});
});
@@ -64,6 +62,7 @@ describe('Job Sidebar Retry Button', () => {
expect(findRetryLink().attributes()).toMatchObject({
'data-method': 'post',
href: job.retry_path,
+ icon: 'retry',
});
});
});
diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js
index 6e327725627..39c71986ce4 100644
--- a/spec/frontend/jobs/components/sidebar_spec.js
+++ b/spec/frontend/jobs/components/sidebar_spec.js
@@ -21,25 +21,54 @@ describe('Sidebar details block', () => {
const findNewIssueButton = () => wrapper.findByTestId('job-new-issue');
const findRetryButton = () => wrapper.find(JobRetryButton);
const findTerminalLink = () => wrapper.findByTestId('terminal-link');
+ const findEraseLink = () => wrapper.findByTestId('job-log-erase-link');
- const createWrapper = ({ props = {} } = {}) => {
+ const createWrapper = (props) => {
store = createStore();
store.state.job = job;
wrapper = extendedWrapper(
shallowMount(Sidebar, {
- ...props,
+ propsData: {
+ ...props,
+ },
+
store,
}),
);
};
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
+ wrapper.destroy();
+ });
+
+ describe('when job log is erasable', () => {
+ const path = '/root/ci-project/-/jobs/1447/erase';
+
+ beforeEach(() => {
+ createWrapper({
+ erasePath: path,
+ });
+ });
+
+ it('renders erase job link', () => {
+ expect(findEraseLink().exists()).toBe(true);
+ });
+
+ it('erase job link has correct path', () => {
+ expect(findEraseLink().attributes('href')).toBe(path);
+ });
+ });
+
+ describe('when job log is not erasable', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('does not render erase button', () => {
+ expect(findEraseLink().exists()).toBe(false);
+ });
});
describe('when there is no retry path retry', () => {
@@ -86,7 +115,7 @@ describe('Sidebar details block', () => {
});
it('should render link to cancel job', () => {
- expect(findCancelButton().text()).toMatch('Cancel');
+ expect(findCancelButton().props('icon')).toBe('cancel');
expect(findCancelButton().attributes('href')).toBe(job.cancel_path);
});
});
diff --git a/spec/frontend/jobs/components/stages_dropdown_spec.js b/spec/frontend/jobs/components/stages_dropdown_spec.js
index b0e95a2d5b6..f638213ef0c 100644
--- a/spec/frontend/jobs/components/stages_dropdown_spec.js
+++ b/spec/frontend/jobs/components/stages_dropdown_spec.js
@@ -1,10 +1,12 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { trimText } from 'helpers/text_helper';
+import Mousetrap from 'mousetrap';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import StagesDropdown from '~/jobs/components/stages_dropdown.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import * as copyToClipboard from '~/behaviors/copy_to_clipboard';
import {
+ mockPipelineWithoutRef,
mockPipelineWithoutMR,
mockPipelineWithAttachedMR,
mockPipelineDetached,
@@ -18,20 +20,19 @@ describe('Stages Dropdown', () => {
const findStageItem = (index) => wrapper.findAllComponents(GlDropdownItem).at(index);
const findPipelineInfoText = () => wrapper.findByTestId('pipeline-info').text();
- const findPipelinePath = () => wrapper.findByTestId('pipeline-path').attributes('href');
- const findMRLinkPath = () => wrapper.findByTestId('mr-link').attributes('href');
- const findCopySourceBranchBtn = () => wrapper.findByTestId('copy-source-ref-link');
- const findSourceBranchLinkPath = () =>
- wrapper.findByTestId('source-branch-link').attributes('href');
- const findTargetBranchLinkPath = () =>
- wrapper.findByTestId('target-branch-link').attributes('href');
const createComponent = (props) => {
wrapper = extendedWrapper(
shallowMount(StagesDropdown, {
propsData: {
+ stages: [],
+ selectedStage: 'deploy',
...props,
},
+ stubs: {
+ GlSprintf,
+ GlLink,
+ },
}),
);
};
@@ -45,7 +46,6 @@ describe('Stages Dropdown', () => {
createComponent({
pipeline: mockPipelineWithoutMR,
stages: [{ name: 'build' }, { name: 'test' }],
- selectedStage: 'deploy',
});
});
@@ -53,10 +53,6 @@ describe('Stages Dropdown', () => {
expect(findStatus().exists()).toBe(true);
});
- it('renders pipeline link', () => {
- expect(findPipelinePath()).toBe('pipeline/28029444');
- });
-
it('renders dropdown with stages', () => {
expect(findStageItem(0).text()).toBe('build');
});
@@ -64,84 +60,133 @@ describe('Stages Dropdown', () => {
it('rendes selected stage', () => {
expect(findSelectedStageText()).toBe('deploy');
});
-
- it(`renders the pipeline info text like "Pipeline #123 for source_branch"`, () => {
- const expected = `Pipeline #${mockPipelineWithoutMR.id} for ${mockPipelineWithoutMR.ref.name}`;
- const actual = trimText(findPipelineInfoText());
-
- expect(actual).toBe(expected);
- });
-
- it(`renders the source ref copy button`, () => {
- expect(findCopySourceBranchBtn().exists()).toBe(true);
- });
});
- describe('with an "attached" merge request pipeline', () => {
- beforeEach(() => {
- createComponent({
- pipeline: mockPipelineWithAttachedMR,
- stages: [],
- selectedStage: 'deploy',
+ describe('pipelineInfo', () => {
+ const allElements = [
+ 'pipeline-path',
+ 'mr-link',
+ 'source-ref-link',
+ 'copy-source-ref-link',
+ 'source-branch-link',
+ 'copy-source-branch-link',
+ 'target-branch-link',
+ 'copy-target-branch-link',
+ ];
+ describe.each([
+ [
+ 'does not have a ref',
+ {
+ pipeline: mockPipelineWithoutRef,
+ text: `Pipeline #${mockPipelineWithoutRef.id}`,
+ foundElements: [
+ { testId: 'pipeline-path', props: [{ href: mockPipelineWithoutRef.path }] },
+ ],
+ },
+ ],
+ [
+ 'hasRef but not triggered by MR',
+ {
+ pipeline: mockPipelineWithoutMR,
+ text: `Pipeline #${mockPipelineWithoutMR.id} for ${mockPipelineWithoutMR.ref.name}`,
+ foundElements: [
+ { testId: 'pipeline-path', props: [{ href: mockPipelineWithoutMR.path }] },
+ { testId: 'source-ref-link', props: [{ href: mockPipelineWithoutMR.ref.path }] },
+ { testId: 'copy-source-ref-link', props: [{ text: mockPipelineWithoutMR.ref.name }] },
+ ],
+ },
+ ],
+ [
+ 'hasRef and MR but not MR pipeline',
+ {
+ pipeline: mockPipelineDetached,
+ text: `Pipeline #${mockPipelineDetached.id} for !${mockPipelineDetached.merge_request.iid} with ${mockPipelineDetached.merge_request.source_branch}`,
+ foundElements: [
+ { testId: 'pipeline-path', props: [{ href: mockPipelineDetached.path }] },
+ { testId: 'mr-link', props: [{ href: mockPipelineDetached.merge_request.path }] },
+ {
+ testId: 'source-branch-link',
+ props: [{ href: mockPipelineDetached.merge_request.source_branch_path }],
+ },
+ {
+ testId: 'copy-source-branch-link',
+ props: [{ text: mockPipelineDetached.merge_request.source_branch }],
+ },
+ ],
+ },
+ ],
+ [
+ 'hasRef and MR and MR pipeline',
+ {
+ pipeline: mockPipelineWithAttachedMR,
+ text: `Pipeline #${mockPipelineWithAttachedMR.id} for !${mockPipelineWithAttachedMR.merge_request.iid} with ${mockPipelineWithAttachedMR.merge_request.source_branch} into ${mockPipelineWithAttachedMR.merge_request.target_branch}`,
+ foundElements: [
+ { testId: 'pipeline-path', props: [{ href: mockPipelineWithAttachedMR.path }] },
+ { testId: 'mr-link', props: [{ href: mockPipelineWithAttachedMR.merge_request.path }] },
+ {
+ testId: 'source-branch-link',
+ props: [{ href: mockPipelineWithAttachedMR.merge_request.source_branch_path }],
+ },
+ {
+ testId: 'copy-source-branch-link',
+ props: [{ text: mockPipelineWithAttachedMR.merge_request.source_branch }],
+ },
+ {
+ testId: 'target-branch-link',
+ props: [{ href: mockPipelineWithAttachedMR.merge_request.target_branch_path }],
+ },
+ {
+ testId: 'copy-target-branch-link',
+ props: [{ text: mockPipelineWithAttachedMR.merge_request.target_branch }],
+ },
+ ],
+ },
+ ],
+ ])('%s', (_, { pipeline, text, foundElements }) => {
+ beforeEach(() => {
+ createComponent({
+ pipeline,
+ });
});
- });
- it(`renders the pipeline info text like "Pipeline #123 for !456 with source_branch into target_branch"`, () => {
- const expected = `Pipeline #${mockPipelineWithAttachedMR.id} for !${mockPipelineWithAttachedMR.merge_request.iid} with ${mockPipelineWithAttachedMR.merge_request.source_branch} into ${mockPipelineWithAttachedMR.merge_request.target_branch}`;
- const actual = trimText(findPipelineInfoText());
-
- expect(actual).toBe(expected);
- });
-
- it(`renders the correct merge request link`, () => {
- expect(findMRLinkPath()).toBe(mockPipelineWithAttachedMR.merge_request.path);
- });
-
- it(`renders the correct source branch link`, () => {
- expect(findSourceBranchLinkPath()).toBe(
- mockPipelineWithAttachedMR.merge_request.source_branch_path,
- );
- });
-
- it(`renders the correct target branch link`, () => {
- expect(findTargetBranchLinkPath()).toBe(
- mockPipelineWithAttachedMR.merge_request.target_branch_path,
- );
- });
-
- it(`renders the source ref copy button`, () => {
- expect(findCopySourceBranchBtn().exists()).toBe(true);
- });
- });
-
- describe('with a detached merge request pipeline', () => {
- beforeEach(() => {
- createComponent({
- pipeline: mockPipelineDetached,
- stages: [],
- selectedStage: 'deploy',
+ it('should render the text', () => {
+ expect(findPipelineInfoText()).toMatchInterpolatedText(text);
});
- });
- it(`renders the pipeline info like "Pipeline #123 for !456 with source_branch"`, () => {
- const expected = `Pipeline #${mockPipelineDetached.id} for !${mockPipelineDetached.merge_request.iid} with ${mockPipelineDetached.merge_request.source_branch}`;
- const actual = trimText(findPipelineInfoText());
+ it('should find components with props', () => {
+ foundElements.forEach((element) => {
+ element.props.forEach((prop) => {
+ const key = Object.keys(prop)[0];
+ expect(wrapper.findByTestId(element.testId).attributes(key)).toBe(prop[key]);
+ });
+ });
+ });
- expect(actual).toBe(expected);
+ it('should not find components', () => {
+ const foundTestIds = foundElements.map((element) => element.testId);
+ allElements
+ .filter((testId) => !foundTestIds.includes(testId))
+ .forEach((testId) => {
+ expect(wrapper.findByTestId(testId).exists()).toBe(false);
+ });
+ });
});
+ });
- it(`renders the correct merge request link`, () => {
- expect(findMRLinkPath()).toBe(mockPipelineDetached.merge_request.path);
- });
+ describe('mousetrap', () => {
+ it.each([
+ ['copy-source-ref-link', mockPipelineWithoutMR],
+ ['copy-source-branch-link', mockPipelineWithAttachedMR],
+ ])(
+ 'calls clickCopyToClipboardButton with `%s` button when `b` is pressed',
+ (button, pipeline) => {
+ const copyToClipboardMock = jest.spyOn(copyToClipboard, 'clickCopyToClipboardButton');
+ createComponent({ pipeline });
- it(`renders the correct source branch link`, () => {
- expect(findSourceBranchLinkPath()).toBe(
- mockPipelineDetached.merge_request.source_branch_path,
- );
- });
+ Mousetrap.trigger('b');
- it(`renders the source ref copy button`, () => {
- expect(findCopySourceBranchBtn().exists()).toBe(true);
- });
+ expect(copyToClipboardMock).toHaveBeenCalledWith(wrapper.findByTestId(button).element);
+ },
+ );
});
});
diff --git a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js
new file mode 100644
index 00000000000..ac79186cb46
--- /dev/null
+++ b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js
@@ -0,0 +1,67 @@
+import cacheConfig from '~/jobs/components/table/graphql/cache_config';
+import {
+ CIJobConnectionExistingCache,
+ CIJobConnectionIncomingCache,
+ CIJobConnectionIncomingCacheRunningStatus,
+} from '../../../mock_data';
+
+const firstLoadArgs = { first: 3, statuses: 'PENDING' };
+const runningArgs = { first: 3, statuses: 'RUNNING' };
+
+describe('jobs/components/table/graphql/cache_config', () => {
+ describe('when fetching data with the same statuses', () => {
+ it('should contain cache nodes and a status when merging caches on first load', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, {
+ args: firstLoadArgs,
+ });
+
+ expect(res.nodes).toHaveLength(CIJobConnectionIncomingCache.nodes.length);
+ expect(res.statuses).toBe('PENDING');
+ });
+
+ it('should add to existing caches when merging caches after first load', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge(
+ CIJobConnectionExistingCache,
+ CIJobConnectionIncomingCache,
+ {
+ args: firstLoadArgs,
+ },
+ );
+
+ expect(res.nodes).toHaveLength(
+ CIJobConnectionIncomingCache.nodes.length + CIJobConnectionExistingCache.nodes.length,
+ );
+ });
+
+ it('should contain the pageInfo key as part of the result', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, {
+ args: firstLoadArgs,
+ });
+
+ expect(res.pageInfo).toEqual(
+ expect.objectContaining({
+ __typename: 'PageInfo',
+ endCursor: 'eyJpZCI6IjIwNTEifQ',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'eyJpZCI6IjIxNzMifQ',
+ }),
+ );
+ });
+ });
+
+ describe('when fetching data with different statuses', () => {
+ it('should reset cache when a cache already exists', () => {
+ const res = cacheConfig.typePolicies.CiJobConnection.merge(
+ CIJobConnectionExistingCache,
+ CIJobConnectionIncomingCacheRunningStatus,
+ {
+ args: runningArgs,
+ },
+ );
+
+ expect(res.nodes).not.toEqual(CIJobConnectionExistingCache.nodes);
+ expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js
index 5ccd38af735..4d51624dfff 100644
--- a/spec/frontend/jobs/components/table/job_table_app_spec.js
+++ b/spec/frontend/jobs/components/table/job_table_app_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoader, GlAlert, GlEmptyState, GlPagination } from '@gitlab/ui';
+import { GlSkeletonLoader, GlAlert, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -8,12 +8,7 @@ import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
-import {
- mockJobsQueryResponse,
- mockJobsQueryEmptyResponse,
- mockJobsQueryResponseLastPage,
- mockJobsQueryResponseFirstPage,
-} from '../../mock_data';
+import { mockJobsQueryResponse, mockJobsQueryEmptyResponse } from '../../mock_data';
const projectPath = 'gitlab-org/gitlab';
Vue.use(VueApollo);
@@ -30,10 +25,9 @@ describe('Job table app', () => {
const findTabs = () => wrapper.findComponent(JobsTableTabs);
const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
- const findPagination = () => wrapper.findComponent(GlPagination);
- const findPrevious = () => findPagination().findAll('.page-item').at(0);
- const findNext = () => findPagination().findAll('.page-item').at(1);
+ const triggerInfiniteScroll = () =>
+ wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
const createMockApolloProvider = (handler) => {
const requestHandlers = [[getJobsQuery, handler]];
@@ -53,7 +47,7 @@ describe('Job table app', () => {
};
},
provide: {
- projectPath,
+ fullPath: projectPath,
},
apolloProvider: createMockApolloProvider(handler),
});
@@ -69,7 +63,6 @@ describe('Job table app', () => {
expect(findSkeletonLoader().exists()).toBe(true);
expect(findTable().exists()).toBe(false);
- expect(findPagination().exists()).toBe(false);
});
});
@@ -83,7 +76,6 @@ describe('Job table app', () => {
it('should display the jobs table with data', () => {
expect(findTable().exists()).toBe(true);
expect(findSkeletonLoader().exists()).toBe(false);
- expect(findPagination().exists()).toBe(true);
});
it('should refetch jobs query on fetchJobsByStatus event', async () => {
@@ -95,41 +87,24 @@ describe('Job table app', () => {
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
});
- });
- describe('pagination', () => {
- it('should disable the next page button on the last page', async () => {
- createComponent({
- handler: jest.fn().mockResolvedValue(mockJobsQueryResponseLastPage),
- mountFn: mount,
- data: {
- pagination: { currentPage: 3 },
- },
+ describe('when infinite scrolling is triggered', () => {
+ beforeEach(() => {
+ triggerInfiniteScroll();
});
- await waitForPromises();
-
- expect(findPrevious().exists()).toBe(true);
- expect(findNext().exists()).toBe(true);
- expect(findNext().classes('disabled')).toBe(true);
- });
-
- it('should disable the previous page button on the first page', async () => {
- createComponent({
- handler: jest.fn().mockResolvedValue(mockJobsQueryResponseFirstPage),
- mountFn: mount,
- data: {
- pagination: {
- currentPage: 1,
- },
- },
+ it('does not display a skeleton loader', () => {
+ expect(findSkeletonLoader().exists()).toBe(false);
});
- await waitForPromises();
+ it('handles infinite scrolling by calling fetch more', async () => {
+ await waitForPromises();
- expect(findPrevious().exists()).toBe(true);
- expect(findPrevious().classes('disabled')).toBe(true);
- expect(findNext().exists()).toBe(true);
+ expect(successHandler).toHaveBeenCalledWith({
+ after: 'eyJpZCI6IjIzMTcifQ',
+ fullPath: 'gitlab-org/gitlab',
+ });
+ });
});
});
diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js
index 2be78bac8a9..73b9df1853d 100644
--- a/spec/frontend/jobs/mock_data.js
+++ b/spec/frontend/jobs/mock_data.js
@@ -1214,6 +1214,11 @@ export const mockPipelineWithoutMR = {
},
};
+export const mockPipelineWithoutRef = {
+ ...mockPipelineWithoutMR,
+ ref: null,
+};
+
export const mockPipelineWithAttachedMR = {
id: 28029444,
details: {
@@ -1579,44 +1584,6 @@ export const mockJobsQueryResponse = {
},
};
-export const mockJobsQueryResponseLastPage = {
- data: {
- project: {
- id: '1',
- jobs: {
- ...mockJobsQueryResponse.data.project.jobs,
- pageInfo: {
- endCursor: 'eyJpZCI6IjIzMTcifQ',
- hasNextPage: false,
- hasPreviousPage: true,
- startCursor: 'eyJpZCI6IjIzMzYifQ',
- __typename: 'PageInfo',
- },
- },
- __typename: 'Project',
- },
- },
-};
-
-export const mockJobsQueryResponseFirstPage = {
- data: {
- project: {
- id: '1',
- jobs: {
- ...mockJobsQueryResponse.data.project.jobs,
- pageInfo: {
- endCursor: 'eyJpZCI6IjIzMTcifQ',
- hasNextPage: true,
- hasPreviousPage: false,
- startCursor: 'eyJpZCI6IjIzMzYifQ',
- __typename: 'PageInfo',
- },
- },
- __typename: 'Project',
- },
- },
-};
-
export const mockJobsQueryEmptyResponse = {
data: {
project: {
@@ -1910,3 +1877,44 @@ export const cannotPlayScheduledJob = {
__typename: 'JobPermissions',
},
};
+
+export const CIJobConnectionIncomingCache = {
+ __typename: 'CiJobConnection',
+ pageInfo: {
+ __typename: 'PageInfo',
+ endCursor: 'eyJpZCI6IjIwNTEifQ',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'eyJpZCI6IjIxNzMifQ',
+ },
+ nodes: [
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2057' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2056' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2051' },
+ ],
+};
+
+export const CIJobConnectionIncomingCacheRunningStatus = {
+ __typename: 'CiJobConnection',
+ pageInfo: {
+ __typename: 'PageInfo',
+ endCursor: 'eyJpZCI6IjIwNTEifQ',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'eyJpZCI6IjIxNzMifQ',
+ },
+ nodes: [
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2000' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2001' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2002' },
+ ],
+};
+
+export const CIJobConnectionExistingCache = {
+ nodes: [
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2057' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2056' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2051' },
+ ],
+ statuses: 'PENDING',
+};
diff --git a/spec/frontend/lib/utils/array_utility_spec.js b/spec/frontend/lib/utils/array_utility_spec.js
index b95286ff254..64ddd400114 100644
--- a/spec/frontend/lib/utils/array_utility_spec.js
+++ b/spec/frontend/lib/utils/array_utility_spec.js
@@ -29,4 +29,17 @@ describe('array_utility', () => {
},
);
});
+
+ describe('getDuplicateItemsFromArray', () => {
+ it.each`
+ array | result
+ ${[]} | ${[]}
+ ${[1, 2, 2, 3, 3, 4]} | ${[2, 3]}
+ ${[1, 2, 3, 2, 3, 4]} | ${[2, 3]}
+ ${['foo', 'bar', 'bar', 'foo', 'baz']} | ${['bar', 'foo']}
+ ${['foo', 'foo', 'bar', 'foo', 'bar']} | ${['foo', 'bar']}
+ `('given $array will return $result', ({ array, result }) => {
+ expect(arrayUtils.getDuplicateItemsFromArray(array)).toEqual(result);
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index 3fea08d5512..0be0bf89210 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -51,31 +51,6 @@ describe('common_utils', () => {
});
});
- describe('parseUrl', () => {
- it('returns an anchor tag with url', () => {
- expect(commonUtils.parseUrl('/some/absolute/url').pathname).toContain('some/absolute/url');
- });
-
- it('url is escaped', () => {
- // IE11 will return a relative pathname while other browsers will return a full pathname.
- // parseUrl uses an anchor element for parsing an url. With relative urls, the anchor
- // element will create an absolute url relative to the current execution context.
- // The JavaScript test suite is executed at '/' which will lead to an absolute url
- // starting with '/'.
- expect(commonUtils.parseUrl('" test="asf"').pathname).toContain('/%22%20test=%22asf%22');
- });
- });
-
- describe('parseUrlPathname', () => {
- it('returns an absolute url when given an absolute url', () => {
- expect(commonUtils.parseUrlPathname('/some/absolute/url')).toEqual('/some/absolute/url');
- });
-
- it('returns an absolute url when given a relative url', () => {
- expect(commonUtils.parseUrlPathname('some/relative/url')).toEqual('/some/relative/url');
- });
- });
-
describe('handleLocationHash', () => {
beforeEach(() => {
jest.spyOn(window.document, 'getElementById');
diff --git a/spec/frontend/lib/utils/ignore_while_pending_spec.js b/spec/frontend/lib/utils/ignore_while_pending_spec.js
new file mode 100644
index 00000000000..b68ba936dde
--- /dev/null
+++ b/spec/frontend/lib/utils/ignore_while_pending_spec.js
@@ -0,0 +1,136 @@
+import waitForPromises from 'helpers/wait_for_promises';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
+
+const TEST_ARGS = [123, { foo: 'bar' }];
+
+describe('~/lib/utils/ignore_while_pending', () => {
+ let spyResolve;
+ let spyReject;
+ let spy;
+ let subject;
+
+ beforeEach(() => {
+ spy = jest.fn().mockImplementation(
+ // NOTE: We can't pass an arrow function here...
+ function foo() {
+ return new Promise((resolve, reject) => {
+ spyResolve = resolve;
+ spyReject = reject;
+ });
+ },
+ );
+ });
+
+ describe('with non-instance method', () => {
+ beforeEach(() => {
+ subject = ignoreWhilePending(spy);
+ });
+
+ it('while pending, will ignore subsequent calls', () => {
+ subject(...TEST_ARGS);
+ subject();
+ subject();
+ subject();
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledWith(...TEST_ARGS);
+ });
+
+ it.each`
+ desc | act
+ ${'when resolved'} | ${() => spyResolve()}
+ ${'when rejected'} | ${() => spyReject(new Error('foo'))}
+ `('$desc, can be triggered again', async ({ act }) => {
+ // We need the empty catch(), since we are testing rejecting the promise,
+ // which would otherwise cause the test to fail.
+ subject(...TEST_ARGS).catch(() => {});
+ subject();
+ subject();
+ subject();
+
+ act();
+ // We need waitForPromises, so that the underlying finally() runs.
+ await waitForPromises();
+
+ subject({ again: 'foo' });
+
+ expect(spy).toHaveBeenCalledTimes(2);
+ expect(spy).toHaveBeenCalledWith(...TEST_ARGS);
+ expect(spy).toHaveBeenCalledWith({ again: 'foo' });
+ });
+
+ it('while pending, returns empty resolutions for ignored calls', async () => {
+ subject(...TEST_ARGS);
+
+ await expect(subject(...TEST_ARGS)).resolves.toBeUndefined();
+ await expect(subject(...TEST_ARGS)).resolves.toBeUndefined();
+ });
+
+ it('when resolved, returns resolution for origin call', async () => {
+ const resolveValue = { original: 1 };
+ const result = subject(...TEST_ARGS);
+
+ spyResolve(resolveValue);
+
+ await expect(result).resolves.toEqual(resolveValue);
+ });
+
+ it('when rejected, returns rejection for original call', async () => {
+ const rejectedErr = new Error('original');
+ const result = subject(...TEST_ARGS);
+
+ spyReject(rejectedErr);
+
+ await expect(result).rejects.toEqual(rejectedErr);
+ });
+ });
+
+ describe('with instance method', () => {
+ let instance1;
+ let instance2;
+
+ beforeEach(() => {
+ // Let's capture the "this" for tests
+ subject = ignoreWhilePending(function instanceMethod(...args) {
+ return spy(this, ...args);
+ });
+
+ instance1 = {};
+ instance2 = {};
+ });
+
+ it('will not ignore calls across instances', () => {
+ subject.call(instance1, { context: 1 });
+ subject.call(instance1, {});
+ subject.call(instance1, {});
+ subject.call(instance2, { context: 2 });
+ subject.call(instance2, {});
+
+ expect(spy.mock.calls).toEqual([
+ [instance1, { context: 1 }],
+ [instance2, { context: 2 }],
+ ]);
+ });
+
+ it('resolving one instance does not resolve other instances', async () => {
+ subject.call(instance1, { context: 1 });
+
+ // We need to save off spyResolve so it's not overwritten by next call
+ const instance1Resolve = spyResolve;
+
+ subject.call(instance2, { context: 2 });
+
+ instance1Resolve();
+ await waitForPromises();
+
+ subject.call(instance1, { context: 1 });
+ subject.call(instance2, { context: 2 });
+
+ expect(spy.mock.calls).toEqual([
+ [instance1, { context: 1 }],
+ [instance2, { context: 2 }],
+ [instance1, { context: 1 }],
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/resize_observer_spec.js b/spec/frontend/lib/utils/resize_observer_spec.js
index 419aff28935..6560562f204 100644
--- a/spec/frontend/lib/utils/resize_observer_spec.js
+++ b/spec/frontend/lib/utils/resize_observer_spec.js
@@ -19,16 +19,11 @@ describe('ResizeObserver Utility', () => {
jest.spyOn(document.documentElement, 'scrollTo');
- setFixtures(`<div id="content-body"><div class="target">element to scroll to</div></div>`);
+ setFixtures(`<div id="content-body"><div id="note_1234">note to scroll to</div></div>`);
- const target = document.querySelector('.target');
+ const target = document.querySelector('#note_1234');
jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ top: 200 });
-
- observer = scrollToTargetOnResize({
- target: '.target',
- container: '#content-body',
- });
});
afterEach(() => {
@@ -38,21 +33,22 @@ describe('ResizeObserver Utility', () => {
describe('Observer behavior', () => {
it('returns null for empty target', () => {
observer = scrollToTargetOnResize({
- target: '',
+ targetId: '',
container: '#content-body',
});
expect(observer).toBe(null);
});
- it('returns ResizeObserver instance', () => {
- expect(observer).toBeInstanceOf(ResizeObserver);
- });
+ it('does not scroll if target does not exist', () => {
+ observer = scrollToTargetOnResize({
+ targetId: 'some_imaginary_id',
+ container: '#content-body',
+ });
- it('scrolls body so anchor is just below sticky header (contentTop)', () => {
triggerResize();
- expect(document.documentElement.scrollTo).toHaveBeenCalledWith({ top: 110 });
+ expect(document.documentElement.scrollTo).not.toHaveBeenCalled();
});
const interactionEvents = ['mousedown', 'touchstart', 'keydown', 'wheel'];
@@ -64,5 +60,24 @@ describe('ResizeObserver Utility', () => {
expect(document.documentElement.scrollTo).not.toHaveBeenCalledWith();
});
+
+ describe('with existing target', () => {
+ beforeEach(() => {
+ observer = scrollToTargetOnResize({
+ targetId: 'note_1234',
+ container: '#content-body',
+ });
+ });
+
+ it('returns ResizeObserver instance', () => {
+ expect(observer).toBeInstanceOf(ResizeObserver);
+ });
+
+ it('scrolls body so anchor is just below sticky header (contentTop)', () => {
+ triggerResize();
+
+ expect(document.documentElement.scrollTo).toHaveBeenCalledWith({ top: 110 });
+ });
+ });
});
});
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index dded32cc890..a5877aa6e3e 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -1,4 +1,6 @@
+import $ from 'jquery';
import { insertMarkdownText, keypressNoteText } from '~/lib/utils/text_markdown';
+import '~/lib/utils/jquery_at_who';
describe('init markdown', () => {
let textArea;
@@ -179,12 +181,13 @@ describe('init markdown', () => {
${'- [ ] item'} | ${'- [ ] item\n- [ ] '}
${'- [x] item'} | ${'- [x] item\n- [x] '}
${'- item\n - second'} | ${'- item\n - second\n - '}
- ${'1. item'} | ${'1. item\n1. '}
- ${'1. [ ] item'} | ${'1. [ ] item\n1. [ ] '}
- ${'1. [x] item'} | ${'1. [x] item\n1. [x] '}
- ${'108. item'} | ${'108. item\n108. '}
+ ${'1. item'} | ${'1. item\n2. '}
+ ${'1. [ ] item'} | ${'1. [ ] item\n2. [ ] '}
+ ${'1. [x] item'} | ${'1. [x] item\n2. [x] '}
+ ${'108. item'} | ${'108. item\n109. '}
${'108. item\n - second'} | ${'108. item\n - second\n - '}
- ${'108. item\n 1. second'} | ${'108. item\n 1. second\n 1. '}
+ ${'108. item\n 1. second'} | ${'108. item\n 1. second\n 2. '}
+ ${'non-item, will not change'} | ${'non-item, will not change'}
`('adds correct list continuation characters', ({ text, expected }) => {
textArea.value = text;
textArea.setSelectionRange(text.length, text.length);
@@ -205,10 +208,10 @@ describe('init markdown', () => {
${'- [ ] item\n- [ ] '} | ${'- [ ] item\n'}
${'- [x] item\n- [x] '} | ${'- [x] item\n'}
${'- item\n - second\n - '} | ${'- item\n - second\n'}
- ${'1. item\n1. '} | ${'1. item\n'}
- ${'1. [ ] item\n1. [ ] '} | ${'1. [ ] item\n'}
- ${'1. [x] item\n1. [x] '} | ${'1. [x] item\n'}
- ${'108. item\n108. '} | ${'108. item\n'}
+ ${'1. item\n2. '} | ${'1. item\n'}
+ ${'1. [ ] item\n2. [ ] '} | ${'1. [ ] item\n'}
+ ${'1. [x] item\n2. [x] '} | ${'1. [x] item\n'}
+ ${'108. item\n109. '} | ${'108. item\n'}
${'108. item\n - second\n - '} | ${'108. item\n - second\n'}
${'108. item\n 1. second\n 1. '} | ${'108. item\n 1. second\n'}
`('adds correct list continuation characters', ({ text, expected }) => {
@@ -223,6 +226,41 @@ describe('init markdown', () => {
expect(textArea.selectionEnd).toBe(text.length);
});
+ // test that when we're in the middle of autocomplete, we don't
+ // add a new list item
+ it.each`
+ text | expected | atwho_selecting
+ ${'- item @'} | ${'- item @'} | ${true}
+ ${'- item @'} | ${'- item @\n- '} | ${false}
+ `('behaves correctly during autocomplete', ({ text, expected, atwho_selecting }) => {
+ jest.spyOn($.fn, 'atwho').mockReturnValue(atwho_selecting);
+
+ textArea.value = text;
+ textArea.setSelectionRange(text.length, text.length);
+
+ textArea.addEventListener('keydown', keypressNoteText);
+ textArea.dispatchEvent(enterEvent);
+
+ expect(textArea.value).toEqual(expected);
+ });
+
+ it.each`
+ text | add_at | expected
+ ${'1. one\n2. two\n3. three'} | ${13} | ${'1. one\n2. two\n2. \n3. three'}
+ ${'108. item\n 5. second\n 6. six\n 7. seven'} | ${36} | ${'108. item\n 5. second\n 6. six\n 6. \n 7. seven'}
+ `(
+ 'adds correct numbered continuation characters when in middle of list',
+ ({ text, add_at, expected }) => {
+ 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 };
@@ -242,8 +280,8 @@ describe('init markdown', () => {
});
describe('with selection', () => {
- const text = 'initial selected value';
- const selected = 'selected';
+ let text = 'initial selected value';
+ let selected = 'selected';
let selectedIndex;
beforeEach(() => {
@@ -389,6 +427,46 @@ describe('init markdown', () => {
expectedText.indexOf(expectedSelectionText, 1) + expectedSelectionText.length,
);
});
+
+ it('adds block tags on line above and below selection', () => {
+ selected = 'this text\nis multiple\nlines';
+ text = `before \n${selected}\nafter `;
+
+ textArea.value = text;
+ selectedIndex = text.indexOf(selected);
+ textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length);
+
+ insertMarkdownText({
+ textArea,
+ text,
+ tag: '',
+ blockTag: '***',
+ selected,
+ wrap: true,
+ });
+
+ expect(textArea.value).toEqual(`before \n***\n${selected}\n***\nafter `);
+ });
+
+ it('removes block tags on line above and below selection', () => {
+ selected = 'this text\nis multiple\nlines';
+ text = `before \n***\n${selected}\n***\nafter `;
+
+ textArea.value = text;
+ selectedIndex = text.indexOf(selected);
+ textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length);
+
+ insertMarkdownText({
+ textArea,
+ text,
+ tag: '',
+ blockTag: '***',
+ selected,
+ wrap: true,
+ });
+
+ expect(textArea.value).toEqual(`before \n${selected}\nafter `);
+ });
});
});
});
@@ -440,7 +518,31 @@ describe('init markdown', () => {
expect(editor.replaceSelectedText).toHaveBeenCalledWith(`***\n${selected}\n***\n`, undefined);
});
- it('uses ace editor to navigate back tag length when nothing is selected', () => {
+ it('removes block tags on line above and below selection', () => {
+ const selected = 'this text\nis multiple\nlines';
+ const text = `before\n***\n${selected}\n***\nafter`;
+
+ editor.getSelection = jest.fn().mockReturnValue({
+ startLineNumber: 2,
+ startColumn: 1,
+ endLineNumber: 4,
+ endColumn: 2,
+ setSelectionRange: jest.fn(),
+ });
+
+ insertMarkdownText({
+ text,
+ tag: '',
+ blockTag: '***',
+ selected,
+ wrap: true,
+ editor,
+ });
+
+ expect(editor.replaceSelectedText).toHaveBeenCalledWith(`${selected}\n`, undefined);
+ });
+
+ it('uses editor to navigate back tag length when nothing is selected', () => {
editor.getSelection = jest.fn().mockReturnValue({
startLineNumber: 1,
startColumn: 1,
@@ -460,7 +562,7 @@ describe('init markdown', () => {
expect(editor.moveCursor).toHaveBeenCalledWith(-1);
});
- it('ace editor does not navigate back when there is selected text', () => {
+ it('editor does not navigate back when there is selected text', () => {
insertMarkdownText({
text: editor.getValue,
tag: '*',
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index c6edba19c56..7608cff4c9e 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -22,6 +22,27 @@ beforeEach(() => {
});
describe('URL utility', () => {
+ describe('parseUrlPathname', () => {
+ it('returns an absolute url when given an absolute url', () => {
+ expect(urlUtils.parseUrlPathname('/some/absolute/url')).toBe('/some/absolute/url');
+ });
+
+ it('returns an absolute url when given a relative url', () => {
+ expect(urlUtils.parseUrlPathname('some/relative/url')).toBe('/some/relative/url');
+ });
+
+ it('returns an absolute url that includes the document.location path when given a relative url', () => {
+ // Change the location to see the `/mypath/` included in the result
+ setWindowLocation(`${TEST_HOST}/mypath/`);
+
+ expect(urlUtils.parseUrlPathname('some/relative/url')).toBe('/mypath/some/relative/url');
+ });
+
+ it('encodes certain character in the url', () => {
+ expect(urlUtils.parseUrlPathname('test="a b"')).toBe('/test=%22a%20b%22');
+ });
+ });
+
describe('webIDEUrl', () => {
afterEach(() => {
gon.relative_url_root = '';
@@ -636,7 +657,7 @@ describe('URL utility', () => {
`('returns "$expectation" with "$protocol" protocol', ({ protocol, expectation }) => {
setWindowLocation(`${protocol}//example.com`);
- expect(urlUtils.getWebSocketProtocol()).toEqual(expectation);
+ expect(urlUtils.getWebSocketProtocol()).toBe(expectation);
});
});
@@ -646,7 +667,7 @@ describe('URL utility', () => {
const path = '/lorem/ipsum?a=bc';
- expect(urlUtils.getWebSocketUrl(path)).toEqual('ws://example.com/lorem/ipsum?a=bc');
+ expect(urlUtils.getWebSocketUrl(path)).toBe('ws://example.com/lorem/ipsum?a=bc');
});
});
@@ -696,7 +717,7 @@ describe('URL utility', () => {
it('should return valid parameter', () => {
setWindowLocation('?scope=all&p=2');
- expect(getParameterByName('p')).toEqual('2');
+ expect(getParameterByName('p')).toBe('2');
expect(getParameterByName('scope')).toBe('all');
});
@@ -737,7 +758,7 @@ describe('URL utility', () => {
it('converts search query object back into a search query', () => {
const searchQueryObject = { one: '1', two: '2' };
- expect(urlUtils.objectToQuery(searchQueryObject)).toEqual('one=1&two=2');
+ expect(urlUtils.objectToQuery(searchQueryObject)).toBe('one=1&two=2');
});
it('returns empty string when `params` is undefined, null or empty string', () => {
@@ -833,15 +854,15 @@ describe('URL utility', () => {
it('adds new params as query string', () => {
const url = 'https://gitlab.com/test';
- expect(
- urlUtils.setUrlParams({ group_id: 'gitlab-org', project_id: 'my-project' }, url),
- ).toEqual('https://gitlab.com/test?group_id=gitlab-org&project_id=my-project');
+ expect(urlUtils.setUrlParams({ group_id: 'gitlab-org', project_id: 'my-project' }, url)).toBe(
+ 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project',
+ );
});
it('updates an existing parameter', () => {
const url = 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project';
- expect(urlUtils.setUrlParams({ project_id: 'gitlab-test' }, url)).toEqual(
+ expect(urlUtils.setUrlParams({ project_id: 'gitlab-test' }, url)).toBe(
'https://gitlab.com/test?group_id=gitlab-org&project_id=gitlab-test',
);
});
@@ -849,7 +870,7 @@ describe('URL utility', () => {
it("removes the project_id param when it's value is null", () => {
const url = 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project';
- expect(urlUtils.setUrlParams({ project_id: null }, url)).toEqual(
+ expect(urlUtils.setUrlParams({ project_id: null }, url)).toBe(
'https://gitlab.com/test?group_id=gitlab-org',
);
});
@@ -857,7 +878,7 @@ describe('URL utility', () => {
it('adds parameters from arrays', () => {
const url = 'https://gitlab.com/test';
- expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url)).toEqual(
+ expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url)).toBe(
'https://gitlab.com/test?labels=foo&labels=bar',
);
});
@@ -865,13 +886,13 @@ describe('URL utility', () => {
it('removes parameters from empty arrays', () => {
const url = 'https://gitlab.com/test?labels=foo&labels=bar';
- expect(urlUtils.setUrlParams({ labels: [] }, url)).toEqual('https://gitlab.com/test');
+ expect(urlUtils.setUrlParams({ labels: [] }, url)).toBe('https://gitlab.com/test');
});
it('removes parameters from empty arrays while keeping other parameters', () => {
const url = 'https://gitlab.com/test?labels=foo&labels=bar&unrelated=unrelated';
- expect(urlUtils.setUrlParams({ labels: [] }, url)).toEqual(
+ expect(urlUtils.setUrlParams({ labels: [] }, url)).toBe(
'https://gitlab.com/test?unrelated=unrelated',
);
});
@@ -879,7 +900,7 @@ describe('URL utility', () => {
it('adds parameters from arrays when railsArraySyntax=true', () => {
const url = 'https://gitlab.com/test';
- expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true)).toEqual(
+ expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true)).toBe(
'https://gitlab.com/test?labels%5B%5D=foo&labels%5B%5D=bar',
);
});
@@ -887,7 +908,7 @@ describe('URL utility', () => {
it('removes parameters from empty arrays when railsArraySyntax=true', () => {
const url = 'https://gitlab.com/test?labels%5B%5D=foo&labels%5B%5D=bar';
- expect(urlUtils.setUrlParams({ labels: [] }, url, false, true)).toEqual(
+ expect(urlUtils.setUrlParams({ labels: [] }, url, false, true)).toBe(
'https://gitlab.com/test',
);
});
@@ -895,7 +916,7 @@ describe('URL utility', () => {
it('decodes URI when decodeURI=true', () => {
const url = 'https://gitlab.com/test';
- expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true, true)).toEqual(
+ expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true, true)).toBe(
'https://gitlab.com/test?labels[]=foo&labels[]=bar',
);
});
@@ -903,7 +924,7 @@ describe('URL utility', () => {
it('removes all existing URL params and sets a new param when cleanParams=true', () => {
const url = 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project';
- expect(urlUtils.setUrlParams({ foo: 'bar' }, url, true)).toEqual(
+ expect(urlUtils.setUrlParams({ foo: 'bar' }, url, true)).toBe(
'https://gitlab.com/test?foo=bar',
);
});
diff --git a/spec/frontend/loading_icon_for_legacy_js_spec.js b/spec/frontend/loading_icon_for_legacy_js_spec.js
new file mode 100644
index 00000000000..46deee555ba
--- /dev/null
+++ b/spec/frontend/loading_icon_for_legacy_js_spec.js
@@ -0,0 +1,43 @@
+import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
+
+describe('loadingIconForLegacyJS', () => {
+ it('sets the correct defaults', () => {
+ const el = loadingIconForLegacyJS();
+
+ expect(el.tagName).toBe('DIV');
+ expect(el.className).toBe('gl-spinner-container');
+ expect(el.querySelector('.gl-spinner-sm')).toEqual(expect.any(HTMLElement));
+ expect(el.querySelector('.gl-spinner-dark')).toEqual(expect.any(HTMLElement));
+ expect(el.querySelector('[aria-label="Loading"]')).toEqual(expect.any(HTMLElement));
+ expect(el.getAttribute('role')).toBe('status');
+ });
+
+ it('renders a span if inline = true', () => {
+ expect(loadingIconForLegacyJS({ inline: true }).tagName).toBe('SPAN');
+ });
+
+ it('can render a different size', () => {
+ const el = loadingIconForLegacyJS({ size: 'lg' });
+
+ expect(el.querySelector('.gl-spinner-lg')).toEqual(expect.any(HTMLElement));
+ });
+
+ it('can render a different color', () => {
+ const el = loadingIconForLegacyJS({ color: 'light' });
+
+ expect(el.querySelector('.gl-spinner-light')).toEqual(expect.any(HTMLElement));
+ });
+
+ it('can render a different aria-label', () => {
+ const el = loadingIconForLegacyJS({ label: 'Foo' });
+
+ expect(el.querySelector('[aria-label="Foo"]')).toEqual(expect.any(HTMLElement));
+ });
+
+ it('can render additional classes', () => {
+ const classes = ['foo', 'bar'];
+ const el = loadingIconForLegacyJS({ classes });
+
+ expect(el.classList).toContain(...classes);
+ });
+});
diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js
index 356df7e7b11..3e4ffb6e61b 100644
--- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js
+++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js
@@ -43,12 +43,12 @@ describe('UserActionButtons', () => {
memberId: member.id,
memberType: 'GroupMember',
message: `Are you sure you want to remove ${member.user.name} from "${member.source.fullName}"?`,
- title: 'Remove member',
+ title: null,
isAccessRequest: false,
isInvite: false,
icon: '',
buttonCategory: 'secondary',
- buttonText: 'Remove user',
+ buttonText: 'Remove member',
userDeletionObstacles: {
name: member.user.name,
obstacles: parseUserDeletionObstacles(member.user),
@@ -135,9 +135,9 @@ describe('UserActionButtons', () => {
describe('isInvitedUser', () => {
it.each`
- isInvitedUser | icon | buttonText | buttonCategory
- ${true} | ${'remove'} | ${null} | ${'primary'}
- ${false} | ${''} | ${'Remove user'} | ${'secondary'}
+ isInvitedUser | icon | buttonText | buttonCategory
+ ${true} | ${'remove'} | ${null} | ${'primary'}
+ ${false} | ${''} | ${'Remove member'} | ${'secondary'}
`(
'passes the correct props to remove-member-button when isInvitedUser is $isInvitedUser',
({ isInvitedUser, icon, buttonText, buttonCategory }) => {
diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
index ee2fbbe57b9..b692eea4aa5 100644
--- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
+++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
@@ -1,12 +1,14 @@
-import { GlFilteredSearchToken } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import setWindowLocation from 'helpers/set_window_location_helper';
import { redirectTo } from '~/lib/utils/url_utility';
import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue';
-import { MEMBER_TYPES } from '~/members/constants';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ MEMBER_TYPES,
+ FILTERED_SEARCH_TOKEN_TWO_FACTOR,
+ FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS,
+} from '~/members/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
jest.mock('~/lib/utils/url_utility', () => {
@@ -32,7 +34,7 @@ describe('MembersFilteredSearchBar', () => {
state: {
filteredSearchBar: {
show: true,
- tokens: ['two_factor'],
+ tokens: [FILTERED_SEARCH_TOKEN_TWO_FACTOR.type],
searchParam: 'search',
placeholder: 'Filter members',
recentSearchesStorageKey: 'group_members',
@@ -70,21 +72,7 @@ describe('MembersFilteredSearchBar', () => {
it('includes tokens set in `filteredSearchBar.tokens`', () => {
createComponent();
- expect(findFilteredSearchBar().props('tokens')).toEqual([
- {
- type: 'two_factor',
- icon: 'lock',
- title: '2FA',
- token: GlFilteredSearchToken,
- unique: true,
- operators: OPERATOR_IS_ONLY,
- options: [
- { value: 'enabled', title: 'Enabled' },
- { value: 'disabled', title: 'Disabled' },
- ],
- requiredPermissions: 'canManageMembers',
- },
- ]);
+ expect(findFilteredSearchBar().props('tokens')).toEqual([FILTERED_SEARCH_TOKEN_TWO_FACTOR]);
});
describe('when `canManageMembers` is false', () => {
@@ -93,7 +81,10 @@ describe('MembersFilteredSearchBar', () => {
state: {
filteredSearchBar: {
show: true,
- tokens: ['two_factor', 'with_inherited_permissions'],
+ tokens: [
+ FILTERED_SEARCH_TOKEN_TWO_FACTOR.type,
+ FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS.type,
+ ],
searchParam: 'search',
placeholder: 'Filter members',
recentSearchesStorageKey: 'group_members',
@@ -105,18 +96,7 @@ describe('MembersFilteredSearchBar', () => {
});
expect(findFilteredSearchBar().props('tokens')).toEqual([
- {
- type: 'with_inherited_permissions',
- icon: 'group',
- title: 'Membership',
- token: GlFilteredSearchToken,
- unique: true,
- operators: OPERATOR_IS_ONLY,
- options: [
- { value: 'exclude', title: 'Direct' },
- { value: 'only', title: 'Inherited' },
- ],
- },
+ FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS,
]);
});
});
@@ -134,7 +114,7 @@ describe('MembersFilteredSearchBar', () => {
expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([
{
- type: 'two_factor',
+ type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type,
value: {
data: 'enabled',
operator: '=',
@@ -183,7 +163,7 @@ describe('MembersFilteredSearchBar', () => {
createComponent();
findFilteredSearchBar().vm.$emit('onFilter', [
- { type: 'two_factor', value: { data: 'enabled', operator: '=' } },
+ { type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type, value: { data: 'enabled', operator: '=' } },
]);
expect(redirectTo).toHaveBeenCalledWith('https://localhost/?two_factor=enabled');
@@ -193,7 +173,7 @@ describe('MembersFilteredSearchBar', () => {
createComponent();
findFilteredSearchBar().vm.$emit('onFilter', [
- { type: 'two_factor', value: { data: 'enabled', operator: '=' } },
+ { type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type, value: { data: 'enabled', operator: '=' } },
{ type: 'filtered-search-term', value: { data: 'foobar' } },
]);
@@ -206,7 +186,7 @@ describe('MembersFilteredSearchBar', () => {
createComponent();
findFilteredSearchBar().vm.$emit('onFilter', [
- { type: 'two_factor', value: { data: 'enabled', operator: '=' } },
+ { type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type, value: { data: 'enabled', operator: '=' } },
{ type: 'filtered-search-term', value: { data: 'foo bar baz' } },
]);
@@ -221,7 +201,7 @@ describe('MembersFilteredSearchBar', () => {
createComponent();
findFilteredSearchBar().vm.$emit('onFilter', [
- { type: 'two_factor', value: { data: 'enabled', operator: '=' } },
+ { type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type, value: { data: 'enabled', operator: '=' } },
{ type: 'filtered-search-term', value: { data: 'foobar' } },
]);
diff --git a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
index 750fff9b0aa..55e666609bd 100644
--- a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
+++ b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
@@ -1,7 +1,7 @@
import { GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import InlineConflictLines from '~/merge_conflicts/components/inline_conflict_lines.vue';
import ParallelConflictLines from '~/merge_conflicts/components/parallel_conflict_lines.vue';
import component from '~/merge_conflicts/merge_conflict_resolver_app.vue';
@@ -18,7 +18,7 @@ describe('Merge Conflict Resolver App', () => {
const decoratedMockFiles = decorateFiles(conflictsMock.files);
const mountComponent = () => {
- wrapper = shallowMount(component, {
+ wrapper = shallowMountExtended(component, {
store,
stubs: { GlSprintf },
provide() {
@@ -41,15 +41,17 @@ describe('Merge Conflict Resolver App', () => {
wrapper.destroy();
});
- const findConflictsCount = () => wrapper.find('[data-testid="conflicts-count"]');
- const findFiles = () => wrapper.findAll('[data-testid="files"]');
- const findFileHeader = (w = wrapper) => w.find('[data-testid="file-name"]');
- const findFileInteractiveButton = (w = wrapper) => w.find('[data-testid="interactive-button"]');
- const findFileInlineButton = (w = wrapper) => w.find('[data-testid="inline-button"]');
- const findSideBySideButton = () => wrapper.find('[data-testid="side-by-side"]');
+ const findLoadingSpinner = () => wrapper.findByTestId('loading-spinner');
+ const findConflictsCount = () => wrapper.findByTestId('conflicts-count');
+ const findFiles = () => wrapper.findAllByTestId('files');
+ const findFileHeader = (w = wrapper) => extendedWrapper(w).findByTestId('file-name');
+ const findFileInteractiveButton = (w = wrapper) =>
+ extendedWrapper(w).findByTestId('interactive-button');
+ const findFileInlineButton = (w = wrapper) => extendedWrapper(w).findByTestId('inline-button');
+ const findSideBySideButton = () => wrapper.findByTestId('side-by-side');
const findInlineConflictLines = (w = wrapper) => w.find(InlineConflictLines);
const findParallelConflictLines = (w = wrapper) => w.find(ParallelConflictLines);
- const findCommitMessageTextarea = () => wrapper.find('[data-testid="commit-message"]');
+ const findCommitMessageTextarea = () => wrapper.findByTestId('commit-message');
it('shows the amount of conflicts', () => {
mountComponent();
@@ -60,6 +62,19 @@ describe('Merge Conflict Resolver App', () => {
expect(title.text().trim()).toBe('Showing 3 conflicts between test-conflicts and main');
});
+ it('shows a loading spinner while loading', () => {
+ store.commit('SET_LOADING_STATE', true);
+ mountComponent();
+
+ expect(findLoadingSpinner().exists()).toBe(true);
+ });
+
+ it('does not show a loading spinner once loaded', () => {
+ mountComponent();
+
+ expect(findLoadingSpinner().exists()).toBe(false);
+ });
+
describe('files', () => {
it('shows one file area for each file', () => {
mountComponent();
diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js
index ced9b71125b..5c24a070342 100644
--- a/spec/frontend/merge_request_tabs_spec.js
+++ b/spec/frontend/merge_request_tabs_spec.js
@@ -4,6 +4,7 @@ import initMrPage from 'helpers/init_vue_mr_page_helper';
import axios from '~/lib/utils/axios_utils';
import MergeRequestTabs from '~/merge_request_tabs';
import '~/lib/utils/common_utils';
+import '~/lib/utils/url_utility';
jest.mock('~/lib/utils/webpack', () => ({
resetServiceWorkersPublicPath: jest.fn(),
diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js
index 73abd81d889..f4bca26f659 100644
--- a/spec/frontend/monitoring/components/charts/time_series_spec.js
+++ b/spec/frontend/monitoring/components/charts/time_series_spec.js
@@ -335,21 +335,6 @@ describe('Time series component', () => {
expect(formattedTooltipData.content).toBe(annotationsMetadata.tooltipData.content);
});
});
-
- describe('onResize', () => {
- const mockWidth = 233;
-
- beforeEach(() => {
- jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({
- width: mockWidth,
- }));
- wrapper.vm.onResize();
- });
-
- it('sets area chart width', () => {
- expect(wrapper.vm.width).toBe(mockWidth);
- });
- });
});
describe('computed', () => {
diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js
index 8d82cf3d2c7..4671d33219d 100644
--- a/spec/frontend/notes/components/note_header_spec.js
+++ b/spec/frontend/notes/components/note_header_spec.js
@@ -1,7 +1,7 @@
import { GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NoteHeader from '~/notes/components/note_header.vue';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
@@ -16,11 +16,12 @@ describe('NoteHeader component', () => {
let wrapper;
const findActionsWrapper = () => wrapper.find({ ref: 'discussionActions' });
+ const findToggleThreadButton = () => wrapper.findByTestId('thread-toggle');
const findChevronIcon = () => wrapper.find({ ref: 'chevronIcon' });
const findActionText = () => wrapper.find({ ref: 'actionText' });
const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' });
const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' });
- const findConfidentialIndicator = () => wrapper.find('[data-testid="confidentialIndicator"]');
+ const findConfidentialIndicator = () => wrapper.findByTestId('confidentialIndicator');
const findSpinner = () => wrapper.find({ ref: 'spinner' });
const findAuthorStatus = () => wrapper.find({ ref: 'authorStatus' });
@@ -40,7 +41,7 @@ describe('NoteHeader component', () => {
};
const createComponent = (props) => {
- wrapper = shallowMount(NoteHeader, {
+ wrapper = shallowMountExtended(NoteHeader, {
store: new Vuex.Store({
actions,
}),
@@ -98,6 +99,19 @@ describe('NoteHeader component', () => {
expect(findChevronIcon().props('name')).toBe('chevron-down');
});
+
+ it.each`
+ text | expanded
+ ${NoteHeader.i18n.showThread} | ${false}
+ ${NoteHeader.i18n.hideThread} | ${true}
+ `('toggle button has text $text is expanded is $expanded', ({ text, expanded }) => {
+ createComponent({
+ includeToggle: true,
+ expanded,
+ });
+
+ expect(findToggleThreadButton().text()).toBe(text);
+ });
});
it('renders an author link if author is passed to props', () => {
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js
index e25162f4da5..9680e273add 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js
@@ -6,6 +6,7 @@ import {
DELETE_TAG_ERROR_MESSAGE,
DELETE_TAGS_SUCCESS_MESSAGE,
DELETE_TAGS_ERROR_MESSAGE,
+ DETAILS_IMPORTING_ERROR_MESSAGE,
ADMIN_GARBAGE_COLLECTION_TIP,
} from '~/packages_and_registries/container_registry/explorer/constants';
@@ -76,6 +77,7 @@ describe('Delete alert', () => {
});
});
});
+
describe('error states', () => {
describe.each`
deleteAlertType | message
@@ -105,6 +107,25 @@ describe('Delete alert', () => {
});
});
+ describe('importing repository error state', () => {
+ beforeEach(() => {
+ mountComponent({
+ deleteAlertType: 'danger_importing',
+ containerRegistryImportingHelpPagePath: 'https://foobar',
+ });
+ });
+
+ it('alert exist and text is appropriate', () => {
+ expect(findAlert().text()).toMatchInterpolatedText(DETAILS_IMPORTING_ERROR_MESSAGE);
+ });
+
+ it('alert body contains link', () => {
+ const alertLink = findLink();
+ expect(alertLink.exists()).toBe(true);
+ expect(alertLink.attributes('href')).toBe('https://foobar');
+ });
+ });
+
describe('dismissing alert', () => {
it('GlAlert dismiss event triggers a change event', () => {
mountComponent({ deleteAlertType: 'success_tags' });
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 f4c22d9bfa7..a8d0d15007c 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
@@ -2,6 +2,7 @@ import { GlDropdownItem, GlIcon, GlDropdown } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -20,7 +21,7 @@ import {
ROOT_IMAGE_TEXT,
ROOT_IMAGE_TOOLTIP,
} from '~/packages_and_registries/container_registry/explorer/constants';
-import getContainerRepositoryTagCountQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql';
+import getContainerRepositoryMetadata from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { imageTagsCountMock } from '../../mock_data';
@@ -52,6 +53,7 @@ describe('Details Header', () => {
const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
const findInfoIcon = () => wrapper.findComponent(GlIcon);
const findMenu = () => wrapper.findComponent(GlDropdown);
+ const findSize = () => findByTestId('image-size');
const waitForMetadataItems = async () => {
// Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available
@@ -72,7 +74,7 @@ describe('Details Header', () => {
localVue = createLocalVue();
localVue.use(VueApollo);
- const requestHandlers = [[getContainerRepositoryTagCountQuery, resolver]];
+ const requestHandlers = [[getContainerRepositoryMetadata, resolver]];
apolloProvider = createMockApollo(requestHandlers);
}
@@ -230,6 +232,30 @@ describe('Details Header', () => {
});
});
+ describe('size metadata item', () => {
+ it('when size is not returned, it hides the item', async () => {
+ mountComponent();
+ await waitForMetadataItems();
+
+ expect(findSize().exists()).toBe(false);
+ });
+
+ it('when size is returned shows the item', async () => {
+ const size = 1000;
+ mountComponent({
+ resolver: jest.fn().mockResolvedValue(imageTagsCountMock({ size })),
+ });
+
+ await waitForPromises();
+ await waitForMetadataItems();
+
+ expect(findSize().props()).toMatchObject({
+ icon: 'disk',
+ text: numberToHumanSize(size),
+ });
+ });
+ });
+
describe('cleanup metadata item', () => {
it('has the correct icon', async () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
index 16625d913a5..fda1db4b7e1 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
@@ -187,6 +187,7 @@ export const imageTagsCountMock = (override) => ({
containerRepository: {
id: containerRepositoryMock.id,
tagsCount: 13,
+ size: null,
...override,
},
},
@@ -238,6 +239,15 @@ export const graphQLDeleteImageRepositoryTagsMock = {
},
};
+export const graphQLDeleteImageRepositoryTagImportingErrorMock = {
+ data: {
+ destroyContainerRepositoryTags: {
+ errors: ['repository importing'],
+ __typename: 'DestroyContainerRepositoryTagsPayload',
+ },
+ },
+};
+
export const dockerCommands = {
dockerBuildCommand: 'foofoo',
dockerPushCommand: 'barbar',
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
index c602b37c3b5..59ca47bee50 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
@@ -18,6 +18,7 @@ import {
UNFINISHED_STATUS,
DELETE_SCHEDULED,
ALERT_DANGER_IMAGE,
+ ALERT_DANGER_IMPORTING,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
ROOT_IMAGE_TEXT,
MISSING_OR_DELETED_IMAGE_TITLE,
@@ -33,6 +34,7 @@ import Tracking from '~/tracking';
import {
graphQLImageDetailsMock,
graphQLDeleteImageRepositoryTagsMock,
+ graphQLDeleteImageRepositoryTagImportingErrorMock,
containerRepositoryMock,
graphQLEmptyImageDetailsMock,
tagsMock,
@@ -329,6 +331,7 @@ describe('Details Page', () => {
const config = {
isAdmin: true,
garbageCollectionHelpPagePath: 'baz',
+ containerRegistryImportingHelpPagePath: 'https://foobar',
};
const deleteAlertType = 'success_tag';
@@ -353,6 +356,35 @@ describe('Details Page', () => {
expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType });
});
+
+ describe('importing repository error', () => {
+ let mutationResolver;
+ let tagsResolver;
+
+ beforeEach(async () => {
+ mutationResolver = jest
+ .fn()
+ .mockResolvedValue(graphQLDeleteImageRepositoryTagImportingErrorMock);
+ tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock));
+
+ mountComponent({ mutationResolver, tagsResolver });
+ await waitForApolloRequestRender();
+ });
+
+ it('displays the proper alert', async () => {
+ findTagsList().vm.$emit('delete', [cleanTags[0]]);
+ await nextTick();
+
+ findDeleteModal().vm.$emit('confirmDelete');
+ await waitForPromises();
+
+ expect(tagsResolver).toHaveBeenCalled();
+
+ const deleteAlert = findDeleteAlert();
+ expect(deleteAlert.exists()).toBe(true);
+ expect(deleteAlert.props('deleteAlertType')).toBe(ALERT_DANGER_IMPORTING);
+ });
+ });
});
describe('Partial Cleanup Alert', () => {
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
index bd126fe532d..da4bfcde217 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
@@ -23,7 +23,7 @@ import deleteContainerRepositoryMutation from '~/packages_and_registries/contain
import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql';
import component from '~/packages_and_registries/container_registry/explorer/pages/list.vue';
import Tracking from '~/tracking';
-import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { $toast } from 'jest/packages_and_registries/shared/mocks';
@@ -55,11 +55,15 @@ describe('List Page', () => {
const findDeleteAlert = () => wrapper.findComponent(GlAlert);
const findImageList = () => wrapper.findComponent(ImageList);
- const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
+ const findPersistedSearch = () => wrapper.findComponent(PersistedSearch);
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
const findDeleteImage = () => wrapper.findComponent(DeleteImage);
const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert);
+ const fireFirstSortUpdate = () => {
+ findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] });
+ };
+
const waitForApolloRequestRender = async () => {
jest.runOnlyPendingTimers();
await waitForPromises();
@@ -117,7 +121,7 @@ describe('List Page', () => {
it('contains registry header', async () => {
mountComponent();
-
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
expect(findRegistryHeader().exists()).toBe(true);
@@ -167,7 +171,7 @@ describe('List Page', () => {
describe('isLoading is true', () => {
it('shows the skeleton loader', async () => {
mountComponent();
-
+ fireFirstSortUpdate();
await nextTick();
expect(findSkeletonLoader().exists()).toBe(true);
@@ -187,7 +191,7 @@ describe('List Page', () => {
it('title has the metadataLoading props set to true', async () => {
mountComponent();
-
+ fireFirstSortUpdate();
await nextTick();
expect(findRegistryHeader().props('metadataLoading')).toBe(true);
@@ -244,6 +248,7 @@ describe('List Page', () => {
describe('unfiltered state', () => {
it('quick start is visible', async () => {
mountComponent();
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
@@ -252,6 +257,7 @@ describe('List Page', () => {
it('list component is visible', async () => {
mountComponent();
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
@@ -264,7 +270,7 @@ describe('List Page', () => {
.fn()
.mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
mountComponent({ detailsResolver });
-
+ fireFirstSortUpdate();
jest.runOnlyPendingTimers();
await waitForPromises();
@@ -274,7 +280,7 @@ describe('List Page', () => {
it('does not block the list ui to show', async () => {
const detailsResolver = jest.fn().mockRejectedValue();
mountComponent({ detailsResolver });
-
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
expect(findImageList().exists()).toBe(true);
@@ -285,6 +291,7 @@ describe('List Page', () => {
const detailsResolver = jest.fn().mockImplementation(() => new Promise(() => {}));
mountComponent({ detailsResolver });
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
expect(findImageList().props('metadataLoading')).toBe(true);
@@ -293,6 +300,7 @@ describe('List Page', () => {
describe('delete image', () => {
const selectImageForDeletion = async () => {
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
findImageList().vm.$emit('delete', deletedContainerRepository);
@@ -346,27 +354,27 @@ describe('List Page', () => {
describe('search and sorting', () => {
const doSearch = async () => {
await waitForApolloRequestRender();
- findRegistrySearch().vm.$emit('filter:changed', [
- { type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } },
- ]);
+ findPersistedSearch().vm.$emit('update', {
+ sort: 'UPDATED_DESC',
+ filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } }],
+ });
- findRegistrySearch().vm.$emit('filter:submit');
+ findPersistedSearch().vm.$emit('filter:submit');
await waitForPromises();
};
- it('has a search box element', async () => {
+ it('has a persisted search box element', async () => {
mountComponent();
-
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
- const registrySearch = findRegistrySearch();
+ const registrySearch = findPersistedSearch();
expect(registrySearch.exists()).toBe(true);
expect(registrySearch.props()).toMatchObject({
- filter: [],
- sorting: { orderBy: 'UPDATED', sort: 'desc' },
+ defaultOrder: 'UPDATED',
+ defaultSort: 'desc',
sortableFields: SORT_FIELDS,
- tokens: [],
});
});
@@ -376,7 +384,7 @@ describe('List Page', () => {
await waitForApolloRequestRender();
- findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' });
+ findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] });
await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' }));
@@ -416,7 +424,7 @@ describe('List Page', () => {
.fn()
.mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
mountComponent({ resolver, detailsResolver });
-
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
findImageList().vm.$emit('prev-page');
@@ -436,7 +444,7 @@ describe('List Page', () => {
.fn()
.mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
mountComponent({ resolver, detailsResolver });
-
+ fireFirstSortUpdate();
await waitForApolloRequestRender();
findImageList().vm.$emit('next-page');
@@ -455,6 +463,7 @@ describe('List Page', () => {
describe('modal', () => {
beforeEach(() => {
mountComponent();
+ fireFirstSortUpdate();
});
it('exists', () => {
@@ -472,6 +481,7 @@ describe('List Page', () => {
describe('tracking', () => {
beforeEach(() => {
mountComponent();
+ fireFirstSortUpdate();
});
const testTrackingCall = (action) => {
@@ -502,62 +512,6 @@ describe('List Page', () => {
});
});
- describe('url query string handling', () => {
- const defaultQueryParams = {
- search: [1, 2],
- sort: 'asc',
- orderBy: 'CREATED',
- };
- const queryChangePayload = 'foo';
-
- it('query:updated event pushes the new query to the router', async () => {
- const push = jest.fn();
- mountComponent({ mocks: { $router: { push } } });
-
- await nextTick();
-
- findRegistrySearch().vm.$emit('query:changed', queryChangePayload);
-
- expect(push).toHaveBeenCalledWith({ query: queryChangePayload });
- });
-
- it('graphql API call has the variables set from the URL', async () => {
- const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
- mountComponent({ query: defaultQueryParams, resolver });
-
- await nextTick();
-
- expect(resolver).toHaveBeenCalledWith(
- expect.objectContaining({
- name: 1,
- sort: 'CREATED_ASC',
- }),
- );
- });
-
- it.each`
- sort | orderBy | search | payload
- ${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }}
- ${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }}
- ${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }}
- ${undefined} | ${undefined} | ${undefined} | ${{}}
- ${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }}
- ${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }}
- ${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }}
- ${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }}
- `(
- 'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload',
- async ({ sort, orderBy, search, payload }) => {
- const resolver = jest.fn().mockResolvedValue({ sort, orderBy });
- mountComponent({ query: { sort, orderBy, search }, resolver });
-
- await nextTick();
-
- expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload));
- },
- );
- });
-
describe('cleanup is on alert', () => {
it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => {
mountComponent({
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap
index 9938357ed24..841a9bf8290 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap
@@ -58,7 +58,7 @@ exports[`Settings Form Remove regex matches snapshot 1`] = `
error=""
label="Remove tags matching:"
name="remove-regex"
- placeholder=".*"
+ placeholder=""
value="asdasdssssdfdf"
/>
`;
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 625aa37fc0f..266f953c3e0 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
@@ -49,6 +49,11 @@ describe('Settings Form', () => {
const findOlderThanDropdown = () => wrapper.find('[data-testid="older-than-dropdown"]');
const findRemoveRegexInput = () => wrapper.find('[data-testid="remove-regex-input"]');
+ const submitForm = async () => {
+ findForm().trigger('submit');
+ return waitForPromises();
+ };
+
const mountComponent = ({
props = defaultProps,
data,
@@ -318,27 +323,24 @@ describe('Settings Form', () => {
mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
});
- findForm().trigger('submit');
- await waitForPromises();
- await nextTick();
+ await submitForm();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE);
});
describe('when submit fails', () => {
describe('user recoverable errors', () => {
- it('when there is an error is shown in a toast', async () => {
+ it('when there is an error is shown in the nameRegex field t', async () => {
mountComponentWithApollo({
mutationResolver: jest
.fn()
.mockResolvedValue(expirationPolicyMutationPayload({ errors: ['foo'] })),
});
- findForm().trigger('submit');
- await waitForPromises();
- await nextTick();
+ await submitForm();
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo');
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE);
+ expect(findRemoveRegexInput().props('error')).toBe('foo');
});
});
@@ -348,9 +350,7 @@ describe('Settings Form', () => {
mutationResolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()),
});
- findForm().trigger('submit');
- await waitForPromises();
- await nextTick();
+ await submitForm();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE);
});
@@ -367,9 +367,7 @@ describe('Settings Form', () => {
});
mountComponent({ mocks: { $apollo: { mutate } } });
- findForm().trigger('submit');
- await waitForPromises();
- await nextTick();
+ await submitForm();
expect(findKeepRegexInput().props('error')).toEqual('baz');
});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js
index 4d6bd65bd93..76d5f8a6659 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js
@@ -4,15 +4,15 @@ import { updateContainerExpirationPolicy } from '~/packages_and_registries/setti
describe('Registry settings cache update', () => {
let client;
- const payload = {
+ const payload = (value) => ({
data: {
updateContainerExpirationPolicy: {
containerExpirationPolicy: {
- enabled: true,
+ ...value,
},
},
},
- };
+ });
const cacheMock = {
project: {
@@ -35,12 +35,12 @@ describe('Registry settings cache update', () => {
});
describe('Registry settings cache update', () => {
it('calls readQuery', () => {
- updateContainerExpirationPolicy('foo')(client, payload);
+ updateContainerExpirationPolicy('foo')(client, payload({ enabled: true }));
expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables);
});
it('writes the correct result in the cache', () => {
- updateContainerExpirationPolicy('foo')(client, payload);
+ updateContainerExpirationPolicy('foo')(client, payload({ enabled: true }));
expect(client.writeQuery).toHaveBeenCalledWith({
...queryAndVariables,
data: {
@@ -52,5 +52,20 @@ describe('Registry settings cache update', () => {
},
});
});
+
+ it('with an empty update preserves the state', () => {
+ updateContainerExpirationPolicy('foo')(client, payload());
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ ...queryAndVariables,
+ data: {
+ project: {
+ containerExpirationPolicy: {
+ enabled: false,
+ },
+ },
+ },
+ });
+ });
});
});
diff --git a/spec/frontend/pages/projects/forks/new/components/app_spec.js b/spec/frontend/pages/projects/forks/new/components/app_spec.js
index a7b4b9c42bd..0342b94a44d 100644
--- a/spec/frontend/pages/projects/forks/new/components/app_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/app_spec.js
@@ -1,19 +1,12 @@
import { shallowMount } from '@vue/test-utils';
import App from '~/pages/projects/forks/new/components/app.vue';
+import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue';
describe('App component', () => {
let wrapper;
const DEFAULT_PROPS = {
forkIllustration: 'illustrations/project-create-new-sm.svg',
- endpoint: '/some/project-full-path/-/forks/new.json',
- projectFullPath: '/some/project-full-path',
- projectId: '10',
- projectName: 'Project Name',
- projectPath: 'project-name',
- projectDescription: 'some project description',
- projectVisibility: 'private',
- restrictedVisibilityLevels: [],
};
const createComponent = (props = {}) => {
@@ -37,7 +30,7 @@ describe('App component', () => {
expect(wrapper.find('img').attributes('src')).toBe('illustrations/project-create-new-sm.svg');
});
- it('renders ForkForm component with prop', () => {
- expect(wrapper.props()).toEqual(expect.objectContaining(DEFAULT_PROPS));
+ it('renders ForkForm component', () => {
+ expect(wrapper.findComponent(ForkForm).exists()).toBe(true);
});
});
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
index dc5f1cb9e61..efbfd83a071 100644
--- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
@@ -40,7 +40,9 @@ describe('ForkForm component', () => {
},
];
- const DEFAULT_PROPS = {
+ const DEFAULT_PROVIDE = {
+ newGroupPath: 'some/groups/path',
+ visibilityHelpPath: 'some/visibility/help/path',
endpoint: '/some/project-full-path/-/forks/new.json',
projectFullPath: '/some/project-full-path',
projectId: '10',
@@ -52,18 +54,14 @@ describe('ForkForm component', () => {
};
const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => {
- axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data);
+ axiosMock.onGet(DEFAULT_PROVIDE.endpoint).replyOnce(statusCode, data);
};
- const createComponentFactory = (mountFn) => (props = {}, data = {}) => {
+ const createComponentFactory = (mountFn) => (provide = {}, data = {}) => {
wrapper = mountFn(ForkForm, {
provide: {
- newGroupPath: 'some/groups/path',
- visibilityHelpPath: 'some/visibility/help/path',
- },
- propsData: {
- ...DEFAULT_PROPS,
- ...props,
+ ...DEFAULT_PROVIDE,
+ ...provide,
},
data() {
return {
@@ -111,7 +109,7 @@ describe('ForkForm component', () => {
mockGetRequest();
createComponent();
- const { projectFullPath } = DEFAULT_PROPS;
+ const { projectFullPath } = DEFAULT_PROVIDE;
const cancelButton = wrapper.find('[data-testid="cancel-button"]');
expect(cancelButton.attributes('href')).toBe(projectFullPath);
@@ -130,10 +128,10 @@ describe('ForkForm component', () => {
mockGetRequest();
createComponent();
- expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROPS.projectName);
- expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROPS.projectPath);
+ expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectName);
+ expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectPath);
expect(findForkDescriptionTextarea().attributes('value')).toBe(
- DEFAULT_PROPS.projectDescription,
+ DEFAULT_PROVIDE.projectDescription,
);
});
@@ -164,7 +162,7 @@ describe('ForkForm component', () => {
it('make GET request from endpoint', async () => {
await axios.waitForAll();
- expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint);
+ expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROVIDE.endpoint);
});
it('generate default option', async () => {
@@ -469,7 +467,7 @@ describe('ForkForm component', () => {
projectName,
projectPath,
projectVisibility,
- } = DEFAULT_PROPS;
+ } = DEFAULT_PROVIDE;
const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`;
const project = {
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js
deleted file mode 100644
index 490dafed4ae..00000000000
--- a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import { GlBadge, GlButton, GlLink } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue';
-
-describe('Fork groups list item component', () => {
- let wrapper;
-
- const DEFAULT_GROUP_DATA = {
- id: 22,
- name: 'Gitlab Org',
- description: 'Ad et ipsam earum id aut nobis.',
- visibility: 'public',
- full_name: 'Gitlab Org',
- created_at: '2020-06-22T03:32:05.664Z',
- updated_at: '2020-06-22T03:32:05.664Z',
- avatar_url: null,
- fork_path: '/twitter/typeahead-js/-/forks?namespace_key=22',
- forked_project_path: null,
- permission: 'Owner',
- relative_path: '/gitlab-org',
- markdown_description:
- '<p data-sourcepos="1:1-1:31" dir="auto">Ad et ipsam earum id aut nobis.</p>',
- can_create_project: true,
- marked_for_deletion: false,
- };
-
- const DUMMY_PATH = '/dummy/path';
-
- const createWrapper = (propsData) => {
- wrapper = shallowMount(ForkGroupsListItem, {
- propsData: {
- ...propsData,
- },
- });
- };
-
- it('renders pending deletion badge if applicable', () => {
- createWrapper({ group: { ...DEFAULT_GROUP_DATA, marked_for_deletion: true } });
-
- expect(wrapper.find(GlBadge).text()).toBe('pending deletion');
- });
-
- it('renders go to fork button if has forked project', () => {
- createWrapper({ group: { ...DEFAULT_GROUP_DATA, forked_project_path: DUMMY_PATH } });
-
- expect(wrapper.find(GlButton).text()).toBe('Go to fork');
- expect(wrapper.find(GlButton).attributes().href).toBe(DUMMY_PATH);
- });
-
- it('renders select button if has no forked project', () => {
- createWrapper({
- group: { ...DEFAULT_GROUP_DATA, forked_project_path: null, fork_path: DUMMY_PATH },
- });
-
- expect(wrapper.find(GlButton).text()).toBe('Select');
- expect(wrapper.find('form').attributes().action).toBe(DUMMY_PATH);
- });
-
- it('renders link to current group', () => {
- const DUMMY_FULL_NAME = 'dummy';
- createWrapper({
- group: { ...DEFAULT_GROUP_DATA, relative_path: DUMMY_PATH, full_name: DUMMY_FULL_NAME },
- });
-
- expect(
- wrapper
- .findAll(GlLink)
- .filter((w) => w.text() === DUMMY_FULL_NAME)
- .at(0)
- .attributes().href,
- ).toBe(DUMMY_PATH);
- });
-});
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js
deleted file mode 100644
index 9f8dbf3d542..00000000000
--- a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import AxiosMockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
-import waitForPromises from 'helpers/wait_for_promises';
-import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import ForkGroupsList from '~/pages/projects/forks/new/components/fork_groups_list.vue';
-import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue';
-
-jest.mock('~/flash');
-
-describe('Fork groups list component', () => {
- let wrapper;
- let axiosMock;
-
- const DEFAULT_PROPS = {
- endpoint: '/dummy',
- };
-
- const replyWith = (...args) => axiosMock.onGet(DEFAULT_PROPS.endpoint).reply(...args);
-
- const createWrapper = (propsData) => {
- wrapper = shallowMount(ForkGroupsList, {
- propsData: {
- ...DEFAULT_PROPS,
- ...propsData,
- },
- stubs: {
- GlTabs: {
- template: '<div><slot></slot><slot name="tabs-end"></slot></div>',
- },
- },
- });
- };
-
- beforeEach(() => {
- axiosMock = new AxiosMockAdapter(axios);
- });
-
- afterEach(() => {
- axiosMock.reset();
-
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
- });
-
- it('fires load groups request on mount', async () => {
- replyWith(200, { namespaces: [] });
- createWrapper();
-
- await waitForPromises();
-
- expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint);
- });
-
- it('displays flash if loading groups fails', async () => {
- replyWith(500);
- createWrapper();
-
- await waitForPromises();
-
- expect(createFlash).toHaveBeenCalled();
- });
-
- it('displays loading indicator while loading groups', () => {
- replyWith(() => new Promise(() => {}));
- createWrapper();
-
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
- });
-
- it('displays empty text if no groups are available', async () => {
- const EMPTY_TEXT = 'No available groups to fork the project.';
- replyWith(200, { namespaces: [] });
- createWrapper();
-
- await waitForPromises();
-
- expect(wrapper.text()).toContain(EMPTY_TEXT);
- });
-
- it('displays filter field when groups are available', async () => {
- replyWith(200, { namespaces: [{ name: 'dummy1' }, { name: 'dummy2' }] });
- createWrapper();
-
- await waitForPromises();
-
- expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true);
- });
-
- it('renders list items for each available group', async () => {
- const namespaces = [{ name: 'dummy1' }, { name: 'dummy2' }, { name: 'otherdummy' }];
-
- replyWith(200, { namespaces });
- createWrapper();
-
- await waitForPromises();
-
- expect(wrapper.findAll(ForkGroupsListItem)).toHaveLength(namespaces.length);
-
- namespaces.forEach((namespace, idx) => {
- expect(wrapper.findAll(ForkGroupsListItem).at(idx).props()).toStrictEqual({
- group: namespace,
- });
- });
- });
-
- it('filters repositories on the fly', async () => {
- replyWith(200, {
- namespaces: [{ name: 'dummy1' }, { name: 'dummy2' }, { name: 'otherdummy' }],
- });
- createWrapper();
- await waitForPromises();
- wrapper.find(GlSearchBoxByType).vm.$emit('input', 'other');
- await nextTick();
-
- expect(wrapper.findAll(ForkGroupsListItem)).toHaveLength(1);
- expect(wrapper.findAll(ForkGroupsListItem).at(0).props().group.name).toBe('otherdummy');
- });
-});
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 86ccaa43786..62cf769cffd 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
@@ -137,9 +137,7 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-link"
data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
data-track-label="Set up CI/CD"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
target="_self"
>
@@ -157,9 +155,7 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-link"
data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
data-track-label="Start a free Ultimate trial"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
target="_self"
>
@@ -177,9 +173,7 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-link"
data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
data-track-label="Add code owners"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
target="_self"
>
@@ -204,9 +198,7 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-link"
data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
data-track-label="Add merge request approval"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
target="_self"
>
@@ -267,9 +259,7 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-link"
data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
data-track-label="Create an issue"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
target="_self"
>
@@ -287,9 +277,7 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-link"
data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
data-track-label="Submit a merge request"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
target="_self"
>
@@ -343,9 +331,7 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-link"
data-testid="uncompleted-learn-gitlab-link"
data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
data-track-label="Run a Security scan using CI/CD"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="https://docs.gitlab.com/ee/foobar/"
rel="noopener noreferrer"
target="_blank"
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 3b113f4dcd7..e21371123e8 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
@@ -12,8 +12,9 @@ const defaultProps = {
completed: false,
};
-const docLinkProps = {
+const openInNewTabProps = {
url: 'https://docs.gitlab.com/ee/user/application_security/security_dashboard/',
+ openInNewTab: true,
};
describe('Learn GitLab Section Link', () => {
@@ -59,9 +60,9 @@ describe('Learn GitLab Section Link', () => {
expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true);
});
- describe('doc links', () => {
+ describe('links marked with openInNewTab', () => {
beforeEach(() => {
- createWrapper('securityScanEnabled', docLinkProps);
+ createWrapper('securityScanEnabled', openInNewTabProps);
});
it('renders links with blank target', () => {
@@ -78,7 +79,6 @@ describe('Learn GitLab Section Link', () => {
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', {
label: 'Run a Security scan using CI/CD',
- property: 'Growth::Conversion::Experiment::LearnGitLab',
});
unmockTracking();
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 ee682b18af3..5f1aff99578 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
@@ -9,7 +9,6 @@ import { testActions, testSections, testProject } from './mock_data';
describe('Learn GitLab', () => {
let wrapper;
let sidebar;
- let inviteMembers = false;
const createWrapper = () => {
wrapper = mount(LearnGitlab, {
@@ -17,7 +16,6 @@ describe('Learn GitLab', () => {
actions: testActions,
sections: testSections,
project: testProject,
- inviteMembers,
},
});
};
@@ -38,7 +36,6 @@ describe('Learn GitLab', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
- inviteMembers = false;
sidebar.remove();
});
@@ -73,7 +70,6 @@ describe('Learn GitLab', () => {
});
it('emits openModal', () => {
- inviteMembers = true;
Cookies.set(INVITE_MODAL_OPEN_COOKIE, true);
createWrapper();
@@ -86,19 +82,11 @@ describe('Learn GitLab', () => {
});
it('does not emit openModal when cookie is not set', () => {
- inviteMembers = true;
-
createWrapper();
expect(spy).not.toHaveBeenCalled();
expect(cookieSpy).toHaveBeenCalledWith(INVITE_MODAL_OPEN_COOKIE);
});
-
- it('does not emit openModal when inviteMembers is false', () => {
- createWrapper();
-
- expect(spy).not.toHaveBeenCalled();
- });
});
describe('when the showSuccessfulInvitationsAlert event is fired', () => {
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 b21965e8f48..5dc64097d81 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
@@ -38,6 +38,7 @@ export const testActions = {
url: 'https://docs.gitlab.com/ee/foobar/',
completed: false,
svg: 'http://example.com/images/illustration.svg',
+ openInNewTab: true,
},
issueCreated: {
url: 'http://example.com/',
diff --git a/spec/frontend/pages/projects/pages_domains/form_spec.js b/spec/frontend/pages/projects/pages_domains/form_spec.js
new file mode 100644
index 00000000000..55336596f30
--- /dev/null
+++ b/spec/frontend/pages/projects/pages_domains/form_spec.js
@@ -0,0 +1,82 @@
+import initForm from '~/pages/projects/pages_domains/form';
+
+const ENABLED_UNLESS_AUTO_SSL_CLASS = 'js-enabled-unless-auto-ssl';
+const SSL_TOGGLE_CLASS = 'js-enable-ssl-gl-toggle';
+const SSL_TOGGLE_INPUT_CLASS = 'js-project-feature-toggle-input';
+const SHOW_IF_AUTO_SSL_CLASS = 'js-shown-if-auto-ssl';
+const SHOW_UNLESS_AUTO_SSL_CLASS = 'js-shown-unless-auto-ssl';
+const D_NONE_CLASS = 'd-none';
+
+describe('Page domains form', () => {
+ let toggle;
+
+ const findEnabledUnless = () => document.querySelector(`.${ENABLED_UNLESS_AUTO_SSL_CLASS}`);
+ const findSslToggle = () => document.querySelector(`.${SSL_TOGGLE_CLASS} button`);
+ const findSslToggleInput = () => document.querySelector(`.${SSL_TOGGLE_INPUT_CLASS}`);
+ const findIfAutoSsl = () => document.querySelector(`.${SHOW_IF_AUTO_SSL_CLASS}`);
+ const findUnlessAutoSsl = () => document.querySelector(`.${SHOW_UNLESS_AUTO_SSL_CLASS}`);
+
+ const create = () => {
+ setFixtures(`
+ <form>
+ <span
+ class="${SSL_TOGGLE_CLASS}"
+ data-label="SSL toggle"
+ ></span>
+ <input class="${SSL_TOGGLE_INPUT_CLASS}" type="hidden" />
+ <span class="${SHOW_UNLESS_AUTO_SSL_CLASS}"></span>
+ <span class="${SHOW_IF_AUTO_SSL_CLASS}"></span>
+ <button class="${ENABLED_UNLESS_AUTO_SSL_CLASS}"></button>
+ </form>
+ `);
+ };
+
+ it('instantiates the toggle', () => {
+ create();
+ initForm();
+
+ expect(findSslToggle()).not.toBe(null);
+ });
+
+ describe('when auto SSL is enabled', () => {
+ beforeEach(() => {
+ create();
+ toggle = initForm();
+ toggle.$emit('change', true);
+ });
+
+ it('sets the correct classes', () => {
+ expect(Array.from(findIfAutoSsl().classList)).not.toContain(D_NONE_CLASS);
+ expect(Array.from(findUnlessAutoSsl().classList)).toContain(D_NONE_CLASS);
+ });
+
+ it('sets the correct disabled value', () => {
+ expect(findEnabledUnless().getAttribute('disabled')).toBe('disabled');
+ });
+
+ it('sets the correct value for the input', () => {
+ expect(findSslToggleInput().getAttribute('value')).toBe('true');
+ });
+ });
+
+ describe('when auto SSL is not enabled', () => {
+ beforeEach(() => {
+ create();
+ toggle = initForm();
+ toggle.$emit('change', false);
+ });
+
+ it('sets the correct classes', () => {
+ expect(Array.from(findIfAutoSsl().classList)).toContain(D_NONE_CLASS);
+ expect(Array.from(findUnlessAutoSsl().classList)).not.toContain(D_NONE_CLASS);
+ });
+
+ it('sets the correct disabled value', () => {
+ expect(findUnlessAutoSsl().getAttribute('disabled')).toBe(null);
+ });
+
+ it('sets the correct value for the input', () => {
+ expect(findSslToggleInput().getAttribute('value')).toBe('false');
+ });
+ });
+});
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
index 1f964e8bae2..e118a35804f 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
@@ -155,6 +155,20 @@ describe('WikiForm', () => {
});
it.each`
+ format | enabled | action
+ ${'markdown'} | ${true} | ${'displays'}
+ ${'rdoc'} | ${false} | ${'hides'}
+ ${'asciidoc'} | ${false} | ${'hides'}
+ ${'org'} | ${false} | ${'hides'}
+ `('$action preview in the markdown field when format is $format', async ({ format, enabled }) => {
+ createWrapper();
+
+ await setFormat(format);
+
+ expect(findClassicEditor().props('enablePreview')).toBe(enabled);
+ });
+
+ it.each`
value | text
${'markdown'} | ${'[Link Title](page-slug)'}
${'rdoc'} | ${'{Link title}[link:page-slug]'}
diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js
index c35bd772c86..2ae36740dfb 100644
--- a/spec/frontend/performance_bar/components/detailed_metric_spec.js
+++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js
@@ -1,10 +1,11 @@
import { shallowMount } from '@vue/test-utils';
+import { GlDropdownItem } from '@gitlab/ui';
import { nextTick } from 'vue';
import { trimText } from 'helpers/text_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DetailedMetric from '~/performance_bar/components/detailed_metric.vue';
import RequestWarning from '~/performance_bar/components/request_warning.vue';
-import { sortOrders } from '~/performance_bar/constants';
+import { sortOrders, sortOrderOptions } from '~/performance_bar/constants';
describe('detailedMetric', () => {
let wrapper;
@@ -29,7 +30,13 @@ describe('detailedMetric', () => {
const findExpandBacktraceBtns = () => wrapper.findAllByTestId('backtrace-expand-btn');
const findExpandedBacktraceBtnAtIndex = (index) => findExpandBacktraceBtns().at(index);
const findDetailsLabel = () => wrapper.findByTestId('performance-bar-details-label');
- const findSortOrderSwitcher = () => wrapper.findByTestId('performance-bar-sort-order');
+ const findSortOrderDropdown = () => wrapper.findByTestId('performance-bar-sort-order');
+ const clickSortOrderDropdownItem = (sortOrder) =>
+ findSortOrderDropdown()
+ .findAllComponents(GlDropdownItem)
+ .filter((item) => item.text() === sortOrderOptions[sortOrder])
+ .at(0)
+ .vm.$emit('click');
const findEmptyDetailNotice = () => wrapper.findByTestId('performance-bar-empty-detail-notice');
const findAllDetailDurations = () =>
wrapper.findAllByTestId('performance-item-duration').wrappers.map((w) => w.text());
@@ -86,7 +93,7 @@ describe('detailedMetric', () => {
});
it('does not display sort by switcher', () => {
- expect(findSortOrderSwitcher().exists()).toBe(false);
+ expect(findSortOrderDropdown().exists()).toBe(false);
});
});
@@ -216,7 +223,7 @@ describe('detailedMetric', () => {
});
it('does not display sort by switcher', () => {
- expect(findSortOrderSwitcher().exists()).toBe(false);
+ expect(findSortOrderDropdown().exists()).toBe(false);
});
it('adds a modal with a table of the details', () => {
@@ -323,14 +330,15 @@ describe('detailedMetric', () => {
});
it('displays sort by switcher', () => {
- expect(findSortOrderSwitcher().exists()).toBe(true);
+ expect(findSortOrderDropdown().exists()).toBe(true);
});
- it('allows switch sorting orders', async () => {
- findSortOrderSwitcher().vm.$emit('input', sortOrders.CHRONOLOGICAL);
+ it('changes sortOrder on select', async () => {
+ clickSortOrderDropdownItem(sortOrders.CHRONOLOGICAL);
await nextTick();
expect(findAllDetailDurations()).toEqual(['23ms', '100ms', '75ms']);
- findSortOrderSwitcher().vm.$emit('input', sortOrders.DURATION);
+
+ clickSortOrderDropdownItem(sortOrders.DURATION);
await nextTick();
expect(findAllDetailDurations()).toEqual(['100ms', '75ms', '23ms']);
});
diff --git a/spec/frontend/performance_bar/components/performance_bar_app_spec.js b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
index 67a4259a8e3..2c9ab4bf78d 100644
--- a/spec/frontend/performance_bar/components/performance_bar_app_spec.js
+++ b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
@@ -18,4 +18,15 @@ describe('performance bar app', () => {
it('sets the class to match the environment', () => {
expect(wrapper.element.getAttribute('class')).toContain('development');
});
+
+ describe('changeCurrentRequest', () => {
+ it('emits a change-request event', () => {
+ expect(wrapper.emitted('change-request')).toBeUndefined();
+
+ wrapper.vm.changeCurrentRequest('123');
+
+ expect(wrapper.emitted('change-request')).toBeDefined();
+ expect(wrapper.emitted('change-request')[0]).toEqual(['123']);
+ });
+ });
});
diff --git a/spec/frontend/performance_bar/components/request_selector_spec.js b/spec/frontend/performance_bar/components/request_selector_spec.js
deleted file mode 100644
index 9cc8c5e73f4..00000000000
--- a/spec/frontend/performance_bar/components/request_selector_spec.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import RequestSelector from '~/performance_bar/components/request_selector.vue';
-
-describe('request selector', () => {
- const requests = [
- {
- id: 'warningReq',
- url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1/discussions.json',
- truncatedUrl: 'discussions.json',
- hasWarnings: true,
- },
- ];
-
- const wrapper = shallowMount(RequestSelector, {
- propsData: {
- requests,
- currentRequest: requests[0],
- },
- });
-
- it('has a warning icon if any requests have warnings', () => {
- expect(wrapper.find('span > gl-emoji').element.dataset.name).toEqual('warning');
- });
-
- it('adds a warning glyph to requests with warnings', () => {
- const requestValue = wrapper.find('[value="warningReq"]').text();
-
- expect(requestValue).toContain('discussions.json');
- expect(requestValue).toContain('(!)');
- });
-});
diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js
index 819b2bcbacf..91cb46002be 100644
--- a/spec/frontend/performance_bar/index_spec.js
+++ b/spec/frontend/performance_bar/index_spec.js
@@ -51,7 +51,7 @@ describe('performance bar wrapper', () => {
mock.restore();
});
- describe('loadRequestDetails', () => {
+ describe('addRequest', () => {
beforeEach(() => {
jest.spyOn(vm.store, 'addRequest');
});
@@ -59,26 +59,46 @@ describe('performance bar wrapper', () => {
it('does nothing if the request cannot be tracked', () => {
jest.spyOn(vm.store, 'canTrackRequest').mockImplementation(() => false);
- vm.loadRequestDetails('123', 'https://gitlab.com/');
+ vm.addRequest('123', 'https://gitlab.com/');
expect(vm.store.addRequest).not.toHaveBeenCalled();
});
it('adds the request immediately', () => {
- vm.loadRequestDetails('123', 'https://gitlab.com/');
+ vm.addRequest('123', 'https://gitlab.com/');
expect(vm.store.addRequest).toHaveBeenCalledWith('123', 'https://gitlab.com/');
});
+ });
- it('makes an HTTP request for the request details', () => {
+ describe('loadRequestDetails', () => {
+ beforeEach(() => {
jest.spyOn(PerformanceBarService, 'fetchRequestDetails');
+ });
- vm.loadRequestDetails('456', 'https://gitlab.com/');
+ it('makes an HTTP request for the request details', () => {
+ vm.addRequest('456', 'https://gitlab.com/');
+ vm.loadRequestDetails('456');
expect(PerformanceBarService.fetchRequestDetails).toHaveBeenCalledWith(
'/-/peek/results',
'456',
);
});
+
+ it('does not make a request if request was not added', () => {
+ vm.loadRequestDetails('456');
+
+ expect(PerformanceBarService.fetchRequestDetails).not.toHaveBeenCalled();
+ });
+
+ it('makes an HTTP request only once for the same request', async () => {
+ vm.addRequest('456', 'https://gitlab.com/');
+ await vm.loadRequestDetails('456');
+
+ vm.loadRequestDetails('456');
+
+ expect(PerformanceBarService.fetchRequestDetails).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js
index 4633602de26..bff8fcda9b9 100644
--- a/spec/frontend/persistent_user_callout_spec.js
+++ b/spec/frontend/persistent_user_callout_spec.js
@@ -21,7 +21,8 @@ describe('PersistentUserCallout', () => {
data-feature-id="${featureName}"
data-group-id="${groupId}"
>
- <button type="button" class="js-close"></button>
+ <button type="button" class="js-close js-close-primary"></button>
+ <button type="button" class="js-close js-close-secondary"></button>
</div>
`;
@@ -64,14 +65,15 @@ describe('PersistentUserCallout', () => {
}
describe('dismiss', () => {
- let button;
+ const buttons = {};
let mockAxios;
let persistentUserCallout;
beforeEach(() => {
const fixture = createFixture();
const container = fixture.querySelector('.container');
- button = fixture.querySelector('.js-close');
+ buttons.primary = fixture.querySelector('.js-close-primary');
+ buttons.secondary = fixture.querySelector('.js-close-secondary');
mockAxios = new MockAdapter(axios);
persistentUserCallout = new PersistentUserCallout(container);
jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {});
@@ -81,29 +83,33 @@ describe('PersistentUserCallout', () => {
mockAxios.restore();
});
- it('POSTs endpoint and removes container when clicking close', () => {
+ it.each`
+ button
+ ${'primary'}
+ ${'secondary'}
+ `('POSTs endpoint and removes container when clicking $button close', async ({ button }) => {
mockAxios.onPost(dismissEndpoint).replyOnce(200);
- button.click();
+ buttons[button].click();
- return waitForPromises().then(() => {
- expect(persistentUserCallout.container.remove).toHaveBeenCalled();
- expect(mockAxios.history.post[0].data).toBe(
- JSON.stringify({ feature_name: featureName, group_id: groupId }),
- );
- });
+ await waitForPromises();
+
+ expect(persistentUserCallout.container.remove).toHaveBeenCalled();
+ expect(mockAxios.history.post[0].data).toBe(
+ JSON.stringify({ feature_name: featureName, group_id: groupId }),
+ );
});
- it('invokes Flash when the dismiss request fails', () => {
+ it('invokes Flash when the dismiss request fails', async () => {
mockAxios.onPost(dismissEndpoint).replyOnce(500);
- button.click();
+ buttons.primary.click();
- return waitForPromises().then(() => {
- expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
- expect(createFlash).toHaveBeenCalledWith({
- message: 'An error occurred while dismissing the alert. Refresh the page and try again.',
- });
+ await waitForPromises();
+
+ expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'An error occurred while dismissing the alert. Refresh the page and try again.',
});
});
});
@@ -132,37 +138,37 @@ describe('PersistentUserCallout', () => {
mockAxios.restore();
});
- it('defers loading of a link until callout is dismissed', () => {
+ it('defers loading of a link until callout is dismissed', async () => {
const { href, target } = deferredLink;
mockAxios.onPost(dismissEndpoint).replyOnce(200);
deferredLink.click();
- return waitForPromises().then(() => {
- expect(windowSpy).toHaveBeenCalledWith(href, target);
- expect(persistentUserCallout.container.remove).toHaveBeenCalled();
- expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName }));
- });
+ await waitForPromises();
+
+ expect(windowSpy).toHaveBeenCalledWith(href, target);
+ expect(persistentUserCallout.container.remove).toHaveBeenCalled();
+ expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName }));
});
- it('does not dismiss callout on non-deferred links', () => {
+ it('does not dismiss callout on non-deferred links', async () => {
normalLink.click();
- return waitForPromises().then(() => {
- expect(windowSpy).not.toHaveBeenCalled();
- expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
- });
+ await waitForPromises();
+
+ expect(windowSpy).not.toHaveBeenCalled();
+ expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
});
- it('does not follow link when notification is closed', () => {
+ it('does not follow link when notification is closed', async () => {
mockAxios.onPost(dismissEndpoint).replyOnce(200);
button.click();
- return waitForPromises().then(() => {
- expect(windowSpy).not.toHaveBeenCalled();
- expect(persistentUserCallout.container.remove).toHaveBeenCalled();
- });
+ await waitForPromises();
+
+ expect(windowSpy).not.toHaveBeenCalled();
+ expect(persistentUserCallout.container.remove).toHaveBeenCalled();
});
});
@@ -187,30 +193,30 @@ describe('PersistentUserCallout', () => {
mockAxios.restore();
});
- it('uses a link to trigger callout and defers following until callout is finished', () => {
+ it('uses a link to trigger callout and defers following until callout is finished', async () => {
const { href } = link;
mockAxios.onPost(dismissEndpoint).replyOnce(200);
link.click();
- return waitForPromises().then(() => {
- expect(window.location.assign).toBeCalledWith(href);
- expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
- expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName }));
- });
+ await waitForPromises();
+
+ expect(window.location.assign).toBeCalledWith(href);
+ expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
+ expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName }));
});
- it('invokes Flash when the dismiss request fails', () => {
+ it('invokes Flash when the dismiss request fails', async () => {
mockAxios.onPost(dismissEndpoint).replyOnce(500);
link.click();
- return waitForPromises().then(() => {
- expect(window.location.assign).not.toHaveBeenCalled();
- expect(createFlash).toHaveBeenCalledWith({
- message:
- 'An error occurred while acknowledging the notification. Refresh the page and try again.',
- });
+ await waitForPromises();
+
+ expect(window.location.assign).not.toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalledWith({
+ message:
+ 'An error occurred while acknowledging the notification. Refresh the page and try again.',
});
});
});
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 7244a179820..59bd71b0e60 100644
--- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
+++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
@@ -17,6 +17,8 @@ describe('Pipeline Editor | Commit Form', () => {
propsData: {
defaultMessage: mockCommitMessage,
currentBranch: mockDefaultBranch,
+ hasUnsavedChanges: true,
+ isNewCiConfigFile: false,
...props,
},
@@ -82,6 +84,27 @@ describe('Pipeline Editor | Commit Form', () => {
});
});
+ describe('submit button', () => {
+ it.each`
+ hasUnsavedChanges | isNewCiConfigFile | isDisabled | btnState
+ ${false} | ${false} | ${true} | ${'disabled'}
+ ${true} | ${false} | ${false} | ${'enabled'}
+ ${true} | ${true} | ${false} | ${'enabled'}
+ ${false} | ${true} | ${false} | ${'enabled'}
+ `(
+ 'is $btnState when hasUnsavedChanges:$hasUnsavedChanges and isNewCiConfigfile:$isNewCiConfigFile',
+ ({ hasUnsavedChanges, isNewCiConfigFile, isDisabled }) => {
+ createComponent({ props: { hasUnsavedChanges, isNewCiConfigFile } });
+
+ if (isDisabled) {
+ expect(findSubmitBtn().attributes('disabled')).toBe('true');
+ } else {
+ expect(findSubmitBtn().attributes('disabled')).toBeUndefined();
+ }
+ },
+ );
+ });
+
describe('when user inputs values', () => {
const anotherMessage = 'Another commit message';
const anotherBranch = 'my-branch';
diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
index b54feea6ff7..33c76309951 100644
--- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
+++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
@@ -51,6 +51,7 @@ describe('Pipeline Editor | Commit section', () => {
const defaultProps = {
ciFileContent: mockCiYml,
commitSha: mockCommitSha,
+ hasUnsavedChanges: true,
isNewCiConfigFile: false,
};
diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
index 4df7768b035..ba06f113120 100644
--- a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
+++ b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
@@ -1,7 +1,6 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import { stubExperiments } from 'helpers/experimentation_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue';
import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue';
@@ -38,7 +37,6 @@ describe('Pipeline editor drawer', () => {
beforeEach(() => {
originalObjects.push(window.gon, window.gl);
- stubExperiments({ pipeline_editor_walkthrough: 'control' });
});
afterEach(() => {
@@ -48,33 +46,15 @@ describe('Pipeline editor drawer', () => {
});
describe('default expanded state', () => {
- describe('when experiment control', () => {
- it('sets the drawer to be opened by default', async () => {
- createComponent();
- expect(findDrawerContent().exists()).toBe(false);
- await nextTick();
- expect(findDrawerContent().exists()).toBe(true);
- });
- });
-
- describe('when experiment candidate', () => {
- beforeEach(() => {
- stubExperiments({ pipeline_editor_walkthrough: 'candidate' });
- });
-
- it('sets the drawer to be closed by default', async () => {
- createComponent();
- expect(findDrawerContent().exists()).toBe(false);
- await nextTick();
- expect(findDrawerContent().exists()).toBe(false);
- });
+ it('sets the drawer to be closed by default', async () => {
+ createComponent();
+ expect(findDrawerContent().exists()).toBe(false);
});
});
describe('when the drawer is collapsed', () => {
beforeEach(async () => {
createComponent();
- await clickToggleBtn();
});
it('shows the left facing arrow icon', () => {
@@ -101,6 +81,7 @@ describe('Pipeline editor drawer', () => {
describe('when the drawer is expanded', () => {
beforeEach(async () => {
createComponent();
+ await clickToggleBtn();
});
it('shows the right facing arrow icon', () => {
diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
index f15d5f334d6..6cdf9a93d55 100644
--- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
+++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { EDITOR_READY_EVENT } from '~/editor/constants';
+import { SOURCE_EDITOR_DEBOUNCE } from '~/pipeline_editor/constants';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
import {
mockCiConfigPath,
@@ -22,7 +23,7 @@ describe('Pipeline Editor | Text editor component', () => {
const MockSourceEditor = {
template: '<div/>',
- props: ['value', 'fileName'],
+ props: ['value', 'fileName', 'editorOptions', 'debounceValue'],
};
const createComponent = (glFeatures = {}, mountFn = shallowMount) => {
@@ -90,6 +91,14 @@ describe('Pipeline Editor | Text editor component', () => {
expect(findEditor().props('fileName')).toBe(mockCiConfigPath);
});
+ it('passes down editor configs options', () => {
+ expect(findEditor().props('editorOptions')).toEqual({ quickSuggestions: true });
+ });
+
+ it('passes down editor debounce value', () => {
+ expect(findEditor().props('debounceValue')).toBe(SOURCE_EDITOR_DEBOUNCE);
+ });
+
it('bubbles up events', () => {
findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail);
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 f6154f50bc0..fee52db9b64 100644
--- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
+++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
@@ -7,7 +7,6 @@ import WalkthroughPopover from '~/pipeline_editor/components/walkthrough_popover
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';
-import { stubExperiments } from 'helpers/experimentation_helper';
import {
CREATE_TAB,
EDITOR_APP_STATUS_EMPTY,
@@ -245,50 +244,30 @@ describe('Pipeline editor tabs component', () => {
});
});
- describe('pipeline_editor_walkthrough experiment', () => {
- describe('when in control path', () => {
- beforeEach(() => {
- stubExperiments({ pipeline_editor_walkthrough: 'control' });
- });
-
- it('does not show walkthrough popover', async () => {
- createComponent({ mountFn: mount });
+ describe('pipeline editor walkthrough', () => {
+ describe('when isNewCiConfigFile prop is true (default)', () => {
+ beforeEach(async () => {
+ createComponent({
+ mountFn: mount,
+ });
await nextTick();
- expect(findWalkthroughPopover().exists()).toBe(false);
});
- });
- describe('when in candidate path', () => {
- beforeEach(() => {
- stubExperiments({ pipeline_editor_walkthrough: 'candidate' });
- });
-
- describe('when isNewCiConfigFile prop is true (default)', () => {
- beforeEach(async () => {
- createComponent({
- mountFn: mount,
- });
- await nextTick();
- });
-
- it('shows walkthrough popover', async () => {
- expect(findWalkthroughPopover().exists()).toBe(true);
- });
+ it('shows walkthrough popover', async () => {
+ expect(findWalkthroughPopover().exists()).toBe(true);
});
+ });
- describe('when isNewCiConfigFile prop is false', () => {
- it('does not show walkthrough popover', async () => {
- createComponent({ props: { isNewCiConfigFile: false }, mountFn: mount });
- await nextTick();
- expect(findWalkthroughPopover().exists()).toBe(false);
- });
+ describe('when isNewCiConfigFile prop is false', () => {
+ it('does not show walkthrough popover', async () => {
+ createComponent({ props: { isNewCiConfigFile: false }, mountFn: mount });
+ await nextTick();
+ expect(findWalkthroughPopover().exists()).toBe(false);
});
});
});
it('sets listeners on walkthrough popover', async () => {
- stubExperiments({ pipeline_editor_walkthrough: 'candidate' });
-
const handler = jest.fn();
createComponent({
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
index 0a2c03b7850..0ce6cc3f2d4 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
@@ -18,12 +18,15 @@ import {
COMMIT_SUCCESS,
COMMIT_SUCCESS_WITH_REDIRECT,
COMMIT_FAILURE,
+ EDITOR_APP_STATUS_LOADING,
} from '~/pipeline_editor/constants';
import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql';
import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.query.graphql';
import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql';
import getLatestCommitShaQuery from '~/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql';
import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql';
+import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql';
+import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue';
@@ -84,9 +87,6 @@ describe('Pipeline editor app component', () => {
initialCiFileContent: {
loading: blobLoading,
},
- ciConfigData: {
- loading: false,
- },
},
},
},
@@ -94,7 +94,11 @@ describe('Pipeline editor app component', () => {
});
};
- const createComponentWithApollo = async ({ provide = {}, stubs = {} } = {}) => {
+ const createComponentWithApollo = async ({
+ provide = {},
+ stubs = {},
+ withUndefinedBranch = false,
+ } = {}) => {
const handlers = [
[getBlobContent, mockBlobContentData],
[getCiConfigData, mockCiConfigData],
@@ -105,6 +109,31 @@ describe('Pipeline editor app component', () => {
mockApollo = createMockApollo(handlers, resolvers);
+ if (!withUndefinedBranch) {
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getCurrentBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: mockDefaultBranch,
+ },
+ },
+ },
+ });
+ }
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ app: {
+ __typename: 'AppData',
+ status: EDITOR_APP_STATUS_LOADING,
+ },
+ },
+ });
+
const options = {
localVue,
mocks: {},
@@ -145,6 +174,55 @@ describe('Pipeline editor app component', () => {
});
});
+ describe('skipping queries', () => {
+ describe('when branchName is undefined', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo({ withUndefinedBranch: true });
+ });
+
+ it('does not calls getBlobContent', () => {
+ expect(mockBlobContentData).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when branchName is defined', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo();
+ });
+
+ it('calls getBlobContent', () => {
+ expect(mockBlobContentData).toHaveBeenCalled();
+ });
+ });
+
+ describe('when commit sha is undefined', () => {
+ beforeEach(async () => {
+ mockLatestCommitShaQuery.mockResolvedValue(undefined);
+ await createComponentWithApollo();
+ });
+
+ it('calls getBlobContent', () => {
+ expect(mockBlobContentData).toHaveBeenCalled();
+ });
+
+ it('does not call ciConfigData', () => {
+ expect(mockCiConfigData).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when commit sha is defined', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+ await createComponentWithApollo();
+ });
+
+ it('calls ciConfigData', () => {
+ expect(mockCiConfigData).toHaveBeenCalled();
+ });
+ });
+ });
+
describe('when queries are called', () => {
beforeEach(() => {
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
diff --git a/spec/frontend/pipeline_wizard/components/input_spec.js b/spec/frontend/pipeline_wizard/components/input_spec.js
new file mode 100644
index 00000000000..ee1f3fe70ff
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/components/input_spec.js
@@ -0,0 +1,79 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import { Document } from 'yaml';
+import InputWrapper from '~/pipeline_wizard/components/input.vue';
+import TextWidget from '~/pipeline_wizard/components/widgets/text.vue';
+
+describe('Pipeline Wizard -- Input Wrapper', () => {
+ let wrapper;
+
+ const createComponent = (props = {}, mountFunc = mount) => {
+ wrapper = mountFunc(InputWrapper, {
+ propsData: {
+ template: new Document({
+ template: {
+ bar: 'baz',
+ foo: { some: '$TARGET' },
+ },
+ }).get('template'),
+ compiled: new Document({ bar: 'baz', foo: { some: '$TARGET' } }),
+ target: '$TARGET',
+ widget: 'text',
+ label: 'some label (required by the text widget)',
+ ...props,
+ },
+ });
+ };
+
+ describe('API', () => {
+ const inputValue = 'dslkfjsdlkfjlskdjfn';
+ let inputChild;
+
+ beforeEach(() => {
+ createComponent({});
+ inputChild = wrapper.find(TextWidget);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('will replace its value in compiled', async () => {
+ await inputChild.vm.$emit('input', inputValue);
+ const expected = new Document({
+ bar: 'baz',
+ foo: { some: inputValue },
+ });
+ expect(wrapper.emitted()['update:compiled']).toEqual([[expected]]);
+ });
+
+ it('will emit a highlight event with the correct path if child emits an input event', async () => {
+ await inputChild.vm.$emit('input', inputValue);
+ const expected = ['foo', 'some'];
+ expect(wrapper.emitted().highlight).toEqual([[expected]]);
+ });
+ });
+
+ describe('Target Path Discovery', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ scenario | template | target | expected
+ ${'simple nested object'} | ${{ foo: { bar: { baz: '$BOO' } } }} | ${'$BOO'} | ${['foo', 'bar', 'baz']}
+ ${'list, first pos.'} | ${{ foo: ['$BOO'] }} | ${'$BOO'} | ${['foo', 0]}
+ ${'list, second pos.'} | ${{ foo: ['bar', '$BOO'] }} | ${'$BOO'} | ${['foo', 1]}
+ ${'lowercase target'} | ${{ foo: { bar: '$jupp' } }} | ${'$jupp'} | ${['foo', 'bar']}
+ ${'root list'} | ${['$BOO']} | ${'$BOO'} | ${[0]}
+ `('$scenario', ({ template, target, expected }) => {
+ createComponent(
+ {
+ template: new Document({ template }).get('template'),
+ target,
+ },
+ shallowMount,
+ );
+ expect(wrapper.vm.path).toEqual(expected);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_wizard/components/step_spec.js b/spec/frontend/pipeline_wizard/components/step_spec.js
new file mode 100644
index 00000000000..2289a349318
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/components/step_spec.js
@@ -0,0 +1,227 @@
+import { parseDocument, Document } from 'yaml';
+import { omit } from 'lodash';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import PipelineWizardStep from '~/pipeline_wizard/components/step.vue';
+import InputWrapper from '~/pipeline_wizard/components/input.vue';
+import StepNav from '~/pipeline_wizard/components/step_nav.vue';
+import {
+ stepInputs,
+ stepTemplate,
+ compiledYamlBeforeSetup,
+ compiledYamlAfterInitialLoad,
+ compiledYaml,
+} from '../mock/yaml';
+
+describe('Pipeline Wizard - Step Page', () => {
+ const inputs = parseDocument(stepInputs).toJS();
+ let wrapper;
+ let input1;
+ let input2;
+
+ const getInputWrappers = () => wrapper.findAllComponents(InputWrapper);
+ const forEachInputWrapper = (cb) => {
+ getInputWrappers().wrappers.forEach(cb);
+ };
+ const getStepNav = () => {
+ return wrapper.findComponent(StepNav);
+ };
+ const mockNextClick = () => {
+ getStepNav().vm.$emit('next');
+ };
+ const mockPrevClick = () => {
+ getStepNav().vm.$emit('back');
+ };
+ const expectFalsyAttributeValue = (testedWrapper, attributeName) => {
+ expect([false, null, undefined]).toContain(testedWrapper.attributes(attributeName));
+ };
+ const findInputWrappers = () => {
+ const inputWrappers = wrapper.findAllComponents(InputWrapper);
+ input1 = inputWrappers.at(0);
+ input2 = inputWrappers.at(1);
+ };
+
+ const createComponent = (props = {}) => {
+ const template = parseDocument(stepTemplate).get('template');
+ const defaultProps = {
+ inputs,
+ template,
+ };
+ wrapper = shallowMountExtended(PipelineWizardStep, {
+ propsData: {
+ ...defaultProps,
+ compiled: parseDocument(compiledYamlBeforeSetup),
+ ...props,
+ },
+ });
+ };
+
+ afterEach(async () => {
+ await wrapper.destroy();
+ });
+
+ describe('input children', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('mounts an inputWrapper for each input type', () => {
+ forEachInputWrapper((inputWrapper, i) =>
+ expect(inputWrapper.attributes('widget')).toBe(inputs[i].widget),
+ );
+ });
+
+ it('passes all unused props to the inputWrapper', () => {
+ const pickChildProperties = (from) => {
+ return omit(from, ['target', 'widget']);
+ };
+ forEachInputWrapper((inputWrapper, i) => {
+ const expectedProps = pickChildProperties(inputs[i]);
+ Object.entries(expectedProps).forEach(([key, value]) => {
+ expect(inputWrapper.attributes(key.toLowerCase())).toEqual(value.toString());
+ });
+ });
+ });
+ });
+
+ const yamlDocument = new Document({ foo: { bar: 'baz' } });
+ const yamlNode = yamlDocument.get('foo');
+
+ describe('prop validation', () => {
+ describe.each`
+ componentProp | required | valid | invalid
+ ${'inputs'} | ${true} | ${[inputs, []]} | ${[['invalid'], [null], [{}, {}]]}
+ ${'template'} | ${true} | ${[yamlNode]} | ${['invalid', null, { foo: 1 }, yamlDocument]}
+ ${'compiled'} | ${true} | ${[yamlDocument]} | ${['invalid', null, { foo: 1 }, yamlNode]}
+ `('testing `$componentProp` prop', ({ componentProp, required, valid, invalid }) => {
+ it('expects prop to be required', () => {
+ expect(PipelineWizardStep.props[componentProp].required).toEqual(required);
+ });
+
+ it('prop validators return false for invalid types', () => {
+ const validatorFunc = PipelineWizardStep.props[componentProp].validator;
+ invalid.forEach((invalidType) => {
+ expect(validatorFunc(invalidType)).toBe(false);
+ });
+ });
+
+ it('prop validators return true for valid types', () => {
+ const validatorFunc = PipelineWizardStep.props[componentProp].validator;
+ valid.forEach((validType) => {
+ expect(validatorFunc(validType)).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('navigation', () => {
+ it('shows the next button', () => {
+ createComponent();
+
+ expect(getStepNav().attributes('nextbuttonenabled')).toEqual('true');
+ });
+
+ it('does not show a back button if hasPreviousStep is false', () => {
+ createComponent({ hasPreviousStep: false });
+
+ expectFalsyAttributeValue(getStepNav(), 'showbackbutton');
+ });
+
+ it('shows a back button if hasPreviousStep is true', () => {
+ createComponent({ hasPreviousStep: true });
+
+ expect(getStepNav().attributes('showbackbutton')).toBe('true');
+ });
+
+ it('lets "back" event bubble upwards', async () => {
+ createComponent();
+
+ await mockPrevClick();
+ await nextTick();
+
+ expect(wrapper.emitted().back).toBeTruthy();
+ });
+
+ it('lets "next" event bubble upwards', async () => {
+ createComponent();
+
+ await mockNextClick();
+ await nextTick();
+
+ expect(wrapper.emitted().next).toBeTruthy();
+ });
+ });
+
+ describe('validation', () => {
+ beforeEach(() => {
+ createComponent({ hasNextPage: true });
+ findInputWrappers();
+ });
+
+ it('sets invalid once one input field has an invalid value', async () => {
+ input1.vm.$emit('update:valid', true);
+ input2.vm.$emit('update:valid', false);
+
+ await mockNextClick();
+
+ expectFalsyAttributeValue(getStepNav(), 'nextbuttonenabled');
+ });
+
+ it('returns to valid state once the invalid input is valid again', async () => {
+ input1.vm.$emit('update:valid', true);
+ input2.vm.$emit('update:valid', false);
+
+ await mockNextClick();
+
+ expectFalsyAttributeValue(getStepNav(), 'nextbuttonenabled');
+
+ input2.vm.$emit('update:valid', true);
+ await nextTick();
+
+ expect(getStepNav().attributes('nextbuttonenabled')).toBe('true');
+ });
+
+ it('passes validate state to all input wrapper children when next is clicked', async () => {
+ forEachInputWrapper((inputWrapper) => {
+ expectFalsyAttributeValue(inputWrapper, 'validate');
+ });
+
+ await mockNextClick();
+
+ expect(input1.attributes('validate')).toBe('true');
+ });
+
+ it('not emitting a valid state is considered valid', async () => {
+ // input1 does not emit a update:valid event
+ input2.vm.$emit('update:valid', true);
+
+ await mockNextClick();
+
+ expect(getStepNav().attributes('nextbuttonenabled')).toBe('true');
+ });
+ });
+
+ describe('template compilation', () => {
+ beforeEach(() => {
+ createComponent();
+ findInputWrappers();
+ });
+
+ it('injects the template when an input wrapper emits a beforeUpdate:compiled event', async () => {
+ input1.vm.$emit('beforeUpdate:compiled');
+
+ expect(wrapper.vm.compiled.toString()).toBe(compiledYamlAfterInitialLoad);
+ });
+
+ it('lets the "update:compiled" event bubble upwards', async () => {
+ const compiled = parseDocument(compiledYaml);
+
+ await input1.vm.$emit('update:compiled', compiled);
+
+ const updateEvents = wrapper.emitted()['update:compiled'];
+ const latestUpdateEvent = updateEvents[updateEvents.length - 1];
+
+ expect(latestUpdateEvent[0].toString()).toBe(compiled.toString());
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js
new file mode 100644
index 00000000000..796356634bc
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js
@@ -0,0 +1,212 @@
+import { GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('Pipeline Wizard - List Widget', () => {
+ const defaultProps = {
+ label: 'This label',
+ description: 'some description',
+ placeholder: 'some placeholder',
+ pattern: '^[a-z]+$',
+ invalidFeedback: 'some feedback',
+ };
+ let wrapper;
+ let addStepBtn;
+
+ const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
+ const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback').text();
+ const findFirstGlFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
+ const findAllGlFormInputGroups = () => wrapper.findAllComponents(GlFormInputGroup);
+ const findGlFormInputGroupByIndex = (index) => findAllGlFormInputGroups().at(index);
+ const setValueOnInputField = (value, atIndex = 0) => {
+ return findGlFormInputGroupByIndex(atIndex).vm.$emit('input', value);
+ };
+ const findAddStepButton = () => wrapper.findByTestId('add-step-button');
+ const addStep = () => findAddStepButton().vm.$emit('click');
+
+ const createComponent = (props = {}, mountFn = shallowMountExtended) => {
+ wrapper = mountFn(ListWidget, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ addStepBtn = findAddStepButton();
+ };
+
+ describe('component setup and interface', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('prints the label inside the legend', () => {
+ createComponent();
+
+ expect(findGlFormGroup().attributes('label')).toBe(defaultProps.label);
+ });
+
+ it('prints the description inside the legend', () => {
+ createComponent();
+
+ expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description);
+ });
+
+ it('sets the input field type attribute to "text"', async () => {
+ createComponent();
+
+ expect(findFirstGlFormInputGroup().attributes('type')).toBe('text');
+ });
+
+ it('passes the placeholder to the first input field', () => {
+ createComponent();
+
+ expect(findFirstGlFormInputGroup().attributes('placeholder')).toBe(defaultProps.placeholder);
+ });
+
+ it('shows a delete button on all fields if there are more than one', async () => {
+ createComponent({}, mountExtended);
+
+ await addStep();
+ await addStep();
+ const inputGroups = findAllGlFormInputGroups().wrappers;
+
+ expect(inputGroups.length).toBe(3);
+ inputGroups.forEach((inputGroup) => {
+ const button = inputGroup.find('[data-testid="remove-step-button"]');
+ expect(button.find('[data-testid="remove-icon"]').exists()).toBe(true);
+ expect(button.attributes('aria-label')).toBe('remove step');
+ });
+ });
+
+ it('null values do not cause an input event', async () => {
+ createComponent();
+
+ await addStep();
+
+ expect(wrapper.emitted('input')).toBe(undefined);
+ });
+
+ it('hides the delete button if there is only one', () => {
+ createComponent({}, mountExtended);
+
+ const inputGroups = findAllGlFormInputGroups().wrappers;
+
+ expect(inputGroups.length).toBe(1);
+ expect(wrapper.findByTestId('remove-step-button').exists()).toBe(false);
+ });
+
+ it('shows an "add step" button', () => {
+ createComponent();
+
+ expect(addStepBtn.attributes('icon')).toBe('plus');
+ expect(addStepBtn.text()).toBe('add another step');
+ });
+
+ it('the "add step" button increases the number of input fields', async () => {
+ createComponent();
+
+ expect(findAllGlFormInputGroups().wrappers.length).toBe(1);
+ await addStep();
+ expect(findAllGlFormInputGroups().wrappers.length).toBe(2);
+ });
+
+ it('does not pass the placeholder on subsequent input fields', async () => {
+ createComponent();
+
+ await addStep();
+ await addStep();
+ const nullOrUndefined = [null, undefined];
+ expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(1).attributes('placeholder'));
+ expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(2).attributes('placeholder'));
+ });
+
+ it('emits an update event on input', async () => {
+ createComponent();
+
+ const localValue = 'somevalue';
+ await setValueOnInputField(localValue);
+ await nextTick();
+
+ expect(wrapper.emitted('input')).toEqual([[[localValue]]]);
+ });
+
+ it('only emits non-null values', async () => {
+ createComponent();
+
+ await addStep();
+ await addStep();
+ await setValueOnInputField('abc', 1);
+ await nextTick();
+
+ const events = wrapper.emitted('input');
+
+ expect(events.length).toBe(1);
+ expect(events[0]).toEqual([['abc']]);
+ });
+ });
+
+ describe('form validation', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('does not show validation state when untouched', async () => {
+ createComponent({}, mountExtended);
+ expect(findGlFormGroup().classes()).not.toContain('is-valid');
+ expect(findGlFormGroup().classes()).not.toContain('is-invalid');
+ });
+
+ it('shows invalid state on blur', async () => {
+ createComponent({}, mountExtended);
+ expect(findGlFormGroup().classes()).not.toContain('is-invalid');
+ const input = findFirstGlFormInputGroup().find('input');
+ await input.setValue('invalid99');
+ await input.trigger('blur');
+ expect(input.classes()).toContain('is-invalid');
+ expect(findGlFormGroup().classes()).toContain('is-invalid');
+ });
+
+ it('shows invalid state when toggling `validate` prop', async () => {
+ createComponent({ required: true, validate: false }, mountExtended);
+ await setValueOnInputField(null);
+ expect(findGlFormGroup().classes()).not.toContain('is-invalid');
+ await wrapper.setProps({ validate: true });
+ expect(findGlFormGroup().classes()).toContain('is-invalid');
+ });
+
+ it.each`
+ scenario | required | values | inputFieldClasses | inputGroupClass | feedback
+ ${'shows invalid if all inputs are empty'} | ${true} | ${[null, null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${'At least one entry is required'}
+ ${'is valid if at least one field has a valid entry'} | ${true} | ${[null, 'abc']} | ${[null, 'is-valid']} | ${'is-valid'} | ${expect.anything()}
+ ${'is invalid if one field has an invalid entry'} | ${true} | ${['abc', '99']} | ${['is-valid', 'is-invalid']} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
+ ${'is not invalid if its not required but all values are null'} | ${false} | ${[null, null]} | ${[null, null]} | ${'is-valid'} | ${expect.anything()}
+ ${'is invalid if pattern does not match even if its not required'} | ${false} | ${['99', null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
+ `('$scenario', async ({ required, values, inputFieldClasses, inputGroupClass, feedback }) => {
+ createComponent({ required, validate: true }, mountExtended);
+
+ await Promise.all(
+ values.map(async (value, i) => {
+ if (i > 0) {
+ await addStep();
+ }
+ await setValueOnInputField(value, i);
+ }),
+ );
+ await nextTick();
+
+ inputFieldClasses.forEach((expected, i) => {
+ const inputWrapper = findGlFormInputGroupByIndex(i).find('input');
+ if (expected === null) {
+ expect(inputWrapper.classes()).not.toContain('is-valid');
+ expect(inputWrapper.classes()).not.toContain('is-invalid');
+ } else {
+ expect(inputWrapper.classes()).toContain(expected);
+ }
+ });
+
+ expect(findGlFormGroup().classes()).toContain(inputGroupClass);
+ expect(findGlFormGroupInvalidFeedback()).toEqual(feedback);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_wizard/components/widgets_spec.js b/spec/frontend/pipeline_wizard/components/widgets_spec.js
new file mode 100644
index 00000000000..5944c76c5d0
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/components/widgets_spec.js
@@ -0,0 +1,49 @@
+import fs from 'fs';
+import { mount } from '@vue/test-utils';
+import { Document } from 'yaml';
+import InputWrapper from '~/pipeline_wizard/components/input.vue';
+
+describe('Test all widgets in ./widgets/* whether they provide a minimal api', () => {
+ const createComponent = (props = {}, mountFunc = mount) => {
+ mountFunc(InputWrapper, {
+ propsData: {
+ template: new Document({
+ template: {
+ bar: 'baz',
+ foo: { some: '$TARGET' },
+ },
+ }).get('template'),
+ compiled: new Document({ bar: 'baz', foo: { some: '$TARGET' } }),
+ target: '$TARGET',
+ widget: 'text',
+ label: 'some label (required by the text widget)',
+ ...props,
+ },
+ });
+ };
+
+ const widgets = fs
+ .readdirSync('./app/assets/javascripts/pipeline_wizard/components/widgets')
+ .map((filename) => [filename.match(/^(.*).vue$/)[1]]);
+ let consoleErrorSpy;
+
+ beforeAll(() => {
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ describe.each(widgets)('`%s` Widget', (name) => {
+ it('passes the input validator', () => {
+ const validatorFunc = InputWrapper.props.widget.validator;
+ expect(validatorFunc(name)).toBe(true);
+ });
+
+ it('mounts without error', () => {
+ createComponent({ widget: name });
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_wizard/components/wrapper_spec.js b/spec/frontend/pipeline_wizard/components/wrapper_spec.js
new file mode 100644
index 00000000000..bd1679baf48
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/components/wrapper_spec.js
@@ -0,0 +1,250 @@
+import { Document, parseDocument } from 'yaml';
+import { GlProgressBar } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import PipelineWizardWrapper, { i18n } from '~/pipeline_wizard/components/wrapper.vue';
+import WizardStep from '~/pipeline_wizard/components/step.vue';
+import CommitStep from '~/pipeline_wizard/components/commit.vue';
+import YamlEditor from '~/pipeline_wizard/components/editor.vue';
+import { sprintf } from '~/locale';
+import { steps as stepsYaml } from '../mock/yaml';
+
+describe('Pipeline Wizard - wrapper.vue', () => {
+ let wrapper;
+ const steps = parseDocument(stepsYaml).toJS();
+
+ const getAsYamlNode = (value) => new Document(value).contents;
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(PipelineWizardWrapper, {
+ propsData: {
+ projectPath: '/user/repo',
+ defaultBranch: 'main',
+ filename: '.gitlab-ci.yml',
+ steps: getAsYamlNode(steps),
+ ...props,
+ },
+ });
+ };
+ const getEditorContent = () => {
+ return wrapper.getComponent(YamlEditor).attributes().doc.toString();
+ };
+ const getStepWrapper = () => wrapper.getComponent(WizardStep);
+ const getGlProgressBarWrapper = () => wrapper.getComponent(GlProgressBar);
+
+ describe('display', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows the steps', () => {
+ createComponent();
+
+ expect(getStepWrapper().exists()).toBe(true);
+ });
+
+ it('shows the progress bar', () => {
+ createComponent();
+
+ const expectedMessage = sprintf(i18n.stepNofN, {
+ currentStep: 1,
+ stepCount: 3,
+ });
+
+ expect(wrapper.findByTestId('step-count').text()).toBe(expectedMessage);
+ expect(getGlProgressBarWrapper().exists()).toBe(true);
+ });
+
+ it('shows the editor', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(YamlEditor).exists()).toBe(true);
+ });
+
+ it('shows the editor header with the default filename', () => {
+ createComponent();
+
+ const expectedMessage = sprintf(i18n.draft, {
+ filename: '.gitlab-ci.yml',
+ });
+
+ expect(wrapper.findByTestId('editor-header').text()).toBe(expectedMessage);
+ });
+
+ it('shows the editor header with a custom filename', async () => {
+ const filename = 'my-file.yml';
+ createComponent({
+ filename,
+ });
+
+ const expectedMessage = sprintf(i18n.draft, {
+ filename,
+ });
+
+ expect(wrapper.findByTestId('editor-header').text()).toBe(expectedMessage);
+ });
+ });
+
+ describe('steps', () => {
+ const totalSteps = steps.length + 1;
+
+ // **Note** on `expectProgressBarValue`
+ // Why are we expecting 50% here and not 66% or even 100%?
+ // The reason is mostly a UX thing.
+ // First, we count the commit step as an extra step, so that would
+ // be 66% by now (2 of 3).
+ // But then we add yet another one to the calc, because when we
+ // arrived on the second step's page, it's not *completed* (which is
+ // what the progress bar indicates). So in that case we're at 33%.
+ // Lastly, we want to start out with the progress bar not at zero,
+ // because UX research indicates that makes a process like this less
+ // intimidating, so we're always adding one step to the value bar
+ // (but not to the step counter. Now we're back at 50%.
+ describe.each`
+ step | navigationEventChain | expectStepNumber | expectCommitStepShown | expectStepDef | expectProgressBarValue
+ ${'initial step'} | ${[]} | ${1} | ${false} | ${steps[0]} | ${25}
+ ${'second step'} | ${['next']} | ${2} | ${false} | ${steps[1]} | ${50}
+ ${'commit step'} | ${['next', 'next']} | ${3} | ${true} | ${null} | ${75}
+ ${'stepping back'} | ${['next', 'back']} | ${1} | ${false} | ${steps[0]} | ${25}
+ ${'clicking next>next>back'} | ${['next', 'next', 'back']} | ${2} | ${false} | ${steps[1]} | ${50}
+ ${'clicking all the way through and back'} | ${['next', 'next', 'back', 'back']} | ${1} | ${false} | ${steps[0]} | ${25}
+ `(
+ '$step',
+ ({
+ navigationEventChain,
+ expectStepNumber,
+ expectCommitStepShown,
+ expectStepDef,
+ expectProgressBarValue,
+ }) => {
+ beforeAll(async () => {
+ createComponent();
+ for (const emittedValue of navigationEventChain) {
+ wrapper.findComponent({ ref: 'step' }).vm.$emit(emittedValue);
+ // We have to wait for the next step to be mounted
+ // before we can emit the next event, so we have to await
+ // inside the loop.
+ // eslint-disable-next-line no-await-in-loop
+ await nextTick();
+ }
+ });
+
+ afterAll(() => {
+ wrapper.destroy();
+ });
+
+ if (expectCommitStepShown) {
+ it('does not show the step wrapper', async () => {
+ expect(wrapper.findComponent(WizardStep).exists()).toBe(false);
+ });
+
+ it('shows the commit step page', () => {
+ expect(wrapper.findComponent(CommitStep).exists()).toBe(true);
+ });
+ } else {
+ it('passes the correct step config to the step component', async () => {
+ expect(getStepWrapper().props('inputs')).toMatchObject(expectStepDef.inputs);
+ });
+
+ it('does not show the commit step page', () => {
+ expect(wrapper.findComponent(CommitStep).exists()).toBe(false);
+ });
+ }
+
+ it('updates the progress bar', () => {
+ expect(getGlProgressBarWrapper().attributes('value')).toBe(`${expectProgressBarValue}`);
+ });
+
+ it('updates the step number', () => {
+ const expectedMessage = sprintf(i18n.stepNofN, {
+ currentStep: expectStepNumber,
+ stepCount: totalSteps,
+ });
+
+ expect(wrapper.findByTestId('step-count').text()).toBe(expectedMessage);
+ });
+ },
+ );
+ });
+
+ describe('editor overlay', () => {
+ beforeAll(() => {
+ createComponent();
+ });
+
+ afterAll(() => {
+ wrapper.destroy();
+ });
+
+ it('initially shows a placeholder', async () => {
+ const editorContent = getEditorContent();
+
+ await nextTick();
+
+ expect(editorContent).toBe('foo: $FOO\nbar: $BAR\n');
+ });
+
+ it('shows an overlay with help text after setup', () => {
+ expect(wrapper.findByTestId('placeholder-overlay').exists()).toBe(true);
+ expect(wrapper.findByTestId('filename').text()).toBe('.gitlab-ci.yml');
+ expect(wrapper.findByTestId('description').text()).toBe(i18n.overlayMessage);
+ });
+
+ it('does not show overlay when content has changed', async () => {
+ const newCompiledDoc = new Document({ faa: 'bur' });
+
+ await getStepWrapper().vm.$emit('update:compiled', newCompiledDoc);
+ await nextTick();
+
+ const overlay = wrapper.findByTestId('placeholder-overlay');
+
+ expect(overlay.exists()).toBe(false);
+ });
+ });
+
+ describe('editor updates', () => {
+ beforeAll(() => {
+ createComponent();
+ });
+
+ afterAll(() => {
+ wrapper.destroy();
+ });
+
+ it('editor reflects changes', async () => {
+ const newCompiledDoc = new Document({ faa: 'bur' });
+ await getStepWrapper().vm.$emit('update:compiled', newCompiledDoc);
+
+ expect(getEditorContent()).toBe(newCompiledDoc.toString());
+ });
+ });
+
+ describe('line highlights', () => {
+ beforeAll(() => {
+ createComponent();
+ });
+
+ afterAll(() => {
+ wrapper.destroy();
+ });
+
+ it('highlight requests by the step get passed on to the editor', async () => {
+ const highlight = 'foo';
+
+ await getStepWrapper().vm.$emit('update:highlight', highlight);
+
+ expect(wrapper.getComponent(YamlEditor).props('highlight')).toBe(highlight);
+ });
+
+ it('removes the highlight when clicking through to the commit step', async () => {
+ // Simulate clicking through all steps until the last one
+ await Promise.all(
+ steps.map(async () => {
+ await getStepWrapper().vm.$emit('next');
+ await nextTick();
+ }),
+ );
+
+ expect(wrapper.getComponent(YamlEditor).props('highlight')).toBe(null);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_wizard/mock/yaml.js b/spec/frontend/pipeline_wizard/mock/yaml.js
new file mode 100644
index 00000000000..5eaeaa32a8c
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/mock/yaml.js
@@ -0,0 +1,85 @@
+export const stepInputs = `
+- label: "Build Steps"
+ description: "Enter the steps necessary for your application."
+ widget: text
+ target: $BUILD_STEPS
+- label: "Select a deployment branch"
+ description: "Select the branch we should use to generate your site from."
+ widget: text
+ target: $BRANCH
+ pattern: "^[a-z]+$"
+ invalidFeedback: "This field may only contain lowercase letters"
+ required: true
+`;
+
+export const stepTemplate = `template:
+ pages:
+ script: $BUILD_STEPS
+ artifacts:
+ paths:
+ - public
+ only:
+ - $BRANCH
+`;
+
+export const compiledYamlBeforeSetup = `abc: def`;
+
+export const compiledYamlAfterInitialLoad = `abc: def
+pages:
+ script: $BUILD_STEPS
+ artifacts:
+ paths:
+ - public
+ only:
+ - $BRANCH
+`;
+
+export const compiledYaml = `abc: def
+pages:
+ script: foo
+ artifacts:
+ paths:
+ - public
+ only:
+ - bar
+`;
+
+export const steps = `
+- inputs:
+ - label: foo
+ target: $FOO
+ widget: text
+ template:
+ foo: $FOO
+- inputs:
+ - label: bar
+ target: $BAR
+ widget: text
+ template:
+ bar: $BAR
+`;
+
+export const fullTemplate = `
+title: some title
+description: some description
+filename: foo.yml
+steps:
+ - inputs:
+ - widget: text
+ label: foo
+ target: $BAR
+ template:
+ foo: $BAR
+`;
+
+export const fullTemplateWithoutFilename = `
+title: some title
+description: some description
+steps:
+ - inputs:
+ - widget: text
+ label: foo
+ target: $BAR
+ template:
+ foo: $BAR
+`;
diff --git a/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js b/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js
new file mode 100644
index 00000000000..dd0304518a3
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js
@@ -0,0 +1,102 @@
+import { parseDocument } from 'yaml';
+import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue';
+import PipelineWizardWrapper from '~/pipeline_wizard/components/wrapper.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ fullTemplate as template,
+ fullTemplateWithoutFilename as templateWithoutFilename,
+} from './mock/yaml';
+
+const projectPath = 'foo/bar';
+const defaultBranch = 'main';
+
+describe('PipelineWizard', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(PipelineWizard, {
+ propsData: {
+ projectPath,
+ defaultBranch,
+ template,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('mounts without error', () => {
+ const consoleSpy = jest.spyOn(console, 'error');
+
+ createComponent();
+
+ expect(consoleSpy).not.toHaveBeenCalled();
+ expect(wrapper.exists()).toBe(true);
+ });
+
+ it('mounts the wizard wrapper', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(PipelineWizardWrapper).exists()).toBe(true);
+ });
+
+ it('passes the correct steps prop to the wizard wrapper', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(PipelineWizardWrapper).props('steps')).toEqual(
+ parseDocument(template).get('steps'),
+ );
+ });
+
+ it('passes all other expected props to the wizard wrapper', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(PipelineWizardWrapper).props()).toEqual(
+ expect.objectContaining({
+ defaultBranch,
+ projectPath,
+ filename: parseDocument(template).get('filename'),
+ }),
+ );
+ });
+
+ it('passes ".gitlab-ci.yml" as default filename to the wizard wrapper', () => {
+ createComponent({ template: templateWithoutFilename });
+
+ expect(wrapper.findComponent(PipelineWizardWrapper).attributes('filename')).toBe(
+ '.gitlab-ci.yml',
+ );
+ });
+
+ it('allows overriding the defaultFilename with `defaultFilename` prop', () => {
+ const defaultFilename = 'foobar.yml';
+
+ createComponent({
+ template: templateWithoutFilename,
+ defaultFilename,
+ });
+
+ expect(wrapper.findComponent(PipelineWizardWrapper).attributes('filename')).toBe(
+ defaultFilename,
+ );
+ });
+
+ it('displays the title', () => {
+ createComponent();
+
+ expect(wrapper.findByTestId('title').text()).toBe(
+ parseDocument(template).get('title').toString(),
+ );
+ });
+
+ it('displays the description', () => {
+ createComponent();
+
+ expect(wrapper.findByTestId('description').text()).toBe(
+ parseDocument(template).get('description').toString(),
+ );
+ });
+});
diff --git a/spec/frontend/pipeline_wizard/validators_spec.js b/spec/frontend/pipeline_wizard/validators_spec.js
new file mode 100644
index 00000000000..1276c642f30
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/validators_spec.js
@@ -0,0 +1,22 @@
+import { Document, parseDocument } from 'yaml';
+import { isValidStepSeq } from '~/pipeline_wizard/validators';
+import { steps as stepsYaml } from './mock/yaml';
+
+describe('prop validation', () => {
+ const steps = parseDocument(stepsYaml).toJS();
+ const getAsYamlNode = (value) => new Document(value).contents;
+
+ it('allows passing yaml nodes to the steps prop', () => {
+ const validSteps = getAsYamlNode(steps);
+ expect(isValidStepSeq(validSteps)).toBe(true);
+ });
+
+ it.each`
+ scenario | stepsValue
+ ${'not a seq'} | ${{ foo: 'bar' }}
+ ${'a step missing an input'} | ${[{ template: 'baz: boo' }]}
+ ${'an empty seq'} | ${[]}
+ `('throws an error when passing $scenario to the steps prop', ({ stepsValue }) => {
+ expect(isValidStepSeq(stepsValue)).toBe(false);
+ });
+});
diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
index 65814ad9a7f..81e19a6c221 100644
--- a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
+++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
@@ -1,4 +1,4 @@
-import { GlIntersectionObserver, GlSkeletonLoader } from '@gitlab/ui';
+import { GlIntersectionObserver, GlSkeletonLoader, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -19,6 +19,7 @@ describe('Jobs app', () => {
let resolverSpy;
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
const findJobsTable = () => wrapper.findComponent(JobsTable);
const triggerInfiniteScroll = () =>
@@ -48,7 +49,29 @@ describe('Jobs app', () => {
wrapper.destroy();
});
- it('displays the loading state', () => {
+ describe('loading spinner', () => {
+ beforeEach(async () => {
+ createComponent(resolverSpy);
+
+ await waitForPromises();
+
+ triggerInfiniteScroll();
+ });
+
+ it('displays loading spinner when fetching more jobs', () => {
+ expect(findLoadingSpinner().exists()).toBe(true);
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('hides loading spinner after jobs have been fetched', async () => {
+ await waitForPromises();
+
+ expect(findLoadingSpinner().exists()).toBe(false);
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+ });
+
+ it('displays the skeleton loader', () => {
createComponent(resolverSpy);
expect(findSkeletonLoader().exists()).toBe(true);
@@ -91,7 +114,7 @@ describe('Jobs app', () => {
});
});
- it('does not display main loading state again after fetchMore', async () => {
+ it('does not display skeleton loader again after fetchMore', async () => {
createComponent(resolverSpy);
expect(findSkeletonLoader().exists()).toBe(true);
diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
index 97b59a09518..0822b293f75 100644
--- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
@@ -27,6 +27,7 @@ describe('Pipelines filtered search', () => {
wrapper = mount(PipelinesFilteredSearch, {
propsData: {
projectId: '21',
+ defaultBranchName: 'main',
params,
},
attachTo: document.body,
@@ -69,6 +70,7 @@ describe('Pipelines filtered search', () => {
title: 'Branch name',
unique: true,
projectId: '21',
+ defaultBranchName: 'main',
operators: OPERATOR_IS_ONLY,
});
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index 1d89f949564..c4639bd8e16 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -1,5 +1,7 @@
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import waitForPromises from 'helpers/wait_for_promises';
import HeaderComponent from '~/pipelines/components/header_component.vue';
import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql';
@@ -17,6 +19,7 @@ import {
describe('Pipeline details header', () => {
let wrapper;
let glModalDirective;
+ let mutate = jest.fn();
const findDeleteModal = () => wrapper.find(GlModal);
const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
@@ -44,7 +47,7 @@ describe('Pipeline details header', () => {
startPolling: jest.fn(),
},
},
- mutate: jest.fn(),
+ mutate,
};
return shallowMount(HeaderComponent, {
@@ -120,6 +123,26 @@ describe('Pipeline details header', () => {
});
});
+ describe('Retry action failed', () => {
+ beforeEach(() => {
+ mutate = jest.fn().mockRejectedValue('error');
+
+ wrapper = createComponent(mockCancelledPipelineHeader);
+ });
+
+ it('retry button loading state should reset on error', async () => {
+ findRetryButton().vm.$emit('click');
+
+ await nextTick();
+
+ expect(findRetryButton().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findRetryButton().props('loading')).toBe(false);
+ });
+ });
+
describe('Cancel action', () => {
beforeEach(() => {
wrapper = createComponent(mockRunningPipelineHeader);
diff --git a/spec/frontend/pipelines/pipeline_labels_spec.js b/spec/frontend/pipelines/pipeline_labels_spec.js
new file mode 100644
index 00000000000..ca0229b1cbe
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_labels_spec.js
@@ -0,0 +1,168 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { trimText } from 'helpers/text_helper';
+import PipelineLabelsComponent from '~/pipelines/components/pipelines_list/pipeline_labels.vue';
+import { mockPipeline } from './mock_data';
+
+const projectPath = 'test/test';
+
+describe('Pipeline label component', () => {
+ let wrapper;
+
+ const findScheduledTag = () => wrapper.findByTestId('pipeline-url-scheduled');
+ const findLatestTag = () => wrapper.findByTestId('pipeline-url-latest');
+ const findYamlTag = () => wrapper.findByTestId('pipeline-url-yaml');
+ const findStuckTag = () => wrapper.findByTestId('pipeline-url-stuck');
+ const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops');
+ const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link');
+ const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached');
+ const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure');
+ const findForkTag = () => wrapper.findByTestId('pipeline-url-fork');
+ const findTrainTag = () => wrapper.findByTestId('pipeline-url-train');
+
+ const defaultProps = mockPipeline(projectPath);
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(PipelineLabelsComponent, {
+ propsData: { ...defaultProps, ...props },
+ provide: {
+ targetProjectFullPath: projectPath,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should not render tags when flags are not set', () => {
+ createComponent();
+
+ expect(findStuckTag().exists()).toBe(false);
+ expect(findLatestTag().exists()).toBe(false);
+ expect(findYamlTag().exists()).toBe(false);
+ expect(findAutoDevopsTag().exists()).toBe(false);
+ expect(findFailureTag().exists()).toBe(false);
+ expect(findScheduledTag().exists()).toBe(false);
+ expect(findForkTag().exists()).toBe(false);
+ expect(findTrainTag().exists()).toBe(false);
+ });
+
+ it('should render the stuck tag when flag is provided', () => {
+ const stuckPipeline = defaultProps.pipeline;
+ stuckPipeline.flags.stuck = true;
+
+ createComponent({
+ ...stuckPipeline.pipeline,
+ });
+
+ expect(findStuckTag().text()).toContain('stuck');
+ });
+
+ it('should render latest tag when flag is provided', () => {
+ const latestPipeline = defaultProps.pipeline;
+ latestPipeline.flags.latest = true;
+
+ createComponent({
+ ...latestPipeline,
+ });
+
+ expect(findLatestTag().text()).toContain('latest');
+ });
+
+ it('should render a yaml badge when it is invalid', () => {
+ const yamlPipeline = defaultProps.pipeline;
+ yamlPipeline.flags.yaml_errors = true;
+
+ createComponent({
+ ...yamlPipeline,
+ });
+
+ expect(findYamlTag().text()).toContain('yaml invalid');
+ });
+
+ it('should render an autodevops badge when flag is provided', () => {
+ const autoDevopsPipeline = defaultProps.pipeline;
+ autoDevopsPipeline.flags.auto_devops = true;
+
+ createComponent({
+ ...autoDevopsPipeline,
+ });
+
+ expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps');
+
+ expect(findAutoDevopsTagLink().attributes()).toMatchObject({
+ href: '/help/topics/autodevops/index.md',
+ target: '_blank',
+ });
+ });
+
+ it('should render a detached badge when flag is provided', () => {
+ const detachedMRPipeline = defaultProps.pipeline;
+ detachedMRPipeline.flags.detached_merge_request_pipeline = true;
+
+ createComponent({
+ ...detachedMRPipeline,
+ });
+
+ expect(findDetachedTag().text()).toBe('merge request');
+ });
+
+ it('should render error badge when pipeline has a failure reason set', () => {
+ const failedPipeline = defaultProps.pipeline;
+ failedPipeline.flags.failure_reason = true;
+ failedPipeline.failure_reason = 'some reason';
+
+ createComponent({
+ ...failedPipeline,
+ });
+
+ expect(findFailureTag().text()).toContain('error');
+ expect(findFailureTag().attributes('title')).toContain('some reason');
+ });
+
+ it('should render scheduled badge when pipeline was triggered by a schedule', () => {
+ const scheduledPipeline = defaultProps.pipeline;
+ scheduledPipeline.source = 'schedule';
+
+ createComponent({
+ ...scheduledPipeline,
+ });
+
+ expect(findScheduledTag().exists()).toBe(true);
+ expect(findScheduledTag().text()).toContain('Scheduled');
+ });
+
+ it('should render the fork badge when the pipeline was run in a fork', () => {
+ const forkedPipeline = defaultProps.pipeline;
+ forkedPipeline.project.full_path = '/test/forked';
+
+ createComponent({
+ ...forkedPipeline,
+ });
+
+ expect(findForkTag().exists()).toBe(true);
+ expect(findForkTag().text()).toBe('fork');
+ });
+
+ it('should render the train badge when the pipeline is a merge train pipeline', () => {
+ const mergeTrainPipeline = defaultProps.pipeline;
+ mergeTrainPipeline.flags.merge_train_pipeline = true;
+
+ createComponent({
+ ...mergeTrainPipeline,
+ });
+
+ expect(findTrainTag().text()).toBe('merge train');
+ });
+
+ it('should not render the train badge when the pipeline is not a merge train pipeline', () => {
+ const mergeTrainPipeline = defaultProps.pipeline;
+ mergeTrainPipeline.flags.merge_train_pipeline = false;
+
+ createComponent({
+ ...mergeTrainPipeline,
+ });
+
+ expect(findTrainTag().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js
index 2f083faaaa6..2a0aeed917c 100644
--- a/spec/frontend/pipelines/pipeline_url_spec.js
+++ b/spec/frontend/pipelines/pipeline_url_spec.js
@@ -1,5 +1,4 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { trimText } from 'helpers/text_helper';
import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue';
import { mockPipeline, mockPipelineBranch, mockPipelineTag } from './mock_data';
@@ -10,16 +9,6 @@ describe('Pipeline Url Component', () => {
const findTableCell = () => wrapper.findByTestId('pipeline-url-table-cell');
const findPipelineUrlLink = () => wrapper.findByTestId('pipeline-url-link');
- const findScheduledTag = () => wrapper.findByTestId('pipeline-url-scheduled');
- const findLatestTag = () => wrapper.findByTestId('pipeline-url-latest');
- const findYamlTag = () => wrapper.findByTestId('pipeline-url-yaml');
- const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure');
- const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops');
- const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link');
- const findStuckTag = () => wrapper.findByTestId('pipeline-url-stuck');
- const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached');
- const findForkTag = () => wrapper.findByTestId('pipeline-url-fork');
- const findTrainTag = () => wrapper.findByTestId('pipeline-url-train');
const findRefName = () => wrapper.findByTestId('merge-request-ref');
const findCommitShortSha = () => wrapper.findByTestId('commit-short-sha');
const findCommitIcon = () => wrapper.findByTestId('commit-icon');
@@ -30,14 +19,11 @@ describe('Pipeline Url Component', () => {
const defaultProps = mockPipeline(projectPath);
- const createComponent = (props, rearrangePipelinesTable = false) => {
+ const createComponent = (props) => {
wrapper = shallowMountExtended(PipelineUrlComponent, {
propsData: { ...defaultProps, ...props },
provide: {
targetProjectFullPath: projectPath,
- glFeatures: {
- rearrangePipelinesTable,
- },
},
});
};
@@ -47,190 +33,44 @@ describe('Pipeline Url Component', () => {
wrapper = null;
});
- describe('with the rearrangePipelinesTable feature flag turned off', () => {
- it('should render pipeline url table cell', () => {
- createComponent();
+ it('should render pipeline url table cell', () => {
+ createComponent();
- expect(findTableCell().exists()).toBe(true);
- });
-
- it('should render a link the provided path and id', () => {
- createComponent();
-
- expect(findPipelineUrlLink().attributes('href')).toBe('foo');
-
- expect(findPipelineUrlLink().text()).toBe('#1');
- });
-
- it('should not render tags when flags are not set', () => {
- createComponent();
-
- expect(findStuckTag().exists()).toBe(false);
- expect(findLatestTag().exists()).toBe(false);
- expect(findYamlTag().exists()).toBe(false);
- expect(findAutoDevopsTag().exists()).toBe(false);
- expect(findFailureTag().exists()).toBe(false);
- expect(findScheduledTag().exists()).toBe(false);
- expect(findForkTag().exists()).toBe(false);
- expect(findTrainTag().exists()).toBe(false);
- });
-
- it('should render the stuck tag when flag is provided', () => {
- const stuckPipeline = defaultProps.pipeline;
- stuckPipeline.flags.stuck = true;
-
- createComponent({
- ...stuckPipeline.pipeline,
- });
-
- expect(findStuckTag().text()).toContain('stuck');
- });
-
- it('should render latest tag when flag is provided', () => {
- const latestPipeline = defaultProps.pipeline;
- latestPipeline.flags.latest = true;
-
- createComponent({
- ...latestPipeline,
- });
-
- expect(findLatestTag().text()).toContain('latest');
- });
-
- it('should render a yaml badge when it is invalid', () => {
- const yamlPipeline = defaultProps.pipeline;
- yamlPipeline.flags.yaml_errors = true;
-
- createComponent({
- ...yamlPipeline,
- });
-
- expect(findYamlTag().text()).toContain('yaml invalid');
- });
-
- it('should render an autodevops badge when flag is provided', () => {
- const autoDevopsPipeline = defaultProps.pipeline;
- autoDevopsPipeline.flags.auto_devops = true;
-
- createComponent({
- ...autoDevopsPipeline,
- });
-
- expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps');
-
- expect(findAutoDevopsTagLink().attributes()).toMatchObject({
- href: '/help/topics/autodevops/index.md',
- target: '_blank',
- });
- });
-
- it('should render a detached badge when flag is provided', () => {
- const detachedMRPipeline = defaultProps.pipeline;
- detachedMRPipeline.flags.detached_merge_request_pipeline = true;
-
- createComponent({
- ...detachedMRPipeline,
- });
-
- expect(findDetachedTag().text()).toContain('detached');
- });
-
- it('should render error badge when pipeline has a failure reason set', () => {
- const failedPipeline = defaultProps.pipeline;
- failedPipeline.flags.failure_reason = true;
- failedPipeline.failure_reason = 'some reason';
-
- createComponent({
- ...failedPipeline,
- });
-
- expect(findFailureTag().text()).toContain('error');
- expect(findFailureTag().attributes('title')).toContain('some reason');
- });
-
- it('should render scheduled badge when pipeline was triggered by a schedule', () => {
- const scheduledPipeline = defaultProps.pipeline;
- scheduledPipeline.source = 'schedule';
-
- createComponent({
- ...scheduledPipeline,
- });
-
- expect(findScheduledTag().exists()).toBe(true);
- expect(findScheduledTag().text()).toContain('Scheduled');
- });
-
- it('should render the fork badge when the pipeline was run in a fork', () => {
- const forkedPipeline = defaultProps.pipeline;
- forkedPipeline.project.full_path = '/test/forked';
-
- createComponent({
- ...forkedPipeline,
- });
-
- expect(findForkTag().exists()).toBe(true);
- expect(findForkTag().text()).toBe('fork');
- });
-
- it('should render the train badge when the pipeline is a merge train pipeline', () => {
- const mergeTrainPipeline = defaultProps.pipeline;
- mergeTrainPipeline.flags.merge_train_pipeline = true;
-
- createComponent({
- ...mergeTrainPipeline,
- });
+ expect(findTableCell().exists()).toBe(true);
+ });
- expect(findTrainTag().text()).toContain('train');
- });
+ it('should render a link the provided path and id', () => {
+ createComponent();
- it('should not render the train badge when the pipeline is not a merge train pipeline', () => {
- const mergeTrainPipeline = defaultProps.pipeline;
- mergeTrainPipeline.flags.merge_train_pipeline = false;
+ expect(findPipelineUrlLink().attributes('href')).toBe('foo');
- createComponent({
- ...mergeTrainPipeline,
- });
+ expect(findPipelineUrlLink().text()).toBe('#1');
+ });
- expect(findTrainTag().exists()).toBe(false);
- });
+ it('should render the commit title, commit reference and commit-short-sha', () => {
+ createComponent({}, true);
- it('should not render the commit wrapper and commit-short-sha', () => {
- createComponent();
+ const commitWrapper = findCommitTitleContainer();
- expect(findCommitTitleContainer().exists()).toBe(false);
- expect(findCommitShortSha().exists()).toBe(false);
- });
+ expect(findCommitTitle(commitWrapper).exists()).toBe(true);
+ expect(findRefName().exists()).toBe(true);
+ expect(findCommitShortSha().exists()).toBe(true);
});
- describe('with the rearrangePipelinesTable feature flag turned on', () => {
- it('should render the commit title, commit reference and commit-short-sha', () => {
- createComponent({}, true);
+ it('should render commit icon tooltip', () => {
+ createComponent({}, true);
- const commitWrapper = findCommitTitleContainer();
-
- expect(findCommitTitle(commitWrapper).exists()).toBe(true);
- expect(findRefName().exists()).toBe(true);
- expect(findCommitShortSha().exists()).toBe(true);
- });
-
- it('should render commit icon tooltip', () => {
- createComponent({}, true);
+ expect(findCommitIcon().attributes('title')).toBe('Commit');
+ });
- expect(findCommitIcon().attributes('title')).toBe('Commit');
- });
+ it.each`
+ pipeline | expectedTitle
+ ${mockPipelineTag()} | ${'Tag'}
+ ${mockPipelineBranch()} | ${'Branch'}
+ ${mockPipeline()} | ${'Merge Request'}
+ `('should render tooltip $expectedTitle for commit icon type', ({ pipeline, expectedTitle }) => {
+ createComponent(pipeline, true);
- it.each`
- pipeline | expectedTitle
- ${mockPipelineTag()} | ${'Tag'}
- ${mockPipelineBranch()} | ${'Branch'}
- ${mockPipeline()} | ${'Merge Request'}
- `(
- 'should render tooltip $expectedTitle for commit icon type',
- ({ pipeline, expectedTitle }) => {
- createComponent(pipeline, true);
-
- expect(findCommitIconType().attributes('title')).toBe(expectedTitle);
- },
- );
+ expect(findCommitIconType().attributes('title')).toBe(expectedTitle);
});
});
diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/pipelines_ci_templates_spec.js
index db66b675fb9..7064f7448ec 100644
--- a/spec/frontend/pipelines/pipelines_ci_templates_spec.js
+++ b/spec/frontend/pipelines/pipelines_ci_templates_spec.js
@@ -1,7 +1,19 @@
import '~/commons';
-import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking } from 'helpers/tracking_helper';
+import { stubExperiments } from 'helpers/experimentation_helper';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import PipelinesCiTemplate from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue';
+import {
+ RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
+ RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
+ RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
+ RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
+ I18N,
+} from '~/pipeline_editor/constants';
const pipelineEditorPath = '/-/ci/editor';
const suggestedCiTemplates = [
@@ -10,16 +22,20 @@ const suggestedCiTemplates = [
{ name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' },
];
+jest.mock('~/experimentation/experiment_tracking');
+
describe('Pipelines CI Templates', () => {
let wrapper;
let trackingSpy;
- const createWrapper = () => {
- return shallowMount(PipelinesCiTemplate, {
+ const createWrapper = (propsData = {}, stubs = {}) => {
+ return shallowMountExtended(PipelinesCiTemplate, {
provide: {
pipelineEditorPath,
suggestedCiTemplates,
},
+ propsData,
+ stubs,
});
};
@@ -28,6 +44,9 @@ describe('Pipelines CI Templates', () => {
const findTemplateLinks = () => wrapper.findAll('[data-testid="template-link"]');
const findTemplateNames = () => wrapper.findAll('[data-testid="template-name"]');
const findTemplateLogos = () => wrapper.findAll('[data-testid="template-logo"]');
+ const findSettingsLink = () => wrapper.findByTestId('settings-link');
+ const findDocumentationLink = () => wrapper.findByTestId('documentation-link');
+ const findSettingsButton = () => wrapper.findByTestId('settings-button');
afterEach(() => {
wrapper.destroy();
@@ -69,7 +88,7 @@ describe('Pipelines CI Templates', () => {
it('has the description of the template', () => {
expect(findTemplateDescriptions().at(0).text()).toBe(
- 'CI/CD template to test and deploy your Android project.',
+ sprintf(I18N.templates.description, { name: 'Android' }),
);
});
@@ -104,4 +123,84 @@ describe('Pipelines CI Templates', () => {
});
});
});
+
+ describe('when the runners_availability_section experiment is active', () => {
+ beforeEach(() => {
+ stubExperiments({ runners_availability_section: 'candidate' });
+ });
+
+ describe('when runners are available', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({ anyRunnersAvailable: true }, { GitlabExperiment, GlSprintf });
+ });
+
+ it('show the runners available section', () => {
+ expect(wrapper.text()).toContain(I18N.runners.title);
+ });
+
+ it('tracks an event when clicking the settings link', () => {
+ findSettingsLink().vm.$emit('click');
+
+ expect(ExperimentTracking).toHaveBeenCalledWith(
+ RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
+ );
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
+ RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
+ );
+ });
+
+ it('tracks an event when clicking the documentation link', () => {
+ findDocumentationLink().vm.$emit('click');
+
+ expect(ExperimentTracking).toHaveBeenCalledWith(
+ RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
+ );
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
+ RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
+ );
+ });
+ });
+
+ describe('when runners are not available', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({ anyRunnersAvailable: false }, { GitlabExperiment, GlButton });
+ });
+
+ it('show the no runners available section', () => {
+ expect(wrapper.text()).toContain(I18N.noRunners.title);
+ });
+
+ it('tracks an event when clicking the settings button', () => {
+ findSettingsButton().trigger('click');
+
+ expect(ExperimentTracking).toHaveBeenCalledWith(
+ RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
+ );
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
+ RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
+ );
+ });
+ });
+ });
+
+ describe.each`
+ experimentVariant | anyRunnersAvailable | templatesRendered
+ ${'control'} | ${true} | ${true}
+ ${'control'} | ${false} | ${true}
+ ${'candidate'} | ${true} | ${true}
+ ${'candidate'} | ${false} | ${false}
+ `(
+ 'when the runners_availability_section experiment variant is $experimentVariant and runners are available: $anyRunnersAvailable',
+ ({ experimentVariant, anyRunnersAvailable, templatesRendered }) => {
+ beforeEach(() => {
+ stubExperiments({ runners_availability_section: experimentVariant });
+ wrapper = createWrapper({ anyRunnersAvailable });
+ });
+
+ it(`renders the templates: ${templatesRendered}`, () => {
+ expect(findTestTemplateLinks().exists()).toBe(templatesRendered);
+ expect(findTemplateLinks().exists()).toBe(templatesRendered);
+ });
+ },
+ );
});
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index c024730570c..20ed12cd1f5 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -10,7 +10,6 @@ import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
-import { getExperimentData, getExperimentVariant } from '~/experimentation/utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue';
@@ -25,14 +24,10 @@ import TablePagination from '~/vue_shared/components/pagination/table_pagination
import { stageReply, users, mockSearch, branches } from './mock_data';
jest.mock('~/flash');
-jest.mock('~/experimentation/utils', () => ({
- ...jest.requireActual('~/experimentation/utils'),
- getExperimentData: jest.fn().mockReturnValue(false),
- getExperimentVariant: jest.fn().mockReturnValue('control'),
-}));
const mockProjectPath = 'twitter/flight';
const mockProjectId = '21';
+const mockDefaultBranchName = 'main';
const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`;
const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id);
const mockPipelineWithStages = mockPipelinesResponse.pipelines.find(
@@ -50,7 +45,6 @@ describe('Pipelines', () => {
ciLintPath: '/ci/lint',
resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`,
newPipelinePath: `${mockProjectPath}/pipelines/new`,
- codeQualityPagePath: `${mockProjectPath}/-/new/master?commit_message=Add+.gitlab-ci.yml+and+create+a+code+quality+job&file_name=.gitlab-ci.yml&template=Code-Quality`,
ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`,
};
@@ -92,6 +86,7 @@ describe('Pipelines', () => {
propsData: {
store: new Store(),
projectId: mockProjectId,
+ defaultBranchName: mockDefaultBranchName,
endpoint: mockPipelinesEndpoint,
params: {},
...props,
@@ -557,73 +552,6 @@ describe('Pipelines', () => {
expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true);
});
- describe('when the code_quality_walkthrough experiment is active', () => {
- beforeAll(() => {
- getExperimentData.mockImplementation((name) => name === 'code_quality_walkthrough');
- });
-
- describe('the control state', () => {
- beforeAll(() => {
- getExperimentVariant.mockReturnValue('control');
- });
-
- it('renders the CI/CD templates', () => {
- expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true);
- });
- });
-
- describe('the candidate state', () => {
- beforeAll(() => {
- getExperimentVariant.mockReturnValue('candidate');
- });
-
- it('renders another CTA button', () => {
- expect(findEmptyState().findComponent(GlButton).text()).toBe('Add a code quality job');
- expect(findEmptyState().findComponent(GlButton).attributes('href')).toBe(
- paths.codeQualityPagePath,
- );
- });
- });
- });
-
- describe('when the ci_runner_templates experiment is active', () => {
- beforeAll(() => {
- getExperimentData.mockImplementation((name) => name === 'ci_runner_templates');
- });
-
- describe('the control state', () => {
- beforeAll(() => {
- getExperimentVariant.mockReturnValue('control');
- });
-
- it('renders the CI/CD templates', () => {
- expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true);
- });
- });
-
- describe('the candidate state', () => {
- beforeAll(() => {
- getExperimentVariant.mockReturnValue('candidate');
- });
-
- it('renders two buttons', () => {
- expect(findEmptyState().findAllComponents(GlButton).length).toBe(2);
- expect(findEmptyState().findAllComponents(GlButton).at(0).text()).toBe(
- 'Install GitLab Runners',
- );
- expect(findEmptyState().findAllComponents(GlButton).at(0).attributes('href')).toBe(
- paths.ciRunnerSettingsPath,
- );
- expect(findEmptyState().findAllComponents(GlButton).at(1).text()).toBe(
- 'Learn about Runners',
- );
- expect(findEmptyState().findAllComponents(GlButton).at(1).attributes('href')).toBe(
- '/help/ci/quick_start/index.md',
- );
- });
- });
- });
-
it('does not render filtered search', () => {
expect(findFilteredSearch().exists()).toBe(false);
});
diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js
index f200d683a7a..7b49baa5a20 100644
--- a/spec/frontend/pipelines/pipelines_table_spec.js
+++ b/spec/frontend/pipelines/pipelines_table_spec.js
@@ -17,7 +17,6 @@ import {
import eventHub from '~/pipelines/event_hub';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
-import CommitComponent from '~/vue_shared/components/commit.vue';
jest.mock('~/pipelines/event_hub');
@@ -37,18 +36,13 @@ describe('Pipelines Table', () => {
return pipelines.find((p) => p.user !== null && p.commit !== null);
};
- const createComponent = (props = {}, rearrangePipelinesTable = false) => {
+ const createComponent = (props = {}) => {
wrapper = extendedWrapper(
mount(PipelinesTable, {
propsData: {
...defaultProps,
...props,
},
- provide: {
- glFeatures: {
- rearrangePipelinesTable,
- },
- },
}),
);
};
@@ -57,7 +51,6 @@ describe('Pipelines Table', () => {
const findStatusBadge = () => wrapper.findComponent(CiBadge);
const findPipelineInfo = () => wrapper.findComponent(PipelineUrl);
const findTriggerer = () => wrapper.findComponent(PipelineTriggerer);
- const findCommit = () => wrapper.findComponent(CommitComponent);
const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph);
const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago);
const findActions = () => wrapper.findComponent(PipelineOperations);
@@ -65,10 +58,7 @@ describe('Pipelines Table', () => {
const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row');
const findStatusTh = () => wrapper.findByTestId('status-th');
const findPipelineTh = () => wrapper.findByTestId('pipeline-th');
- const findTriggererTh = () => wrapper.findByTestId('triggerer-th');
- const findCommitTh = () => wrapper.findByTestId('commit-th');
const findStagesTh = () => wrapper.findByTestId('stages-th');
- const findTimeAgoTh = () => wrapper.findByTestId('timeago-th');
const findActionsTh = () => wrapper.findByTestId('actions-th');
const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button');
const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button');
@@ -82,7 +72,7 @@ describe('Pipelines Table', () => {
wrapper = null;
});
- describe('Pipelines Table with rearrangePipelinesTable feature flag turned off', () => {
+ describe('Pipelines Table', () => {
beforeEach(() => {
createComponent({ pipelines: [pipeline], viewType: 'root' });
});
@@ -93,11 +83,8 @@ describe('Pipelines Table', () => {
it('should render table head with correct columns', () => {
expect(findStatusTh().text()).toBe('Status');
- expect(findPipelineTh().text()).toBe('Pipeline ID');
- expect(findTriggererTh().text()).toBe('Triggerer');
- expect(findCommitTh().text()).toBe('Commit');
+ expect(findPipelineTh().text()).toBe('Pipeline');
expect(findStagesTh().text()).toBe('Stages');
- expect(findTimeAgoTh().text()).toBe('Duration');
expect(findActionsTh().text()).toBe('Actions');
});
@@ -125,27 +112,6 @@ describe('Pipelines Table', () => {
});
});
- describe('triggerer cell', () => {
- it('should render the pipeline triggerer', () => {
- expect(findTriggerer().exists()).toBe(true);
- });
- });
-
- describe('commit cell', () => {
- it('should render commit information', () => {
- expect(findCommit().exists()).toBe(true);
- });
-
- it('should display and link to commit', () => {
- expect(findCommit().text()).toContain(pipeline.commit.short_id);
- expect(findCommit().props('commitUrl')).toBe(pipeline.commit.commit_path);
- });
-
- it('should display the commit author', () => {
- expect(findCommit().props('author')).toEqual(pipeline.commit.author);
- });
- });
-
describe('stages cell', () => {
it('should render a pipeline mini graph', () => {
expect(findPipelineMiniGraph().exists()).toBe(true);
@@ -163,7 +129,7 @@ describe('Pipelines Table', () => {
pipeline = createMockPipeline();
pipeline.details.stages = null;
- createComponent({ pipelines: [pipeline] }, true);
+ createComponent({ pipelines: [pipeline] });
});
it('stages are not rendered', () => {
@@ -176,7 +142,7 @@ describe('Pipelines Table', () => {
});
it('when update graph dropdown is set, should update graph dropdown', () => {
- createComponent({ pipelines: [pipeline], updateGraphDropdown: true }, true);
+ createComponent({ pipelines: [pipeline], updateGraphDropdown: true });
expect(findPipelineMiniGraph().props('updateDropdown')).toBe(true);
});
@@ -207,30 +173,11 @@ describe('Pipelines Table', () => {
expect(findCancelBtn().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL);
});
});
- });
-
- describe('Pipelines Table with rearrangePipelinesTable feature flag turned on', () => {
- beforeEach(() => {
- createComponent({ pipelines: [pipeline], viewType: 'root' }, true);
- });
-
- it('should render table head with correct columns', () => {
- expect(findStatusTh().text()).toBe('Status');
- expect(findPipelineTh().text()).toBe('Pipeline');
- expect(findStagesTh().text()).toBe('Stages');
- expect(findActionsTh().text()).toBe('Actions');
- });
describe('triggerer cell', () => {
it('should render the pipeline triggerer', () => {
expect(findTriggerer().exists()).toBe(true);
});
});
-
- describe('commit cell', () => {
- it('should not render commit information', () => {
- expect(findCommit().exists()).toBe(false);
- });
- });
});
});
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 2e44f40eda4..42ae154fb5e 100644
--- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
@@ -1,5 +1,7 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import PipelineBranchNameToken from '~/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue';
import { branches, mockBranchesAfterMap } from '../mock_data';
@@ -10,6 +12,8 @@ describe('Pipeline Branch Name Token', () => {
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const getBranchSuggestions = () =>
+ findAllFilteredSearchSuggestions().wrappers.map((w) => w.text());
const stubs = {
GlFilteredSearchToken: {
@@ -24,6 +28,7 @@ describe('Pipeline Branch Name Token', () => {
title: 'Branch name',
unique: true,
projectId: '21',
+ defaultBranchName: null,
disabled: false,
},
value: {
@@ -31,6 +36,19 @@ describe('Pipeline Branch Name Token', () => {
},
};
+ const optionsWithDefaultBranchName = (options) => {
+ return {
+ propsData: {
+ ...defaultProps,
+ config: {
+ ...defaultProps.config,
+ defaultBranchName: 'main',
+ },
+ },
+ ...options,
+ };
+ };
+
const createComponent = (options, data) => {
wrapper = shallowMount(PipelineBranchNameToken, {
propsData: {
@@ -94,5 +112,34 @@ describe('Pipeline Branch Name Token', () => {
expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length);
});
+
+ it('shows the default branch first if no branch was searched for', async () => {
+ const mockBranches = [{ name: 'branch-1' }];
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches });
+
+ createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false });
+ await nextTick();
+ expect(getBranchSuggestions()).toEqual(['main', 'branch-1']);
+ });
+
+ it('does not show the default branch if a search term was provided', async () => {
+ const mockBranches = [{ name: 'branch-1' }];
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches });
+
+ createComponent(optionsWithDefaultBranchName(), { loading: false });
+
+ findFilteredSearchToken().vm.$emit('input', { data: 'branch-1' });
+ await waitForPromises();
+ expect(getBranchSuggestions()).toEqual(['branch-1']);
+ });
+
+ it('shows the default branch only once if it appears in the results', async () => {
+ const mockBranches = [{ name: 'main' }];
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches });
+
+ createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false });
+ await nextTick();
+ expect(getBranchSuggestions()).toEqual(['main']);
+ });
});
});
diff --git a/spec/frontend/protected_branches/protected_branch_create_spec.js b/spec/frontend/protected_branches/protected_branch_create_spec.js
new file mode 100644
index 00000000000..b3de2d5e031
--- /dev/null
+++ b/spec/frontend/protected_branches/protected_branch_create_spec.js
@@ -0,0 +1,114 @@
+import ProtectedBranchCreate from '~/protected_branches/protected_branch_create';
+
+const FORCE_PUSH_TOGGLE_TESTID = 'force-push-toggle';
+const CODE_OWNER_TOGGLE_TESTID = 'code-owner-toggle';
+const IS_CHECKED_CLASS = 'is-checked';
+const IS_DISABLED_CLASS = 'is-disabled';
+const IS_LOADING_CLASS = 'toggle-loading';
+
+describe('ProtectedBranchCreate', () => {
+ beforeEach(() => {
+ jest.spyOn(ProtectedBranchCreate.prototype, 'buildDropdowns').mockImplementation();
+ });
+
+ const findForcePushToggle = () =>
+ document.querySelector(`div[data-testid="${FORCE_PUSH_TOGGLE_TESTID}"] button`);
+ const findCodeOwnerToggle = () =>
+ document.querySelector(`div[data-testid="${CODE_OWNER_TOGGLE_TESTID}"] button`);
+
+ const create = ({
+ forcePushToggleChecked = false,
+ codeOwnerToggleChecked = false,
+ hasLicense = true,
+ } = {}) => {
+ setFixtures(`
+ <form class="js-new-protected-branch">
+ <span
+ class="js-force-push-toggle"
+ data-label="Toggle allowed to force push"
+ data-is-checked="${forcePushToggleChecked}"
+ data-testid="${FORCE_PUSH_TOGGLE_TESTID}"></span>
+ <span
+ class="js-code-owner-toggle"
+ data-label="Toggle code owner approval"
+ data-is-checked="${codeOwnerToggleChecked}"
+ data-testid="${CODE_OWNER_TOGGLE_TESTID}"></span>
+ <input type="submit" />
+ </form>
+ `);
+
+ return new ProtectedBranchCreate({ hasLicense });
+ };
+
+ describe('when license supports code owner approvals', () => {
+ it('instantiates the code owner toggle', () => {
+ create();
+
+ expect(findCodeOwnerToggle()).not.toBe(null);
+ });
+ });
+
+ describe('when license does not support code owner approvals', () => {
+ it('does not instantiate the code owner toggle', () => {
+ create({ hasLicense: false });
+
+ expect(findCodeOwnerToggle()).toBe(null);
+ });
+ });
+
+ describe.each`
+ description | checkedOption | finder
+ ${'force push'} | ${'forcePushToggleChecked'} | ${findForcePushToggle}
+ ${'code owner'} | ${'codeOwnerToggleChecked'} | ${findCodeOwnerToggle}
+ `('when unchecked $description toggle button', ({ checkedOption, finder }) => {
+ it('is not changed', () => {
+ create({ [checkedOption]: false });
+
+ const toggle = finder();
+
+ expect(toggle).not.toHaveClass(IS_CHECKED_CLASS);
+ expect(toggle.querySelector(`.${IS_LOADING_CLASS}`)).toBe(null);
+ expect(toggle).not.toHaveClass(IS_DISABLED_CLASS);
+ });
+ });
+
+ describe('form data', () => {
+ let protectedBranchCreate;
+
+ beforeEach(() => {
+ protectedBranchCreate = create({
+ forcePushToggleChecked: false,
+ codeOwnerToggleChecked: true,
+ });
+
+ // Mock access levels. This should probably be improved in future iterations.
+ protectedBranchCreate.merge_access_levels_dropdown = {
+ getSelectedItems: () => [],
+ };
+ protectedBranchCreate.push_access_levels_dropdown = {
+ getSelectedItems: () => [],
+ };
+ });
+
+ afterEach(() => {
+ protectedBranchCreate = null;
+ });
+
+ it('returns the default form data if toggles are untouched', () => {
+ expect(protectedBranchCreate.getFormData().protected_branch).toMatchObject({
+ allow_force_push: false,
+ code_owner_approval_required: true,
+ });
+ });
+
+ it('reflects toggles changes if any', () => {
+ findForcePushToggle().click();
+ findCodeOwnerToggle().click();
+
+ expect(protectedBranchCreate.getFormData().protected_branch).toMatchObject({
+ allow_force_push: true,
+ code_owner_approval_required: false,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js
index b41b5028736..959ca6ecde2 100644
--- a/spec/frontend/protected_branches/protected_branch_edit_spec.js
+++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js
@@ -8,59 +8,116 @@ import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit';
jest.mock('~/flash');
const TEST_URL = `${TEST_HOST}/url`;
+const FORCE_PUSH_TOGGLE_TESTID = 'force-push-toggle';
+const CODE_OWNER_TOGGLE_TESTID = 'code-owner-toggle';
const IS_CHECKED_CLASS = 'is-checked';
+const IS_DISABLED_CLASS = 'is-disabled';
+const IS_LOADING_SELECTOR = '.toggle-loading';
describe('ProtectedBranchEdit', () => {
let mock;
beforeEach(() => {
- setFixtures(`<div id="wrap" data-url="${TEST_URL}">
- <button class="js-force-push-toggle">Toggle</button>
- </div>`);
-
jest.spyOn(ProtectedBranchEdit.prototype, 'buildDropdowns').mockImplementation();
mock = new MockAdapter(axios);
});
- const findForcePushesToggle = () => document.querySelector('.js-force-push-toggle');
+ const findForcePushToggle = () =>
+ document.querySelector(`div[data-testid="${FORCE_PUSH_TOGGLE_TESTID}"] button`);
+ const findCodeOwnerToggle = () =>
+ document.querySelector(`div[data-testid="${CODE_OWNER_TOGGLE_TESTID}"] button`);
- const create = ({ isChecked = false }) => {
- if (isChecked) {
- findForcePushesToggle().classList.add(IS_CHECKED_CLASS);
- }
+ const create = ({
+ forcePushToggleChecked = false,
+ codeOwnerToggleChecked = false,
+ hasLicense = true,
+ } = {}) => {
+ setFixtures(`<div id="wrap" data-url="${TEST_URL}">
+ <span
+ class="js-force-push-toggle"
+ data-label="Toggle allowed to force push"
+ data-is-checked="${forcePushToggleChecked}"
+ data-testid="${FORCE_PUSH_TOGGLE_TESTID}"></span>
+ <span
+ class="js-code-owner-toggle"
+ data-label="Toggle code owner approval"
+ data-is-checked="${codeOwnerToggleChecked}"
+ data-testid="${CODE_OWNER_TOGGLE_TESTID}"></span>
+ </div>`);
- return new ProtectedBranchEdit({ $wrap: $('#wrap'), hasLicense: false });
+ return new ProtectedBranchEdit({ $wrap: $('#wrap'), hasLicense });
};
afterEach(() => {
mock.restore();
});
- describe('when unchecked toggle button', () => {
+ describe('when license supports code owner approvals', () => {
+ beforeEach(() => {
+ create();
+ });
+
+ it('instantiates the code owner toggle', () => {
+ expect(findCodeOwnerToggle()).not.toBe(null);
+ });
+ });
+
+ describe('when license does not support code owner approvals', () => {
+ beforeEach(() => {
+ create({ hasLicense: false });
+ });
+
+ it('does not instantiate the code owner toggle', () => {
+ expect(findCodeOwnerToggle()).toBe(null);
+ });
+ });
+
+ describe('when toggles are not available in the DOM on page load', () => {
+ beforeEach(() => {
+ create({ hasLicense: true });
+ setFixtures('');
+ });
+
+ it('does not instantiate the force push toggle', () => {
+ expect(findForcePushToggle()).toBe(null);
+ });
+
+ it('does not instantiate the code owner toggle', () => {
+ expect(findCodeOwnerToggle()).toBe(null);
+ });
+ });
+
+ describe.each`
+ description | checkedOption | patchParam | finder
+ ${'force push'} | ${'forcePushToggleChecked'} | ${'allow_force_push'} | ${findForcePushToggle}
+ ${'code owner'} | ${'codeOwnerToggleChecked'} | ${'code_owner_approval_required'} | ${findCodeOwnerToggle}
+ `('when unchecked $description toggle button', ({ checkedOption, patchParam, finder }) => {
let toggle;
beforeEach(() => {
- create({ isChecked: false });
+ create({ [checkedOption]: false });
- toggle = findForcePushesToggle();
+ toggle = finder();
});
it('is not changed', () => {
expect(toggle).not.toHaveClass(IS_CHECKED_CLASS);
- expect(toggle).not.toBeDisabled();
+ expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null);
+ expect(toggle).not.toHaveClass(IS_DISABLED_CLASS);
});
describe('when clicked', () => {
beforeEach(() => {
- mock.onPatch(TEST_URL, { protected_branch: { allow_force_push: true } }).replyOnce(200, {});
+ mock.onPatch(TEST_URL, { protected_branch: { [patchParam]: true } }).replyOnce(200, {});
toggle.click();
});
it('checks and disables button', () => {
expect(toggle).toHaveClass(IS_CHECKED_CLASS);
- expect(toggle).toBeDisabled();
+ expect(toggle.querySelector(IS_LOADING_SELECTOR)).not.toBe(null);
+ expect(toggle).toHaveClass(IS_DISABLED_CLASS);
});
it('sends update to BE', () =>
@@ -68,7 +125,8 @@ describe('ProtectedBranchEdit', () => {
// Args are asserted in the `.onPatch` call
expect(mock.history.patch).toHaveLength(1);
- expect(toggle).not.toBeDisabled();
+ expect(toggle).not.toHaveClass(IS_DISABLED_CLASS);
+ expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null);
expect(createFlash).not.toHaveBeenCalled();
}));
});
diff --git a/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap
index 5f05b7fc68b..5053778369e 100644
--- a/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap
+++ b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap
@@ -10,30 +10,37 @@ Object {
Object {
"default": false,
"name": "add_images_and_changes",
+ "value": undefined,
},
Object {
"default": false,
"name": "conflict-contains-conflict-markers",
+ "value": undefined,
},
Object {
"default": false,
"name": "deleted-image-test",
+ "value": undefined,
},
Object {
"default": false,
"name": "diff-files-image-to-symlink",
+ "value": undefined,
},
Object {
"default": false,
"name": "diff-files-symlink-to-image",
+ "value": undefined,
},
Object {
"default": false,
"name": "markdown",
+ "value": undefined,
},
Object {
"default": true,
"name": "master",
+ "value": undefined,
},
],
"totalCount": 123,
@@ -54,12 +61,15 @@ Object {
"list": Array [
Object {
"name": "v1.1.1",
+ "value": undefined,
},
Object {
"name": "v1.1.0",
+ "value": undefined,
},
Object {
"name": "v1.0.0",
+ "value": undefined,
},
],
"totalCount": 456,
diff --git a/spec/frontend/ref/stores/mutations_spec.js b/spec/frontend/ref/stores/mutations_spec.js
index de1d5c557ce..37eee18dc10 100644
--- a/spec/frontend/ref/stores/mutations_spec.js
+++ b/spec/frontend/ref/stores/mutations_spec.js
@@ -48,6 +48,14 @@ describe('Ref selector Vuex store mutations', () => {
});
});
+ describe(`${types.SET_USE_SYMBOLIC_REF_NAMES}`, () => {
+ it('sets useSymbolicRefNames on the state', () => {
+ mutations[types.SET_USE_SYMBOLIC_REF_NAMES](state, true);
+
+ expect(state.useSymbolicRefNames).toBe(true);
+ });
+ });
+
describe(`${types.SET_PROJECT_ID}`, () => {
it('updates the project ID', () => {
const newProjectId = '4';
diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js
index c0f7738bec5..17f079ba5a6 100644
--- a/spec/frontend/releases/components/asset_links_form_spec.js
+++ b/spec/frontend/releases/components/asset_links_form_spec.js
@@ -256,9 +256,7 @@ describe('Release edit component', () => {
},
});
- expect(findUrlValidationMessage().text()).toBe(
- 'This URL is already used for another link; duplicate URLs are not allowed',
- );
+ expect(findUrlValidationMessage().text()).toBe('This URL already exists.');
});
it('shows a validation error message when a URL has a bad format', () => {
diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js
index 66f24ac9559..c32969c131e 100644
--- a/spec/frontend/releases/stores/modules/detail/getters_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js
@@ -134,6 +134,14 @@ describe('Release edit/new getters', () => {
// Missing title
{ id: 7, url: 'https://example.com/valid/1', name: '' },
{ id: 8, url: 'https://example.com/valid/2', name: ' ' },
+
+ // Duplicate title
+ { id: 9, url: 'https://example.com/1', name: 'Link 7' },
+ { id: 10, url: 'https://example.com/2', name: 'Link 7' },
+
+ // title validation ignores leading/trailing whitespace
+ { id: 11, url: 'https://example.com/3', name: ' Link 7\t ' },
+ { id: 12, url: 'https://example.com/4', name: ' Link 7\n\r\n ' },
],
},
},
@@ -201,6 +209,21 @@ describe('Release edit/new getters', () => {
expect(actualErrors).toMatchObject(expectedErrors);
});
+
+ it('returns a validation error if links share a title', () => {
+ const expectedErrors = {
+ assets: {
+ links: {
+ 9: { isTitleDuplicate: true },
+ 10: { isTitleDuplicate: true },
+ 11: { isTitleDuplicate: true },
+ 12: { isTitleDuplicate: true },
+ },
+ },
+ };
+
+ expect(actualErrors).toMatchObject(expectedErrors);
+ });
});
});
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index 109e5cef49b..96c03419dd6 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -1,5 +1,6 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
import Vue, { nextTick } from 'vue';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
@@ -10,20 +11,26 @@ import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobButtonGroup from '~/repository/components/blob_button_group.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
-import BlobEdit from '~/repository/components/blob_edit.vue';
+import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import ForkSuggestion from '~/repository/components/fork_suggestion.vue';
import { loadViewer } from '~/repository/components/blob_viewers';
import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue';
import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue';
import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
import blobInfoQuery from '~/repository/queries/blob_info.query.graphql';
+import userInfoQuery from '~/repository/queries/user_info.query.graphql';
+import applicationInfoQuery from '~/repository/queries/application_info.query.graphql';
+import CodeIntelligence from '~/code_navigation/components/app.vue';
import { redirectTo } from '~/lib/utils/url_utility';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import httpStatusCodes from '~/lib/utils/http_status';
import {
simpleViewerMock,
richViewerMock,
projectMock,
+ userInfoMock,
+ applicationInfoMock,
userPermissionsMock,
propsMock,
refMock,
@@ -35,9 +42,14 @@ jest.mock('~/lib/utils/common_utils');
let wrapper;
let mockResolver;
+let userInfoMockResolver;
+let applicationInfoMockResolver;
const mockAxios = new MockAdapter(axios);
+const createMockStore = () =>
+ new Vuex.Store({ actions: { fetchData: jest.fn, setInitialData: jest.fn() } });
+
const createComponent = async (mockData = {}, mountFn = shallowMount) => {
Vue.use(VueApollo);
@@ -71,10 +83,23 @@ const createComponent = async (mockData = {}, mountFn = shallowMount) => {
data: { isBinary, project },
});
- const fakeApollo = createMockApollo([[blobInfoQuery, mockResolver]]);
+ userInfoMockResolver = jest.fn().mockResolvedValue({
+ data: { ...userInfoMock },
+ });
+
+ applicationInfoMockResolver = jest.fn().mockResolvedValue({
+ data: { ...applicationInfoMock },
+ });
+
+ const fakeApollo = createMockApollo([
+ [blobInfoQuery, mockResolver],
+ [userInfoQuery, userInfoMockResolver],
+ [applicationInfoQuery, applicationInfoMockResolver],
+ ]);
wrapper = extendedWrapper(
mountFn(BlobContentViewer, {
+ store: createMockStore(),
apolloProvider: fakeApollo,
propsData: propsMock,
mixins: [{ data: () => ({ ref: refMock }) }],
@@ -96,16 +121,21 @@ const createComponent = async (mockData = {}, mountFn = shallowMount) => {
await waitForPromises();
};
+const execImmediately = (callback) => {
+ callback();
+};
+
describe('Blob content viewer component', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findBlobHeader = () => wrapper.findComponent(BlobHeader);
- const findBlobEdit = () => wrapper.findComponent(BlobEdit);
- const findPipelineEditor = () => wrapper.findByTestId('pipeline-editor');
+ const findWebIdeLink = () => wrapper.findComponent(WebIdeLink);
const findBlobContent = () => wrapper.findComponent(BlobContent);
const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup);
const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion);
+ const findCodeIntelligence = () => wrapper.findComponent(CodeIntelligence);
beforeEach(() => {
+ jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
isLoggedIn.mockReturnValue(true);
});
@@ -219,6 +249,26 @@ describe('Blob content viewer component', () => {
loadViewer.mockRestore();
});
+ it('renders a CodeIntelligence component with the correct props', async () => {
+ loadViewer.mockReturnValue(SourceViewer);
+
+ await createComponent();
+
+ expect(findCodeIntelligence().props()).toMatchObject({
+ codeNavigationPath: simpleViewerMock.codeNavigationPath,
+ blobPath: simpleViewerMock.path,
+ pathPrefix: simpleViewerMock.projectBlobPathRoot,
+ });
+ });
+
+ it('does not load a CodeIntelligence component when no viewers are loaded', async () => {
+ const url = 'some_file.js?format=json&viewer=rich';
+ mockAxios.onGet(url).replyOnce(httpStatusCodes.INTERNAL_SERVER_ERROR);
+ await createComponent({ blob: { ...richViewerMock, fileType: 'unknown' } });
+
+ expect(findCodeIntelligence().exists()).toBe(false);
+ });
+
it('does not render a BlobContent component if a Blob viewer is available', async () => {
loadViewer.mockReturnValue(() => true);
await createComponent({ blob: richViewerMock });
@@ -255,45 +305,43 @@ describe('Blob content viewer component', () => {
describe('BlobHeader action slot', () => {
const { ideEditPath, editBlobPath } = simpleViewerMock;
- it('renders BlobHeaderEdit buttons in simple viewer', async () => {
+ it('renders WebIdeLink button in simple viewer', async () => {
await createComponent({ inject: { BlobContent: true, BlobReplace: true } }, mount);
- expect(findBlobEdit().props()).toMatchObject({
- editPath: editBlobPath,
- webIdePath: ideEditPath,
+ expect(findWebIdeLink().props()).toMatchObject({
+ editUrl: editBlobPath,
+ webIdeUrl: ideEditPath,
showEditButton: true,
+ showGitpodButton: applicationInfoMock.gitpodEnabled,
+ gitpodEnabled: userInfoMock.currentUser.gitpodEnabled,
+ showPipelineEditorButton: true,
+ gitpodUrl: simpleViewerMock.gitpodBlobUrl,
+ pipelineEditorUrl: simpleViewerMock.pipelineEditorPath,
+ userPreferencesGitpodPath: userInfoMock.currentUser.preferencesGitpodPath,
+ userProfileEnableGitpodPath: userInfoMock.currentUser.profileEnableGitpodPath,
});
});
- it('renders BlobHeaderEdit button in rich viewer', async () => {
+ it('renders WebIdeLink button in rich viewer', async () => {
await createComponent({ blob: richViewerMock }, mount);
- expect(findBlobEdit().props()).toMatchObject({
- editPath: editBlobPath,
- webIdePath: ideEditPath,
+ expect(findWebIdeLink().props()).toMatchObject({
+ editUrl: editBlobPath,
+ webIdeUrl: ideEditPath,
showEditButton: true,
});
});
- it('renders BlobHeaderEdit button for binary files', async () => {
+ it('renders WebIdeLink button for binary files', async () => {
await createComponent({ blob: richViewerMock, isBinary: true }, mount);
- expect(findBlobEdit().props()).toMatchObject({
- editPath: editBlobPath,
- webIdePath: ideEditPath,
+ expect(findWebIdeLink().props()).toMatchObject({
+ editUrl: editBlobPath,
+ webIdeUrl: ideEditPath,
showEditButton: false,
});
});
- it('renders Pipeline Editor button for .gitlab-ci files', async () => {
- const pipelineEditorPath = 'some/path/.gitlab-ce';
- const blob = { ...simpleViewerMock, pipelineEditorPath };
- await createComponent({ blob, inject: { BlobContent: true, BlobReplace: true } }, mount);
-
- expect(findPipelineEditor().exists()).toBe(true);
- expect(findPipelineEditor().attributes('href')).toBe(pipelineEditorPath);
- });
-
describe('blob header binary file', () => {
it('passes the correct isBinary value when viewing a binary file', async () => {
await createComponent({ blob: richViewerMock, isBinary: true });
@@ -318,7 +366,7 @@ describe('Blob content viewer component', () => {
expect(findBlobHeader().props('hideViewerSwitcher')).toBe(true);
expect(findBlobHeader().props('isBinary')).toBe(true);
- expect(findBlobEdit().props('showEditButton')).toBe(false);
+ expect(findWebIdeLink().props('showEditButton')).toBe(false);
});
});
@@ -401,12 +449,12 @@ describe('Blob content viewer component', () => {
beforeEach(() => createComponent({}, mount));
it('simple edit redirects to the simple editor', () => {
- findBlobEdit().vm.$emit('edit', 'simple');
+ findWebIdeLink().vm.$emit('edit', 'simple');
expect(redirectTo).toHaveBeenCalledWith(simpleViewerMock.editBlobPath);
});
it('IDE edit redirects to the IDE editor', () => {
- findBlobEdit().vm.$emit('edit', 'ide');
+ findWebIdeLink().vm.$emit('edit', 'ide');
expect(redirectTo).toHaveBeenCalledWith(simpleViewerMock.ideEditPath);
});
@@ -435,7 +483,7 @@ describe('Blob content viewer component', () => {
mount,
);
- findBlobEdit().vm.$emit('edit', 'simple');
+ findWebIdeLink().vm.$emit('edit', 'simple');
await nextTick();
expect(findForkSuggestion().exists()).toBe(showForkSuggestion);
diff --git a/spec/frontend/repository/components/blob_edit_spec.js b/spec/frontend/repository/components/blob_edit_spec.js
deleted file mode 100644
index e2de7bc2957..00000000000
--- a/spec/frontend/repository/components/blob_edit_spec.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import BlobEdit from '~/repository/components/blob_edit.vue';
-import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
-
-const DEFAULT_PROPS = {
- editPath: 'some_file.js/edit',
- webIdePath: 'some_file.js/ide/edit',
- showEditButton: true,
- needsToFork: false,
-};
-
-describe('BlobEdit component', () => {
- let wrapper;
-
- const createComponent = (consolidatedEditButton = false, props = {}) => {
- wrapper = shallowMount(BlobEdit, {
- propsData: {
- ...DEFAULT_PROPS,
- ...props,
- },
- provide: {
- glFeatures: {
- consolidatedEditButton,
- },
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- const findButtons = () => wrapper.findAll(GlButton);
- const findEditButton = () => wrapper.find('[data-testid="edit"]');
- const findWebIdeButton = () => wrapper.find('[data-testid="web-ide"]');
- const findWebIdeLink = () => wrapper.find(WebIdeLink);
-
- it('renders component', () => {
- createComponent();
-
- const { editPath, webIdePath } = DEFAULT_PROPS;
-
- expect(wrapper.props()).toMatchObject({
- editPath,
- webIdePath,
- });
- });
-
- it('renders both buttons', () => {
- createComponent();
-
- expect(findButtons()).toHaveLength(2);
- });
-
- it('renders the Edit button', () => {
- createComponent();
-
- expect(findEditButton().text()).toBe('Edit');
- expect(findEditButton()).not.toBeDisabled();
- });
-
- it('renders the Web IDE button', () => {
- createComponent();
-
- expect(findWebIdeButton().text()).toBe('Web IDE');
- expect(findWebIdeButton()).not.toBeDisabled();
- });
-
- it('renders WebIdeLink component', () => {
- createComponent(true);
-
- const { editPath: editUrl, webIdePath: webIdeUrl, needsToFork } = DEFAULT_PROPS;
-
- expect(findWebIdeLink().props()).toMatchObject({
- editUrl,
- webIdeUrl,
- isBlob: true,
- showEditButton: true,
- needsToFork,
- });
- });
-
- describe('Without Edit button', () => {
- const showEditButton = false;
-
- it('renders WebIdeLink component without an edit button', () => {
- createComponent(true, { showEditButton });
-
- expect(findWebIdeLink().props()).toMatchObject({ showEditButton });
- });
-
- it('does not render an Edit button', () => {
- createComponent(false, { showEditButton });
-
- expect(findEditButton().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/repository/components/blob_viewers/audio_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/audio_viewer_spec.js
new file mode 100644
index 00000000000..baf16b57d7d
--- /dev/null
+++ b/spec/frontend/repository/components/blob_viewers/audio_viewer_spec.js
@@ -0,0 +1,23 @@
+import { shallowMount } from '@vue/test-utils';
+import AudioViewer from '~/repository/components/blob_viewers/audio_viewer.vue';
+
+describe('Audio Viewer', () => {
+ let wrapper;
+
+ const DEFAULT_BLOB_DATA = {
+ rawPath: 'some/audio.mid',
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(AudioViewer, { propsData: { blob: DEFAULT_BLOB_DATA } });
+ };
+
+ const findContent = () => wrapper.find('[data-testid="audio"]');
+
+ it('renders an audio source component', () => {
+ createComponent();
+
+ expect(findContent().exists()).toBe(true);
+ expect(findContent().attributes('src')).toBe(DEFAULT_BLOB_DATA.rawPath);
+ });
+});
diff --git a/spec/frontend/repository/components/blob_viewers/csv_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/csv_viewer_spec.js
new file mode 100644
index 00000000000..7d43e4e660b
--- /dev/null
+++ b/spec/frontend/repository/components/blob_viewers/csv_viewer_spec.js
@@ -0,0 +1,27 @@
+import { shallowMount } from '@vue/test-utils';
+import CsvViewer from '~/repository/components/blob_viewers/csv_viewer.vue';
+
+describe('CSV Viewer', () => {
+ let wrapper;
+
+ const DEFAULT_BLOB_DATA = {
+ rawPath: 'some/file.csv',
+ name: 'file.csv',
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(CsvViewer, {
+ propsData: { blob: DEFAULT_BLOB_DATA },
+ stubs: ['CsvViewer'],
+ });
+ };
+
+ const findCsvViewerComp = () => wrapper.find('[data-testid="csv"]');
+
+ it('renders a Source Editor component', () => {
+ createComponent();
+ expect(findCsvViewerComp().exists()).toBe(true);
+ expect(findCsvViewerComp().props('remoteFile')).toBeTruthy();
+ expect(findCsvViewerComp().props('csv')).toBe(DEFAULT_BLOB_DATA.rawPath);
+ });
+});
diff --git a/spec/frontend/repository/components/blob_viewers/download_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/download_viewer_spec.js
index 5fe25ced302..0a91e5ce890 100644
--- a/spec/frontend/repository/components/blob_viewers/download_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_viewers/download_viewer_spec.js
@@ -23,6 +23,8 @@ describe('Text Viewer', () => {
});
};
+ const findLink = () => wrapper.findComponent(GlLink);
+
it('renders download human readable file size text', () => {
createComponent();
@@ -42,7 +44,7 @@ describe('Text Viewer', () => {
createComponent();
const { rawPath, name } = DEFAULT_BLOB_DATA;
- expect(wrapper.findComponent(GlLink).attributes()).toMatchObject({
+ expect(findLink().attributes()).toMatchObject({
rel: 'nofollow',
target: '_blank',
href: rawPath,
@@ -50,6 +52,13 @@ describe('Text Viewer', () => {
});
});
+ it('renders the correct link href when stored externally', () => {
+ const externalStorageUrl = 'https://cdn.test.com/project/some/file.js?token=1234';
+ createComponent({ externalStorageUrl });
+
+ expect(findLink().attributes('href')).toBe(externalStorageUrl);
+ });
+
it('renders download icon', () => {
createComponent();
diff --git a/spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js
index 5caeb85834d..599443bf862 100644
--- a/spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_viewers/lfs_viewer_spec.js
@@ -10,9 +10,9 @@ describe('LFS Viewer', () => {
rawPath: '/some/file/path',
};
- const createComponent = () => {
+ const createComponent = (blobData = {}) => {
wrapper = shallowMount(LfsViewer, {
- propsData: { blob: { ...DEFAULT_BLOB_DATA } },
+ propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blobData } },
stubs: { GlSprintf },
});
};
@@ -38,4 +38,11 @@ describe('LFS Viewer', () => {
download: name,
});
});
+
+ it('renders the correct link href when stored externally', () => {
+ const externalStorageUrl = 'https://cdn.test.com/project/some/file.js?token=1234';
+ createComponent({ externalStorageUrl });
+
+ expect(findLink().attributes('href')).toBe(externalStorageUrl);
+ });
});
diff --git a/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js
index 10eea691335..b61500ea0ad 100644
--- a/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js
@@ -1,4 +1,5 @@
import { GlButton } from '@gitlab/ui';
+import { nextTick } from 'vue';
import Component from '~/repository/components/blob_viewers/pdf_viewer.vue';
import PdfViewer from '~/blob/pdf/pdf_viewer.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -8,9 +9,9 @@ describe('PDF Viewer', () => {
const DEFAULT_BLOB_DATA = { rawPath: 'some/pdf_blob.pdf' };
- const createComponent = (rawSize = 999) => {
+ const createComponent = (rawSize = 999, externalStorageUrl) => {
wrapper = shallowMountExtended(Component, {
- propsData: { blob: { ...DEFAULT_BLOB_DATA, rawSize } },
+ propsData: { blob: { ...DEFAULT_BLOB_DATA, rawSize, externalStorageUrl } },
});
};
@@ -45,10 +46,14 @@ describe('PDF Viewer', () => {
});
describe('Too many pages', () => {
- beforeEach(() => {
- createComponent();
- findPDFViewer().vm.$emit('pdflabload', 100);
- });
+ const loadComponent = (externalStorageUrl) => {
+ const rawSize = 999;
+ const totalPages = 100;
+ createComponent(rawSize, externalStorageUrl);
+ findPDFViewer().vm.$emit('pdflabload', totalPages);
+ };
+
+ beforeEach(() => loadComponent());
it('does not a PDF Viewer component', () => {
expect(findPDFViewer().exists()).toBe(false);
@@ -56,6 +61,15 @@ describe('PDF Viewer', () => {
it('renders a download button', () => {
expect(findDownLoadButton().exists()).toBe(true);
+ expect(findDownLoadButton().attributes('href')).toBe(DEFAULT_BLOB_DATA.rawPath);
+ });
+
+ it('renders the correct href when stored externally', async () => {
+ const externalStorageUrl = 'https://cdn.test.com/project/some/file.js?token=1234';
+ loadComponent(externalStorageUrl);
+ await nextTick();
+
+ expect(findDownLoadButton().attributes('href')).toBe(externalStorageUrl);
});
});
});
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js
index 0e300291d05..0e3e7075e99 100644
--- a/spec/frontend/repository/components/breadcrumbs_spec.js
+++ b/spec/frontend/repository/components/breadcrumbs_spec.js
@@ -59,6 +59,20 @@ describe('Repository breadcrumbs component', () => {
expect(wrapper.findAll(RouterLinkStub).length).toEqual(linkCount);
});
+ it.each`
+ routeName | path | linkTo
+ ${'treePath'} | ${'app/assets/javascripts'} | ${'/-/tree/app/assets/javascripts'}
+ ${'treePathDecoded'} | ${'app/assets/javascripts'} | ${'/-/tree/app/assets/javascripts'}
+ ${'blobPath'} | ${'app/assets/index.js'} | ${'/-/blob/app/assets/index.js'}
+ ${'blobPathDecoded'} | ${'app/assets/index.js'} | ${'/-/blob/app/assets/index.js'}
+ `(
+ 'links to the correct router path when routeName is $routeName',
+ ({ routeName, path, linkTo }) => {
+ factory(path, {}, { name: routeName });
+ expect(wrapper.findAll(RouterLinkStub).at(3).props('to')).toEqual(linkTo);
+ },
+ );
+
it('escapes hash in directory path', () => {
factory('app/assets/javascripts#');
diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js
index 5a6551cb94a..0a5766a25f9 100644
--- a/spec/frontend/repository/mock_data.js
+++ b/spec/frontend/repository/mock_data.js
@@ -9,9 +9,13 @@ export const simpleViewerMock = {
path: 'some_file.js',
webPath: 'some_file.js',
editBlobPath: 'some_file.js/edit',
+ gitpodBlobUrl: 'https://gitpod.io#path/to/blob.js',
ideEditPath: 'some_file.js/ide/edit',
forkAndEditPath: 'some_file.js/fork/edit',
ideForkAndEditPath: 'some_file.js/fork/ide',
+ forkAndViewPath: 'some_file.js/fork/view',
+ codeNavigationPath: '',
+ projectBlobPathRoot: '',
environmentFormattedExternalUrl: '',
environmentExternalUrlForRouteMap: '',
canModifyBlob: true,
@@ -22,7 +26,7 @@ export const simpleViewerMock = {
externalStorage: 'lfs',
rawPath: 'some_file.js',
replacePath: 'some_file.js/replace',
- pipelineEditorPath: '',
+ pipelineEditorPath: 'path/to/pipeline/editor',
simpleViewer: {
fileType: 'text',
tooLarge: false,
@@ -67,6 +71,17 @@ export const projectMock = {
},
};
+export const userInfoMock = {
+ currentUser: {
+ id: '123',
+ gitpodEnabled: true,
+ preferencesGitpodPath: '/-/profile/preferences#user_gitpod_enabled',
+ profileEnableGitpodPath: '/-/profile?user%5Bgitpod_enabled%5D=true',
+ },
+};
+
+export const applicationInfoMock = { gitpodEnabled: true };
+
export const propsMock = { path: 'some_file.js', projectPath: 'some/path' };
export const refMock = 'default-ref';
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 ff6a632a4f8..d121c6be218 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,7 +7,7 @@ import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/runner/components/runner_header.vue';
-import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql';
+import runnerQuery from '~/runner/graphql/details/runner.query.graphql';
import AdminRunnerEditApp from '~//runner/admin_runner_edit/admin_runner_edit_app.vue';
import { captureException } from '~/runner/sentry_utils';
@@ -29,7 +29,7 @@ describe('AdminRunnerEditApp', () => {
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(AdminRunnerEditApp, {
- apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]),
+ apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]),
propsData: {
runnerId: mockRunnerId,
...props,
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 4b651961112..f994ff24c21 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
@@ -9,7 +9,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/runner/components/runner_header.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
-import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql';
+import runnerQuery from '~/runner/graphql/details/runner.query.graphql';
import AdminRunnerShowApp from '~/runner/admin_runner_show/admin_runner_show_app.vue';
import { captureException } from '~/runner/sentry_utils';
@@ -42,7 +42,7 @@ describe('AdminRunnerShowApp', () => {
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(AdminRunnerShowApp, {
- apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]),
+ apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]),
propsData: {
runnerId: mockRunnerId,
...props,
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 995f0cf7ba1..cdaec0a3a8b 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { GlLink } from '@gitlab/ui';
+import { GlToast, GlLink } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
@@ -18,6 +18,7 @@ import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
+import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
@@ -34,8 +35,8 @@ import {
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
-import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
-import getRunnersCountQuery from '~/runner/graphql/get_runners_count.query.graphql';
+import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql';
+import adminRunnersCountQuery from '~/runner/graphql/list/admin_runners_count.query.graphql';
import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
@@ -51,6 +52,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
}));
Vue.use(VueApollo);
+Vue.use(GlToast);
describe('AdminRunnersApp', () => {
let wrapper;
@@ -58,20 +60,19 @@ describe('AdminRunnersApp', () => {
let mockRunnersCountQuery;
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
+ const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
- const findRunnerPaginationPrev = () =>
- findRunnerPagination().findByLabelText('Go to previous page');
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const handlers = [
- [getRunnersQuery, mockRunnersQuery],
- [getRunnersCountQuery, mockRunnersCountQuery],
+ [adminRunnersQuery, mockRunnersQuery],
+ [adminRunnersCountQuery, mockRunnersCountQuery],
];
wrapper = mountFn(AdminRunnersApp, {
@@ -94,6 +95,7 @@ describe('AdminRunnersApp', () => {
afterEach(() => {
mockRunnersQuery.mockReset();
+ mockRunnersCountQuery.mockReset();
wrapper.destroy();
});
@@ -188,6 +190,21 @@ describe('AdminRunnersApp', () => {
expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`);
});
+ it('renders runner actions for each runner', async () => {
+ createComponent({ mountFn: mountExtended });
+
+ await waitForPromises();
+
+ const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell);
+
+ const runner = runnersData.data.runners.nodes[0];
+
+ expect(runnerActions.props()).toEqual({
+ runner,
+ editUrl: runner.editAdminUrl,
+ });
+ });
+
it('requests the runners with no filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: undefined,
@@ -212,6 +229,41 @@ describe('AdminRunnersApp', () => {
]);
});
+ describe('Single runner row', () => {
+ let showToast;
+
+ const mockRunner = runnersData.data.runners.nodes[0];
+ const { id: graphqlId, shortSha } = mockRunner;
+ const id = getIdFromGraphQLId(graphqlId);
+
+ beforeEach(async () => {
+ mockRunnersQuery.mockClear();
+
+ createComponent({ mountFn: mountExtended });
+ showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
+
+ await waitForPromises();
+ });
+
+ it('Links to the runner page', async () => {
+ const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink);
+
+ expect(runnerLink.text()).toBe(`#${id} (${shortSha})`);
+ expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`);
+ });
+
+ it('When runner is deleted, data is refetched and a toast message is shown', async () => {
+ expect(mockRunnersQuery).toHaveBeenCalledTimes(1);
+
+ findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' });
+
+ expect(mockRunnersQuery).toHaveBeenCalledTimes(2);
+
+ expect(showToast).toHaveBeenCalledTimes(1);
+ expect(showToast).toHaveBeenCalledWith('Runner deleted');
+ });
+ });
+
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
@@ -316,14 +368,6 @@ describe('AdminRunnersApp', () => {
await waitForPromises();
});
- it('more pages can be selected', () => {
- expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next');
- });
-
- it('cannot navigate to the previous page', () => {
- expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true');
- });
-
it('navigates to the next page', async () => {
await findRunnerPaginationNext().trigger('click');
diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
index dcb0af67784..0d579106860 100644
--- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
+++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
@@ -1,84 +1,37 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { createAlert } from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { captureException } from '~/runner/sentry_utils';
-import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
+import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
-import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
-import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
-import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
-import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
+import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import { runnersData } from '../../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
-const getRunnersQueryName = getRunnersQuery.definitions[0].name.value;
-const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value;
-
-Vue.use(VueApollo);
-
-jest.mock('~/flash');
-jest.mock('~/runner/sentry_utils');
-
-describe('RunnerTypeCell', () => {
+describe('RunnerActionsCell', () => {
let wrapper;
- const mockToastShow = jest.fn();
- const runnerDeleteMutationHandler = jest.fn();
-
const findEditBtn = () => wrapper.findComponent(RunnerEditButton);
const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton);
- const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal);
- const findDeleteBtn = () => wrapper.findByTestId('delete-runner');
- const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value;
+ const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton);
- const createComponent = (runner = {}, options) => {
- wrapper = shallowMountExtended(RunnerActionCell, {
+ const createComponent = ({ runner = {}, ...props } = {}) => {
+ wrapper = shallowMountExtended(RunnerActionsCell, {
propsData: {
+ editUrl: mockRunner.editAdminUrl,
runner: {
id: mockRunner.id,
shortSha: mockRunner.shortSha,
editAdminUrl: mockRunner.editAdminUrl,
userPermissions: mockRunner.userPermissions,
- active: mockRunner.active,
...runner,
},
+ ...props,
},
- apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteMutationHandler]]),
- directives: {
- GlTooltip: createMockDirective(),
- GlModal: createMockDirective(),
- },
- mocks: {
- $toast: {
- show: mockToastShow,
- },
- },
- ...options,
});
};
- beforeEach(() => {
- runnerDeleteMutationHandler.mockResolvedValue({
- data: {
- runnerDelete: {
- errors: [],
- },
- },
- });
- });
-
afterEach(() => {
- mockToastShow.mockReset();
- runnerDeleteMutationHandler.mockReset();
-
wrapper.destroy();
});
@@ -91,18 +44,20 @@ describe('RunnerTypeCell', () => {
it('Does not render the runner edit link when user cannot update', () => {
createComponent({
- userPermissions: {
- ...mockRunner.userPermissions,
- updateRunner: false,
+ runner: {
+ userPermissions: {
+ ...mockRunner.userPermissions,
+ updateRunner: false,
+ },
},
});
expect(findEditBtn().exists()).toBe(false);
});
- it('Does not render the runner edit link when editAdminUrl is not provided', () => {
+ it('Does not render the runner edit link when editUrl is not provided', () => {
createComponent({
- editAdminUrl: null,
+ editUrl: null,
});
expect(findEditBtn().exists()).toBe(false);
@@ -118,9 +73,11 @@ describe('RunnerTypeCell', () => {
it('Does not render the runner pause button when user cannot update', () => {
createComponent({
- userPermissions: {
- ...mockRunner.userPermissions,
- updateRunner: false,
+ runner: {
+ userPermissions: {
+ ...mockRunner.userPermissions,
+ updateRunner: false,
+ },
},
});
@@ -129,147 +86,35 @@ describe('RunnerTypeCell', () => {
});
describe('Delete action', () => {
- beforeEach(() => {
- createComponent(
- {},
- {
- stubs: { RunnerDeleteModal },
- },
- );
- });
+ it('Renders a compact delete button', () => {
+ createComponent();
- it('Renders delete button', () => {
- expect(findDeleteBtn().exists()).toBe(true);
+ expect(findDeleteBtn().props('compact')).toBe(true);
});
- it('Delete button opens delete modal', () => {
- const modalId = getBinding(findDeleteBtn().element, 'gl-modal').value;
+ it('Emits delete events', () => {
+ const value = { name: 'Runner' };
- expect(findRunnerDeleteModal().attributes('modal-id')).toBeDefined();
- expect(findRunnerDeleteModal().attributes('modal-id')).toBe(modalId);
- });
-
- it('Delete modal shows the runner name', () => {
- expect(findRunnerDeleteModal().props('runnerName')).toBe(
- `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`,
- );
- });
- it('The delete button does not have a loading icon', () => {
- expect(findDeleteBtn().props('loading')).toBe(false);
- expect(getTooltip(findDeleteBtn())).toBe('Delete runner');
- });
+ createComponent();
- it('When delete mutation is called, current runners are refetched', () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate');
+ expect(wrapper.emitted('deleted')).toBe(undefined);
- findRunnerDeleteModal().vm.$emit('primary');
+ findDeleteBtn().vm.$emit('deleted', value);
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: runnerDeleteMutation,
- variables: {
- input: {
- id: mockRunner.id,
- },
- },
- awaitRefetchQueries: true,
- refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName],
- });
+ expect(wrapper.emitted('deleted')).toEqual([[value]]);
});
it('Does not render the runner delete button when user cannot delete', () => {
createComponent({
- userPermissions: {
- ...mockRunner.userPermissions,
- deleteRunner: false,
+ runner: {
+ userPermissions: {
+ ...mockRunner.userPermissions,
+ deleteRunner: false,
+ },
},
});
expect(findDeleteBtn().exists()).toBe(false);
- expect(findRunnerDeleteModal().exists()).toBe(false);
- });
-
- describe('When delete is clicked', () => {
- beforeEach(async () => {
- findRunnerDeleteModal().vm.$emit('primary');
- await waitForPromises();
- });
-
- it('The delete mutation is called correctly', () => {
- expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1);
- expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({
- input: { id: mockRunner.id },
- });
- });
-
- it('The delete button has a loading icon', () => {
- expect(findDeleteBtn().props('loading')).toBe(true);
- expect(getTooltip(findDeleteBtn())).toBe('');
- });
-
- it('The toast notification is shown', async () => {
- await waitForPromises();
- expect(mockToastShow).toHaveBeenCalledTimes(1);
- expect(mockToastShow).toHaveBeenCalledWith(
- expect.stringContaining(`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`),
- );
- });
- });
-
- describe('When delete fails', () => {
- describe('On a network error', () => {
- const mockErrorMsg = 'Delete error!';
-
- beforeEach(async () => {
- runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
-
- findRunnerDeleteModal().vm.$emit('primary');
- await waitForPromises();
- });
-
- it('error is reported to sentry', () => {
- expect(captureException).toHaveBeenCalledWith({
- error: new Error(mockErrorMsg),
- component: 'RunnerActionsCell',
- });
- });
-
- it('error is shown to the user', () => {
- expect(createAlert).toHaveBeenCalledTimes(1);
- });
-
- it('toast notification is not shown', () => {
- expect(mockToastShow).not.toHaveBeenCalled();
- });
- });
-
- describe('On a validation error', () => {
- const mockErrorMsg = 'Runner not found!';
- const mockErrorMsg2 = 'User not allowed!';
-
- beforeEach(async () => {
- runnerDeleteMutationHandler.mockResolvedValue({
- data: {
- runnerDelete: {
- errors: [mockErrorMsg, mockErrorMsg2],
- },
- },
- });
-
- findRunnerDeleteModal().vm.$emit('primary');
- await waitForPromises();
- });
-
- it('error is reported to sentry', () => {
- expect(captureException).toHaveBeenCalledWith({
- error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
- component: 'RunnerActionsCell',
- });
- });
-
- it('error is shown to the user', () => {
- expect(createAlert).toHaveBeenCalledTimes(1);
- });
- });
});
});
});
diff --git a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js
index d2deb49a5f7..2510aaf0334 100644
--- a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js
+++ b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js
@@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
-import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
+import runnersRegistrationTokenResetMutation from '~/runner/graphql/list/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js
new file mode 100644
index 00000000000..81c870f23cf
--- /dev/null
+++ b/spec/frontend/runner/components/runner_delete_button_spec.js
@@ -0,0 +1,233 @@
+import Vue from 'vue';
+import { GlButton } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import runnerDeleteMutation from '~/runner/graphql/shared/runner_delete.mutation.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
+import { captureException } from '~/runner/sentry_utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/flash';
+import { I18N_DELETE_RUNNER } from '~/runner/constants';
+
+import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
+import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
+import { runnersData } from '../mock_data';
+
+const mockRunner = runnersData.data.runners.nodes[0];
+const mockRunnerId = getIdFromGraphQLId(mockRunner.id);
+
+Vue.use(VueApollo);
+
+jest.mock('~/flash');
+jest.mock('~/runner/sentry_utils');
+
+describe('RunnerDeleteButton', () => {
+ let wrapper;
+ let runnerDeleteHandler;
+
+ const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
+ const getModal = () => getBinding(wrapper.element, 'gl-modal').value;
+ const findBtn = () => wrapper.findComponent(GlButton);
+ const findModal = () => wrapper.findComponent(RunnerDeleteModal);
+
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+ const { runner, ...propsData } = props;
+
+ wrapper = mountFn(RunnerDeleteButton, {
+ propsData: {
+ runner: {
+ id: mockRunner.id,
+ shortSha: mockRunner.shortSha,
+ ...runner,
+ },
+ ...propsData,
+ },
+ apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]),
+ directives: {
+ GlTooltip: createMockDirective(),
+ GlModal: createMockDirective(),
+ },
+ });
+ };
+
+ const clickOkAndWait = async () => {
+ findModal().vm.$emit('primary');
+ await waitForPromises();
+ };
+
+ beforeEach(() => {
+ runnerDeleteHandler = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ data: {
+ runnerDelete: {
+ errors: [],
+ },
+ },
+ });
+ });
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays a delete button without an icon', () => {
+ expect(findBtn().props()).toMatchObject({
+ loading: false,
+ icon: '',
+ });
+ expect(findBtn().classes('btn-icon')).toBe(false);
+ expect(findBtn().text()).toBe(I18N_DELETE_RUNNER);
+ });
+
+ it('Displays a modal with the runner name', () => {
+ expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`);
+ });
+
+ it('Displays a modal when clicked', () => {
+ const modalId = `delete-runner-modal-${mockRunnerId}`;
+
+ expect(getModal()).toBe(modalId);
+ expect(findModal().attributes('modal-id')).toBe(modalId);
+ });
+
+ it('Does not display redundant text for screen readers', () => {
+ expect(findBtn().attributes('aria-label')).toBe(undefined);
+ });
+
+ describe(`Before the delete button is clicked`, () => {
+ it('The mutation has not been called', () => {
+ expect(runnerDeleteHandler).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('Immediately after the delete button is clicked', () => {
+ beforeEach(async () => {
+ findModal().vm.$emit('primary');
+ });
+
+ it('The button has a loading state', async () => {
+ expect(findBtn().props('loading')).toBe(true);
+ });
+
+ it('The stale tooltip is removed', async () => {
+ expect(getTooltip()).toBe('');
+ });
+ });
+
+ describe('After clicking on the delete button', () => {
+ beforeEach(async () => {
+ await clickOkAndWait();
+ });
+
+ it('The mutation to delete is called', () => {
+ expect(runnerDeleteHandler).toHaveBeenCalledTimes(1);
+ expect(runnerDeleteHandler).toHaveBeenCalledWith({
+ input: {
+ id: mockRunner.id,
+ },
+ });
+ });
+
+ it('The user can be notified with an event', () => {
+ const deleted = wrapper.emitted('deleted');
+
+ expect(deleted).toHaveLength(1);
+ expect(deleted[0][0].message).toMatch(`#${mockRunnerId}`);
+ expect(deleted[0][0].message).toMatch(`${mockRunner.shortSha}`);
+ });
+ });
+
+ describe('When update fails', () => {
+ describe('On a network error', () => {
+ const mockErrorMsg = 'Update error!';
+
+ beforeEach(async () => {
+ runnerDeleteHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
+
+ await clickOkAndWait();
+ });
+
+ it('error is reported to sentry', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ error: new Error(mockErrorMsg),
+ component: 'RunnerDeleteButton',
+ });
+ });
+
+ it('error is shown to the user', () => {
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('On a validation error', () => {
+ const mockErrorMsg = 'Runner not found!';
+ const mockErrorMsg2 = 'User not allowed!';
+
+ beforeEach(async () => {
+ runnerDeleteHandler.mockResolvedValueOnce({
+ data: {
+ runnerDelete: {
+ errors: [mockErrorMsg, mockErrorMsg2],
+ },
+ },
+ });
+
+ await clickOkAndWait();
+ });
+
+ it('error is reported to sentry', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
+ component: 'RunnerDeleteButton',
+ });
+ });
+
+ it('error is shown to the user', () => {
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('When displaying a compact button for an active runner', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ runner: {
+ active: true,
+ },
+ compact: true,
+ },
+ mountFn: mountExtended,
+ });
+ });
+
+ it('Displays no text', () => {
+ expect(findBtn().text()).toBe('');
+ expect(findBtn().classes('btn-icon')).toBe(true);
+ });
+
+ it('Display correctly for screen readers', () => {
+ expect(findBtn().attributes('aria-label')).toBe(I18N_DELETE_RUNNER);
+ expect(getTooltip()).toBe(I18N_DELETE_RUNNER);
+ });
+
+ describe('Immediately after the button is clicked', () => {
+ beforeEach(async () => {
+ findModal().vm.$emit('primary');
+ });
+
+ it('The button has a loading state', async () => {
+ expect(findBtn().props('loading')).toBe(true);
+ });
+
+ it('The stale tooltip is removed', async () => {
+ expect(getTooltip()).toBe('');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js
index 97339056370..9abb2861005 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 getRunnerJobsQuery from '~/runner/graphql/get_runner_jobs.query.graphql';
+import runnerJobsQuery from '~/runner/graphql/details/runner_jobs.query.graphql';
import { runnerData, runnerJobsData } from '../mock_data';
@@ -34,7 +34,7 @@ describe('RunnerJobs', () => {
const createComponent = ({ mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerJobs, {
- apolloProvider: createMockApollo([[getRunnerJobsQuery, mockRunnerJobsQuery]]),
+ apolloProvider: createMockApollo([[runnerJobsQuery, mockRunnerJobsQuery]]),
propsData: {
runner: mockRunner,
},
diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
index 42d6ecca09e..a0f42738d2c 100644
--- a/spec/frontend/runner/components/runner_list_spec.js
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -1,4 +1,4 @@
-import { GlTable, GlSkeletonLoader } from '@gitlab/ui';
+import { GlTableLite, GlSkeletonLoader } from '@gitlab/ui';
import {
extendedWrapper,
shallowMountExtended,
@@ -6,8 +6,6 @@ import {
} from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
-import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
-import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
@@ -17,19 +15,20 @@ describe('RunnerList', () => {
let wrapper;
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
- const findTable = () => wrapper.findComponent(GlTable);
+ const findTable = () => wrapper.findComponent(GlTableLite);
const findHeaders = () => wrapper.findAll('th');
const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]');
const findCell = ({ row = 0, fieldKey }) =>
extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`));
- const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => {
+ const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => {
wrapper = mountFn(RunnerList, {
propsData: {
runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount,
...props,
},
+ ...options,
});
};
@@ -90,11 +89,31 @@ describe('RunnerList', () => {
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
// Actions
- const actions = findCell({ fieldKey: 'actions' });
+ expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
+ });
+
+ describe('Scoped cell slots', () => {
+ it('Render #runner-name slot in "summary" cell', () => {
+ createComponent(
+ {
+ scopedSlots: { 'runner-name': ({ runner }) => `Summary: ${runner.id}` },
+ },
+ mountExtended,
+ );
+
+ expect(findCell({ fieldKey: 'summary' }).text()).toContain(`Summary: ${mockRunners[0].id}`);
+ });
- expect(actions.findComponent(RunnerEditButton).exists()).toBe(true);
- expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true);
- expect(actions.findByTestId('delete-runner').exists()).toBe(true);
+ it('Render #runner-actions-cell slot in "actions" cell', () => {
+ createComponent(
+ {
+ scopedSlots: { 'runner-actions-cell': ({ runner }) => `Actions: ${runner.id}` },
+ },
+ mountExtended,
+ );
+
+ expect(findCell({ fieldKey: 'actions' }).text()).toBe(`Actions: ${mockRunners[0].id}`);
+ });
});
describe('Table data formatting', () => {
@@ -143,7 +162,8 @@ describe('RunnerList', () => {
describe('When data is loading', () => {
it('shows a busy state', () => {
createComponent({ props: { runners: [], loading: true } });
- expect(findTable().attributes('busy')).toBeTruthy();
+
+ expect(findTable().classes('gl-opacity-6')).toBe(true);
});
it('when there are no runners, shows an skeleton loader', () => {
diff --git a/spec/frontend/runner/components/runner_pagination_spec.js b/spec/frontend/runner/components/runner_pagination_spec.js
index ecd6e6bd7f9..e144b52ceb3 100644
--- a/spec/frontend/runner/components/runner_pagination_spec.js
+++ b/spec/frontend/runner/components/runner_pagination_spec.js
@@ -45,14 +45,6 @@ describe('RunnerPagination', () => {
expect(findPagination().props('nextPage')).toBe(2);
});
- it('Shows prev page disabled', () => {
- expect(findPagination().find('[aria-disabled]').text()).toBe('Prev');
- });
-
- it('Shows next page link', () => {
- expect(findPagination().find('a').text()).toBe('Next');
- });
-
it('Goes to the second page', () => {
findPagination().vm.$emit('input', 2);
@@ -84,7 +76,7 @@ describe('RunnerPagination', () => {
const links = findPagination().findAll('a');
expect(links).toHaveLength(2);
- expect(links.at(0).text()).toBe('Prev');
+ expect(links.at(0).text()).toBe('Previous');
expect(links.at(1).text()).toBe('Next');
});
@@ -124,14 +116,6 @@ describe('RunnerPagination', () => {
expect(findPagination().props('prevPage')).toBe(2);
expect(findPagination().props('nextPage')).toBe(null);
});
-
- it('Shows next page link', () => {
- expect(findPagination().find('a').text()).toBe('Prev');
- });
-
- it('Shows next page disabled', () => {
- expect(findPagination().find('[aria-disabled]').text()).toBe('Next');
- });
});
describe('When only one page', () => {
diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js
index 278f3dec2ee..3d9df03977e 100644
--- a/spec/frontend/runner/components/runner_pause_button_spec.js
+++ b/spec/frontend/runner/components/runner_pause_button_spec.js
@@ -4,10 +4,16 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
-import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql';
+import runnerToggleActiveMutation from '~/runner/graphql/shared/runner_toggle_active.mutation.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { captureException } from '~/runner/sentry_utils';
import { createAlert } from '~/flash';
+import {
+ I18N_PAUSE,
+ I18N_PAUSE_TOOLTIP,
+ I18N_RESUME,
+ I18N_RESUME_TOOLTIP,
+} from '~/runner/constants';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import { runnersData } from '../mock_data';
@@ -74,10 +80,10 @@ describe('RunnerPauseButton', () => {
describe('Pause/Resume action', () => {
describe.each`
- runnerState | icon | content | isActive | newActiveValue
- ${'paused'} | ${'play'} | ${'Resume'} | ${false} | ${true}
- ${'active'} | ${'pause'} | ${'Pause'} | ${true} | ${false}
- `('When the runner is $runnerState', ({ icon, content, isActive, newActiveValue }) => {
+ runnerState | icon | content | tooltip | isActive | newActiveValue
+ ${'paused'} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} | ${false} | ${true}
+ ${'active'} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} | ${true} | ${false}
+ `('When the runner is $runnerState', ({ icon, content, tooltip, isActive, newActiveValue }) => {
beforeEach(() => {
createComponent({
props: {
@@ -91,7 +97,11 @@ describe('RunnerPauseButton', () => {
it(`Displays a ${icon} button`, () => {
expect(findBtn().props('loading')).toBe(false);
expect(findBtn().props('icon')).toBe(icon);
+ });
+
+ it('Displays button content', () => {
expect(findBtn().text()).toBe(content);
+ expect(getTooltip()).toBe(tooltip);
});
it('Does not display redundant text for screen readers', () => {
@@ -218,8 +228,8 @@ describe('RunnerPauseButton', () => {
});
it('Display correctly for screen readers', () => {
- expect(findBtn().attributes('aria-label')).toBe('Pause');
- expect(getTooltip()).toBe('Pause');
+ expect(findBtn().attributes('aria-label')).toBe(I18N_PAUSE);
+ expect(getTooltip()).toBe(I18N_PAUSE_TOOLTIP);
});
describe('Immediately after the button is clicked', () => {
diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js
index 68a2130d6d9..96de8d11bca 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 getRunnerProjectsQuery from '~/runner/graphql/get_runner_projects.query.graphql';
+import runnerProjectsQuery from '~/runner/graphql/details/runner_projects.query.graphql';
import { runnerData, runnerProjectsData } from '../mock_data';
@@ -40,7 +40,7 @@ describe('RunnerProjects', () => {
const createComponent = ({ mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerProjects, {
- apolloProvider: createMockApollo([[getRunnerProjectsQuery, mockRunnerProjectsQuery]]),
+ apolloProvider: createMockApollo([[runnerProjectsQuery, mockRunnerProjectsQuery]]),
propsData: {
runner: mockRunner,
},
diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js
index 8b76be396ef..b071791e39f 100644
--- a/spec/frontend/runner/components/runner_update_form_spec.js
+++ b/spec/frontend/runner/components/runner_update_form_spec.js
@@ -13,7 +13,7 @@ import {
ACCESS_LEVEL_REF_PROTECTED,
ACCESS_LEVEL_NOT_PROTECTED,
} from '~/runner/constants';
-import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
+import runnerUpdateMutation from '~/runner/graphql/details/runner_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { runnerData } from '../mock_data';
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 7cb1f49d4f7..70e303e8626 100644
--- a/spec/frontend/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -1,5 +1,5 @@
import Vue, { nextTick } from 'vue';
-import { GlLink } from '@gitlab/ui';
+import { GlButton, GlLink, GlToast } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
@@ -17,6 +17,7 @@ import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
+import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
@@ -30,19 +31,22 @@ import {
PARAM_KEY_STATUS,
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
+ I18N_EDIT,
} from '~/runner/constants';
-import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
-import getGroupRunnersCountQuery from '~/runner/graphql/get_group_runners_count.query.graphql';
+import getGroupRunnersQuery from '~/runner/graphql/list/group_runners.query.graphql';
+import getGroupRunnersCountQuery from '~/runner/graphql/list/group_runners_count.query.graphql';
import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue';
import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData } from '../mock_data';
Vue.use(VueApollo);
+Vue.use(GlToast);
const mockGroupFullPath = 'group1';
const mockRegistrationToken = 'AABBCC';
-const mockGroupRunnersLimitedCount = groupRunnersData.data.group.runners.edges.length;
+const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges;
+const mockGroupRunnersLimitedCount = mockGroupRunnersEdges.length;
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
@@ -57,12 +61,12 @@ describe('GroupRunnersApp', () => {
let mockGroupRunnersCountQuery;
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
+ const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
+ const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`));
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
- const findRunnerPaginationPrev = () =>
- findRunnerPagination().findByLabelText('Go to previous page');
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
@@ -156,20 +160,7 @@ describe('GroupRunnersApp', () => {
it('shows the runners list', () => {
const runners = findRunnerList().props('runners');
- expect(runners).toEqual(groupRunnersData.data.group.runners.edges.map(({ node }) => node));
- });
-
- it('runner item links to the runner group page', async () => {
- const { webUrl, node } = groupRunnersData.data.group.runners.edges[0];
- const { id, shortSha } = node;
-
- createComponent({ mountFn: mountExtended });
-
- await waitForPromises();
-
- const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink);
- expect(runnerLink.text()).toBe(`#${getIdFromGraphQLId(id)} (${shortSha})`);
- expect(runnerLink.attributes('href')).toBe(webUrl);
+ expect(runners).toEqual(mockGroupRunnersEdges.map(({ node }) => node));
});
it('requests the runners with group path and no other filters', () => {
@@ -196,6 +187,50 @@ describe('GroupRunnersApp', () => {
);
});
+ describe('Single runner row', () => {
+ let showToast;
+
+ const { webUrl, editUrl, node } = mockGroupRunnersEdges[0];
+ const { id: graphqlId, shortSha } = node;
+ const id = getIdFromGraphQLId(graphqlId);
+
+ beforeEach(async () => {
+ mockGroupRunnersQuery.mockClear();
+
+ createComponent({ mountFn: mountExtended });
+ showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
+
+ await waitForPromises();
+ });
+
+ it('view link is displayed correctly', () => {
+ const viewLink = findRunnerRow(id).findByTestId('td-summary').findComponent(GlLink);
+
+ expect(viewLink.text()).toBe(`#${id} (${shortSha})`);
+ expect(viewLink.attributes('href')).toBe(webUrl);
+ });
+
+ it('edit link is displayed correctly', () => {
+ const editLink = findRunnerRow(id).findByTestId('td-actions').findComponent(GlButton);
+
+ expect(editLink.attributes()).toMatchObject({
+ 'aria-label': I18N_EDIT,
+ href: editUrl,
+ });
+ });
+
+ it('When runner is deleted, data is refetched and a toast is shown', async () => {
+ expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(1);
+
+ findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' });
+
+ expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(2);
+
+ expect(showToast).toHaveBeenCalledTimes(1);
+ expect(showToast).toHaveBeenCalledWith('Runner deleted');
+ });
+ });
+
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`);
@@ -303,14 +338,6 @@ describe('GroupRunnersApp', () => {
await waitForPromises();
});
- it('more pages can be selected', () => {
- expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next');
- });
-
- it('cannot navigate to the previous page', () => {
- expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true');
- });
-
it('navigates to the next page', async () => {
await findRunnerPaginationNext().trigger('click');
diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js
index d80caa47752..49c25039719 100644
--- a/spec/frontend/runner/mock_data.js
+++ b/spec/frontend/runner/mock_data.js
@@ -1,18 +1,18 @@
// Fixtures generated by: spec/frontend/fixtures/runner.rb
-// Admin queries
-import runnersData from 'test_fixtures/graphql/runner/get_runners.query.graphql.json';
-import runnersCountData from 'test_fixtures/graphql/runner/get_runners_count.query.graphql.json';
-import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json';
-import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json';
-import runnerWithGroupData from 'test_fixtures/graphql/runner/get_runner.query.graphql.with_group.json';
-import runnerProjectsData from 'test_fixtures/graphql/runner/get_runner_projects.query.graphql.json';
-import runnerJobsData from 'test_fixtures/graphql/runner/get_runner_jobs.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';
+import runnersCountData from 'test_fixtures/graphql/runner/list/admin_runners_count.query.graphql.json';
+import groupRunnersData from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.json';
+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';
-// Group queries
-import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json';
-import groupRunnersCountData from 'test_fixtures/graphql/runner/get_group_runners_count.query.graphql.json';
-import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.paginated.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';
export {
runnersData,
diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js
index 7ce5efb3c52..0a44688bfe0 100644
--- a/spec/frontend/search/topbar/components/app_spec.js
+++ b/spec/frontend/search/topbar/components/app_spec.js
@@ -1,4 +1,4 @@
-import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui';
+import { GlSearchBoxByClick } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
@@ -36,40 +36,19 @@ describe('GlobalSearchTopbar', () => {
wrapper.destroy();
});
- const findTopbarForm = () => wrapper.find(GlForm);
- const findGlSearchBox = () => wrapper.find(GlSearchBoxByType);
+ const findGlSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findGroupFilter = () => wrapper.find(GroupFilter);
const findProjectFilter = () => wrapper.find(ProjectFilter);
- const findSearchButton = () => wrapper.find(GlButton);
describe('template', () => {
beforeEach(() => {
createComponent();
});
- it('renders Topbar Form always', () => {
- expect(findTopbarForm().exists()).toBe(true);
- });
-
describe('Search box', () => {
it('renders always', () => {
expect(findGlSearchBox().exists()).toBe(true);
});
-
- describe('onSearch', () => {
- const testSearch = 'test search';
-
- beforeEach(() => {
- findGlSearchBox().vm.$emit('input', testSearch);
- });
-
- it('calls setQuery when input event is fired from GlSearchBoxByType', () => {
- expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
- key: 'search',
- value: testSearch,
- });
- });
- });
});
describe.each`
@@ -92,10 +71,6 @@ describe('GlobalSearchTopbar', () => {
expect(findProjectFilter().exists()).toBe(showFilters);
});
});
-
- it('renders SearchButton always', () => {
- expect(findSearchButton().exists()).toBe(true);
- });
});
describe('actions', () => {
@@ -103,8 +78,8 @@ describe('GlobalSearchTopbar', () => {
createComponent();
});
- it('clicking SearchButton calls applyQuery', () => {
- findTopbarForm().vm.$emit('submit', { preventDefault: () => {} });
+ it('clicking search button inside search box calls applyQuery', () => {
+ findGlSearchBox().vm.$emit('submit', { preventDefault: () => {} });
expect(actionSpies.applyQuery).toHaveBeenCalled();
});
diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js
index 2b74be19480..f0d902bf9fe 100644
--- a/spec/frontend/security_configuration/components/feature_card_spec.js
+++ b/spec/frontend/security_configuration/components/feature_card_spec.js
@@ -50,7 +50,7 @@ describe('FeatureCard component', () => {
expect(enableLinks.exists()).toBe(expectEnableAction);
if (expectEnableAction) {
expect(enableLinks).toHaveLength(1);
- expect(enableLinks.at(0).props('category')).toBe('primary');
+ expect(enableLinks.at(0).props('category')).toBe('secondary');
}
const configureLinks = findConfigureLinks();
diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js
index 18c9ada6bde..b8c1bef0ddd 100644
--- a/spec/frontend/security_configuration/components/training_provider_list_spec.js
+++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js
@@ -1,15 +1,20 @@
import * as Sentry from '@sentry/browser';
-import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader, GlIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import {
TRACK_TOGGLE_TRAINING_PROVIDER_ACTION,
TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
+ TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION,
+ TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL,
} from '~/security_configuration/constants';
+import { TEMP_PROVIDER_URLS } from '~/security_configuration/components/constants';
import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
+import { updateSecurityTrainingOptimisticResponse } from '~/security_configuration/graphql/cache_utils';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql';
import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
@@ -17,16 +22,30 @@ import waitForPromises from 'helpers/wait_for_promises';
import {
dismissUserCalloutResponse,
dismissUserCalloutErrorResponse,
- securityTrainingProviders,
- securityTrainingProvidersResponse,
+ getSecurityTrainingProvidersData,
updateSecurityTrainingProvidersResponse,
updateSecurityTrainingProvidersErrorResponse,
testProjectPath,
- textProviderIds,
+ testProviderIds,
+ testProviderName,
+ tempProviderLogos,
} from '../mock_data';
Vue.use(VueApollo);
+const TEST_TRAINING_PROVIDERS_ALL_DISABLED = getSecurityTrainingProvidersData();
+const TEST_TRAINING_PROVIDERS_FIRST_ENABLED = getSecurityTrainingProvidersData({
+ providerOverrides: { first: { isEnabled: true, isPrimary: true } },
+});
+const TEST_TRAINING_PROVIDERS_ALL_ENABLED = getSecurityTrainingProvidersData({
+ providerOverrides: {
+ first: { isEnabled: true, isPrimary: true },
+ second: { isEnabled: true, isPrimary: false },
+ third: { isEnabled: true, isPrimary: false },
+ },
+});
+const TEST_TRAINING_PROVIDERS_DEFAULT = TEST_TRAINING_PROVIDERS_ALL_DISABLED;
+
describe('TrainingProviderList component', () => {
let wrapper;
let apolloProvider;
@@ -35,7 +54,7 @@ describe('TrainingProviderList component', () => {
const defaultHandlers = [
[
securityTrainingProvidersQuery,
- jest.fn().mockResolvedValue(securityTrainingProvidersResponse),
+ jest.fn().mockResolvedValue(TEST_TRAINING_PROVIDERS_DEFAULT.response),
],
[
configureSecurityTrainingProvidersMutation,
@@ -50,10 +69,13 @@ describe('TrainingProviderList component', () => {
};
const createComponent = () => {
- wrapper = shallowMount(TrainingProviderList, {
+ wrapper = shallowMountExtended(TrainingProviderList, {
provide: {
projectFullPath: testProjectPath,
},
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
apolloProvider,
});
};
@@ -65,10 +87,12 @@ describe('TrainingProviderList component', () => {
const findLinks = () => wrapper.findAllComponents(GlLink);
const findToggles = () => wrapper.findAllComponents(GlToggle);
const findFirstToggle = () => findToggles().at(0);
+ const findPrimaryProviderRadios = () => wrapper.findAllByTestId('primary-provider-radio');
const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findErrorAlert = () => wrapper.findComponent(GlAlert);
+ const findLogos = () => wrapper.findAllByTestId('provider-logo');
- const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', textProviderIds[0]);
+ const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', testProviderIds[0]);
afterEach(() => {
wrapper.destroy();
@@ -104,7 +128,7 @@ describe('TrainingProviderList component', () => {
Mutation: {
configureSecurityTrainingProviders: () => ({
errors: [],
- securityTrainingProviders: [],
+ TEST_TRAINING_PROVIDERS_DEFAULT: [],
}),
},
},
@@ -119,10 +143,10 @@ describe('TrainingProviderList component', () => {
});
it('renders correct amount of cards', () => {
- expect(findCards()).toHaveLength(securityTrainingProviders.length);
+ expect(findCards()).toHaveLength(TEST_TRAINING_PROVIDERS_DEFAULT.data.length);
});
- securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => {
+ TEST_TRAINING_PROVIDERS_DEFAULT.data.forEach(({ name, description, isEnabled }, index) => {
it(`shows the name for card ${index}`, () => {
expect(findCards().at(index).text()).toContain(name);
});
@@ -131,23 +155,76 @@ describe('TrainingProviderList component', () => {
expect(findCards().at(index).text()).toContain(description);
});
- it(`shows the learn more link for card ${index}`, () => {
- expect(findLinks().at(index).attributes()).toEqual({
- target: '_blank',
- href: url,
- });
+ it(`shows the learn more link for enabled card ${index}`, () => {
+ const learnMoreLink = findCards().at(index).find(GlLink);
+ const tempLogo = TEMP_PROVIDER_URLS[name];
+
+ if (tempLogo) {
+ expect(learnMoreLink.attributes()).toEqual({
+ target: '_blank',
+ href: TEMP_PROVIDER_URLS[name],
+ });
+ } else {
+ expect(learnMoreLink.exists()).toBe(false);
+ }
});
it(`shows the toggle with the correct value for card ${index}`, () => {
expect(findToggles().at(index).props('value')).toEqual(isEnabled);
});
+ it(`shows a radio button to select the provider as primary within card ${index}`, () => {
+ const primaryProviderRadioForCurrentCard = findPrimaryProviderRadios().at(index);
+
+ // if the given provider is not enabled it should not be possible select it as primary
+ expect(primaryProviderRadioForCurrentCard.find('input').attributes('disabled')).toBe(
+ isEnabled ? undefined : 'disabled',
+ );
+
+ expect(primaryProviderRadioForCurrentCard.text()).toBe(
+ TrainingProviderList.i18n.primaryTraining,
+ );
+ });
+
+ it('shows a info-tooltip that describes the purpose of a primary provider', () => {
+ const infoIcon = findPrimaryProviderRadios().at(index).find(GlIcon);
+ const tooltip = getBinding(infoIcon.element, 'gl-tooltip');
+
+ expect(infoIcon.props()).toMatchObject({
+ name: 'information-o',
+ });
+ expect(tooltip.value).toBe(TrainingProviderList.i18n.primaryTrainingDescription);
+ });
+
it('does not show loader when query is populated', () => {
expect(findLoader().exists()).toBe(false);
});
});
});
+ describe('provider logo', () => {
+ beforeEach(async () => {
+ wrapper.vm.$options.TEMP_PROVIDER_LOGOS = tempProviderLogos;
+ await waitForQueryToBeLoaded();
+ });
+
+ const providerIndexArray = [0, 1];
+
+ it.each(providerIndexArray)('displays the correct width for provider %s', (provider) => {
+ expect(findLogos().at(provider).attributes('style')).toBe('width: 18px;');
+ });
+
+ it.each(providerIndexArray)('has a11y decorative attribute for provider %s', (provider) => {
+ expect(findLogos().at(provider).attributes('role')).toBe('presentation');
+ });
+
+ it.each(providerIndexArray)('renders the svg content for provider %s', (provider) => {
+ expect(findLogos().at(provider).html()).toContain(
+ tempProviderLogos[testProviderName[provider]].svg,
+ );
+ });
+ });
+
describe('storing training provider settings', () => {
beforeEach(async () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
@@ -157,26 +234,15 @@ describe('TrainingProviderList component', () => {
await toggleFirstProvider();
});
- it.each`
- loading | wait | desc
- ${true} | ${false} | ${'enables loading of GlToggle when mutation is called'}
- ${false} | ${true} | ${'disables loading of GlToggle when mutation is complete'}
- `('$desc', async ({ loading, wait }) => {
- if (wait) {
- await waitForMutationToBeLoaded();
- }
- expect(findFirstToggle().props('isLoading')).toBe(loading);
- });
-
it('calls mutation when toggle is changed', () => {
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: configureSecurityTrainingProvidersMutation,
variables: {
input: {
- providerId: textProviderIds[0],
+ providerId: testProviderIds[0],
isEnabled: true,
- isPrimary: false,
+ isPrimary: true,
projectPath: testProjectPath,
},
},
@@ -184,6 +250,20 @@ describe('TrainingProviderList component', () => {
);
});
+ it('returns an optimistic response when calling the mutation', () => {
+ const optimisticResponse = updateSecurityTrainingOptimisticResponse({
+ id: TEST_TRAINING_PROVIDERS_DEFAULT.data[0].id,
+ isEnabled: true,
+ isPrimary: true,
+ });
+
+ expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ optimisticResponse,
+ }),
+ );
+ });
+
it('dismisses the callout when the feature gets first enabled', async () => {
// wait for configuration update mutation to complete
await waitForMutationToBeLoaded();
@@ -237,13 +317,62 @@ describe('TrainingProviderList component', () => {
// Once https://gitlab.com/gitlab-org/gitlab/-/issues/348985 and https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79492
// are merged this will be much easer to do and should be tackled then.
expect(trackingSpy).toHaveBeenCalledWith(undefined, TRACK_TOGGLE_TRAINING_PROVIDER_ACTION, {
- property: securityTrainingProviders[0].id,
+ property: TEST_TRAINING_PROVIDERS_DEFAULT.data[0].id,
label: TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
extra: {
providerIsEnabled: true,
},
});
});
+
+ it(`tracks when a provider's "Learn more" link is clicked`, () => {
+ const firstProviderLink = findLinks().at(0);
+ const [{ id: firstProviderId }] = TEST_TRAINING_PROVIDERS_DEFAULT.data;
+
+ expect(trackingSpy).not.toHaveBeenCalled();
+
+ firstProviderLink.vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(
+ undefined,
+ TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION,
+ {
+ label: TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL,
+ property: firstProviderId,
+ },
+ );
+ });
+ });
+ });
+
+ describe('primary provider settings', () => {
+ it.each`
+ description | initialProviderData | expectedMutationInput
+ ${'sets the provider to be non-primary when it gets disabled'} | ${TEST_TRAINING_PROVIDERS_FIRST_ENABLED.response} | ${{ providerId: TEST_TRAINING_PROVIDERS_FIRST_ENABLED.data[0].id, isEnabled: false, isPrimary: false }}
+ ${'sets a provider to be primary when it is the only one enabled'} | ${TEST_TRAINING_PROVIDERS_ALL_DISABLED.response} | ${{ providerId: TEST_TRAINING_PROVIDERS_ALL_DISABLED.data[0].id, isEnabled: true, isPrimary: true }}
+ ${'sets the first other enabled provider to be primary when the primary one gets disabled'} | ${TEST_TRAINING_PROVIDERS_ALL_ENABLED.response} | ${{ providerId: TEST_TRAINING_PROVIDERS_ALL_ENABLED.data[1].id, isEnabled: true, isPrimary: true }}
+ `('$description', async ({ initialProviderData, expectedMutationInput }) => {
+ createApolloProvider({
+ handlers: [
+ [securityTrainingProvidersQuery, jest.fn().mockResolvedValue(initialProviderData)],
+ ],
+ });
+ jest.spyOn(apolloProvider.defaultClient, 'mutate');
+ createComponent();
+
+ await waitForQueryToBeLoaded();
+ await toggleFirstProvider();
+
+ expect(apolloProvider.defaultClient.mutate).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ variables: {
+ input: expect.objectContaining({
+ ...expectedMutationInput,
+ }),
+ },
+ }),
+ );
});
});
diff --git a/spec/frontend/security_configuration/graphql/cache_utils_spec.js b/spec/frontend/security_configuration/graphql/cache_utils_spec.js
new file mode 100644
index 00000000000..a40611cc022
--- /dev/null
+++ b/spec/frontend/security_configuration/graphql/cache_utils_spec.js
@@ -0,0 +1,108 @@
+import {
+ updateSecurityTrainingCache,
+ updateSecurityTrainingOptimisticResponse,
+} from '~/security_configuration/graphql/cache_utils';
+
+describe('EE - Security configuration graphQL cache utils', () => {
+ describe('updateSecurityTrainingOptimisticResponse', () => {
+ it('returns an optimistic response in the correct shape', () => {
+ const changes = { isEnabled: true, isPrimary: true };
+ const mutationResponse = updateSecurityTrainingOptimisticResponse(changes);
+
+ expect(mutationResponse).toEqual({
+ __typename: 'Mutation',
+ securityTrainingUpdate: {
+ __typename: 'SecurityTrainingUpdatePayload',
+ training: {
+ __typename: 'ProjectSecurityTraining',
+ ...changes,
+ },
+ errors: [],
+ },
+ });
+ });
+ });
+
+ describe('updateSecurityTrainingCache', () => {
+ let mockCache;
+
+ beforeEach(() => {
+ // freezing the data makes sure that we don't mutate the original project
+ const mockCacheData = Object.freeze({
+ project: {
+ securityTrainingProviders: [
+ { id: 1, isEnabled: true, isPrimary: true },
+ { id: 2, isEnabled: true, isPrimary: false },
+ { id: 3, isEnabled: false, isPrimary: false },
+ ],
+ },
+ });
+
+ mockCache = {
+ readQuery: () => mockCacheData,
+ writeQuery: jest.fn(),
+ };
+ });
+
+ it('does not update the cache when the primary provider is not getting disabled', () => {
+ const providerAfterUpdate = {
+ id: 2,
+ isEnabled: true,
+ isPrimary: false,
+ };
+
+ updateSecurityTrainingCache({
+ query: 'GraphQL query',
+ variables: { fullPath: 'gitlab/project' },
+ })(mockCache, {
+ data: {
+ securityTrainingUpdate: {
+ training: {
+ ...providerAfterUpdate,
+ },
+ },
+ },
+ });
+
+ expect(mockCache.writeQuery).not.toHaveBeenCalled();
+ });
+
+ it('sets the previous primary provider to be non-primary when another provider gets set as primary', () => {
+ const providerAfterUpdate = {
+ id: 2,
+ isEnabled: true,
+ isPrimary: true,
+ };
+
+ const expectedTrainingProvidersWrittenToCache = [
+ // this was the previous primary primary provider and it should not be primary any longer
+ { id: 1, isEnabled: true, isPrimary: false },
+ { id: 2, isEnabled: true, isPrimary: true },
+ { id: 3, isEnabled: false, isPrimary: false },
+ ];
+
+ updateSecurityTrainingCache({
+ query: 'GraphQL query',
+ variables: { fullPath: 'gitlab/project' },
+ })(mockCache, {
+ data: {
+ securityTrainingUpdate: {
+ training: {
+ ...providerAfterUpdate,
+ },
+ },
+ },
+ });
+
+ expect(mockCache.writeQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: {
+ project: {
+ securityTrainingProviders: expectedTrainingProvidersWrittenToCache,
+ },
+ },
+ }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js
index b042e870467..18a480bf082 100644
--- a/spec/frontend/security_configuration/mock_data.js
+++ b/spec/frontend/security_configuration/mock_data.js
@@ -1,33 +1,57 @@
export const testProjectPath = 'foo/bar';
+export const testProviderIds = [101, 102, 103];
+export const testProviderName = ['Kontra', 'Secure Code Warrior', 'Other Vendor'];
+export const testTrainingUrls = [
+ 'https://www.vendornameone.com/url',
+ 'https://www.vendornametwo.com/url',
+];
-export const textProviderIds = [101, 102];
-
-export const securityTrainingProviders = [
+const createSecurityTrainingProviders = ({ providerOverrides = {} }) => [
{
- id: textProviderIds[0],
- name: 'Vendor Name 1',
+ id: testProviderIds[0],
+ name: testProviderName[0],
description: 'Interactive developer security education',
url: 'https://www.example.org/security/training',
isEnabled: false,
isPrimary: false,
+ ...providerOverrides.first,
},
{
- id: textProviderIds[1],
- name: 'Vendor Name 2',
+ id: testProviderIds[1],
+ name: testProviderName[1],
description: 'Security training with guide and learning pathways.',
url: 'https://www.vendornametwo.com/',
- isEnabled: true,
+ isEnabled: false,
+ isPrimary: false,
+ ...providerOverrides.second,
+ },
+ {
+ id: testProviderIds[2],
+ name: testProviderName[2],
+ description: 'Security training for the everyday developer.',
+ url: 'https://www.vendornamethree.com/',
+ isEnabled: false,
isPrimary: false,
+ ...providerOverrides.third,
},
];
-export const securityTrainingProvidersResponse = {
- data: {
- project: {
- id: 1,
- securityTrainingProviders,
+export const getSecurityTrainingProvidersData = (providerOverrides = {}) => {
+ const securityTrainingProviders = createSecurityTrainingProviders(providerOverrides);
+ const response = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/1',
+ __typename: 'Project',
+ securityTrainingProviders,
+ },
},
- },
+ };
+
+ return {
+ response,
+ data: securityTrainingProviders,
+ };
};
export const dismissUserCalloutResponse = {
@@ -76,3 +100,14 @@ export const updateSecurityTrainingProvidersErrorResponse = {
},
},
};
+
+// Will remove once this issue is resolved where the svg path will be available in the GraphQL query
+// https://gitlab.com/gitlab-org/gitlab/-/issues/346899
+export const tempProviderLogos = {
+ [testProviderName[0]]: {
+ svg: `<svg>${[testProviderName[0]]}</svg>`,
+ },
+ [testProviderName[1]]: {
+ svg: `<svg>${[testProviderName[1]]}</svg>`,
+ },
+};
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
index def46255994..5fd364afbe4 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
@@ -1,4 +1,4 @@
-import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui';
+import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
@@ -76,7 +76,16 @@ describe('Sidebar assignees widget', () => {
SidebarEditableItem,
UserSelect,
GlSearchBoxByType,
- GlDropdown,
+ GlDropdown: {
+ template: `
+ <div>
+ <slot name="footer"></slot>
+ </div>
+ `,
+ methods: {
+ show: jest.fn(),
+ },
+ },
},
});
};
@@ -340,21 +349,9 @@ describe('Sidebar assignees widget', () => {
});
});
- it('when realtime feature flag is disabled', async () => {
+ it('includes the real-time assignees component', async () => {
createComponent();
await waitForPromises();
- expect(findRealtimeAssignees().exists()).toBe(false);
- });
-
- it('when realtime feature flag is enabled', async () => {
- createComponent({
- provide: {
- glFeatures: {
- realTimeIssueSidebar: true,
- },
- },
- });
- await waitForPromises();
expect(findRealtimeAssignees().exists()).toBe(true);
});
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
index 88a5f4ea8b7..71424aaead3 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
@@ -1,5 +1,6 @@
-import { GlAvatarLabeled } from '@gitlab/ui';
+import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { IssuableType } from '~/issues/constants';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
const user = {
@@ -13,14 +14,24 @@ describe('Sidebar participant component', () => {
let wrapper;
const findAvatar = () => wrapper.findComponent(GlAvatarLabeled);
+ const findIcon = () => wrapper.findComponent(GlIcon);
- const createComponent = (status = null) => {
+ const createComponent = ({
+ status = null,
+ issuableType = IssuableType.Issue,
+ canMerge = false,
+ } = {}) => {
wrapper = shallowMount(SidebarParticipant, {
propsData: {
user: {
...user,
+ canMerge,
status,
},
+ issuableType,
+ },
+ stubs: {
+ GlAvatarLabeled,
},
});
};
@@ -29,15 +40,35 @@ describe('Sidebar participant component', () => {
wrapper.destroy();
});
- it('when user is not busy', () => {
+ it('does not show `Busy` status when user is not busy', () => {
createComponent();
expect(findAvatar().props('label')).toBe(user.name);
});
- it('when user is busy', () => {
- createComponent({ availability: 'BUSY' });
+ it('shows `Busy` status when user is busy', () => {
+ createComponent({ status: { availability: 'BUSY' } });
expect(findAvatar().props('label')).toBe(`${user.name} (Busy)`);
});
+
+ it('does not render a warning icon', () => {
+ createComponent();
+
+ expect(findIcon().exists()).toBe(false);
+ });
+
+ describe('when on merge request sidebar', () => {
+ it('when project member cannot merge', () => {
+ createComponent({ issuableType: IssuableType.MergeRequest });
+
+ expect(findIcon().exists()).toBe(true);
+ });
+
+ it('when project member can merge', () => {
+ createComponent({ issuableType: IssuableType.MergeRequest, canMerge: true });
+
+ expect(findIcon().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/sidebar/components/attention_requested_toggle_spec.js b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js
index 0939297a754..a9ae23c1624 100644
--- a/spec/frontend/sidebar/components/attention_requested_toggle_spec.js
+++ b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js
@@ -16,7 +16,10 @@ describe('Attention require toggle', () => {
});
it('renders button', () => {
- factory({ type: 'reviewer', user: { attention_requested: false } });
+ factory({
+ type: 'reviewer',
+ user: { attention_requested: false, can_update_merge_request: true },
+ });
expect(findToggle().exists()).toBe(true);
});
@@ -28,7 +31,10 @@ describe('Attention require toggle', () => {
`(
'renders $icon icon when attention_requested is $attentionRequested',
({ attentionRequested, icon }) => {
- factory({ type: 'reviewer', user: { attention_requested: attentionRequested } });
+ factory({
+ type: 'reviewer',
+ user: { attention_requested: attentionRequested, can_update_merge_request: true },
+ });
expect(findToggle().props('icon')).toBe(icon);
},
@@ -41,27 +47,47 @@ describe('Attention require toggle', () => {
`(
'renders button with variant $variant when attention_requested is $attentionRequested',
({ attentionRequested, variant }) => {
- factory({ type: 'reviewer', user: { attention_requested: attentionRequested } });
+ factory({
+ type: 'reviewer',
+ user: { attention_requested: attentionRequested, can_update_merge_request: true },
+ });
expect(findToggle().props('variant')).toBe(variant);
},
);
it('emits toggle-attention-requested on click', async () => {
- factory({ type: 'reviewer', user: { attention_requested: true } });
+ factory({
+ type: 'reviewer',
+ user: { attention_requested: true, can_update_merge_request: true },
+ });
await findToggle().trigger('click');
expect(wrapper.emitted('toggle-attention-requested')[0]).toEqual([
{
- user: { attention_requested: true },
+ user: { attention_requested: true, can_update_merge_request: true },
callback: expect.anything(),
},
]);
});
+ it('does not emit toggle-attention-requested on click if can_update_merge_request is false', async () => {
+ factory({
+ type: 'reviewer',
+ user: { attention_requested: true, can_update_merge_request: false },
+ });
+
+ await findToggle().trigger('click');
+
+ expect(wrapper.emitted('toggle-attention-requested')).toBe(undefined);
+ });
+
it('sets loading on click', async () => {
- factory({ type: 'reviewer', user: { attention_requested: true } });
+ factory({
+ type: 'reviewer',
+ user: { attention_requested: true, can_update_merge_request: true },
+ });
await findToggle().trigger('click');
@@ -69,14 +95,24 @@ describe('Attention require toggle', () => {
});
it.each`
- type | attentionRequested | tooltip
- ${'reviewer'} | ${true} | ${AttentionRequestedToggle.i18n.removeAttentionRequested}
- ${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.attentionRequestedReviewer}
- ${'assignee'} | ${false} | ${AttentionRequestedToggle.i18n.attentionRequestedAssignee}
+ 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.attentionRequestedNoPermission} | ${false}
+ ${'reviewer'} | ${false} | ${AttentionRequestedToggle.i18n.noAttentionRequestedNoPermission} | ${false}
+ ${'assignee'} | ${true} | ${AttentionRequestedToggle.i18n.attentionRequestedNoPermission} | ${false}
+ ${'assignee'} | ${false} | ${AttentionRequestedToggle.i18n.noAttentionRequestedNoPermission} | ${false}
`(
- 'sets tooltip as $tooltip when attention_requested is $attentionRequested and type is $type',
- ({ type, attentionRequested, tooltip }) => {
- factory({ type, user: { attention_requested: attentionRequested } });
+ 'sets tooltip as $tooltip when attention_requested is $attentionRequested, type is $type and, can_update_merge_request is $canUpdateMergeRequest',
+ ({ type, attentionRequested, tooltip, canUpdateMergeRequest }) => {
+ factory({
+ type,
+ user: {
+ attention_requested: attentionRequested,
+ can_update_merge_request: canUpdateMergeRequest,
+ },
+ });
expect(findToggle().attributes('aria-label')).toBe(tooltip);
},
diff --git a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js
new file mode 100644
index 00000000000..7a736624fc0
--- /dev/null
+++ b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js
@@ -0,0 +1,52 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue';
+import {
+ STATUS_LABELS,
+ STATUS_TRIGGERED,
+ STATUS_ACKNOWLEDGED,
+} from '~/sidebar/components/incidents/constants';
+
+describe('EscalationStatus', () => {
+ let wrapper;
+
+ function createComponent(props) {
+ wrapper = mountExtended(EscalationStatus, {
+ propsData: {
+ value: STATUS_TRIGGERED,
+ ...props,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownComponent = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+
+ describe('status', () => {
+ it('shows the current status', () => {
+ createComponent({ value: STATUS_ACKNOWLEDGED });
+
+ expect(findDropdownComponent().props('text')).toBe(STATUS_LABELS[STATUS_ACKNOWLEDGED]);
+ });
+
+ it('shows the None option when status is null', () => {
+ createComponent({ value: null });
+
+ expect(findDropdownComponent().props('text')).toBe('None');
+ });
+ });
+
+ describe('events', () => {
+ it('selects an item', async () => {
+ createComponent();
+
+ await findDropdownItems().at(1).vm.$emit('click');
+
+ expect(wrapper.emitted().input[0][0]).toBe(STATUS_ACKNOWLEDGED);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js b/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js
new file mode 100644
index 00000000000..edd65db0325
--- /dev/null
+++ b/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js
@@ -0,0 +1,18 @@
+import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants';
+import { getStatusLabel } from '~/sidebar/components/incidents/utils';
+
+describe('EscalationUtils', () => {
+ describe('getStatusLabel', () => {
+ it('returns a label when provided with a valid status', () => {
+ const label = getStatusLabel(STATUS_ACKNOWLEDGED);
+
+ expect(label).toEqual('Acknowledged');
+ });
+
+ it("returns 'None' when status is null", () => {
+ const label = getStatusLabel(null);
+
+ expect(label).toEqual('None');
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/incidents/mock_data.js b/spec/frontend/sidebar/components/incidents/mock_data.js
new file mode 100644
index 00000000000..bbb6c61b162
--- /dev/null
+++ b/spec/frontend/sidebar/components/incidents/mock_data.js
@@ -0,0 +1,39 @@
+import { STATUS_TRIGGERED, STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants';
+
+export const fetchData = {
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/2',
+ issuable: {
+ __typename: 'Issue',
+ id: 'gid://gitlab/Issue/4',
+ escalationStatus: STATUS_TRIGGERED,
+ },
+ },
+};
+
+export const mutationData = {
+ issueSetEscalationStatus: {
+ __typename: 'IssueSetEscalationStatusPayload',
+ errors: [],
+ clientMutationId: null,
+ issue: {
+ __typename: 'Issue',
+ id: 'gid://gitlab/Issue/4',
+ escalationStatus: STATUS_ACKNOWLEDGED,
+ },
+ },
+};
+
+export const fetchError = {
+ workspace: {
+ __typename: 'Project',
+ },
+};
+
+export const mutationError = {
+ issueSetEscalationStatus: {
+ __typename: 'IssueSetEscalationStatusPayload',
+ errors: ['hello'],
+ },
+};
diff --git a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js
new file mode 100644
index 00000000000..a8dc610672c
--- /dev/null
+++ b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js
@@ -0,0 +1,207 @@
+import { createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import SidebarEscalationStatus from '~/sidebar/components/incidents/sidebar_escalation_status.vue';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
+import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants';
+import { createAlert } from '~/flash';
+import { logError } from '~/lib/logger';
+import { fetchData, fetchError, mutationData, mutationError } from './mock_data';
+
+jest.mock('~/lib/logger');
+jest.mock('~/flash');
+
+const localVue = createLocalVue();
+
+describe('SidebarEscalationStatus', () => {
+ let wrapper;
+ const queryResolverMock = jest.fn();
+ const mutationResolverMock = jest.fn();
+
+ function createMockApolloProvider({ hasFetchError = false, hasMutationError = false } = {}) {
+ localVue.use(VueApollo);
+
+ queryResolverMock.mockResolvedValue({ data: hasFetchError ? fetchError : fetchData });
+ mutationResolverMock.mockResolvedValue({
+ data: hasMutationError ? mutationError : mutationData,
+ });
+
+ const requestHandlers = [
+ [escalationStatusQuery, queryResolverMock],
+ [escalationStatusMutation, mutationResolverMock],
+ ];
+
+ return createMockApollo(requestHandlers);
+ }
+
+ function createComponent({ mockApollo } = {}) {
+ let config;
+
+ if (mockApollo) {
+ config = { apolloProvider: mockApollo };
+ } else {
+ config = { mocks: { $apollo: { queries: { status: { loading: false } } } } };
+ }
+
+ wrapper = mountExtended(SidebarEscalationStatus, {
+ propsData: {
+ iid: '1',
+ projectPath: 'gitlab-org/gitlab',
+ issuableType: 'issue',
+ },
+ provide: {
+ canUpdate: true,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ localVue,
+ ...config,
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findSidebarComponent = () => wrapper.findComponent(SidebarEditableItem);
+ const findStatusComponent = () => wrapper.findComponent(EscalationStatus);
+ const findEditButton = () => wrapper.findByTestId('edit-button');
+ const findIcon = () => wrapper.findByTestId('status-icon');
+
+ const clickEditButton = async () => {
+ findEditButton().vm.$emit('click');
+ await nextTick();
+ };
+ const selectAcknowledgedStatus = async () => {
+ findStatusComponent().vm.$emit('input', STATUS_ACKNOWLEDGED);
+ // wait for apollo requests
+ await waitForPromises();
+ };
+
+ describe('sidebar', () => {
+ it('renders the sidebar component', () => {
+ createComponent();
+ expect(findSidebarComponent().exists()).toBe(true);
+ });
+
+ describe('status icon', () => {
+ it('is visible', () => {
+ createComponent();
+
+ expect(findIcon().exists()).toBe(true);
+ expect(findIcon().isVisible()).toBe(true);
+ });
+
+ it('has correct tooltip', async () => {
+ const mockApollo = createMockApolloProvider();
+ createComponent({ mockApollo });
+
+ // wait for apollo requests
+ await waitForPromises();
+
+ const tooltip = getBinding(findIcon().element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ expect(tooltip.value).toBe('Status: Triggered');
+ });
+ });
+
+ describe('status dropdown', () => {
+ beforeEach(async () => {
+ const mockApollo = createMockApolloProvider();
+ createComponent({ mockApollo });
+
+ // wait for apollo requests
+ await waitForPromises();
+ });
+
+ it('is closed by default', () => {
+ expect(findStatusComponent().exists()).toBe(true);
+ expect(findStatusComponent().isVisible()).toBe(false);
+ });
+
+ it('is shown after clicking the edit button', async () => {
+ await clickEditButton();
+
+ expect(findStatusComponent().isVisible()).toBe(true);
+ });
+
+ it('is hidden after clicking the edit button, when open already', async () => {
+ await clickEditButton();
+ await clickEditButton();
+
+ expect(findStatusComponent().isVisible()).toBe(false);
+ });
+ });
+
+ describe('update Status event', () => {
+ beforeEach(async () => {
+ const mockApollo = createMockApolloProvider();
+ createComponent({ mockApollo });
+
+ // wait for apollo requests
+ await waitForPromises();
+
+ await clickEditButton();
+ await selectAcknowledgedStatus();
+ });
+
+ it('calls the mutation', () => {
+ const mutationVariables = {
+ iid: '1',
+ projectPath: 'gitlab-org/gitlab',
+ status: STATUS_ACKNOWLEDGED,
+ };
+
+ expect(mutationResolverMock).toHaveBeenCalledWith(mutationVariables);
+ });
+
+ it('closes the dropdown', () => {
+ expect(findStatusComponent().isVisible()).toBe(false);
+ });
+
+ it('updates the status', () => {
+ // Sometimes status has a intermediate wrapping component. A quirk of vue-test-utils
+ // means that in that case 'value' is exposed as a prop. If no wrapping component
+ // exists it is exposed as an attribute.
+ const statusValue =
+ findStatusComponent().props('value') || findStatusComponent().attributes('value');
+ expect(statusValue).toBe(STATUS_ACKNOWLEDGED);
+ });
+ });
+
+ describe('mutation errors', () => {
+ it('should error upon fetch', async () => {
+ const mockApollo = createMockApolloProvider({ hasFetchError: true });
+ createComponent({ mockApollo });
+
+ // wait for apollo requests
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalled();
+ expect(logError).toHaveBeenCalled();
+ });
+
+ it('should error upon mutation', async () => {
+ const mockApollo = createMockApolloProvider({ hasMutationError: true });
+ createComponent({ mockApollo });
+
+ // wait for apollo requests
+ await waitForPromises();
+
+ await clickEditButton();
+ await selectAcknowledgedStatus();
+
+ expect(createAlert).toHaveBeenCalled();
+ expect(logError).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js
index 30972484a08..fbca00636b6 100644
--- a/spec/frontend/sidebar/mock_data.js
+++ b/spec/frontend/sidebar/mock_data.js
@@ -428,7 +428,7 @@ const mockUser1 = {
export const mockUser2 = {
__typename: 'UserCore',
- id: 'gid://gitlab/User/4',
+ id: 'gid://gitlab/User/5',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
@@ -457,6 +457,33 @@ export const searchResponse = {
},
};
+export const searchResponseOnMR = {
+ data: {
+ workspace: {
+ __typename: 'Project',
+ id: '1',
+ users: {
+ nodes: [
+ {
+ id: 'gid://gitlab/User/1',
+ user: mockUser1,
+ mergeRequestInteraction: {
+ canMerge: true,
+ },
+ },
+ {
+ id: 'gid://gitlab/User/4',
+ user: mockUser2,
+ mergeRequestInteraction: {
+ canMerge: false,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
export const projectMembersResponse = {
data: {
workspace: {
diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js
index 5f77e21c1f8..68d20060c37 100644
--- a/spec/frontend/sidebar/sidebar_assignees_spec.js
+++ b/spec/frontend/sidebar/sidebar_assignees_spec.js
@@ -14,7 +14,7 @@ describe('sidebar assignees', () => {
let wrapper;
let mediator;
let axiosMock;
- const createComponent = (realTimeIssueSidebar = false, props) => {
+ const createComponent = (props) => {
wrapper = shallowMount(SidebarAssignees, {
propsData: {
issuableIid: '1',
@@ -25,11 +25,6 @@ describe('sidebar assignees', () => {
changing: false,
...props,
},
- provide: {
- glFeatures: {
- realTimeIssueSidebar,
- },
- },
// Attaching to document is required because this component emits something from the parent element :/
attachTo: document.body,
});
@@ -86,27 +81,17 @@ describe('sidebar assignees', () => {
expect(wrapper.find(Assigness).exists()).toBe(true);
});
- describe('when realTimeIssueSidebar is turned on', () => {
- describe('when issuableType is issue', () => {
- it('finds AssigneesRealtime componeont', () => {
- createComponent(true);
-
- expect(wrapper.find(AssigneesRealtime).exists()).toBe(true);
- });
- });
-
- describe('when issuableType is MR', () => {
- it('does not find AssigneesRealtime componeont', () => {
- createComponent(true, { issuableType: 'MR' });
+ describe('when issuableType is issue', () => {
+ it('finds AssigneesRealtime component', () => {
+ createComponent();
- expect(wrapper.find(AssigneesRealtime).exists()).toBe(false);
- });
+ expect(wrapper.find(AssigneesRealtime).exists()).toBe(true);
});
});
- describe('when realTimeIssueSidebar is turned off', () => {
- it('does not find AssigneesRealtime', () => {
- createComponent(false, { issuableType: 'issue' });
+ describe('when issuableType is MR', () => {
+ it('does not find AssigneesRealtime component', () => {
+ createComponent({ issuableType: 'MR' });
expect(wrapper.find(AssigneesRealtime).exists()).toBe(false);
});
diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js
index 3d7baaff10a..c472a98bf0b 100644
--- a/spec/frontend/sidebar/sidebar_mediator_spec.js
+++ b/spec/frontend/sidebar/sidebar_mediator_spec.js
@@ -5,9 +5,11 @@ import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import toast from '~/vue_shared/plugins/global_toast';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Mock from './mock_data';
jest.mock('~/vue_shared/plugins/global_toast');
+jest.mock('~/commons/nav/user_merge_requests');
describe('Sidebar mediator', () => {
const { mediator: mediatorMockData } = Mock;
@@ -137,6 +139,7 @@ describe('Sidebar mediator', () => {
});
expect(attentionRequiredService).toHaveBeenCalledWith(1);
+ expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
});
it.each`
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
index e12255fe825..6fc358a6a15 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
@@ -14,6 +14,7 @@ exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = `
/>
<source-editor-stub
+ debouncevalue="250"
editoroptions="[object Object]"
fileglobalid="blob_local_7"
filename="foo/bar/test.md"
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 c193bb08543..2b26c306c68 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
@@ -29,6 +29,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
>
<markdown-header-stub
data-testid="markdownHeader"
+ enablepreview="true"
linecontent=""
suggestionstartindex="0"
/>
diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js
index 1637ac2039c..b1303cf2b5e 100644
--- a/spec/frontend/terraform/components/empty_state_spec.js
+++ b/spec/frontend/terraform/components/empty_state_spec.js
@@ -8,7 +8,7 @@ describe('EmptyStateComponent', () => {
const propsData = {
image: '/image/path',
};
- const docsUrl = '/help/user/infrastructure/terraform_state';
+ const docsUrl = '/help/user/infrastructure/iac/terraform_state';
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findLink = () => wrapper.findComponent(GlLink);
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 4fe51db8412..6c336152e9a 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -8,6 +8,7 @@ initializeTestTimeout(process.env.CI ? 6000 : 500);
afterEach(() =>
// give Promises a bit more time so they fail the right test
+ // eslint-disable-next-line no-restricted-syntax
new Promise(setImmediate).then(() => {
// wait for pending setTimeout()s
jest.runOnlyPendingTimers();
diff --git a/spec/frontend/toggle_buttons_spec.js b/spec/frontend/toggle_buttons_spec.js
deleted file mode 100644
index 435fd35744f..00000000000
--- a/spec/frontend/toggle_buttons_spec.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import $ from 'jquery';
-import waitForPromises from 'helpers/wait_for_promises';
-import setupToggleButtons from '~/toggle_buttons';
-
-function generateMarkup(isChecked = true) {
- return `
- <button type="button" class="${isChecked ? 'is-checked' : ''} js-project-feature-toggle">
- <input type="hidden" class="js-project-feature-toggle-input" value="${isChecked}" />
- </button>
- `;
-}
-
-function setupFixture(isChecked, clickCallback) {
- const wrapper = document.createElement('div');
- wrapper.innerHTML = generateMarkup(isChecked);
-
- setupToggleButtons(wrapper, clickCallback);
-
- return wrapper;
-}
-
-describe('ToggleButtons', () => {
- describe('when input value is true', () => {
- it('should initialize as checked', () => {
- const wrapper = setupFixture(true);
-
- expect(
- wrapper.querySelector('.js-project-feature-toggle').classList.contains('is-checked'),
- ).toEqual(true);
-
- expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('true');
- });
-
- it('should toggle to unchecked when clicked', () => {
- const wrapper = setupFixture(true);
- const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
-
- toggleButton.click();
-
- return waitForPromises().then(() => {
- expect(toggleButton.classList.contains('is-checked')).toEqual(false);
- expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('false');
- });
- });
- });
-
- describe('when input value is false', () => {
- it('should initialize as unchecked', () => {
- const wrapper = setupFixture(false);
-
- expect(
- wrapper.querySelector('.js-project-feature-toggle').classList.contains('is-checked'),
- ).toEqual(false);
-
- expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('false');
- });
-
- it('should toggle to checked when clicked', () => {
- const wrapper = setupFixture(false);
- const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
-
- toggleButton.click();
-
- return waitForPromises().then(() => {
- expect(toggleButton.classList.contains('is-checked')).toEqual(true);
- expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('true');
- });
- });
- });
-
- it('should emit `trigger-change` event', () => {
- const changeSpy = jest.fn();
- const wrapper = setupFixture(false);
- const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
- const input = wrapper.querySelector('.js-project-feature-toggle-input');
-
- $(input).on('trigger-change', changeSpy);
-
- toggleButton.click();
-
- return waitForPromises().then(() => {
- expect(changeSpy).toHaveBeenCalled();
- });
- });
-
- describe('clickCallback', () => {
- it('should show loading indicator while waiting', () => {
- const isChecked = true;
- const clickCallback = (newValue, toggleButton) => {
- const input = toggleButton.querySelector('.js-project-feature-toggle-input');
-
- expect(newValue).toEqual(false);
-
- // Check for the loading state
- expect(toggleButton.classList.contains('is-checked')).toEqual(false);
- expect(toggleButton.classList.contains('is-loading')).toEqual(true);
- expect(toggleButton.disabled).toEqual(true);
- expect(input.value).toEqual('true');
-
- // After the callback finishes, check that the loading state is gone
- return waitForPromises().then(() => {
- expect(toggleButton.classList.contains('is-checked')).toEqual(false);
- expect(toggleButton.classList.contains('is-loading')).toEqual(false);
- expect(toggleButton.disabled).toEqual(false);
- expect(input.value).toEqual('false');
- });
- };
-
- const wrapper = setupFixture(isChecked, clickCallback);
- const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
-
- toggleButton.click();
- });
- });
-});
diff --git a/spec/frontend/toggles/index_spec.js b/spec/frontend/toggles/index_spec.js
index 575b1b6080c..19c4d6f1f1d 100644
--- a/spec/frontend/toggles/index_spec.js
+++ b/spec/frontend/toggles/index_spec.js
@@ -99,10 +99,12 @@ describe('toggles/index.js', () => {
const name = 'toggle-name';
const help = 'Help text';
const foo = 'bar';
+ const id = 'an-id';
beforeEach(() => {
initToggleWithOptions({
name,
+ id,
isChecked: true,
disabled: true,
isLoading: true,
@@ -144,6 +146,10 @@ describe('toggles/index.js', () => {
it('passes custom dataset to the wrapper', () => {
expect(toggleWrapper.dataset.foo).toBe('bar');
});
+
+ it('passes an id to the wrapper', () => {
+ expect(toggleWrapper.id).toBe(id);
+ });
});
});
});
diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js
index b7a2e4f4f51..d85299cdfc3 100644
--- a/spec/frontend/tracking/tracking_spec.js
+++ b/spec/frontend/tracking/tracking_spec.js
@@ -255,6 +255,23 @@ describe('Tracking', () => {
expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST);
});
+ describe('allowed hashes/fragments', () => {
+ it.each`
+ hash | appends | description
+ ${'note_abc_123'} | ${true} | ${'appends'}
+ ${'diff-content-819'} | ${true} | ${'appends'}
+ ${'first_heading'} | ${false} | ${'does not append'}
+ `('$description `$hash` hash', ({ hash, appends }) => {
+ window.gl.snowplowPseudonymizedPageUrl = TEST_HOST;
+ window.location.hash = hash;
+
+ Tracking.setAnonymousUrls();
+
+ const url = appends ? `${TEST_HOST}#${hash}` : TEST_HOST;
+ expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', url);
+ });
+ });
+
it('does not set the referrer URL by default', () => {
window.gl.snowplowPseudonymizedPageUrl = TEST_HOST;
@@ -361,6 +378,16 @@ describe('Tracking', () => {
expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'click_input2', {
value: '0',
});
+
+ expect(snowplowSpy).toHaveBeenCalledWith(
+ 'trackStructEvent',
+ TEST_CATEGORY,
+ 'click_input2',
+ undefined,
+ undefined,
+ 0,
+ [standardContext],
+ );
});
it('handles checkbox values correctly', () => {
diff --git a/spec/frontend/users_select/index_spec.js b/spec/frontend/users_select/index_spec.js
index 0d2aae78944..3757e63c4f9 100644
--- a/spec/frontend/users_select/index_spec.js
+++ b/spec/frontend/users_select/index_spec.js
@@ -108,4 +108,39 @@ describe('~/users_select/index', () => {
});
});
});
+
+ describe('XSS', () => {
+ const escaped = '&gt;&lt;script&gt;alert(1)&lt;/script&gt;';
+ const issuableType = 'merge_request';
+ const user = {
+ availability: 'not_set',
+ can_merge: true,
+ name: 'name',
+ };
+ const selected = true;
+ const username = 'username';
+ const img = '<img user-avatar />';
+ const elsClassName = 'elsclass';
+
+ it.each`
+ prop | val | element
+ ${'username'} | ${'><script>alert(1)</script>'} | ${'.dropdown-menu-user-username'}
+ ${'name'} | ${'><script>alert(1)</script>'} | ${'.dropdown-menu-user-full-name'}
+ `('properly escapes the $prop $val', ({ prop, val, element }) => {
+ const u = prop === 'username' ? val : username;
+ const n = prop === 'name' ? val : user.name;
+ const row = UsersSelect.prototype.renderRow(
+ issuableType,
+ { ...user, name: n },
+ selected,
+ u,
+ img,
+ elsClassName,
+ );
+ const fragment = document.createRange().createContextualFragment(row);
+ const output = fragment.querySelector(element).innerHTML.trim();
+
+ expect(output).toBe(escaped);
+ });
+ });
});
diff --git a/spec/frontend/vue_mr_widget/components/extensions/child_content_spec.js b/spec/frontend/vue_mr_widget/components/extensions/child_content_spec.js
new file mode 100644
index 00000000000..198a4c2823a
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/extensions/child_content_spec.js
@@ -0,0 +1,40 @@
+import { shallowMount } from '@vue/test-utils';
+import ChildContent from '~/vue_merge_request_widget/components/extensions/child_content.vue';
+
+let wrapper;
+const mockData = () => ({
+ header: 'Test header',
+ text: 'Test content',
+ icon: {
+ name: 'error',
+ },
+});
+
+function factory(propsData) {
+ wrapper = shallowMount(ChildContent, {
+ propsData: {
+ ...propsData,
+ widgetLabel: 'Test',
+ },
+ });
+}
+
+describe('MR widget extension child content', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders child components', () => {
+ factory({
+ data: {
+ ...mockData(),
+ children: [mockData()],
+ },
+ level: 2,
+ });
+
+ expect(wrapper.find('[data-testid="child-content"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="child-content"]').props('level')).toBe(3);
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
index 27604868b3e..6386746aee4 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
@@ -2,11 +2,6 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
-import ActionsButton from '~/vue_shared/components/actions_button.vue';
-import {
- REBASE_BUTTON_KEY,
- REBASE_WITHOUT_CI_BUTTON_KEY,
-} from '~/vue_merge_request_widget/constants';
let wrapper;
@@ -38,8 +33,8 @@ function createWrapper(propsData, mergeRequestWidgetGraphql, rebaseWithoutCiUi)
describe('Merge request widget rebase component', () => {
const findRebaseMessage = () => wrapper.find('[data-testid="rebase-message"]');
const findRebaseMessageText = () => findRebaseMessage().text();
- const findRebaseButtonActions = () => wrapper.find(ActionsButton);
const findStandardRebaseButton = () => wrapper.find('[data-testid="standard-rebase-button"]');
+ const findRebaseWithoutCiButton = () => wrapper.find('[data-testid="rebase-without-ci-button"]');
afterEach(() => {
wrapper.destroy();
@@ -112,7 +107,7 @@ describe('Merge request widget rebase component', () => {
expect(findRebaseMessageText()).toContain('Something went wrong!');
});
- describe('Rebase button with flag rebaseWithoutCiUi', () => {
+ describe('Rebase buttons with flag rebaseWithoutCiUi', () => {
beforeEach(() => {
createWrapper(
{
@@ -130,30 +125,13 @@ describe('Merge request widget rebase component', () => {
);
});
- it('rebase button with actions is rendered', () => {
- expect(findRebaseButtonActions().exists()).toBe(true);
- expect(findStandardRebaseButton().exists()).toBe(false);
- });
-
- it('has rebase and rebase without CI actions', () => {
- const actionNames = findRebaseButtonActions()
- .props('actions')
- .map((action) => action.key);
-
- expect(actionNames).toStrictEqual([REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY]);
- });
-
- it('defaults to rebase action', () => {
- expect(findRebaseButtonActions().props('selectedKey')).toStrictEqual(REBASE_BUTTON_KEY);
+ it('renders both buttons', () => {
+ expect(findRebaseWithoutCiButton().exists()).toBe(true);
+ expect(findStandardRebaseButton().exists()).toBe(true);
});
it('starts the rebase when clicking', async () => {
- // ActionButtons use the actions props instead of emitting
- // a click event, therefore simulating the behavior here:
- findRebaseButtonActions()
- .props('actions')
- .find((x) => x.key === REBASE_BUTTON_KEY)
- .handle();
+ findStandardRebaseButton().vm.$emit('click');
await nextTick();
@@ -161,12 +139,7 @@ describe('Merge request widget rebase component', () => {
});
it('starts the CI-skipping rebase when clicking on "Rebase without CI"', async () => {
- // ActionButtons use the actions props instead of emitting
- // a click event, therefore simulating the behavior here:
- findRebaseButtonActions()
- .props('actions')
- .find((x) => x.key === REBASE_WITHOUT_CI_BUTTON_KEY)
- .handle();
+ findRebaseWithoutCiButton().vm.$emit('click');
await nextTick();
@@ -193,7 +166,7 @@ describe('Merge request widget rebase component', () => {
it('standard rebase button is rendered', () => {
expect(findStandardRebaseButton().exists()).toBe(true);
- expect(findRebaseButtonActions().exists()).toBe(false);
+ expect(findRebaseWithoutCiButton().exists()).toBe(false);
});
it('calls rebase method with skip_ci false', () => {
@@ -240,7 +213,7 @@ describe('Merge request widget rebase component', () => {
});
});
- it('does not render the rebase actions button with rebaseWithoutCiUI flag enabled', () => {
+ it('does not render the "Rebase without pipeline" button with rebaseWithoutCiUI flag enabled', () => {
createWrapper(
{
mr: {
@@ -254,7 +227,7 @@ describe('Merge request widget rebase component', () => {
{ rebaseWithoutCiUi: true },
);
- expect(findRebaseButtonActions().exists()).toBe(false);
+ expect(findRebaseWithoutCiButton().exists()).toBe(false);
});
it('does not render the standard rebase button with rebaseWithoutCiUI flag disabled', () => {
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
index 6ea8ca10c02..15522f7ac1d 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
@@ -1,3 +1,4 @@
+import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RelatedLinks from '~/vue_merge_request_widget/components/mr_widget_related_links.vue';
@@ -85,13 +86,29 @@ describe('MRWidgetRelatedLinks', () => {
expect(content).toContain('Mentions issues #23 and #42');
});
- it('should have assing issues link', () => {
- createComponent({
- relatedLinks: {
- assignToMe: '<a href="#">Assign yourself to these issues</a>',
- },
+ describe('should have correct assign issues link', () => {
+ it.each([
+ [1, 'Assign yourself to this issue'],
+ [2, 'Assign yourself to these issues'],
+ ])('when issue count is %s, link displays correct text', (unassignedCount, text) => {
+ const assignToMe = '/assign';
+
+ createComponent({
+ relatedLinks: { assignToMe, unassignedCount },
+ });
+
+ const glLinkWrapper = wrapper.findComponent(GlLink);
+
+ expect(glLinkWrapper.attributes('href')).toBe(assignToMe);
+ expect(glLinkWrapper.text()).toBe(text);
});
- expect(wrapper.text().trim()).toContain('Assign yourself to these issues');
+ it('when no link is present', () => {
+ createComponent({
+ relatedLinks: { assignToMe: '#', unassignedCount: 0 },
+ });
+
+ expect(wrapper.findComponent(GlLink).exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
index 9dcde3e4f33..7a92484695c 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
@@ -19,7 +19,7 @@ describe('MRWidgetConflicts', () => {
const userCannotMergeText =
'Users who can write to the source or target branches can resolve the conflicts.';
const resolveConflictsBtnText = 'Resolve conflicts';
- const mergeLocallyBtnText = 'Merge locally';
+ const mergeLocallyBtnText = 'Resolve locally';
async function createComponent(propsData = {}) {
wrapper = extendedWrapper(
@@ -224,8 +224,8 @@ describe('MRWidgetConflicts', () => {
});
});
- it('should not allow you to resolve the conflicts', () => {
- expect(findResolveButton().exists()).toBe(false);
+ it('should allow you to resolve the conflicts', () => {
+ expect(findResolveButton().exists()).toBe(true);
});
});
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 78585ed75bc..0e364eb6800 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
@@ -1,7 +1,12 @@
-import { shallowMount } from '@vue/test-utils';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlSprintf } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import produce from 'immer';
+import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json';
import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
import simplePoll from '~/lib/utils/simple_poll';
import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue';
import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue';
@@ -19,9 +24,11 @@ jest.mock('~/commons/nav/user_merge_requests', () => ({
refreshUserMergeRequestCounts: jest.fn(),
}));
-const commitMessage = 'This is the commit message';
-const squashCommitMessage = 'This is the squash commit message';
-const commitMessageWithDescription = 'This is the commit message description';
+const commitMessage = readyToMergeResponse.data.project.mergeRequest.defaultMergeCommitMessage;
+const squashCommitMessage =
+ readyToMergeResponse.data.project.mergeRequest.defaultSquashCommitMessage;
+const commitMessageWithDescription =
+ readyToMergeResponse.data.project.mergeRequest.defaultMergeCommitMessageWithDescription;
const createTestMr = (customConfig) => {
const mr = {
isPipelineActive: false,
@@ -42,6 +49,8 @@ const createTestMr = (customConfig) => {
commitMessage,
squashCommitMessage,
commitMessageWithDescription,
+ defaultMergeCommitMessage: commitMessage,
+ defaultSquashCommitMessage: squashCommitMessage,
shouldRemoveSourceBranch: true,
canRemoveSourceBranch: false,
targetBranch: 'main',
@@ -61,15 +70,25 @@ const createTestService = () => ({
merge: jest.fn(),
poll: jest.fn().mockResolvedValue(),
});
+const localVue = createLocalVue();
+localVue.use(VueApollo);
let wrapper;
+let readyToMergeResponseSpy;
const findMergeButton = () => wrapper.find('[data-testid="merge-button"]');
const findPipelineFailedConfirmModal = () =>
wrapper.findComponent(MergeFailedPipelineConfirmationDialog);
+const createReadyToMergeResponse = (customMr) => {
+ return produce(readyToMergeResponse, (draft) => {
+ Object.assign(draft.data.project.mergeRequest, customMr);
+ });
+};
+
const createComponent = (customConfig = {}, mergeRequestWidgetGraphql = false) => {
wrapper = shallowMount(ReadyToMerge, {
+ localVue,
propsData: {
mr: createTestMr(customConfig),
service: createTestService(),
@@ -82,10 +101,29 @@ const createComponent = (customConfig = {}, mergeRequestWidgetGraphql = false) =
stubs: {
CommitEdit,
},
+ apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]),
});
};
+const findCheckboxElement = () => wrapper.find(SquashBeforeMerge);
+const findCommitsHeaderElement = () => wrapper.find(CommitsHeader);
+const findCommitEditElements = () => wrapper.findAll(CommitEdit);
+const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown);
+const findFirstCommitEditLabel = () => findCommitEditElements().at(0).props('label');
+const findTipLink = () => wrapper.find(GlSprintf);
+const findCommitEditWithInputId = (inputId) =>
+ findCommitEditElements().wrappers.find((x) => x.props('inputId') === inputId);
+const findMergeCommitMessage = () => findCommitEditWithInputId('merge-message-edit').props('value');
+const findSquashCommitMessage = () =>
+ findCommitEditWithInputId('squash-message-edit').props('value');
+
+const triggerApprovalUpdated = () => eventHub.$emit('ApprovalUpdated');
+
describe('ReadyToMerge', () => {
+ beforeEach(() => {
+ readyToMergeResponseSpy = jest.fn().mockResolvedValueOnce(readyToMergeResponse);
+ });
+
afterEach(() => {
wrapper.destroy();
});
@@ -447,13 +485,6 @@ describe('ReadyToMerge', () => {
});
describe('render children components', () => {
- const findCheckboxElement = () => wrapper.find(SquashBeforeMerge);
- const findCommitsHeaderElement = () => wrapper.find(CommitsHeader);
- const findCommitEditElements = () => wrapper.findAll(CommitEdit);
- const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown);
- const findFirstCommitEditLabel = () => findCommitEditElements().at(0).props('label');
- const findTipLink = () => wrapper.find(GlSprintf);
-
describe('squash checkbox', () => {
it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => {
createComponent({
@@ -772,4 +803,65 @@ describe('ReadyToMerge', () => {
expect(findPipelineFailedConfirmModal().props()).toEqual({ visible: true });
});
});
+
+ describe('updating graphql data triggers commit message update when default changed', () => {
+ const UPDATED_MERGE_COMMIT_MESSAGE = 'New merge message from BE';
+ const UPDATED_SQUASH_COMMIT_MESSAGE = 'New squash message from BE';
+ const USER_COMMIT_MESSAGE = 'Merge message provided manually by user';
+
+ const createDefaultGqlComponent = () =>
+ createComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: true } }, true);
+
+ beforeEach(() => {
+ readyToMergeResponseSpy = jest
+ .fn()
+ .mockResolvedValueOnce(createReadyToMergeResponse({ squash: true, squashOnMerge: true }))
+ .mockResolvedValue(
+ createReadyToMergeResponse({
+ squash: true,
+ squashOnMerge: true,
+ defaultMergeCommitMessage: UPDATED_MERGE_COMMIT_MESSAGE,
+ defaultSquashCommitMessage: UPDATED_SQUASH_COMMIT_MESSAGE,
+ }),
+ );
+ });
+
+ describe.each`
+ desc | finderFn | initialValue | updatedValue | inputId
+ ${'merge commit message'} | ${findMergeCommitMessage} | ${commitMessage} | ${UPDATED_MERGE_COMMIT_MESSAGE} | ${'#merge-message-edit'}
+ ${'squash commit message'} | ${findSquashCommitMessage} | ${squashCommitMessage} | ${UPDATED_SQUASH_COMMIT_MESSAGE} | ${'#squash-message-edit'}
+ `('with $desc', ({ finderFn, initialValue, updatedValue, inputId }) => {
+ it('should have initial value', async () => {
+ createDefaultGqlComponent();
+
+ await waitForPromises();
+
+ expect(finderFn()).toBe(initialValue);
+ });
+
+ it('should have updated value after graphql refetch', async () => {
+ createDefaultGqlComponent();
+ await waitForPromises();
+
+ triggerApprovalUpdated();
+ await waitForPromises();
+
+ expect(finderFn()).toBe(updatedValue);
+ });
+
+ it('should not update if user has touched', async () => {
+ createDefaultGqlComponent();
+ await waitForPromises();
+
+ const input = wrapper.find(inputId);
+ input.element.value = USER_COMMIT_MESSAGE;
+ input.trigger('input');
+
+ triggerApprovalUpdated();
+ await waitForPromises();
+
+ expect(finderFn()).toBe(USER_COMMIT_MESSAGE);
+ });
+ });
+ });
});
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 a9fe29a484a..ea422a57956 100644
--- a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js
+++ b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js
@@ -100,15 +100,15 @@ describe('Accessibility extension', () => {
await waitForPromises();
});
- it('displays all report list items', async () => {
- expect(findAllExtensionListItems()).toHaveLength(10);
+ it('displays all report list items in viewport', async () => {
+ expect(findAllExtensionListItems()).toHaveLength(7);
});
it('displays report list item formatted', () => {
const text = {
newError: trimText(findAllExtensionListItems().at(0).text()),
resolvedError: findAllExtensionListItems().at(3).text(),
- existingError: trimText(findAllExtensionListItems().at(8).text()),
+ existingError: trimText(findAllExtensionListItems().at(6).text()),
};
expect(text.newError).toBe(
@@ -118,7 +118,7 @@ describe('Accessibility extension', () => {
'The accessibility scanning found an error of the following type: WCAG2AA.Principle1.Guideline1_1.1_1_1.H30.2 Learn more Message: Img element is the only content of the link, but is missing alt text. The alt text should describe the purpose of the link.',
);
expect(text.existingError).toBe(
- 'The accessibility scanning found an error of the following type: WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1 Learn more Message: Iframe element requires a non-empty title attribute that identifies the frame.',
+ 'The accessibility scanning found an error of the following type: WCAG2AA.Principle1.Guideline1_1.1_1_1.H37 Learn more Message: Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.',
);
});
});
diff --git a/spec/frontend/vue_mr_widget/extentions/code_quality/index_spec.js b/spec/frontend/vue_mr_widget/extentions/code_quality/index_spec.js
new file mode 100644
index 00000000000..9a72e4a086b
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/extentions/code_quality/index_spec.js
@@ -0,0 +1,145 @@
+import MockAdapter from 'axios-mock-adapter';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { trimText } from 'helpers/text_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
+import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
+import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality';
+import httpStatusCodes from '~/lib/utils/http_status';
+import {
+ codeQualityResponseNewErrors,
+ codeQualityResponseResolvedErrors,
+ codeQualityResponseResolvedAndNewErrors,
+ codeQualityResponseNoErrors,
+} from './mock_data';
+
+describe('Code Quality extension', () => {
+ let wrapper;
+ let mock;
+
+ registerExtension(codeQualityExtension);
+
+ const endpoint = '/root/repo/-/merge_requests/4/accessibility_reports.json';
+
+ const mockApi = (statusCode, data) => {
+ mock.onGet(endpoint).reply(statusCode, data);
+ };
+
+ const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
+ const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
+
+ const createComponent = () => {
+ wrapper = mountExtended(extensionsContainer, {
+ propsData: {
+ mr: {
+ codeQuality: endpoint,
+ blobPath: {
+ head_path: 'example/path',
+ base_path: 'example/path',
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ describe('summary', () => {
+ it('displays loading text', () => {
+ mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors);
+
+ createComponent();
+
+ expect(wrapper.text()).toBe('Code Quality test metrics results are being parsed');
+ });
+
+ it('displays failed loading text', async () => {
+ mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
+
+ createComponent();
+
+ await waitForPromises();
+ expect(wrapper.text()).toBe('Code Quality failed loading results');
+ });
+
+ it('displays quality degradation', async () => {
+ mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors);
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toBe('Code Quality degraded on 2 points.');
+ });
+
+ it('displays quality improvement', async () => {
+ mockApi(httpStatusCodes.OK, codeQualityResponseResolvedErrors);
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toBe('Code Quality improved on 2 points.');
+ });
+
+ it('displays quality improvement and degradation', async () => {
+ mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors);
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toBe('Code Quality improved on 1 point and degraded on 1 point.');
+ });
+
+ it('displays no detected errors', async () => {
+ mockApi(httpStatusCodes.OK, codeQualityResponseNoErrors);
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toBe('No changes to Code Quality.');
+ });
+ });
+
+ describe('expanded data', () => {
+ beforeEach(async () => {
+ mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors);
+
+ createComponent();
+
+ await waitForPromises();
+
+ findToggleCollapsedButton().trigger('click');
+
+ await waitForPromises();
+ });
+
+ it('displays all report list items in viewport', async () => {
+ expect(findAllExtensionListItems()).toHaveLength(2);
+ });
+
+ it('displays report list item formatted', () => {
+ const text = {
+ newError: trimText(findAllExtensionListItems().at(0).text().replace(/\s+/g, ' ').trim()),
+ resolvedError: findAllExtensionListItems().at(1).text().replace(/\s+/g, ' ').trim(),
+ };
+
+ expect(text.newError).toContain(
+ "Minor - Parsing error: 'return' outside of function in index.js:12",
+ );
+ expect(text.resolvedError).toContain(
+ "Minor - Parsing error: 'return' outside of function in index.js:12",
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/extentions/code_quality/mock_data.js b/spec/frontend/vue_mr_widget/extentions/code_quality/mock_data.js
new file mode 100644
index 00000000000..f5ad0ce7377
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/extentions/code_quality/mock_data.js
@@ -0,0 +1,87 @@
+export const codeQualityResponseNewErrors = {
+ status: 'failed',
+ new_errors: [
+ {
+ description: "Parsing error: 'return' outside of function",
+ severity: 'minor',
+ file_path: 'index.js',
+ line: 12,
+ },
+ {
+ description: 'TODO found',
+ severity: 'minor',
+ file_path: '.gitlab-ci.yml',
+ line: 73,
+ },
+ ],
+ resolved_errors: [],
+ existing_errors: [],
+ summary: {
+ total: 2,
+ resolved: 0,
+ errored: 2,
+ },
+};
+
+export const codeQualityResponseResolvedErrors = {
+ status: 'failed',
+ new_errors: [],
+ resolved_errors: [
+ {
+ description: "Parsing error: 'return' outside of function",
+ severity: 'minor',
+ file_path: 'index.js',
+ line: 12,
+ },
+ {
+ description: 'TODO found',
+ severity: 'minor',
+ file_path: '.gitlab-ci.yml',
+ line: 73,
+ },
+ ],
+ existing_errors: [],
+ summary: {
+ total: 2,
+ resolved: 2,
+ errored: 0,
+ },
+};
+
+export const codeQualityResponseResolvedAndNewErrors = {
+ status: 'failed',
+ new_errors: [
+ {
+ description: "Parsing error: 'return' outside of function",
+ severity: 'minor',
+ file_path: 'index.js',
+ line: 12,
+ },
+ ],
+ resolved_errors: [
+ {
+ description: "Parsing error: 'return' outside of function",
+ severity: 'minor',
+ file_path: 'index.js',
+ line: 12,
+ },
+ ],
+ existing_errors: [],
+ summary: {
+ total: 2,
+ resolved: 1,
+ errored: 1,
+ },
+};
+
+export const codeQualityResponseNoErrors = {
+ status: 'failed',
+ new_errors: [],
+ resolved_errors: [],
+ existing_errors: [],
+ summary: {
+ total: 0,
+ resolved: 0,
+ errored: 0,
+ },
+};
diff --git a/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js b/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js
index 913d5860b48..295b9df30b9 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js
@@ -1,4 +1,4 @@
-import { GlModal, GlSprintf } from '@gitlab/ui';
+import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue';
@@ -27,7 +27,7 @@ describe('MRWidgetHowToMerge', () => {
const findModal = () => wrapper.find(GlModal);
const findInstructionsFields = () =>
wrapper.findAll('[ data-testid="how-to-merge-instructions"]');
- const findTipLink = () => wrapper.find(GlSprintf);
+ const findTipLink = () => wrapper.find("[data-testid='docs-tip']");
it('renders a modal', () => {
expect(findModal().exists()).toBe(true);
diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
index 56c9bae0b76..0540107ea5f 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -947,6 +947,8 @@ describe('MrWidgetOptions', () => {
wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(),
).toBe(false);
+ await nextTick();
+
const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]');
expect(collapsedSection.exists()).toBe(true);
expect(collapsedSection.text()).toContain('Hello world');
diff --git a/spec/frontend/vue_shared/components/__snapshots__/content_transition_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/content_transition_spec.js.snap
new file mode 100644
index 00000000000..fd804990b5e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/__snapshots__/content_transition_spec.js.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`~/vue_shared/components/content_transition.vue default shows all transitions and only default is visible 1`] = `
+<div>
+ <transition-stub
+ name="test_transition_name"
+ >
+ <div
+ data-testval="default"
+ >
+ <p>
+ Default
+ </p>
+ </div>
+ </transition-stub>
+ <transition-stub
+ name="test_transition_name"
+ >
+ <div
+ data-testval="foo"
+ style="display: none;"
+ >
+ <p>
+ Foo
+ </p>
+ </div>
+ </transition-stub>
+ <transition-stub
+ name="test_transition_name"
+ >
+ <div
+ data-testval="bar"
+ style="display: none;"
+ >
+ <p>
+ Bar
+ </p>
+ </div>
+ </transition-stub>
+</div>
+`;
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 fef50bdaccc..28b3bf5287a 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
@@ -127,5 +127,18 @@ describe('ColorPicker', () => {
expect(wrapper.emitted().input[0]).toStrictEqual([setColor]);
});
+
+ it('shows the suggested colors passed using props', () => {
+ const customColors = {
+ '#ff0000': 'Red',
+ '#808080': 'Gray',
+ };
+
+ createComponent(shallowMount, { suggestedColors: customColors });
+ expect(description()).toBe('Enter any color or choose one of the suggested colors below.');
+ expect(presetColors()).toHaveLength(2);
+ expect(presetColors().at(0).attributes('title')).toBe('Red');
+ expect(presetColors().at(1).attributes('title')).toBe('Gray');
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/content_transition_spec.js b/spec/frontend/vue_shared/components/content_transition_spec.js
new file mode 100644
index 00000000000..8bb6d31cce7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_transition_spec.js
@@ -0,0 +1,109 @@
+import { groupBy, mapValues } from 'lodash';
+import { shallowMount } from '@vue/test-utils';
+import ContentTransition from '~/vue_shared/components/content_transition.vue';
+
+const TEST_CURRENT_SLOT = 'default';
+const TEST_TRANSITION_NAME = 'test_transition_name';
+const TEST_SLOTS = [
+ { key: 'default', attributes: { 'data-testval': 'default' } },
+ { key: 'foo', attributes: { 'data-testval': 'foo' } },
+ { key: 'bar', attributes: { 'data-testval': 'bar' } },
+];
+
+describe('~/vue_shared/components/content_transition.vue', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const createComponent = (props = {}, slots = {}) => {
+ wrapper = shallowMount(ContentTransition, {
+ propsData: {
+ transitionName: TEST_TRANSITION_NAME,
+ currentSlot: TEST_CURRENT_SLOT,
+ slots: TEST_SLOTS,
+ ...props,
+ },
+ slots: {
+ default: '<p>Default</p>',
+ foo: '<p>Foo</p>',
+ bar: '<p>Bar</p>',
+ dne: '<p>DOES NOT EXIST</p>',
+ ...slots,
+ },
+ });
+ };
+
+ const findTransitionsData = () =>
+ wrapper.findAll('transition-stub').wrappers.map((transition) => {
+ const child = transition.find('[data-testval]');
+ const { style, ...attributes } = child.attributes();
+
+ return {
+ transitionName: transition.attributes('name'),
+ isVisible: child.isVisible(),
+ attributes,
+ text: transition.text(),
+ };
+ });
+ const findVisibleData = () => {
+ const group = groupBy(findTransitionsData(), (x) => x.attributes['data-testval']);
+
+ return mapValues(group, (x) => x[0].isVisible);
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows all transitions and only default is visible', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('render transitions for each slot', () => {
+ expect(findTransitionsData()).toEqual([
+ {
+ attributes: {
+ 'data-testval': 'default',
+ },
+ isVisible: true,
+ text: 'Default',
+ transitionName: 'test_transition_name',
+ },
+ {
+ attributes: {
+ 'data-testval': 'foo',
+ },
+ isVisible: false,
+ text: 'Foo',
+ transitionName: 'test_transition_name',
+ },
+ {
+ attributes: {
+ 'data-testval': 'bar',
+ },
+ isVisible: false,
+ text: 'Bar',
+ transitionName: 'test_transition_name',
+ },
+ ]);
+ });
+ });
+
+ describe('with currentSlot=foo', () => {
+ beforeEach(() => {
+ createComponent({ currentSlot: 'foo' });
+ });
+
+ it('should only show the foo slot', () => {
+ expect(findVisibleData()).toEqual({
+ default: false,
+ foo: true,
+ bar: false,
+ });
+ });
+ });
+});
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 dd9bf2ff598..af8a2a496ea 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
@@ -1,12 +1,24 @@
-import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlFilteredSearchToken,
+ GlLoadingIcon,
+ GlFilteredSearchSuggestion,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlDropdownText,
+} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
mockRegularLabel,
mockLabels,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
-import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ DEFAULT_NONE_ANY,
+ OPERATOR_IS,
+ OPERATOR_IS_NOT,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import {
getRecentlyUsedSuggestions,
setTokenValueToRecentlyUsed,
@@ -32,6 +44,7 @@ const defaultStubs = {
<div>
<slot name="view-token"></slot>
<slot name="view"></slot>
+ <slot name="suggestions"></slot>
</div>
`,
},
@@ -43,6 +56,7 @@ const defaultStubs = {
},
};
+const mockSuggestionListTestId = 'suggestion-list';
const defaultSlots = {
'view-token': `
<div class="js-view-token">${mockRegularLabel.title}</div>
@@ -52,6 +66,10 @@ const defaultSlots = {
`,
};
+const defaultScopedSlots = {
+ 'suggestions-list': `<div data-testid="${mockSuggestionListTestId}" :data-suggestions="JSON.stringify(props.suggestions)"></div>`,
+};
+
const mockProps = {
config: { ...mockLabelToken, recentSuggestionsStorageKey: mockStorageKey },
value: { data: '' },
@@ -62,8 +80,15 @@ const mockProps = {
getActiveTokenValue: (labels, data) => labels.find((label) => label.title === data),
};
-function createComponent({ props = {}, stubs = defaultStubs, slots = defaultSlots } = {}) {
- return mount(BaseToken, {
+function createComponent({
+ props = {},
+ data = {},
+ stubs = defaultStubs,
+ slots = defaultSlots,
+ scopedSlots = defaultScopedSlots,
+ mountFn = mount,
+} = {}) {
+ return mountFn(BaseToken, {
propsData: {
...mockProps,
...props,
@@ -72,9 +97,17 @@ function createComponent({ props = {}, stubs = defaultStubs, slots = defaultSlot
portalName: 'fake target',
alignSuggestions: jest.fn(),
suggestionsListClass: () => 'custom-class',
+ filteredSearchSuggestionListInstance: {
+ register: jest.fn(),
+ unregister: jest.fn(),
+ },
+ },
+ data() {
+ return data;
},
stubs,
slots,
+ scopedSlots,
});
}
@@ -82,6 +115,9 @@ describe('BaseToken', () => {
let wrapper;
const findGlFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
+ const findMockSuggestionList = () => wrapper.findByTestId(mockSuggestionListTestId);
+ const getMockSuggestionListSuggestions = () =>
+ JSON.parse(findMockSuggestionList().attributes('data-suggestions'));
afterEach(() => {
wrapper.destroy();
@@ -136,6 +172,187 @@ describe('BaseToken', () => {
});
});
+ describe('suggestions', () => {
+ describe('with suggestions disabled', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ props: {
+ config: {
+ suggestionsDisabled: true,
+ },
+ suggestions: [{ id: 'Foo' }],
+ },
+ mountFn: shallowMountExtended,
+ });
+ });
+
+ it('does not render suggestions', () => {
+ expect(findMockSuggestionList().exists()).toBe(false);
+ });
+ });
+
+ describe('with available suggestions', () => {
+ let mockSuggestions;
+
+ describe.each`
+ hasSuggestions | searchKey | shouldRenderSuggestions
+ ${true} | ${null} | ${true}
+ ${true} | ${'foo'} | ${true}
+ ${false} | ${null} | ${false}
+ `(
+ `when hasSuggestions is $hasSuggestions`,
+ ({ hasSuggestions, searchKey, shouldRenderSuggestions }) => {
+ beforeEach(async () => {
+ mockSuggestions = hasSuggestions ? [{ id: 'Foo' }] : [];
+ const props = { defaultSuggestions: [], suggestions: mockSuggestions };
+
+ getRecentlyUsedSuggestions.mockReturnValue([]);
+ wrapper = createComponent({ props, mountFn: shallowMountExtended, stubs: {} });
+ findGlFilteredSearchToken().vm.$emit('input', { data: searchKey });
+
+ await nextTick();
+ });
+
+ it(`${shouldRenderSuggestions ? 'should' : 'should not'} render suggestions`, () => {
+ expect(findMockSuggestionList().exists()).toBe(shouldRenderSuggestions);
+
+ if (shouldRenderSuggestions) {
+ expect(getMockSuggestionListSuggestions()).toEqual(mockSuggestions);
+ }
+ });
+ },
+ );
+ });
+
+ describe('with preloaded suggestions', () => {
+ const mockPreloadedSuggestions = [{ id: 'Foo' }, { id: 'Bar' }];
+
+ describe.each`
+ searchKey | shouldRenderPreloadedSuggestions
+ ${null} | ${true}
+ ${'foo'} | ${false}
+ `('when searchKey is $searchKey', ({ shouldRenderPreloadedSuggestions, searchKey }) => {
+ beforeEach(async () => {
+ const props = { preloadedSuggestions: mockPreloadedSuggestions };
+ wrapper = createComponent({ props, mountFn: shallowMountExtended, stubs: {} });
+ findGlFilteredSearchToken().vm.$emit('input', { data: searchKey });
+
+ await nextTick();
+ });
+
+ it(`${
+ shouldRenderPreloadedSuggestions ? 'should' : 'should not'
+ } render preloaded suggestions`, () => {
+ expect(findMockSuggestionList().exists()).toBe(shouldRenderPreloadedSuggestions);
+
+ if (shouldRenderPreloadedSuggestions) {
+ expect(getMockSuggestionListSuggestions()).toEqual(mockPreloadedSuggestions);
+ }
+ });
+ });
+ });
+
+ describe('with recent suggestions', () => {
+ let mockSuggestions;
+
+ describe.each`
+ searchKey | recentEnabled | shouldRenderRecentSuggestions
+ ${null} | ${true} | ${true}
+ ${'foo'} | ${true} | ${false}
+ ${null} | ${false} | ${false}
+ `(
+ 'when searchKey is $searchKey and recentEnabled is $recentEnabled',
+ ({ shouldRenderRecentSuggestions, recentEnabled, searchKey }) => {
+ beforeEach(async () => {
+ const props = { value: { data: '', operator: '=' }, defaultSuggestions: [] };
+
+ if (recentEnabled) {
+ mockSuggestions = [{ id: 'Foo' }, { id: 'Bar' }];
+ getRecentlyUsedSuggestions.mockReturnValue(mockSuggestions);
+ }
+
+ props.config = { recentSuggestionsStorageKey: recentEnabled ? mockStorageKey : null };
+
+ wrapper = createComponent({ props, mountFn: shallowMountExtended, stubs: {} });
+ findGlFilteredSearchToken().vm.$emit('input', { data: searchKey });
+
+ await nextTick();
+ });
+
+ it(`${
+ shouldRenderRecentSuggestions ? 'should' : 'should not'
+ } render recent suggestions`, () => {
+ expect(findMockSuggestionList().exists()).toBe(shouldRenderRecentSuggestions);
+ expect(wrapper.findComponent(GlDropdownSectionHeader).exists()).toBe(
+ shouldRenderRecentSuggestions,
+ );
+ expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(
+ shouldRenderRecentSuggestions,
+ );
+
+ if (shouldRenderRecentSuggestions) {
+ expect(getMockSuggestionListSuggestions()).toEqual(mockSuggestions);
+ }
+ });
+ },
+ );
+ });
+
+ describe('with default suggestions', () => {
+ describe.each`
+ operator | shouldRenderFilteredSearchSuggestion
+ ${OPERATOR_IS} | ${true}
+ ${OPERATOR_IS_NOT} | ${false}
+ `('when operator is $operator', ({ shouldRenderFilteredSearchSuggestion, operator }) => {
+ beforeEach(() => {
+ const props = {
+ defaultSuggestions: DEFAULT_NONE_ANY,
+ value: { data: '', operator },
+ };
+
+ wrapper = createComponent({ props, mountFn: shallowMountExtended });
+ });
+
+ it(`${
+ shouldRenderFilteredSearchSuggestion ? 'should' : 'should not'
+ } render GlFilteredSearchSuggestion`, () => {
+ const filteredSearchSuggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion)
+ .wrappers;
+
+ if (shouldRenderFilteredSearchSuggestion) {
+ expect(filteredSearchSuggestions.map((c) => c.props())).toMatchObject(
+ DEFAULT_NONE_ANY.map((opt) => ({ value: opt.value })),
+ );
+ } else {
+ expect(filteredSearchSuggestions).toHaveLength(0);
+ }
+ });
+ });
+ });
+
+ describe('with no suggestions', () => {
+ it.each`
+ data | expected
+ ${{ searchKey: 'search' }} | ${'No matches found'}
+ ${{ hasFetched: true }} | ${'No suggestions found'}
+ `('shows $expected text', ({ data, expected }) => {
+ wrapper = createComponent({
+ props: {
+ config: { recentSuggestionsStorageKey: null },
+ defaultSuggestions: [],
+ preloadedSuggestions: [],
+ suggestions: [],
+ suggestionsLoading: false,
+ },
+ data,
+ mountFn: shallowMountExtended,
+ });
+
+ expect(wrapper.findComponent(GlDropdownText).text()).toBe(expected);
+ });
+ });
+ });
+
describe('methods', () => {
describe('handleTokenValueSelected', () => {
const mockTokenValue = mockLabels[0];
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index c7ad47b6ef7..b5daa389fc6 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -4,6 +4,7 @@ import $ from 'jquery';
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 { mountExtended } from 'helpers/vue_test_utils_helper';
const markdownPreviewPath = `${TEST_HOST}/preview`;
@@ -32,7 +33,7 @@ describe('Markdown field component', () => {
axiosMock.restore();
});
- function createSubject(lines = []) {
+ function createSubject({ lines = [], enablePreview = true } = {}) {
// We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression
// caused by mixing Vanilla JS and Vue.
subject = mountExtended(
@@ -61,6 +62,7 @@ describe('Markdown field component', () => {
isSubmitting: false,
textareaValue,
lines,
+ enablePreview,
},
provide: {
glFeatures: {
@@ -74,7 +76,7 @@ describe('Markdown field component', () => {
const getPreviewLink = () => subject.findByTestId('preview-tab');
const getWriteLink = () => subject.findByTestId('write-tab');
const getMarkdownButton = () => subject.find('.js-md');
- const getAllMarkdownButtons = () => subject.findAll('.js-md');
+ const getListBulletedButton = () => subject.findAll('.js-md[title="Add a bullet list"]');
const getVideo = () => subject.find('video');
const getAttachButton = () => subject.find('.button-attach-file');
const clickAttachButton = () => getAttachButton().trigger('click');
@@ -183,7 +185,7 @@ describe('Markdown field component', () => {
it('converts a line', async () => {
const textarea = subject.find('textarea').element;
textarea.setSelectionRange(0, 0);
- const markdownButton = getAllMarkdownButtons().wrappers[5];
+ const markdownButton = getListBulletedButton();
markdownButton.trigger('click');
await nextTick();
@@ -193,7 +195,7 @@ describe('Markdown field component', () => {
it('converts multiple lines', async () => {
const textarea = subject.find('textarea').element;
textarea.setSelectionRange(0, 50);
- const markdownButton = getAllMarkdownButtons().wrappers[5];
+ const markdownButton = getListBulletedButton();
markdownButton.trigger('click');
await nextTick();
@@ -266,17 +268,46 @@ describe('Markdown field component', () => {
'You are about to add 11 people to the discussion. They will all receive a notification.',
);
});
+
+ it('removes warning when all mention is removed while endpoint is loading', async () => {
+ axiosMock.onPost(markdownPreviewPath).reply(200, { references: { users } });
+ jest.spyOn(axios, 'post');
+
+ subject.setProps({ textareaValue: 'hello @all' });
+
+ await nextTick();
+
+ subject.setProps({ textareaValue: 'hello @allan' });
+
+ await axios.waitFor(markdownPreviewPath);
+
+ expect(axios.post).toHaveBeenCalled();
+ expect(subject.text()).not.toContain(
+ 'You are about to add 11 people to the discussion. They will all receive a notification.',
+ );
+ });
});
});
});
describe('suggestions', () => {
it('escapes new line characters', () => {
- createSubject([{ rich_text: 'hello world\\n' }]);
+ createSubject({ lines: [{ rich_text: 'hello world\\n' }] });
expect(subject.find('[data-testid="markdownHeader"]').props('lineContent')).toBe(
'hello world%br',
);
});
});
+
+ it('allows enabling and disabling Markdown Preview', () => {
+ createSubject({ enablePreview: false });
+
+ expect(subject.findComponent(MarkdownFieldHeader).props('enablePreview')).toBe(false);
+
+ subject.destroy();
+ createSubject({ enablePreview: true });
+
+ expect(subject.findComponent(MarkdownFieldHeader).props('enablePreview')).toBe(true);
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index 700ec75fcee..9ffb9c6a541 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -46,6 +46,7 @@ describe('Markdown field header component', () => {
const buttons = [
'Add bold text (⌘B)',
'Add italic text (⌘I)',
+ 'Add strikethrough text (⌘⇧X)',
'Insert a quote',
'Insert suggestion',
'Insert code',
@@ -157,4 +158,12 @@ describe('Markdown field header component', () => {
expect(wrapper.find('.js-suggestion-btn').exists()).toBe(false);
});
+
+ it('hides preview tab when previewMarkdown property is false', () => {
+ createWrapper({
+ enablePreview: false,
+ });
+
+ expect(wrapper.findByTestId('preview-tab').exists()).toBe(false);
+ });
});
diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap
index 573bc9abe4d..f878d685b6d 100644
--- a/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap
+++ b/spec/frontend/vue_shared/components/notes/__snapshots__/noteable_warning_spec.js.snap
@@ -34,21 +34,19 @@ exports[`Issue Warning Component when noteable is locked and confidential render
<span>
<span>
This issue is
- <a
+ <gl-link-stub
href=""
- rel="noopener noreferrer"
target="_blank"
>
confidential
- </a>
+ </gl-link-stub>
and
- <a
+ <gl-link-stub
href=""
- rel="noopener noreferrer"
target="_blank"
>
locked
- </a>
+ </gl-link-stub>
.
</span>
diff --git a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js
index accbf14572d..99b65ca6937 100644
--- a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js
+++ b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
@@ -16,6 +16,9 @@ describe('Issue Warning Component', () => {
propsData: {
...props,
},
+ stubs: {
+ GlSprintf,
+ },
});
afterEach(() => {
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
index 36050a42da7..8270ff31574 100644
--- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
@@ -221,7 +221,7 @@ describe('AlertManagementEmptyState', () => {
findPagination().vm.$emit('input', 3);
await nextTick();
- expect(findPagination().findAll('.page-item').at(0).text()).toBe('Prev');
+ expect(findPagination().findAll('.page-item').at(0).text()).toBe('Previous');
});
it('returns prevPage number', async () => {
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 b2906973dbd..6954bd5ccff 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
@@ -2,6 +2,7 @@
exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
<gl-modal-stub
+ actionprimary="[object Object]"
actionsecondary="[object Object]"
dismisslabel="Close"
modalclass=""
@@ -11,100 +12,161 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
titletag="h4"
>
<p>
- For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet.
+ Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.
</p>
- <ul
- class="gl-list-style-none gl-p-0 gl-mb-0"
+ <gl-form-radio-group-stub
+ checked="[object Object]"
+ disabledfield="disabled"
+ htmlfield="html"
+ label="Choose your preferred GitLab Runner"
+ label-sr-only=""
+ options=""
+ textfield="text"
+ valuefield="value"
>
- <li>
- <gl-link-stub
- class="gl-display-flex gl-font-weight-bold"
- href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml&stackName=linux-docker-nonspot&param_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host"
- target="_blank"
+ <gl-form-radio-stub
+ class="gl-py-5 gl-pl-8 gl-border-b"
+ value="[object Object]"
+ >
+ <div
+ class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold"
>
- <img
- alt="linux-docker-nonspot"
- class="gl-mt-2 gl-mr-5 gl-mb-6"
- height="46"
- src="/assets/aws-cloud-formation.png"
- title="linux-docker-nonspot"
- width="46"
- />
- Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor.
-
- </gl-link-stub>
- </li>
- <li>
- <gl-link-stub
- class="gl-display-flex gl-font-weight-bold"
- href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml&stackName=linux-docker-spotonly&param_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host"
- target="_blank"
+ Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.
+
+ <gl-accordion-stub
+ class="gl-pt-3"
+ headerlevel="3"
+ >
+ <gl-accordion-item-stub
+ class="gl-font-weight-normal"
+ title="More Details"
+ title-visible="Less Details"
+ >
+ <p
+ class="gl-pt-2"
+ >
+ No spot. This is the default choice for Linux Docker executor.
+ </p>
+
+ <p
+ class="gl-m-0"
+ >
+ A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.
+ </p>
+ </gl-accordion-item-stub>
+ </gl-accordion-stub>
+ </div>
+ </gl-form-radio-stub>
+ <gl-form-radio-stub
+ class="gl-py-5 gl-pl-8 gl-border-b"
+ value="[object Object]"
+ >
+ <div
+ class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold"
>
- <img
- alt="linux-docker-spotonly"
- class="gl-mt-2 gl-mr-5 gl-mb-6"
- height="46"
- src="/assets/aws-cloud-formation.png"
- title="linux-docker-spotonly"
- width="46"
- />
Amazon Linux 2 Docker HA with manual scaling and optional scheduling. 100% spot.
-
- </gl-link-stub>
- </li>
- <li>
- <gl-link-stub
- class="gl-display-flex gl-font-weight-bold"
- href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml&stackName=win2019-shell-non-spot&param_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host"
- target="_blank"
+
+ <gl-accordion-stub
+ class="gl-pt-3"
+ headerlevel="3"
+ >
+ <gl-accordion-item-stub
+ class="gl-font-weight-normal"
+ title="More Details"
+ title-visible="Less Details"
+ >
+ <p
+ class="gl-pt-2"
+ >
+ 100% spot.
+ </p>
+
+ <p
+ class="gl-m-0"
+ >
+ Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.
+ </p>
+ </gl-accordion-item-stub>
+ </gl-accordion-stub>
+ </div>
+ </gl-form-radio-stub>
+ <gl-form-radio-stub
+ class="gl-py-5 gl-pl-8 gl-border-b"
+ value="[object Object]"
+ >
+ <div
+ class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold"
>
- <img
- alt="win2019-shell-non-spot"
- class="gl-mt-2 gl-mr-5 gl-mb-6"
- height="46"
- src="/assets/aws-cloud-formation.png"
- title="win2019-shell-non-spot"
- width="46"
- />
- Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor.
-
- </gl-link-stub>
- </li>
- <li>
- <gl-link-stub
- class="gl-display-flex gl-font-weight-bold"
- href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml&stackName=win2019-shell-spot&param_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host"
- target="_blank"
+ Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.
+
+ <gl-accordion-stub
+ class="gl-pt-3"
+ headerlevel="3"
+ >
+ <gl-accordion-item-stub
+ class="gl-font-weight-normal"
+ title="More Details"
+ title-visible="Less Details"
+ >
+ <p
+ class="gl-pt-2"
+ >
+ No spot. Default choice for Windows Shell executor.
+ </p>
+
+ <p
+ class="gl-m-0"
+ >
+ Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.
+ </p>
+ </gl-accordion-item-stub>
+ </gl-accordion-stub>
+ </div>
+ </gl-form-radio-stub>
+ <gl-form-radio-stub
+ class="gl-py-5 gl-pl-8"
+ value="[object Object]"
+ >
+ <div
+ class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold"
>
- <img
- alt="win2019-shell-spot"
- class="gl-mt-2 gl-mr-5 gl-mb-6"
- height="46"
- src="/assets/aws-cloud-formation.png"
- title="win2019-shell-spot"
- width="46"
- />
Windows 2019 Shell with manual scaling and optional scheduling. 100% spot.
-
- </gl-link-stub>
- </li>
- </ul>
+
+ <gl-accordion-stub
+ class="gl-pt-3"
+ headerlevel="3"
+ >
+ <gl-accordion-item-stub
+ class="gl-font-weight-normal"
+ title="More Details"
+ title-visible="Less Details"
+ >
+ <p
+ class="gl-pt-2"
+ >
+ 100% spot.
+ </p>
+
+ <p
+ class="gl-m-0"
+ >
+ Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.
+ </p>
+ </gl-accordion-item-stub>
+ </gl-accordion-stub>
+ </div>
+ </gl-form-radio-stub>
+ </gl-form-radio-group-stub>
<p>
<gl-sprintf-stub
- message="Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}."
+ message="Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}."
/>
</p>
-
- <p
- class="gl-font-sm gl-mb-0"
- >
- If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account.
- </p>
</gl-modal-stub>
`;
diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js
index ad692a38e65..a9ba4946358 100644
--- a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js
+++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js
@@ -1,27 +1,29 @@
-import { GlLink } from '@gitlab/ui';
+import { GlModal, GlFormRadio } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
-import { getBaseURL } from '~/lib/utils/url_utility';
+import { getBaseURL, visitUrl } from '~/lib/utils/url_utility';
+import { mockTracking } from 'helpers/tracking_helper';
import {
- EXPERIMENT_NAME,
CF_BASE_URL,
TEMPLATES_BASE_URL,
EASY_BUTTONS,
} from '~/vue_shared/components/runner_aws_deployments/constants';
import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue';
-jest.mock('~/experimentation/experiment_tracking');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
describe('RunnerAwsDeploymentsModal', () => {
let wrapper;
- const findEasyButtons = () => wrapper.findAllComponents(GlLink);
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findEasyButtons = () => wrapper.findAllComponents(GlFormRadio);
const createComponent = () => {
wrapper = shallowMount(RunnerAwsDeploymentsModal, {
propsData: {
modalId: 'runner-aws-deployments-modal',
- imgSrc: '/assets/aws-cloud-formation.png',
},
});
};
@@ -43,34 +45,30 @@ describe('RunnerAwsDeploymentsModal', () => {
});
describe('first easy button', () => {
- const findFirstButton = () => findEasyButtons().at(0);
-
it('should contain the correct description', () => {
- expect(findFirstButton().text()).toBe(EASY_BUTTONS[0].description);
+ expect(findEasyButtons().at(0).text()).toContain(EASY_BUTTONS[0].description);
});
it('should contain the correct link', () => {
- const link = findFirstButton().attributes('href');
+ const templateUrl = encodeURIComponent(TEMPLATES_BASE_URL + EASY_BUTTONS[0].templateName);
+ const { stackName } = EASY_BUTTONS[0];
+ const instanceUrl = encodeURIComponent(getBaseURL());
+ const url = `${CF_BASE_URL}templateURL=${templateUrl}&stackName=${stackName}&param_3GITLABRunnerInstanceURL=${instanceUrl}`;
+
+ findModal().vm.$emit('primary');
- expect(link.startsWith(CF_BASE_URL)).toBe(true);
- expect(
- link.includes(
- `templateURL=${encodeURIComponent(TEMPLATES_BASE_URL + EASY_BUTTONS[0].templateName)}`,
- ),
- ).toBe(true);
- expect(link.includes(`stackName=${EASY_BUTTONS[0].stackName}`)).toBe(true);
- expect(
- link.includes(`param_3GITLABRunnerInstanceURL=${encodeURIComponent(getBaseURL())}`),
- ).toBe(true);
+ expect(visitUrl).toHaveBeenCalledWith(url, true);
});
it('should track an event when clicked', () => {
- findFirstButton().vm.$emit('click');
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ findModal().vm.$emit('primary');
- expect(ExperimentTracking).toHaveBeenCalledWith(EXPERIMENT_NAME);
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
- `template_clicked_${EASY_BUTTONS[0].stackName}`,
- );
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
+ label: EASY_BUTTONS[0].stackName,
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
index 2010bac7060..ab579945e22 100644
--- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
@@ -7,6 +7,7 @@ import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vu
import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants';
import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import waitForPromises from 'helpers/wait_for_promises';
+import * as sourceViewerUtils from '~/vue_shared/components/source_viewer/utils';
jest.mock('highlight.js/lib/core');
Vue.use(VueRouter);
@@ -36,6 +37,7 @@ describe('Source Viewer component', () => {
beforeEach(() => {
hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
+ jest.spyOn(sourceViewerUtils, 'wrapLines');
return createComponent();
});
@@ -73,6 +75,10 @@ describe('Source Viewer component', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
+ it('calls the wrapLines helper method with highlightedContent and mappedLanguage', () => {
+ expect(sourceViewerUtils.wrapLines).toHaveBeenCalledWith(highlightedContent, mappedLanguage);
+ });
+
it('renders Line Numbers', () => {
expect(findLineNumbers().props('lines')).toBe(1);
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js
index 937c3b26c67..0631e7efd54 100644
--- a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js
@@ -2,12 +2,25 @@ import { wrapLines } from '~/vue_shared/components/source_viewer/utils';
describe('Wrap lines', () => {
it.each`
- input | output
- ${'line 1'} | ${'<span id="LC1" class="line">line 1</span>'}
- ${'line 1\nline 2'} | ${`<span id="LC1" class="line">line 1</span>\n<span id="LC2" class="line">line 2</span>`}
- ${'<span class="hljs-code">line 1\nline 2</span>'} | ${`<span id="LC1" class="hljs-code">line 1\n<span id="LC2" class="line">line 2</span></span>`}
- ${'<span class="hljs-code">```bash'} | ${'<span id="LC1" class="hljs-code">```bash'}
- `('returns lines wrapped in spans containing line numbers', ({ input, output }) => {
- expect(wrapLines(input)).toBe(output);
+ content | language | output
+ ${'line 1'} | ${'javascript'} | ${'<span id="LC1" lang="javascript" class="line">line 1</span>'}
+ ${'line 1\nline 2'} | ${'html'} | ${`<span id="LC1" lang="html" class="line">line 1</span>\n<span id="LC2" lang="html" class="line">line 2</span>`}
+ ${'<span class="hljs-code">line 1\nline 2</span>'} | ${'html'} | ${`<span id="LC1" lang="html" class="hljs-code">line 1\n<span id="LC2" lang="html" class="line">line 2</span></span>`}
+ ${'<span class="hljs-code">```bash'} | ${'bash'} | ${'<span id="LC1" lang="bash" class="hljs-code">```bash'}
+ ${'<span class="hljs-code">```bash'} | ${'valid-language1'} | ${'<span id="LC1" lang="valid-language1" class="hljs-code">```bash'}
+ ${'<span class="hljs-code">```bash'} | ${'valid_language2'} | ${'<span id="LC1" lang="valid_language2" class="hljs-code">```bash'}
+ `('returns lines wrapped in spans containing line numbers', ({ content, language, output }) => {
+ expect(wrapLines(content, language)).toBe(output);
+ });
+
+ it.each`
+ language
+ ${'invalidLanguage>'}
+ ${'"invalidLanguage"'}
+ ${'<invalidLanguage'}
+ `('returns lines safely without XSS language is not valid', ({ language }) => {
+ expect(wrapLines('<span class="hljs-code">```bash', language)).toBe(
+ '<span id="LC1" lang="" class="hljs-code">```bash',
+ );
});
});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js
new file mode 100644
index 00000000000..f624f84eabd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js
@@ -0,0 +1,127 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlAvatar, GlTooltip } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
+import { placeholderImage } from '~/lazy_loader';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image_new.vue';
+
+jest.mock('images/no_avatar.png', () => 'default-avatar-url');
+
+const PROVIDED_PROPS = {
+ size: 32,
+ imgSrc: 'myavatarurl.com',
+ imgAlt: 'mydisplayname',
+ cssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+};
+
+describe('User Avatar Image Component', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Initialization', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: {
+ ...PROVIDED_PROPS,
+ },
+ provide: {
+ glFeatures: {
+ glAvatarForAllUserAvatars: true,
+ },
+ },
+ });
+ });
+
+ it('should render `GlAvatar` and provide correct properties to it', () => {
+ const avatar = wrapper.findComponent(GlAvatar);
+
+ expect(avatar.attributes('data-src')).toBe(
+ `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
+ );
+ expect(avatar.props()).toMatchObject({
+ src: `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
+ alt: PROVIDED_PROPS.imgAlt,
+ size: PROVIDED_PROPS.size,
+ });
+ });
+
+ it('should add correct CSS classes', () => {
+ const classes = wrapper.findComponent(GlAvatar).classes();
+ expect(classes).toContain(PROVIDED_PROPS.cssClasses);
+ expect(classes).not.toContain('lazy');
+ });
+ });
+
+ describe('Initialization when lazy', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: {
+ ...PROVIDED_PROPS,
+ lazy: true,
+ },
+ provide: {
+ glFeatures: {
+ glAvatarForAllUserAvatars: true,
+ },
+ },
+ });
+ });
+
+ it('should add lazy attributes', () => {
+ const avatar = wrapper.findComponent(GlAvatar);
+
+ expect(avatar.classes()).toContain('lazy');
+ expect(avatar.attributes()).toMatchObject({
+ src: placeholderImage,
+ 'data-src': `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
+ });
+ });
+ });
+
+ describe('Initialization without src', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: {
+ ...PROVIDED_PROPS,
+ imgSrc: null,
+ },
+ provide: {
+ glFeatures: {
+ glAvatarForAllUserAvatars: true,
+ },
+ },
+ });
+ });
+
+ it('should have default avatar image', () => {
+ const avatar = wrapper.findComponent(GlAvatar);
+
+ expect(avatar.props('src')).toBe(`${defaultAvatarUrl}?width=${PROVIDED_PROPS.size}`);
+ });
+ });
+
+ describe('Dynamic tooltip content', () => {
+ const slots = {
+ default: ['Action!'],
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: PROVIDED_PROPS,
+ slots,
+ });
+ });
+
+ it('renders the tooltip slot', () => {
+ expect(wrapper.findComponent(GlTooltip).exists()).toBe(true);
+ });
+
+ it('renders the tooltip content', () => {
+ expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js
new file mode 100644
index 00000000000..5051b2b9cae
--- /dev/null
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js
@@ -0,0 +1,122 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlTooltip } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
+import { placeholderImage } from '~/lazy_loader';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image_old.vue';
+
+jest.mock('images/no_avatar.png', () => 'default-avatar-url');
+
+const PROVIDED_PROPS = {
+ size: 32,
+ imgSrc: 'myavatarurl.com',
+ imgAlt: 'mydisplayname',
+ cssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+};
+
+const DEFAULT_PROPS = {
+ size: 20,
+};
+
+describe('User Avatar Image Component', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Initialization', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: {
+ ...PROVIDED_PROPS,
+ },
+ });
+ });
+
+ it('should have <img> as a child element', () => {
+ const imageElement = wrapper.find('img');
+
+ expect(imageElement.exists()).toBe(true);
+ expect(imageElement.attributes('src')).toBe(
+ `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
+ );
+ expect(imageElement.attributes('data-src')).toBe(
+ `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
+ );
+ expect(imageElement.attributes('alt')).toBe(PROVIDED_PROPS.imgAlt);
+ });
+
+ it('should properly render img css', () => {
+ const classes = wrapper.find('img').classes();
+ expect(classes).toEqual(['avatar', 's32', PROVIDED_PROPS.cssClasses]);
+ expect(classes).not.toContain('lazy');
+ });
+ });
+
+ describe('Initialization when lazy', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: {
+ ...PROVIDED_PROPS,
+ lazy: true,
+ },
+ });
+ });
+
+ it('should add lazy attributes', () => {
+ const imageElement = wrapper.find('img');
+
+ expect(imageElement.classes()).toContain('lazy');
+ expect(imageElement.attributes('src')).toBe(placeholderImage);
+ expect(imageElement.attributes('data-src')).toBe(
+ `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
+ );
+ });
+ });
+
+ describe('Initialization without src', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage);
+ });
+
+ it('should have default avatar image', () => {
+ const imageElement = wrapper.find('img');
+
+ expect(imageElement.attributes('src')).toBe(
+ `${defaultAvatarUrl}?width=${DEFAULT_PROPS.size}`,
+ );
+ });
+ });
+
+ describe('dynamic tooltip content', () => {
+ const props = PROVIDED_PROPS;
+ const slots = {
+ default: ['Action!'],
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: { props },
+ slots,
+ });
+ });
+
+ it('renders the tooltip slot', () => {
+ expect(wrapper.findComponent(GlTooltip).exists()).toBe(true);
+ });
+
+ it('renders the tooltip content', () => {
+ expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
+ });
+
+ it('does not render tooltip data attributes on avatar image', () => {
+ const avatarImg = wrapper.find('img');
+
+ expect(avatarImg.attributes('title')).toBeFalsy();
+ expect(avatarImg.attributes('data-placement')).not.toBeDefined();
+ expect(avatarImg.attributes('data-container')).not.toBeDefined();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js
index 2c3fc70e116..75d2a936b34 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -1,12 +1,10 @@
import { shallowMount } from '@vue/test-utils';
-import defaultAvatarUrl from 'images/no_avatar.png';
-import { placeholderImage } from '~/lazy_loader';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import UserAvatarImageNew from '~/vue_shared/components/user_avatar/user_avatar_image_new.vue';
+import UserAvatarImageOld from '~/vue_shared/components/user_avatar/user_avatar_image_old.vue';
-jest.mock('images/no_avatar.png', () => 'default-avatar-url');
-
-const DEFAULT_PROPS = {
- size: 99,
+const PROVIDED_PROPS = {
+ size: 32,
imgSrc: 'myavatarurl.com',
imgAlt: 'mydisplayname',
cssClasses: 'myextraavatarclass',
@@ -21,89 +19,43 @@ describe('User Avatar Image Component', () => {
wrapper.destroy();
});
- describe('Initialization', () => {
+ describe('when `glAvatarForAllUserAvatars` feature flag enabled', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
- ...DEFAULT_PROPS,
+ ...PROVIDED_PROPS,
+ },
+ provide: {
+ glFeatures: {
+ glAvatarForAllUserAvatars: true,
+ },
},
});
});
- it('should have <img> as a child element', () => {
- const imageElement = wrapper.find('img');
-
- expect(imageElement.exists()).toBe(true);
- expect(imageElement.attributes('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
- expect(imageElement.attributes('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
- expect(imageElement.attributes('alt')).toBe(DEFAULT_PROPS.imgAlt);
- });
-
- it('should properly render img css', () => {
- const classes = wrapper.find('img').classes();
- expect(classes).toEqual(expect.arrayContaining(['avatar', 's99', DEFAULT_PROPS.cssClasses]));
- expect(classes).not.toContain('lazy');
+ it('should render `UserAvatarImageNew` component', () => {
+ expect(wrapper.findComponent(UserAvatarImageNew).exists()).toBe(true);
+ expect(wrapper.findComponent(UserAvatarImageOld).exists()).toBe(false);
});
});
- describe('Initialization when lazy', () => {
+ describe('when `glAvatarForAllUserAvatars` feature flag disabled', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
- ...DEFAULT_PROPS,
- lazy: true,
+ ...PROVIDED_PROPS,
+ },
+ provide: {
+ glFeatures: {
+ glAvatarForAllUserAvatars: false,
+ },
},
});
});
- it('should add lazy attributes', () => {
- const imageElement = wrapper.find('img');
-
- expect(imageElement.classes()).toContain('lazy');
- expect(imageElement.attributes('src')).toBe(placeholderImage);
- expect(imageElement.attributes('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
- });
- });
-
- describe('Initialization without src', () => {
- beforeEach(() => {
- wrapper = shallowMount(UserAvatarImage);
- });
-
- it('should have default avatar image', () => {
- const imageElement = wrapper.find('img');
-
- expect(imageElement.attributes('src')).toBe(`${defaultAvatarUrl}?width=20`);
- });
- });
-
- describe('dynamic tooltip content', () => {
- const props = DEFAULT_PROPS;
- const slots = {
- default: ['Action!'],
- };
-
- beforeEach(() => {
- wrapper = shallowMount(UserAvatarImage, {
- propsData: { props },
- slots,
- });
- });
-
- it('renders the tooltip slot', () => {
- expect(wrapper.find('.js-user-avatar-image-tooltip').exists()).toBe(true);
- });
-
- it('renders the tooltip content', () => {
- expect(wrapper.find('.js-user-avatar-image-tooltip').text()).toContain(slots.default[0]);
- });
-
- it('does not render tooltip data attributes for on avatar image', () => {
- const avatarImg = wrapper.find('img');
-
- expect(avatarImg.attributes('title')).toBeFalsy();
- expect(avatarImg.attributes('data-placement')).not.toBeDefined();
- expect(avatarImg.attributes('data-container')).not.toBeDefined();
+ it('should render `UserAvatarImageOld` component', () => {
+ expect(wrapper.findComponent(UserAvatarImageNew).exists()).toBe(false);
+ expect(wrapper.findComponent(UserAvatarImageOld).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js
new file mode 100644
index 00000000000..5ba80b31b99
--- /dev/null
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js
@@ -0,0 +1,102 @@
+import { GlAvatarLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { TEST_HOST } from 'spec/test_constants';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link_new.vue';
+
+describe('User Avatar Link Component', () => {
+ let wrapper;
+
+ const findUserName = () => wrapper.findByTestId('user-avatar-link-username');
+
+ const defaultProps = {
+ linkHref: `${TEST_HOST}/myavatarurl.com`,
+ imgSize: 32,
+ imgSrc: `${TEST_HOST}/myavatarurl.com`,
+ imgAlt: 'mydisplayname',
+ imgCssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+ username: 'username',
+ };
+
+ const createWrapper = (props, slots) => {
+ wrapper = shallowMountExtended(UserAvatarLink, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ ...slots,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render GlLink with correct props', () => {
+ const link = wrapper.findComponent(GlAvatarLink);
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(defaultProps.linkHref);
+ });
+
+ it('should render UserAvatarImage and provide correct props to it', () => {
+ expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true);
+ expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({
+ cssClasses: defaultProps.imgCssClasses,
+ imgAlt: defaultProps.imgAlt,
+ imgSrc: defaultProps.imgSrc,
+ lazy: false,
+ size: defaultProps.imgSize,
+ tooltipPlacement: defaultProps.tooltipPlacement,
+ tooltipText: '',
+ });
+ });
+
+ describe('when username provided', () => {
+ beforeEach(() => {
+ createWrapper({ username: defaultProps.username });
+ });
+
+ it('should render provided username', () => {
+ expect(findUserName().text()).toBe(defaultProps.username);
+ });
+
+ it('should provide the tooltip data for the username', () => {
+ expect(findUserName().attributes()).toEqual(
+ expect.objectContaining({
+ title: defaultProps.tooltipText,
+ 'tooltip-placement': defaultProps.tooltipPlacement,
+ }),
+ );
+ });
+ });
+
+ describe('when username is NOT provided', () => {
+ beforeEach(() => {
+ createWrapper({ username: '' });
+ });
+
+ it('should NOT render username', () => {
+ expect(findUserName().exists()).toBe(false);
+ });
+ });
+
+ describe('avatar-badge slot', () => {
+ const badge = '<span>User badge</span>';
+
+ beforeEach(() => {
+ createWrapper(defaultProps, {
+ 'avatar-badge': badge,
+ });
+ });
+
+ it('should render provided `avatar-badge` slot content', () => {
+ expect(wrapper.html()).toContain(badge);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js
new file mode 100644
index 00000000000..2d513c46e77
--- /dev/null
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js
@@ -0,0 +1,102 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { TEST_HOST } from 'spec/test_constants';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link_old.vue';
+
+describe('User Avatar Link Component', () => {
+ let wrapper;
+
+ const findUserName = () => wrapper.find('[data-testid="user-avatar-link-username"]');
+
+ const defaultProps = {
+ linkHref: `${TEST_HOST}/myavatarurl.com`,
+ imgSize: 32,
+ imgSrc: `${TEST_HOST}/myavatarurl.com`,
+ imgAlt: 'mydisplayname',
+ imgCssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+ username: 'username',
+ };
+
+ const createWrapper = (props, slots) => {
+ wrapper = shallowMountExtended(UserAvatarLink, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ ...slots,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render GlLink with correct props', () => {
+ const link = wrapper.findComponent(GlLink);
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(defaultProps.linkHref);
+ });
+
+ it('should render UserAvatarImage and povide correct props to it', () => {
+ expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true);
+ expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({
+ cssClasses: defaultProps.imgCssClasses,
+ imgAlt: defaultProps.imgAlt,
+ imgSrc: defaultProps.imgSrc,
+ lazy: false,
+ size: defaultProps.imgSize,
+ tooltipPlacement: defaultProps.tooltipPlacement,
+ tooltipText: '',
+ });
+ });
+
+ describe('when username provided', () => {
+ beforeEach(() => {
+ createWrapper({ username: defaultProps.username });
+ });
+
+ it('should render provided username', () => {
+ expect(findUserName().text()).toBe(defaultProps.username);
+ });
+
+ it('should provide the tooltip data for the username', () => {
+ expect(findUserName().attributes()).toEqual(
+ expect.objectContaining({
+ title: defaultProps.tooltipText,
+ 'tooltip-placement': defaultProps.tooltipPlacement,
+ }),
+ );
+ });
+ });
+
+ describe('when username is NOT provided', () => {
+ beforeEach(() => {
+ createWrapper({ username: '' });
+ });
+
+ it('should NOT render username', () => {
+ expect(findUserName().exists()).toBe(false);
+ });
+ });
+
+ describe('avatar-badge slot', () => {
+ const badge = '<span>User badge</span>';
+
+ beforeEach(() => {
+ createWrapper(defaultProps, {
+ 'avatar-badge': badge,
+ });
+ });
+
+ it('should render provided `avatar-badge` slot content', () => {
+ expect(wrapper.html()).toContain(badge);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
index d3fec680b54..b36b83d1fea 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
@@ -1,118 +1,61 @@
-import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { each } from 'lodash';
-import { trimText } from 'helpers/text_helper';
-import { TEST_HOST } from 'spec/test_constants';
-import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import UserAvatarLinkNew from '~/vue_shared/components/user_avatar/user_avatar_link_new.vue';
+import UserAvatarLinkOld from '~/vue_shared/components/user_avatar/user_avatar_link_old.vue';
+
+const PROVIDED_PROPS = {
+ size: 32,
+ imgSrc: 'myavatarurl.com',
+ imgAlt: 'mydisplayname',
+ cssClasses: 'myextraavatarclass',
+ tooltipText: 'tooltip text',
+ tooltipPlacement: 'bottom',
+};
describe('User Avatar Link Component', () => {
let wrapper;
- const defaultProps = {
- linkHref: `${TEST_HOST}/myavatarurl.com`,
- imgSize: 99,
- imgSrc: `${TEST_HOST}/myavatarurl.com`,
- imgAlt: 'mydisplayname',
- imgCssClasses: 'myextraavatarclass',
- tooltipText: 'tooltip text',
- tooltipPlacement: 'bottom',
- username: 'username',
- };
-
- const createWrapper = (props) => {
- wrapper = shallowMount(UserAvatarLink, {
- propsData: {
- ...defaultProps,
- ...props,
- },
- });
- };
-
- beforeEach(() => {
- createWrapper();
- });
-
afterEach(() => {
wrapper.destroy();
- wrapper = null;
- });
-
- it('should have user-avatar-image registered as child component', () => {
- expect(wrapper.vm.$options.components.userAvatarImage).toBeDefined();
- });
-
- it('user-avatar-link should have user-avatar-image as child component', () => {
- expect(wrapper.find(UserAvatarImage).exists()).toBe(true);
- });
-
- it('should render GlLink as a child element', () => {
- const link = wrapper.find(GlLink);
-
- expect(link.exists()).toBe(true);
- expect(link.attributes('href')).toBe(defaultProps.linkHref);
- });
-
- it('should return necessary props as defined', () => {
- each(defaultProps, (val, key) => {
- expect(wrapper.vm[key]).toBeDefined();
- });
});
- describe('no username', () => {
+ describe('when `glAvatarForAllUserAvatars` feature flag enabled', () => {
beforeEach(() => {
- createWrapper({
- username: '',
+ wrapper = shallowMount(UserAvatarLink, {
+ propsData: {
+ ...PROVIDED_PROPS,
+ },
+ provide: {
+ glFeatures: {
+ glAvatarForAllUserAvatars: true,
+ },
+ },
});
});
- it('should only render image tag in link', () => {
- const childElements = wrapper.vm.$el.childNodes;
-
- expect(wrapper.find('img')).not.toBe('null');
-
- // Vue will render the hidden component as <!---->
- expect(childElements[1].tagName).toBeUndefined();
- });
-
- it('should render avatar image tooltip', () => {
- expect(wrapper.vm.shouldShowUsername).toBe(false);
- expect(wrapper.vm.avatarTooltipText).toEqual(defaultProps.tooltipText);
+ it('should render `UserAvatarLinkNew` component', () => {
+ expect(wrapper.findComponent(UserAvatarLinkNew).exists()).toBe(true);
+ expect(wrapper.findComponent(UserAvatarLinkOld).exists()).toBe(false);
});
});
- describe('username', () => {
- it('should not render avatar image tooltip', () => {
- expect(wrapper.find('.js-user-avatar-image-tooltip').exists()).toBe(false);
- });
-
- it('should render username prop in <span>', () => {
- expect(trimText(wrapper.find('.js-user-avatar-link-username').text())).toEqual(
- defaultProps.username,
- );
- });
-
- it('should render text tooltip for <span>', () => {
- expect(wrapper.find('.js-user-avatar-link-username').attributes('title')).toEqual(
- defaultProps.tooltipText,
- );
- });
-
- it('should render text tooltip placement for <span>', () => {
- expect(wrapper.find('.js-user-avatar-link-username').attributes('tooltip-placement')).toBe(
- defaultProps.tooltipPlacement,
- );
- });
- });
-
- describe('lazy', () => {
- it('passes lazy prop to avatar image', () => {
- createWrapper({
- username: '',
- lazy: true,
+ describe('when `glAvatarForAllUserAvatars` feature flag disabled', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarLink, {
+ propsData: {
+ ...PROVIDED_PROPS,
+ },
+ provide: {
+ glFeatures: {
+ glAvatarForAllUserAvatars: false,
+ },
+ },
});
+ });
- expect(wrapper.find(UserAvatarImage).props('lazy')).toBe(true);
+ it('should render `UserAvatarLinkOld` component', () => {
+ expect(wrapper.findComponent(UserAvatarLinkNew).exists()).toBe(false);
+ expect(wrapper.findComponent(UserAvatarLinkOld).exists()).toBe(true);
});
});
});
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 09633daf587..3329199a46b 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
@@ -271,6 +271,12 @@ describe('User Popover Component', () => {
expect(securityBotDocsLink.text()).toBe('Learn more about GitLab Security Bot');
});
+ it("does not show a link to the bot's documentation if there is no website_url", () => {
+ createWrapper({ user: { ...SECURITY_BOT_USER, websiteUrl: null } });
+ const securityBotDocsLink = findSecurityBotDocsLink();
+ expect(securityBotDocsLink.exists()).toBe(false);
+ });
+
it("doesn't escape user's name", () => {
createWrapper({ user: { ...SECURITY_BOT_USER, name: '%<>\';"' } });
const securityBotDocsLink = findSecurityBotDocsLink();
diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js
index 411a15e1c74..cb476910944 100644
--- a/spec/frontend/vue_shared/components/user_select_spec.js
+++ b/spec/frontend/vue_shared/components/user_select_spec.js
@@ -1,4 +1,4 @@
-import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui';
+import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import Vue, { nextTick } from 'vue';
@@ -6,11 +6,14 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
-import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
+import searchUsersQueryOnMR from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
+import { IssuableType } from '~/issues/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import {
searchResponse,
+ searchResponseOnMR,
projectMembersResponse,
participantsQueryResponse,
} from '../../sidebar/mock_data';
@@ -28,7 +31,7 @@ const assignee = {
const mockError = jest.fn().mockRejectedValue('Error!');
const waitForSearch = async () => {
- jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick();
await waitForPromises();
};
@@ -58,6 +61,7 @@ describe('User select dropdown', () => {
} = {}) => {
fakeApollo = createMockApollo([
[searchUsersQuery, searchQueryHandler],
+ [searchUsersQueryOnMR, jest.fn().mockResolvedValue(searchResponseOnMR)],
[getIssueParticipantsQuery, participantsQueryHandler],
]);
wrapper = shallowMount(UserSelect, {
@@ -76,7 +80,18 @@ describe('User select dropdown', () => {
...props,
},
stubs: {
- GlDropdown,
+ GlDropdown: {
+ template: `
+ <div>
+ <slot name="header"></slot>
+ <slot></slot>
+ <slot name="footer"></slot>
+ </div>
+ `,
+ methods: {
+ hide: jest.fn(),
+ },
+ },
},
});
};
@@ -132,11 +147,19 @@ describe('User select dropdown', () => {
expect(findSelectedParticipants()).toHaveLength(1);
});
+ it('does not render a `Cannot merge` tooltip', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findUnselectedParticipants().at(0).attributes('title')).toBe('');
+ });
+
describe('when search is empty', () => {
it('renders a merged list of participants and project members', async () => {
createComponent();
await waitForPromises();
- expect(findUnselectedParticipants()).toHaveLength(3);
+
+ expect(findUnselectedParticipants()).toHaveLength(4);
});
it('renders `Unassigned` link with the checkmark when there are no selected users', async () => {
@@ -162,7 +185,7 @@ describe('User select dropdown', () => {
},
});
await waitForPromises();
- findUnassignLink().vm.$emit('click');
+ findUnassignLink().trigger('click');
expect(wrapper.emitted('input')).toEqual([[[]]]);
});
@@ -175,7 +198,7 @@ describe('User select dropdown', () => {
});
await waitForPromises();
- findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
+ findSelectedParticipants().at(0).trigger('click');
expect(wrapper.emitted('input')).toEqual([[[]]]);
});
@@ -187,8 +210,9 @@ describe('User select dropdown', () => {
});
await waitForPromises();
- findUnselectedParticipants().at(0).vm.$emit('click');
- expect(wrapper.emitted('input')).toEqual([
+ findUnselectedParticipants().at(0).trigger('click');
+
+ expect(wrapper.emitted('input')).toMatchObject([
[
[
{
@@ -214,7 +238,7 @@ describe('User select dropdown', () => {
});
await waitForPromises();
- findUnselectedParticipants().at(0).vm.$emit('click');
+ findUnselectedParticipants().at(0).trigger('click');
expect(wrapper.emitted('input')[0][0]).toHaveLength(2);
});
});
@@ -232,7 +256,7 @@ describe('User select dropdown', () => {
createComponent();
await waitForPromises();
findSearchField().vm.$emit('input', 'roo');
- jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick();
expect(findParticipantsLoading().exists()).toBe(true);
@@ -273,4 +297,19 @@ describe('User select dropdown', () => {
expect(findEmptySearchResults().exists()).toBe(true);
});
});
+
+ describe('when on merge request sidebar', () => {
+ beforeEach(() => {
+ createComponent({ props: { issuableType: IssuableType.MergeRequest, issuableId: 1 } });
+ return waitForPromises();
+ });
+
+ it('does not render a `Cannot merge` tooltip for a user that has merge permission', () => {
+ expect(findUnselectedParticipants().at(0).attributes('title')).toBe('');
+ });
+
+ it('renders a `Cannot merge` tooltip for a user that does not have merge permission', () => {
+ expect(findUnselectedParticipants().at(1).attributes('title')).toBe('Cannot merge');
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
index 5589cbfd08f..e79935f8fa6 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -12,6 +12,7 @@ import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_help
const TEST_EDIT_URL = '/gitlab-test/test/-/edit/main/';
const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/main/-/';
const TEST_GITPOD_URL = 'https://gitpod.test/';
+const TEST_PIPELINE_EDITOR_URL = '/-/ci/editor?branch_name="main"';
const TEST_USER_PREFERENCES_GITPOD_PATH = '/-/profile/preferences#user_gitpod_enabled';
const TEST_USER_PROFILE_ENABLE_GITPOD_PATH = '/-/profile?user%5Bgitpod_enabled%5D=true';
const forkPath = '/some/fork/path';
@@ -66,6 +67,16 @@ const ACTION_GITPOD_ENABLE = {
href: undefined,
handle: expect.any(Function),
};
+const ACTION_PIPELINE_EDITOR = {
+ href: TEST_PIPELINE_EDITOR_URL,
+ key: 'pipeline_editor',
+ secondaryText: 'Edit, lint, and visualize your pipeline.',
+ tooltip: 'Edit, lint, and visualize your pipeline.',
+ text: 'Edit in pipeline editor',
+ attrs: {
+ 'data-qa-selector': 'pipeline_editor_button',
+ },
+};
describe('Web IDE link component', () => {
let wrapper;
@@ -76,6 +87,7 @@ describe('Web IDE link component', () => {
editUrl: TEST_EDIT_URL,
webIdeUrl: TEST_WEB_IDE_URL,
gitpodUrl: TEST_GITPOD_URL,
+ pipelineEditorUrl: TEST_PIPELINE_EDITOR_URL,
forkPath,
...props,
},
@@ -107,6 +119,10 @@ describe('Web IDE link component', () => {
expectedActions: [ACTION_WEB_IDE, ACTION_EDIT],
},
{
+ props: { showPipelineEditorButton: true },
+ expectedActions: [ACTION_PIPELINE_EDITOR, ACTION_WEB_IDE, ACTION_EDIT],
+ },
+ {
props: { webIdeText: 'Test Web IDE' },
expectedActions: [{ ...ACTION_WEB_IDE_EDIT_FORK, text: 'Test Web IDE' }, ACTION_EDIT],
},
@@ -193,12 +209,34 @@ describe('Web IDE link component', () => {
expect(findActionsButton().props('actions')).toEqual(expectedActions);
});
+ describe('when pipeline editor action is available', () => {
+ beforeEach(() => {
+ createComponent({
+ showEditButton: false,
+ showWebIdeButton: true,
+ showGitpodButton: true,
+ showPipelineEditorButton: true,
+ userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH,
+ userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH,
+ gitpodEnabled: true,
+ });
+ });
+
+ it('selected Pipeline Editor by default', () => {
+ expect(findActionsButton().props()).toMatchObject({
+ actions: [ACTION_PIPELINE_EDITOR, ACTION_WEB_IDE, ACTION_GITPOD],
+ selectedKey: ACTION_PIPELINE_EDITOR.key,
+ });
+ });
+ });
+
describe('with multiple actions', () => {
beforeEach(() => {
createComponent({
showEditButton: false,
showWebIdeButton: true,
showGitpodButton: true,
+ showPipelineEditorButton: false,
userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH,
userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH,
gitpodEnabled: true,
@@ -240,6 +278,7 @@ describe('Web IDE link component', () => {
props: {
showWebIdeButton: true,
showEditButton: false,
+ showPipelineEditorButton: false,
forkPath,
forkModalId: 'edit-modal',
},
@@ -249,6 +288,7 @@ describe('Web IDE link component', () => {
props: {
showWebIdeButton: false,
showEditButton: true,
+ showPipelineEditorButton: false,
forkPath,
forkModalId: 'webide-modal',
},
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
index 93de6dbe306..11e3302d409 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
@@ -66,10 +66,12 @@ describe('IssuableTitle', () => {
});
await nextTick();
- const titleEl = wrapperWithTitle.find('h2');
+ const titleEl = wrapperWithTitle.find('[data-testid="title"]');
expect(titleEl.exists()).toBe(true);
- expect(titleEl.html()).toBe('<h2 dir="auto" class="title qa-title"><b>Sample</b> title</h2>');
+ expect(titleEl.html()).toBe(
+ '<h1 dir="auto" data-testid="title" class="title qa-title"><b>Sample</b> title</h1>',
+ );
wrapperWithTitle.destroy();
});
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
new file mode 100644
index 00000000000..305f43ad8ba
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -0,0 +1,40 @@
+import { GlModal } 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 WorkItemTitle from '~/work_items/components/item_title.vue';
+import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import { resolvers } from '~/work_items/graphql/resolvers';
+
+describe('WorkItemDetailModal component', () => {
+ let wrapper;
+
+ Vue.use(VueApollo);
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
+
+ const createComponent = () => {
+ wrapper = shallowMount(WorkItemDetailModal, {
+ apolloProvider: createMockApollo([], resolvers),
+ propsData: { visible: true },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders modal', () => {
+ createComponent();
+
+ expect(findModal().props()).toMatchObject({ visible: true });
+ });
+
+ it('renders work item title', () => {
+ createComponent();
+
+ expect(findWorkItemTitle().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index a98722bc465..832795fc4ac 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -1,8 +1,12 @@
export const workItemQueryResponse = {
workItem: {
- __typename: 'LocalWorkItem',
+ __typename: 'WorkItem',
id: '1',
- type: 'FEATURE',
+ title: 'Test',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'work-item-type-1',
+ },
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
nodes: [
@@ -17,20 +21,29 @@ export const workItemQueryResponse = {
};
export const updateWorkItemMutationResponse = {
- __typename: 'LocalUpdateWorkItemPayload',
- workItem: {
- __typename: 'LocalWorkItem',
- id: '1',
- widgets: {
- __typename: 'LocalWorkItemWidgetConnection',
- nodes: [
- {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- enabled: true,
- contentText: 'Updated title',
+ data: {
+ workItemUpdate: {
+ __typename: 'LocalUpdateWorkItemPayload',
+ workItem: {
+ __typename: 'LocalWorkItem',
+ id: '1',
+ title: 'Updated title',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'work-item-type-1',
},
- ],
+ widgets: {
+ __typename: 'LocalWorkItemWidgetConnection',
+ nodes: [
+ {
+ __typename: 'LocalTitleWidget',
+ type: 'TITLE',
+ enabled: true,
+ contentText: 'Updated title',
+ },
+ ],
+ },
+ },
},
},
};
@@ -48,3 +61,20 @@ export const projectWorkItemTypesQueryResponse = {
},
},
};
+
+export const createWorkItemMutationResponse = {
+ data: {
+ workItemCreate: {
+ __typename: 'WorkItemCreatePayload',
+ workItem: {
+ __typename: 'WorkItem',
+ id: '1',
+ title: 'Updated title',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'work-item-type-1',
+ },
+ },
+ },
+ },
+};
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 b9fef0eaa6a..185b05c5191 100644
--- a/spec/frontend/work_items/pages/create_work_item_spec.js
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -8,7 +8,10 @@ import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import ItemTitle from '~/work_items/components/item_title.vue';
import { resolvers } from '~/work_items/graphql/resolvers';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
-import { projectWorkItemTypesQueryResponse } from '../mock_data';
+import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
+import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data';
+
+jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] }));
Vue.use(VueApollo);
@@ -17,6 +20,7 @@ describe('Create work item component', () => {
let fakeApollo;
const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
+ const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
const findAlert = () => wrapper.findComponent(GlAlert);
const findTitleInput = () => wrapper.findComponent(ItemTitle);
@@ -28,8 +32,19 @@ describe('Create work item component', () => {
const findContent = () => wrapper.find('[data-testid="content"]');
const findLoadingTypesIcon = () => wrapper.find('[data-testid="loading-types"]');
- const createComponent = ({ data = {}, props = {}, queryHandler = querySuccessHandler } = {}) => {
- fakeApollo = createMockApollo([[projectWorkItemTypesQuery, queryHandler]], resolvers);
+ const createComponent = ({
+ data = {},
+ props = {},
+ queryHandler = querySuccessHandler,
+ mutationHandler = mutationSuccessHandler,
+ } = {}) => {
+ fakeApollo = createMockApollo(
+ [
+ [projectWorkItemTypesQuery, queryHandler],
+ [createWorkItemMutation, mutationHandler],
+ ],
+ resolvers,
+ );
wrapper = shallowMount(CreateWorkItem, {
apolloProvider: fakeApollo,
data() {
@@ -124,7 +139,8 @@ describe('Create work item component', () => {
wrapper.find('form').trigger('submit');
await waitForPromises();
- expect(wrapper.emitted('onCreate')).toEqual([[mockTitle]]);
+ const expected = { id: '1', title: mockTitle };
+ expect(wrapper.emitted('onCreate')).toEqual([[expected]]);
});
it('does not right margin for create button', () => {
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 d0e40680b55..728495e0e23 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -9,11 +9,12 @@ import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutati
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
import ItemTitle from '~/work_items/components/item_title.vue';
import { resolvers } from '~/work_items/graphql/resolvers';
-import { workItemQueryResponse } from '../mock_data';
+import { workItemQueryResponse, updateWorkItemMutationResponse } from '../mock_data';
Vue.use(VueApollo);
const WORK_ITEM_ID = '1';
+const WORK_ITEM_GID = `gid://gitlab/WorkItem/${WORK_ITEM_ID}`;
describe('Work items root component', () => {
const mockUpdatedTitle = 'Updated title';
@@ -23,15 +24,19 @@ describe('Work items root component', () => {
const findTitle = () => wrapper.findComponent(ItemTitle);
const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => {
- fakeApollo = createMockApollo([], resolvers, {
- possibleTypes: {
- LocalWorkItemWidget: ['LocalTitleWidget'],
+ fakeApollo = createMockApollo(
+ [[updateWorkItemMutation, jest.fn().mockResolvedValue(updateWorkItemMutationResponse)]],
+ resolvers,
+ {
+ possibleTypes: {
+ LocalWorkItemWidget: ['LocalTitleWidget'],
+ },
},
- });
+ );
fakeApollo.clients.defaultClient.cache.writeQuery({
query: workItemQuery,
variables: {
- id: WORK_ITEM_ID,
+ id: WORK_ITEM_GID,
},
data: queryResponse,
});
@@ -49,7 +54,7 @@ describe('Work items root component', () => {
fakeApollo = null;
});
- it('renders the title if title is in the widgets list', () => {
+ it('renders the title', () => {
createComponent();
expect(findTitle().exists()).toBe(true);
@@ -66,35 +71,11 @@ describe('Work items root component', () => {
mutation: updateWorkItemMutation,
variables: {
input: {
- id: WORK_ITEM_ID,
+ id: WORK_ITEM_GID,
title: mockUpdatedTitle,
},
},
});
-
- await waitForPromises();
- expect(findTitle().props('initialTitle')).toBe(mockUpdatedTitle);
- });
-
- it('does not render the title if title is not in the widgets list', () => {
- const queryResponse = {
- workItem: {
- ...workItemQueryResponse.workItem,
- widgets: {
- __typename: 'WorkItemWidgetConnection',
- nodes: [
- {
- __typename: 'SomeOtherWidget',
- type: 'OTHER',
- contentText: 'Test',
- },
- ],
- },
- },
- };
- createComponent({ queryResponse });
-
- expect(findTitle().exists()).toBe(false);
});
describe('tracking', () => {
diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js
index c583b5a5d4f..8c9054920a8 100644
--- a/spec/frontend/work_items/router_spec.js
+++ b/spec/frontend/work_items/router_spec.js
@@ -21,6 +21,7 @@ describe('Work items router', () => {
mocks: {
$apollo: {
queries: {
+ workItem: {},
workItemTypes: {},
},
},