From a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 16 Jun 2021 18:25:58 +0000 Subject: Add latest changes from gitlab-org/gitlab@14-0-stable-ee --- .../__helpers__/mock_user_callout_dismisser.js | 16 + spec/frontend/__helpers__/vue_test_utils_helper.js | 11 +- .../__helpers__/vue_test_utils_helper_spec.js | 3 +- .../components/alert_management_table_spec.js | 32 +- .../__snapshots__/alerts_form_spec.js.snap | 119 +++++ .../alerts_settings/components/alerts_form_spec.js | 50 ++ .../components/alerts_integrations_list_spec.js | 2 +- .../components/alerts_settings_wrapper_spec.js | 11 +- spec/frontend/api_spec.js | 68 ++- .../batch_comments/components/preview_item_spec.js | 4 +- .../stores/modules/batch_comments/actions_spec.js | 6 + .../behaviors/shortcuts/keybindings_spec.js | 11 + .../blob/components/table_contents_spec.js | 67 +++ spec/frontend/boards/board_card_inner_spec.js | 20 +- spec/frontend/boards/board_list_spec.js | 7 +- spec/frontend/boards/boards_util_spec.js | 112 ++++- .../__snapshots__/board_blocked_icon_spec.js.snap | 2 +- spec/frontend/boards/components/board_card_spec.js | 38 +- .../components/board_content_sidebar_spec.js | 32 +- .../components/board_filtered_search_spec.js | 4 +- spec/frontend/boards/components/board_form_spec.js | 20 +- .../board_list_header_deprecated_spec.js | 13 +- .../boards/components/board_list_header_spec.js | 14 +- .../sidebar/board_sidebar_due_date_spec.js | 137 ------ .../sidebar/board_sidebar_labels_select_spec.js | 6 +- .../sidebar/board_sidebar_milestone_select_spec.js | 178 -------- .../sidebar/board_sidebar_subscription_spec.js | 11 +- .../sidebar/board_sidebar_time_tracker_spec.js | 13 +- .../components/sidebar/board_sidebar_title_spec.js | 6 +- .../boards/project_select_deprecated_spec.js | 8 +- spec/frontend/boards/stores/actions_spec.js | 171 +++---- spec/frontend/boards/stores/mutations_spec.js | 47 ++ .../components/delete_branch_button_spec.js | 96 ++++ .../components/delete_branch_modal_spec.js | 157 +++++++ .../ci_variable_list/store/actions_spec.js | 12 +- .../remove_cluster_confirmation_spec.js.snap | 2 + .../clusters/components/applications_spec.js | 41 -- .../components/fluentd_output_settings_spec.js | 186 -------- .../ingress_modsecurity_settings_spec.js | 192 -------- .../forms/components/integration_form_spec.js | 13 - spec/frontend/clusters/services/mock_data.js | 3 - .../clusters/stores/clusters_store_spec.js | 23 - spec/frontend/commit/pipelines/pipelines_spec.js | 280 ------------ .../commit/pipelines/pipelines_table_spec.js | 253 +++++++++++ .../__snapshots__/toolbar_link_button_spec.js.snap | 36 ++ .../components/content_editor_spec.js | 9 +- .../components/toolbar_link_button_spec.js | 151 +++++++ .../components/toolbar_text_style_dropdown_spec.js | 131 ++++++ .../content_editor/components/top_toolbar_spec.js | 19 +- .../extensions/code_block_highlight_spec.js | 37 ++ .../content_editor/extensions/link_spec.js | 61 +++ .../track_input_rules_and_shortcuts_spec.js | 45 +- spec/frontend/content_editor/test_utils.js | 132 ++++-- .../eks_cluster/store/actions_spec.js | 6 +- .../__snapshots__/base_spec.js.snap | 9 + spec/frontend/cycle_analytics/base_spec.js | 197 ++++++++ spec/frontend/cycle_analytics/mock_data.js | 175 ++++--- .../cycle_analytics/path_navigation_spec.js | 148 ++++++ .../frontend/cycle_analytics/store/actions_spec.js | 191 +++++++- .../frontend/cycle_analytics/store/getters_spec.js | 16 + .../cycle_analytics/store/mutations_spec.js | 34 +- spec/frontend/cycle_analytics/utils_spec.js | 129 ++++-- spec/frontend/deploy_freeze/store/actions_spec.js | 8 +- .../frontend/diffs/components/diff_content_spec.js | 1 + spec/frontend/diffs/components/diff_stats_spec.js | 53 ++- .../diffs/components/settings_dropdown_spec.js | 1 - spec/frontend/diffs/mock_data/diff_file.js | 2 + spec/frontend/diffs/store/actions_spec.js | 62 +-- spec/frontend/diffs/store/utils_spec.js | 328 -------------- spec/frontend/diffs/utils/diff_file_spec.js | 78 +++- spec/frontend/diffs/utils/workers_spec.js | 309 +++++++++++++ spec/frontend/editor/editor_ci_schema_ext_spec.js | 7 +- .../emoji/awards_app/store/actions_spec.js | 191 ++++---- .../components/error_details_spec.js | 10 +- spec/frontend/error_tracking/store/actions_spec.js | 2 +- .../error_tracking/store/details/actions_spec.js | 2 +- .../error_tracking/store/list/actions_spec.js | 2 +- .../feature_flags/components/empty_state_spec.js | 136 ++++++ .../feature_flags/components/feature_flags_spec.js | 145 ++---- .../components/feature_flags_tab_spec.js | 167 ------- .../components/user_lists_table_spec.js | 98 ---- .../feature_flags/store/index/actions_spec.js | 165 +------ .../feature_flags/store/index/mutations_spec.js | 94 +--- .../filtered_search_manager_spec.js | 2 +- spec/frontend/fixtures/api_markdown.rb | 2 +- spec/frontend/fixtures/releases.rb | 11 + spec/frontend/fixtures/runner.rb | 75 +++ spec/frontend/fixtures/services.rb | 2 +- spec/frontend/fixtures/startup_css.rb | 88 ++++ spec/frontend/fixtures/static/projects.json | 9 + spec/frontend/flash_spec.js | 49 +- .../frontend/frequent_items/components/app_spec.js | 18 +- .../__snapshots__/grafana_integration_spec.js.snap | 42 +- .../components/grafana_integration_spec.js | 22 +- spec/frontend/groups/components/group_item_spec.js | 122 ++--- spec/frontend/ide/components/branches/item_spec.js | 2 +- .../ide/components/commit_sidebar/actions_spec.js | 14 +- .../ide/components/commit_sidebar/form_spec.js | 2 +- .../new_merge_request_option_spec.js | 6 +- spec/frontend/ide/components/ide_review_spec.js | 4 +- spec/frontend/ide/components/ide_spec.js | 4 +- .../frontend/ide/components/ide_status_bar_spec.js | 2 +- spec/frontend/ide/components/ide_tree_list_spec.js | 6 +- spec/frontend/ide/components/ide_tree_spec.js | 4 +- .../ide/components/merge_requests/list_spec.js | 2 +- spec/frontend/ide/components/nav_dropdown_spec.js | 4 +- .../ide/components/new_dropdown/index_spec.js | 2 +- .../ide/components/new_dropdown/modal_spec.js | 18 +- .../ide/components/repo_commit_section_spec.js | 6 +- spec/frontend/ide/components/repo_editor_spec.js | 8 +- spec/frontend/ide/ide_router_spec.js | 12 +- spec/frontend/ide/mock_data.js | 14 +- spec/frontend/ide/services/index_spec.js | 2 +- spec/frontend/ide/stores/actions/file_spec.js | 16 +- .../ide/stores/actions/merge_request_spec.js | 14 +- spec/frontend/ide/stores/actions/project_spec.js | 32 +- spec/frontend/ide/stores/actions/tree_spec.js | 22 +- spec/frontend/ide/stores/actions_spec.js | 6 +- spec/frontend/ide/stores/getters_spec.js | 36 +- .../ide/stores/modules/commit/actions_spec.js | 22 +- .../ide/stores/modules/commit/getters_spec.js | 4 +- .../stores/modules/terminal/actions/checks_spec.js | 2 +- .../terminal/actions/session_controls_spec.js | 12 +- .../terminal/actions/session_status_spec.js | 6 +- spec/frontend/ide/stores/mutations/branch_spec.js | 10 +- spec/frontend/ide/stores/mutations/file_spec.js | 6 +- spec/frontend/ide/stores/mutations/tree_spec.js | 24 +- spec/frontend/ide/stores/mutations_spec.js | 22 +- spec/frontend/ide/stores/utils_spec.js | 18 +- spec/frontend/ide/utils_spec.js | 34 +- .../components/import_table_row_spec.js | 117 ++++- .../import_projects/store/actions_spec.js | 18 +- .../incidents/components/incidents_list_spec.js | 31 -- .../__snapshots__/alerts_form_spec.js.snap | 114 ----- .../incidents_settings_tabs_spec.js.snap | 18 +- .../__snapshots__/pagerduty_form_spec.js.snap | 16 +- .../components/alerts_form_spec.js | 51 --- .../components/incidents_settings_service_spec.js | 7 +- .../components/pagerduty_form_spec.js | 23 +- .../edit/components/active_checkbox_spec.js | 1 + .../edit/components/confirmation_modal_spec.js | 8 +- .../edit/components/dynamic_field_spec.js | 16 +- .../edit/components/integration_form_spec.js | 61 ++- .../edit/components/jira_issues_fields_spec.js | 54 ++- .../edit/components/jira_trigger_fields_spec.js | 18 +- .../edit/components/jira_upgrade_cta_spec.js | 9 +- .../edit/components/override_dropdown_spec.js | 10 +- .../edit/components/trigger_fields_spec.js | 23 +- .../index/components/integrations_list_spec.js | 6 +- .../invite_members/components/group_select_spec.js | 33 +- .../components/invite_members_modal_spec.js | 50 +- .../components/invite_members_trigger_spec.js | 16 +- .../issuable/components/csv_export_modal_spec.js | 44 +- .../components/csv_import_export_buttons_spec.js | 58 +-- .../issuable/components/csv_import_modal_spec.js | 48 +- .../issuable/components/issuable_by_email_spec.js | 11 +- .../issuable/components/status_box_spec.js | 4 +- .../components/related_issues_root_spec.js | 6 +- .../issuable_list/components/issuable_item_spec.js | 4 +- spec/frontend/issue_show/components/app_spec.js | 41 +- .../issue_show/components/description_spec.js | 2 +- .../issue_show/components/edit_actions_spec.js | 180 +++++--- .../issue_show/components/fields/type_spec.js | 84 ++++ spec/frontend/issue_show/components/form_spec.js | 17 + .../components/incidents/incident_tabs_spec.js | 2 +- spec/frontend/issue_show/issue_spec.js | 2 +- spec/frontend/issue_show/mock_data.js | 59 --- spec/frontend/issue_show/mock_data/apollo_mock.js | 9 + spec/frontend/issue_show/mock_data/mock_data.js | 60 +++ .../components/issuables_list_app_spec.js | 5 +- .../issues_list/components/issues_list_app_spec.js | 134 +++++- spec/frontend/issues_list/mock_data.js | 14 +- spec/frontend/issues_list/utils_spec.js | 10 +- .../__snapshots__/jira_import_form_spec.js.snap | 6 + .../components/jira_import_progress_spec.js | 2 +- .../jobs/components/table/job_table_app_spec.js | 90 +++- spec/frontend/lib/utils/datetime_utility_spec.js | 17 +- spec/frontend/lib/utils/number_utility_spec.js | 4 + spec/frontend/lib/utils/table_utility_spec.js | 11 + spec/frontend/lib/utils/url_utility_spec.js | 88 ++-- .../logs/components/environment_logs_spec.js | 3 +- spec/frontend/logs/mock_data.js | 27 -- spec/frontend/logs/stores/actions_spec.js | 27 -- spec/frontend/logs/stores/getters_spec.js | 48 +- spec/frontend/logs/stores/mutations_spec.js | 36 -- spec/frontend/members/components/app_spec.js | 2 +- .../members/components/members_tabs_spec.js | 10 +- .../modals/remove_group_link_modal_spec.js | 2 +- .../members/components/table/expires_at_spec.js | 2 +- .../members/components/table/role_dropdown_spec.js | 2 +- spec/frontend/members/index_spec.js | 19 +- spec/frontend/members/mock_data.js | 13 +- spec/frontend/members/utils_spec.js | 10 +- spec/frontend/monitoring/alert_widget_spec.js | 2 +- .../__snapshots__/dashboard_template_spec.js.snap | 1 - .../components/charts/time_series_spec.js | 8 +- .../monitoring/components/dashboard_panel_spec.js | 26 ++ .../monitoring/components/dashboard_spec.js | 44 +- .../components/dashboard_url_time_spec.js | 2 +- spec/frontend/monitoring/store/actions_spec.js | 14 +- .../frontend/nav/components/responsive_app_spec.js | 173 +++++++ .../nav/components/responsive_header_spec.js | 67 +++ .../nav/components/responsive_home_spec.js | 137 ++++++ spec/frontend/nav/components/top_nav_app_spec.js | 31 +- .../nav/components/top_nav_container_view_spec.js | 60 +-- .../nav/components/top_nav_dropdown_menu_spec.js | 121 +++-- .../nav/components/top_nav_menu_item_spec.js | 76 +++- .../nav/components/top_nav_menu_sections_spec.js | 107 +++++ .../nav/components/top_nav_new_dropdown_spec.js | 122 +++++ spec/frontend/nav/mock_data.js | 4 + .../frontend/notes/components/comment_form_spec.js | 41 +- .../notes/components/discussion_actions_spec.js | 11 +- .../discussion_reply_placeholder_spec.js | 23 +- .../notes/components/noteable_discussion_spec.js | 12 + spec/frontend/notes/stores/actions_spec.js | 180 ++++++-- .../components/metrics_settings_spec.js | 16 +- .../components/__snapshots__/file_sha_spec.js.snap | 30 ++ .../packages/details/components/app_spec.js | 109 ++++- .../packages/details/components/file_sha_spec.js | 33 ++ .../components/installations_commands_spec.js | 4 + .../details/components/package_files_spec.js | 132 +++++- .../packages/details/store/actions_spec.js | 62 ++- .../packages/details/store/mutations_spec.js | 9 + spec/frontend/packages/list/stores/actions_spec.js | 6 +- spec/frontend/packages/mock_data.js | 17 + .../__snapshots__/package_list_row_spec.js.snap | 4 +- .../terraform_installation_spec.js.snap | 44 ++ .../components/details_title_spec.js | 93 ++++ .../components/terraform_installation_spec.js | 61 +++ .../components/registry_settings_app_spec.js | 26 ++ .../cleanup_policy_enabled_alert_spec.js.snap | 19 + .../cleanup_policy_enabled_alert_spec.js | 49 ++ .../components/promote_milestone_modal_spec.js | 4 +- .../projects/forks/new/components/app_spec.js | 1 + .../forks/new/components/fork_form_spec.js | 182 +++++++- .../forks/new/components/fork_groups_list_spec.js | 2 +- .../__snapshots__/code_coverage_spec.js.snap | 2 +- .../__snapshots__/learn_gitlab_a_spec.js.snap | 2 + .../__snapshots__/learn_gitlab_b_spec.js.snap | 2 + .../components/interval_pattern_input_spec.js | 27 ++ .../pages/shared/nav/sidebar_tracking_spec.js | 160 +++++++ .../shared/wikis/components/wiki_form_spec.js | 115 +++-- .../frontend/pages/users/activity_calendar_spec.js | 16 + .../components/commit/commit_section_spec.js | 9 +- .../drawer/cards/first_pipeline_card_spec.js | 12 +- .../drawer/ui/pipeline_visual_reference_spec.js | 31 -- .../components/editor/text_editor_spec.js | 51 ++- .../components/file-nav/branch_switcher_spec.js | 153 +++++-- spec/frontend/pipeline_editor/mock_data.js | 1 + .../frontend/pipelines/components/dag/mock_data.js | 14 + .../graph/graph_component_wrapper_spec.js | 2 +- .../pipelines/graph/linked_pipeline_spec.js | 6 + .../__snapshots__/links_inner_spec.js.snap | 7 + .../pipelines/graph_shared/links_inner_spec.js | 23 +- spec/frontend/pipelines/header_component_spec.js | 16 + spec/frontend/pipelines/mock_data.js | 51 +++ .../notification/pipeline_notification_spec.js | 79 ---- spec/frontend/pipelines/parsing_utils_spec.js | 10 +- .../frontend/pipelines/pipeline_graph/mock_data.js | 32 ++ .../pipelines/pipeline_graph/utils_spec.js | 22 + spec/frontend/pipelines/pipelines_spec.js | 32 +- .../pipelines/test_reports/stores/actions_spec.js | 2 +- .../account/components/update_username_spec.js | 12 +- .../projects/commit/components/form_modal_spec.js | 7 +- .../projects/commits/store/actions_spec.js | 6 +- .../protected_branch_edit_spec.js | 6 +- .../__snapshots__/registry_breadcrumb_spec.js.snap | 1 + .../components/details_page/tags_list_row_spec.js | 8 +- spec/frontend/registry/explorer/pages/list_spec.js | 53 ++- .../related_merge_requests/store/actions_spec.js | 6 +- .../releases/__snapshots__/util_spec.js.snap | 56 ++- .../components/app_index_apollo_client_spec.js | 394 ++++++++++++++++ .../components/releases_empty_state_spec.js | 56 +++ .../releases_pagination_apollo_client_spec.js | 126 ++++++ .../components/releases_sort_apollo_client_spec.js | 103 +++++ .../releases/stores/modules/detail/actions_spec.js | 26 +- .../codequality_report/store/actions_spec.js | 3 + .../codequality_report/store/mutations_spec.js | 9 + .../components/blob_content_viewer_spec.js | 69 ++- .../repository/components/blob_header_edit_spec.js | 82 ++++ .../repository/components/blob_replace_spec.js | 67 +++ .../repository/components/table/row_spec.js | 22 + .../repository/components/tree_content_spec.js | 5 +- .../components/upload_blob_modal_spec.js | 80 ++++ spec/frontend/repository/log_tree_spec.js | 15 + .../components/cells/runner_actions_cell_spec.js | 201 ++++++++ .../components/cells/runner_name_cell_spec.js | 42 ++ .../components/cells/runner_type_cell_spec.js | 48 ++ .../components/runner_filtered_search_bar_spec.js | 137 ++++++ .../frontend/runner/components/runner_list_spec.js | 130 ++++++ .../components/runner_manual_setup_help_spec.js | 84 ++++ .../runner/components/runner_pagination_spec.js | 160 +++++++ .../frontend/runner/components/runner_tags_spec.js | 64 +++ .../runner/components/runner_type_alert_spec.js | 61 +++ .../runner/components/runner_type_badge_spec.js | 10 +- .../runner/components/runner_type_help_spec.js | 32 ++ .../runner/components/runner_update_form_spec.js | 263 +++++++++++ spec/frontend/runner/mock_data.js | 6 + .../runner_detail/runner_details_app_spec.js | 21 +- .../runner/runner_list/runner_list_app_spec.js | 232 ++++++++++ .../runner/runner_list/runner_search_utils_spec.js | 239 ++++++++++ spec/frontend/search/mock_data.js | 28 +- spec/frontend/search/store/actions_spec.js | 21 +- .../components/searchable_dropdown_item_spec.js | 97 ++++ .../topbar/components/searchable_dropdown_spec.js | 58 ++- .../components/redesigned_app_spec.js | 232 ++++++++++ .../components/section_layout_spec.js | 49 ++ .../components/upgrade_banner_spec.js | 60 +++ spec/frontend/security_configuration/utils_spec.js | 81 ++++ .../__snapshots__/self_monitor_form_spec.js.snap | 4 +- .../__snapshots__/empty_state_spec.js.snap | 2 +- .../components/missing_prometheus_spec.js | 2 +- .../set_status_modal_wrapper_spec.js | 28 +- spec/frontend/sidebar/assignees_spec.js | 4 +- .../assignees/sidebar_invite_members_spec.js | 11 +- .../components/date/sidebar_date_widget_spec.js | 7 +- .../components/sidebar_dropdown_widget_spec.js | 503 +++++++++++++++++++++ .../components/time_tracking/report_spec.js | 2 +- .../components/time_tracking/time_tracker_spec.js | 131 ++++-- spec/frontend/sidebar/mock_data.js | 96 ++++ spec/frontend/sidebar/track_invite_members_spec.js | 37 ++ .../snippet_visibility_edit_spec.js.snap | 1 + .../snippets/components/snippet_blob_edit_spec.js | 8 +- .../components/edit_area_spec.js | 4 +- .../rich_content_editor/editor_service_spec.js | 214 +++++++++ .../modals/add_image/add_image_modal_spec.js | 73 +++ .../modals/add_image/upload_image_tab_spec.js | 41 ++ .../modals/insert_video_modal_spec.js | 44 ++ .../rich_content_editor_integration_spec.js | 69 +++ .../rich_content_editor_spec.js | 222 +++++++++ .../services/build_custom_renderer_spec.js | 32 ++ .../build_html_to_markdown_renderer_spec.js | 218 +++++++++ .../renderers/build_uneditable_token_spec.js | 88 ++++ .../services/renderers/mock_data.js | 54 +++ .../renderers/render_attribute_definition_spec.js | 25 + .../renderers/render_embedded_ruby_spec.js | 24 + .../render_font_awesome_html_inline_spec.js | 33 ++ .../services/renderers/render_heading_spec.js | 12 + .../services/renderers/render_html_block_spec.js | 37 ++ .../render_identifier_instance_text_spec.js | 55 +++ .../renderers/render_identifier_paragraph_spec.js | 84 ++++ .../services/renderers/render_list_item_spec.js | 12 + .../services/renderers/render_softbreak_spec.js | 23 + .../services/renderers/render_utils_spec.js | 109 +++++ .../services/sanitize_html_spec.js | 11 + .../rich_content_editor/toolbar_item_spec.js | 57 +++ .../frontend/tracking/get_standard_context_spec.js | 53 +++ spec/frontend/tracking_spec.js | 152 ++++--- .../user_lists/components/user_lists_spec.js | 195 ++++++++ .../user_lists/components/user_lists_table_spec.js | 98 ++++ .../user_lists/store/index/actions_spec.js | 203 +++++++++ .../user_lists/store/index/mutations_spec.js | 121 +++++ .../components/approvals/approvals_spec.js | 8 +- .../components/mr_widget_alert_message_spec.js | 77 +--- .../components/mr_widget_pipeline_spec.js | 2 +- .../components/states/mr_widget_closed_spec.js | 4 +- .../components/states/mr_widget_merged_spec.js | 6 +- .../states/mr_widget_pipeline_blocked_spec.js | 2 +- .../states/mr_widget_ready_to_merge_spec.js | 47 +- .../components/states/mr_widget_wip_spec.js | 10 +- .../deployment/deployment_actions_spec.js | 8 +- .../vue_mr_widget/mr_widget_options_spec.js | 6 +- .../vue_shared/alert_details/alert_status_spec.js | 38 +- .../sidebar/alert_sidebar_assignees_spec.js | 6 +- .../sidebar/alert_sidebar_status_spec.js | 53 +-- .../__snapshots__/awards_list_spec.js.snap | 19 +- .../__snapshots__/expand_button_spec.js.snap | 4 + .../components/alert_details_table_spec.js | 25 +- .../vue_shared/components/awards_list_spec.js | 9 +- .../filtered_search_utils_spec.js | 75 ++- .../components/filtered_search_bar/mock_data.js | 4 +- .../store/modules/filters/actions_spec.js | 2 +- .../tokens/author_token_spec.js | 212 ++++++--- .../filtered_search_bar/tokens/base_token_spec.js | 17 + .../filtered_search_bar/tokens/emoji_token_spec.js | 6 +- .../filtered_search_bar/tokens/epic_token_spec.js | 43 +- .../filtered_search_bar/tokens/label_token_spec.js | 69 +-- .../form/__snapshots__/title_spec.js.snap | 1 + .../components/issue/issue_assignees_spec.js | 4 +- .../__snapshots__/code_instruction_spec.js.snap | 1 + .../rich_content_editor/editor_service_spec.js | 214 --------- .../modals/add_image/add_image_modal_spec.js | 73 --- .../modals/add_image/upload_image_tab_spec.js | 41 -- .../modals/insert_video_modal_spec.js | 44 -- .../rich_content_editor_integration_spec.js | 69 --- .../rich_content_editor_spec.js | 222 --------- .../services/build_custom_renderer_spec.js | 32 -- .../build_html_to_markdown_renderer_spec.js | 218 --------- .../renderers/build_uneditable_token_spec.js | 88 ---- .../services/renderers/mock_data.js | 54 --- .../renderers/render_attribute_definition_spec.js | 25 - .../renderers/render_embedded_ruby_spec.js | 24 - .../render_font_awesome_html_inline_spec.js | 33 -- .../services/renderers/render_heading_spec.js | 12 - .../services/renderers/render_html_block_spec.js | 37 -- .../render_identifier_instance_text_spec.js | 55 --- .../renderers/render_identifier_paragraph_spec.js | 84 ---- .../services/renderers/render_list_item_spec.js | 12 - .../services/renderers/render_softbreak_spec.js | 23 - .../services/renderers/render_utils_spec.js | 109 ----- .../services/sanitize_html_spec.js | 11 - .../rich_content_editor/toolbar_item_spec.js | 57 --- .../runner_aws_deployments_modal_spec.js.snap | 110 +++++ .../runner_aws_deployments_modal_spec.js | 75 +++ .../runner_aws_deployments_spec.js | 41 ++ .../merge_request_artifact_download_spec.js | 108 +++++ .../components/sidebar/labels_select/base_spec.js | 127 ------ .../sidebar/labels_select/dropdown_button_spec.js | 90 ---- .../labels_select/dropdown_create_label_spec.js | 103 ----- .../sidebar/labels_select/dropdown_footer_spec.js | 75 --- .../sidebar/labels_select/dropdown_header_spec.js | 39 -- .../labels_select/dropdown_search_input_spec.js | 39 -- .../sidebar/labels_select/dropdown_title_spec.js | 41 -- .../labels_select/dropdown_value_collapsed_spec.js | 95 ---- .../sidebar/labels_select/dropdown_value_spec.js | 84 ---- .../components/sidebar/labels_select/mock_data.js | 57 --- .../dropdown_value_collapsed_spec.js | 95 ++++ .../labels_select_vue/labels_select_root_spec.js | 2 +- .../sidebar/labels_select_vue/mock_data.js | 17 + .../components/user_callout_dismisser_mock_data.js | 30 ++ .../components/user_callout_dismisser_spec.js | 306 +++++++++++++ .../vue_shared/components/user_select_spec.js | 15 +- .../vue_shared/components/web_ide_link_spec.js | 4 +- spec/frontend/whats_new/components/feature_spec.js | 12 +- 424 files changed, 15259 insertions(+), 7047 deletions(-) create mode 100644 spec/frontend/__helpers__/mock_user_callout_dismisser.js create mode 100644 spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap create mode 100644 spec/frontend/alerts_settings/components/alerts_form_spec.js create mode 100644 spec/frontend/blob/components/table_contents_spec.js delete mode 100644 spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js delete mode 100644 spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js create mode 100644 spec/frontend/branches/components/delete_branch_button_spec.js create mode 100644 spec/frontend/branches/components/delete_branch_modal_spec.js delete mode 100644 spec/frontend/clusters/components/fluentd_output_settings_spec.js delete mode 100644 spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js delete mode 100644 spec/frontend/commit/pipelines/pipelines_spec.js create mode 100644 spec/frontend/commit/pipelines/pipelines_table_spec.js create mode 100644 spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap create mode 100644 spec/frontend/content_editor/components/toolbar_link_button_spec.js create mode 100644 spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js create mode 100644 spec/frontend/content_editor/extensions/code_block_highlight_spec.js create mode 100644 spec/frontend/content_editor/extensions/link_spec.js create mode 100644 spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap create mode 100644 spec/frontend/cycle_analytics/base_spec.js create mode 100644 spec/frontend/cycle_analytics/path_navigation_spec.js create mode 100644 spec/frontend/cycle_analytics/store/getters_spec.js create mode 100644 spec/frontend/diffs/utils/workers_spec.js create mode 100644 spec/frontend/feature_flags/components/empty_state_spec.js delete mode 100644 spec/frontend/feature_flags/components/feature_flags_tab_spec.js delete mode 100644 spec/frontend/feature_flags/components/user_lists_table_spec.js create mode 100644 spec/frontend/fixtures/runner.rb create mode 100644 spec/frontend/fixtures/startup_css.rb delete mode 100644 spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap delete mode 100644 spec/frontend/incidents_settings/components/alerts_form_spec.js create mode 100644 spec/frontend/issue_show/components/fields/type_spec.js delete mode 100644 spec/frontend/issue_show/mock_data.js create mode 100644 spec/frontend/issue_show/mock_data/apollo_mock.js create mode 100644 spec/frontend/issue_show/mock_data/mock_data.js create mode 100644 spec/frontend/lib/utils/table_utility_spec.js create mode 100644 spec/frontend/nav/components/responsive_app_spec.js create mode 100644 spec/frontend/nav/components/responsive_header_spec.js create mode 100644 spec/frontend/nav/components/responsive_home_spec.js create mode 100644 spec/frontend/nav/components/top_nav_menu_sections_spec.js create mode 100644 spec/frontend/nav/components/top_nav_new_dropdown_spec.js create mode 100644 spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap create mode 100644 spec/frontend/packages/details/components/file_sha_spec.js create mode 100644 spec/frontend/packages_and_registries/infrastructure_registry/components/__snapshots__/terraform_installation_spec.js.snap create mode 100644 spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js create mode 100644 spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js create mode 100644 spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap create mode 100644 spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js create mode 100644 spec/frontend/pages/shared/nav/sidebar_tracking_spec.js create mode 100644 spec/frontend/pages/users/activity_calendar_spec.js delete mode 100644 spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js delete mode 100644 spec/frontend/pipelines/notification/pipeline_notification_spec.js create mode 100644 spec/frontend/releases/components/app_index_apollo_client_spec.js create mode 100644 spec/frontend/releases/components/releases_empty_state_spec.js create mode 100644 spec/frontend/releases/components/releases_pagination_apollo_client_spec.js create mode 100644 spec/frontend/releases/components/releases_sort_apollo_client_spec.js create mode 100644 spec/frontend/repository/components/blob_header_edit_spec.js create mode 100644 spec/frontend/repository/components/blob_replace_spec.js create mode 100644 spec/frontend/runner/components/cells/runner_actions_cell_spec.js create mode 100644 spec/frontend/runner/components/cells/runner_name_cell_spec.js create mode 100644 spec/frontend/runner/components/cells/runner_type_cell_spec.js create mode 100644 spec/frontend/runner/components/runner_filtered_search_bar_spec.js create mode 100644 spec/frontend/runner/components/runner_list_spec.js create mode 100644 spec/frontend/runner/components/runner_manual_setup_help_spec.js create mode 100644 spec/frontend/runner/components/runner_pagination_spec.js create mode 100644 spec/frontend/runner/components/runner_tags_spec.js create mode 100644 spec/frontend/runner/components/runner_type_alert_spec.js create mode 100644 spec/frontend/runner/components/runner_type_help_spec.js create mode 100644 spec/frontend/runner/components/runner_update_form_spec.js create mode 100644 spec/frontend/runner/mock_data.js create mode 100644 spec/frontend/runner/runner_list/runner_list_app_spec.js create mode 100644 spec/frontend/runner/runner_list/runner_search_utils_spec.js create mode 100644 spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js create mode 100644 spec/frontend/security_configuration/components/redesigned_app_spec.js create mode 100644 spec/frontend/security_configuration/components/section_layout_spec.js create mode 100644 spec/frontend/security_configuration/components/upgrade_banner_spec.js create mode 100644 spec/frontend/security_configuration/utils_spec.js create mode 100644 spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js create mode 100644 spec/frontend/sidebar/track_invite_members_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js create mode 100644 spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js create mode 100644 spec/frontend/tracking/get_standard_context_spec.js create mode 100644 spec/frontend/user_lists/components/user_lists_spec.js create mode 100644 spec/frontend/user_lists/components/user_lists_table_spec.js create mode 100644 spec/frontend/user_lists/store/index/actions_spec.js create mode 100644 spec/frontend/user_lists/store/index/mutations_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_softbreak_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js create mode 100644 spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap create mode 100644 spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js create mode 100644 spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js create mode 100644 spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js delete mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js create mode 100644 spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js create mode 100644 spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js create mode 100644 spec/frontend/vue_shared/components/user_callout_dismisser_spec.js (limited to 'spec/frontend') diff --git a/spec/frontend/__helpers__/mock_user_callout_dismisser.js b/spec/frontend/__helpers__/mock_user_callout_dismisser.js new file mode 100644 index 00000000000..652f36028dc --- /dev/null +++ b/spec/frontend/__helpers__/mock_user_callout_dismisser.js @@ -0,0 +1,16 @@ +/** + * Mock factory for the UserCalloutDismisser component. + * @param {slotProps} The slot props to pass to the default slot content. + * @returns {VueComponent} + */ +export const makeMockUserCalloutDismisser = ({ + dismiss = () => {}, + shouldShowCallout = true, +} = {}) => ({ + render() { + return this.$scopedSlots.default({ + dismiss, + shouldShowCallout, + }); + }, +}); diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js index a94cee84f74..2aae91f8a39 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper.js @@ -1,5 +1,5 @@ import * as testingLibrary from '@testing-library/dom'; -import { createWrapper, WrapperArray, mount, shallowMount } from '@vue/test-utils'; +import { createWrapper, WrapperArray, ErrorWrapper, mount, shallowMount } from '@vue/test-utils'; import { isArray, upperFirst } from 'lodash'; const vNodeContainsText = (vnode, text) => @@ -81,14 +81,9 @@ export const extendedWrapper = (wrapper) => { options, ); - // Return VTU `ErrorWrapper` if element is not found - // https://github.com/vuejs/vue-test-utils/blob/dev/packages/test-utils/src/error-wrapper.js - // VTU does not expose `ErrorWrapper` so, as of now, this is the best way to - // create an `ErrorWrapper` + // Element not found, return an `ErrorWrapper` if (!elements.length) { - const emptyElement = document.createElement('div'); - - return createWrapper(emptyElement).find('testing-library-element-not-found'); + return new ErrorWrapper(query); } return createWrapper(elements[0], this.options || {}); diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js index dfe5a483223..3bb228f94b8 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js @@ -4,6 +4,7 @@ import { shallowMount, Wrapper as VTUWrapper, WrapperArray as VTUWrapperArray, + ErrorWrapper as VTUErrorWrapper, } from '@vue/test-utils'; import { extendedWrapper, @@ -195,7 +196,7 @@ describe('Vue test utils helpers', () => { }); it('returns a VTU error wrapper', () => { - expect(wrapper[findMethod](text, options).exists()).toBe(false); + expect(wrapper[findMethod](text, options)).toBeInstanceOf(VTUErrorWrapper); }); }); }); diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index 826fb820d9b..20e8bc059ec 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -7,7 +7,6 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import mockAlerts from 'jest/vue_shared/alert_details/mocks/alerts.json'; import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; import { visitUrl } from '~/lib/utils/url_utility'; -import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import defaultProvideValues from '../mocks/alerts_provide_config.json'; @@ -41,8 +40,7 @@ describe('AlertManagementTable', () => { resolved: 11, all: 26, }; - const findDeprecationNotice = () => - wrapper.findComponent(AlertDeprecationWarning).findComponent(GlAlert); + const findDeprecationNotice = () => wrapper.findByTestId('alerts-deprecation-warning'); function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) { wrapper = extendedWrapper( @@ -239,19 +237,21 @@ describe('AlertManagementTable', () => { expect(visitUrl).toHaveBeenCalledWith('/1527542/details', true); }); - describe('deprecation notice', () => { - it('shows the deprecation notice when available', () => { - mountComponent({ provide: { hasManagedPrometheus: true } }); - - expect(findDeprecationNotice().exists()).toBe(true); - }); - - it('hides the deprecation notice when not available', () => { - mountComponent(); - - expect(findDeprecationNotice().exists()).toBe(false); - }); - }); + it.each` + managedAlertsDeprecation | hasManagedPrometheus | isVisible + ${false} | ${false} | ${false} + ${false} | ${true} | ${true} + ${true} | ${false} | ${false} + ${true} | ${true} | ${false} + `( + 'when the deprecation feature flag is $managedAlertsDeprecation and has managed prometheus is $hasManagedPrometheus', + ({ hasManagedPrometheus, managedAlertsDeprecation, isVisible }) => { + mountComponent({ + provide: { hasManagedPrometheus, glFeatures: { managedAlertsDeprecation } }, + }); + expect(findDeprecationNotice().exists()).toBe(isVisible); + }, + ); describe('alert issue links', () => { beforeEach(() => { diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap new file mode 100644 index 00000000000..3a374084dbc --- /dev/null +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Alert integration settings form default state should match the default snapshot 1`] = ` +
+

+ +

+ +
+ + + + Create an incident. Incidents are created for each alert triggered. + + + + + + + + + + + No template selected + + + + + + + + + Send a single email notification to Owners and Maintainers for new alerts. + + + + + + + + Automatically close associated incident when a recovery alert notification resolves an alert + + + + + + + Save changes + + +
+
+`; diff --git a/spec/frontend/alerts_settings/components/alerts_form_spec.js b/spec/frontend/alerts_settings/components/alerts_form_spec.js new file mode 100644 index 00000000000..a045954dfb8 --- /dev/null +++ b/spec/frontend/alerts_settings/components/alerts_form_spec.js @@ -0,0 +1,50 @@ +import { shallowMount } from '@vue/test-utils'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_form.vue'; + +describe('Alert integration settings form', () => { + let wrapper; + const service = { updateSettings: jest.fn().mockResolvedValue() }; + + const findForm = () => wrapper.find({ ref: 'settingsForm' }); + + beforeEach(() => { + wrapper = shallowMount(AlertsSettingsForm, { + provide: { + service, + alertSettings: { + issueTemplateKey: 'selecte_tmpl', + createIssue: true, + sendEmail: false, + templates: [], + autoCloseIncident: true, + }, + }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('default state', () => { + it('should match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('form', () => { + it('should call service `updateSettings` on submit', () => { + findForm().trigger('submit'); + expect(service.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + create_issue: wrapper.vm.createIssueEnabled, + issue_template_key: wrapper.vm.issueTemplate, + send_email: wrapper.vm.sendEmailEnabled, + auto_close_incident: wrapper.vm.autoCloseIncident, + }), + ); + }); + }); +}); diff --git a/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js index c43d78a1cf3..3ffbb7ab60a 100644 --- a/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js @@ -80,7 +80,7 @@ describe('AlertIntegrationsList', () => { const cell = finsStatusCell().at(0); const activatedIcon = cell.find(GlIcon); expect(cell.text()).toBe(i18n.status.enabled.name); - expect(activatedIcon.attributes('name')).toBe('check-circle-filled'); + expect(activatedIcon.attributes('name')).toBe('check'); expect(activatedIcon.attributes('title')).toBe(i18n.status.enabled.tooltip); }); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 595c3f1a289..1c4dde39585 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -95,6 +95,10 @@ describe('AlertsSettingsWrapper', () => { }, provide: { ...provide, + alertSettings: { + templates: [], + }, + service: {}, }, mocks: { $apollo: { @@ -129,12 +133,17 @@ describe('AlertsSettingsWrapper', () => { wrapper = mount(AlertsSettingsWrapper, { localVue, apolloProvider: fakeApollo, + provide: { + alertSettings: { + templates: [], + }, + service: {}, + }, }); } afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('template', () => { diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 139128e6d4a..f708d8c7728 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -116,6 +116,24 @@ describe('Api', () => { }); }); }); + + describe('deleteProjectPackageFile', () => { + const packageFileId = 'package_file_id'; + + it('delete a package', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages/${packageId}/package_files/${packageFileId}`; + + jest.spyOn(axios, 'delete'); + mock.onDelete(expectedUrl).replyOnce(httpStatus.OK, true); + + return Api.deleteProjectPackageFile(projectId, packageId, packageFileId).then( + ({ data }) => { + expect(data).toEqual(true); + expect(axios.delete).toHaveBeenCalledWith(expectedUrl); + }, + ); + }); + }); }); describe('container registry', () => { @@ -1503,33 +1521,55 @@ describe('Api', () => { 'Content-Type': 'application/json', }; - describe('when usage data increment unique users is called with feature flag disabled', () => { + describe('when user is set', () => { beforeEach(() => { - gon.features = { ...gon.features, usageDataApi: false }; + window.gon.current_user_id = 1; }); - it('returns null', () => { - jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true); + describe('when usage data increment unique users is called with feature flag disabled', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: false }; + }); - expect(axios.post).toHaveBeenCalledTimes(0); - expect(Api.trackRedisHllUserEvent(event)).toEqual(null); + it('returns null and does not call the endpoint', () => { + jest.spyOn(axios, 'post'); + + const result = Api.trackRedisHllUserEvent(event); + + expect(result).toEqual(null); + expect(axios.post).toHaveBeenCalledTimes(0); + }); + }); + + describe('when usage data increment unique users is called', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: true }; + }); + + it('resolves the Promise', () => { + jest.spyOn(axios, 'post'); + mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); + + return Api.trackRedisHllUserEvent(event).then(({ data }) => { + expect(data).toEqual(true); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers }); + }); + }); }); }); - describe('when usage data increment unique users is called', () => { + describe('when user is not set and feature flag enabled', () => { beforeEach(() => { gon.features = { ...gon.features, usageDataApi: true }; }); - it('resolves the Promise', () => { + it('returns null and does not call the endpoint', () => { jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); - return Api.trackRedisHllUserEvent(event).then(({ data }) => { - expect(data).toEqual(true); - expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers }); - }); + const result = Api.trackRedisHllUserEvent(event); + + expect(result).toEqual(null); + expect(axios.post).toHaveBeenCalledTimes(0); }); }); }); diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js index 03a28ce8001..cb71edd1238 100644 --- a/spec/frontend/batch_comments/components/preview_item_spec.js +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -104,7 +104,7 @@ describe('Batch comments draft preview item component', () => { notes: [ { author: { - name: 'Author Name', + name: "Author 'Nick' Name", }, }, ], @@ -114,7 +114,7 @@ describe('Batch comments draft preview item component', () => { it('renders title', () => { expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain( - "Author Name's thread", + "Author 'Nick' Name's thread", ); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index da19265ce82..b0e9e5dd00b 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -139,9 +139,14 @@ describe('Batch comments store actions', () => { it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', (done) => { const commit = jest.fn(); + const dispatch = jest.fn(); const context = { getters, commit, + dispatch, + state: { + drafts: [{ line_code: '123' }, { line_code: null, discussion_id: '1' }], + }, }; res = { id: 1 }; mock.onAny().reply(200, res); @@ -150,6 +155,7 @@ describe('Batch comments store actions', () => { .fetchDrafts(context) .then(() => { expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); + expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true }); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/behaviors/shortcuts/keybindings_spec.js b/spec/frontend/behaviors/shortcuts/keybindings_spec.js index 53ce06e78c6..3ad44a16ae1 100644 --- a/spec/frontend/behaviors/shortcuts/keybindings_spec.js +++ b/spec/frontend/behaviors/shortcuts/keybindings_spec.js @@ -5,6 +5,7 @@ import { getCustomizations, keybindingGroups, TOGGLE_PERFORMANCE_BAR, + HIDE_APPEARING_CONTENT, LOCAL_STORAGE_KEY, WEB_IDE_COMMIT, } from '~/behaviors/shortcuts/keybindings'; @@ -95,4 +96,14 @@ describe('~/behaviors/shortcuts/keybindings', () => { expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']); }); }); + + describe('when tooltips or popovers are visible', () => { + beforeEach(() => { + setupCustomizations(); + }); + + it('returns the default keybinding for the command', () => { + expect(keysFor(HIDE_APPEARING_CONTENT)).toEqual(['esc']); + }); + }); }); diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js new file mode 100644 index 00000000000..09633dc5d5d --- /dev/null +++ b/spec/frontend/blob/components/table_contents_spec.js @@ -0,0 +1,67 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import TableContents from '~/blob/components/table_contents.vue'; + +let wrapper; + +function createComponent() { + wrapper = shallowMount(TableContents); +} + +async function setLoaded(loaded) { + document.querySelector('.blob-viewer').setAttribute('data-loaded', loaded); + + await nextTick(); +} + +describe('Markdown table of contents component', () => { + beforeEach(() => { + setFixtures(` +
+

Hello

+

World

+

Testing

+

GitLab

+
+ `); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('not loaded', () => { + it('does not populate dropdown', () => { + createComponent(); + + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(false); + }); + }); + + describe('loaded', () => { + it('populates dropdown', async () => { + createComponent(); + + await setLoaded(true); + + const dropdownItems = wrapper.findAllComponents(GlDropdownItem); + + expect(dropdownItems.exists()).toBe(true); + expect(dropdownItems.length).toBe(4); + }); + + it('sets padding for dropdown items', async () => { + createComponent(); + + await setLoaded(true); + + const dropdownLinks = wrapper.findAll('[data-testid="tableContentsLink"]'); + + expect(dropdownLinks.at(0).element.style.paddingLeft).toBe('0px'); + expect(dropdownLinks.at(1).element.style.paddingLeft).toBe('8px'); + expect(dropdownLinks.at(2).element.style.paddingLeft).toBe('16px'); + expect(dropdownLinks.at(3).element.style.paddingLeft).toBe('8px'); + }); + }); +}); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 36043b09636..15ea5d4eec4 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,4 +1,4 @@ -import { GlLabel } from '@gitlab/ui'; +import { GlLabel, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { range } from 'lodash'; import Vuex from 'vuex'; @@ -63,6 +63,7 @@ describe('Board card component', () => { }, stubs: { GlLabel: true, + GlLoadingIcon: true, }, mocks: { $apollo: { @@ -121,6 +122,10 @@ describe('Board card component', () => { expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); }); + it('does not render loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + }); + describe('blocked', () => { it('renders blocked icon if issue is blocked', async () => { createWrapper({ @@ -399,4 +404,17 @@ describe('Board card component', () => { }); }); }); + + describe('loading', () => { + it('renders loading icon', async () => { + createWrapper({ + item: { + ...issue, + isLoading: true, + }, + }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index bf39c3f3e42..76629c96f22 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -80,6 +80,7 @@ const createComponent = ({ rootPath: '/', weightFeatureAvailable: false, boardWeight: null, + canAdminList: true, }, stubs: { BoardCard, @@ -181,12 +182,6 @@ describe('Board list component', () => { }); }); - it('loads more issues after scrolling', () => { - wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - - expect(actions.fetchItemsForList).toHaveBeenCalled(); - }); - it('does not load issues if already loading', () => { wrapper = createComponent({ state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js index 0feb1411003..289905a1948 100644 --- a/spec/frontend/boards/boards_util_spec.js +++ b/spec/frontend/boards/boards_util_spec.js @@ -1,17 +1,103 @@ -import { transformNotFilters } from '~/boards/boards_util'; +import { filterVariables } from '~/boards/boards_util'; -describe('transformNotFilters', () => { - const filters = { - 'not[labelName]': ['label'], - 'not[assigneeUsername]': 'assignee', - }; - - it('formats not filters, transforms epicId to fullEpicId', () => { - const result = transformNotFilters(filters); - - expect(result).toEqual({ - labelName: ['label'], - assigneeUsername: 'assignee', +describe('filterVariables', () => { + it.each([ + [ + 'correctly processes array filter values', + { + filters: { + 'not[filterA]': ['val1', 'val2'], + }, + expected: { + not: { + filterA: ['val1', 'val2'], + }, + }, + }, + ], + [ + "renames a filter if 'remap' method is available", + { + filters: { + filterD: 'some value', + }, + expected: { + filterA: 'some value', + not: {}, + }, + }, + ], + [ + 'correctly processes a negated filter that supports negation', + { + filters: { + 'not[filterA]': 'some value 1', + 'not[filterB]': 'some value 2', + }, + expected: { + not: { + filterA: 'some value 1', + }, + }, + }, + ], + [ + 'correctly removes an unsupported filter depending on issuableType', + { + issuableType: 'epic', + filters: { + filterA: 'some value 1', + filterE: 'some value 2', + }, + expected: { + filterE: 'some value 2', + not: {}, + }, + }, + ], + [ + 'applies a transform when the filter value needs to be modified', + { + filters: { + filterC: 'abc', + 'not[filterC]': 'def', + }, + expected: { + filterC: 'ABC', + not: { + filterC: 'DEF', + }, + }, + }, + ], + ])('%s', (_, { filters, issuableType = 'issue', expected }) => { + const result = filterVariables({ + filters, + issuableType, + filterInfo: { + filterA: { + negatedSupport: true, + }, + filterB: { + negatedSupport: false, + }, + filterC: { + negatedSupport: true, + transform: (val) => val.toUpperCase(), + }, + filterD: { + remap: () => 'filterA', + }, + filterE: { + negatedSupport: true, + }, + }, + filterFields: { + issue: ['filterA', 'filterB', 'filterC', 'filterD'], + epic: ['filterE'], + }, }); + + expect(result).toEqual(expected); }); }); diff --git a/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap b/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap index c000f300e4d..3fb0706fd10 100644 --- a/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap +++ b/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`BoardBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = ` -"
+"
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index ceafa6ead94..9a9ce7b8dc1 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -1,5 +1,6 @@ import { GlLabel } from '@gitlab/ui'; -import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import BoardCard from '~/boards/components/board_card.vue'; @@ -12,8 +13,7 @@ describe('Board card', () => { let store; let mockActions; - const localVue = createLocalVue(); - localVue.use(Vuex); + Vue.use(Vuex); const createStore = ({ initialState = {} } = {}) => { mockActions = { @@ -41,14 +41,14 @@ describe('Board card', () => { provide = {}, mountFn = shallowMount, stubs = { BoardCardInner }, + item = mockIssue, } = {}) => { wrapper = mountFn(BoardCard, { - localVue, stubs, store, propsData: { list: mockLabelList, - item: mockIssue, + item, disabled: false, index: 0, ...propsData, @@ -72,6 +72,10 @@ describe('Board card', () => { await wrapper.vm.$nextTick(); }; + beforeEach(() => { + window.gon = { features: {} }; + }); + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -140,6 +144,10 @@ describe('Board card', () => { }); describe('when using multi-select', () => { + beforeEach(() => { + window.gon = { features: { boardMultiSelect: true } }; + }); + it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => { await multiSelectCard(); @@ -151,4 +159,24 @@ describe('Board card', () => { }); }); }); + + describe('when card is loading', () => { + it('card is disabled and user cannot drag', () => { + createStore(); + mountComponent({ item: { ...mockIssue, isLoading: true } }); + + expect(wrapper.classes()).toContain('is-disabled'); + expect(wrapper.classes()).not.toContain('user-can-drag'); + }); + }); + + describe('when card is not loading', () => { + it('user can drag', () => { + createStore(); + mountComponent(); + + expect(wrapper.classes()).not.toContain('is-disabled'); + expect(wrapper.classes()).toContain('user-can-drag'); + }); + }); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 01c99a02db2..10d739c65f5 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -1,13 +1,13 @@ import { GlDrawer } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; +import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import { stubComponent } from 'helpers/stub_component'; import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; -import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; -import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; +import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; @@ -68,6 +68,9 @@ describe('BoardContentSidebar', () => { iterations: { loading: false, }, + attributesList: { + loading: false, + }, }, }, }, @@ -84,38 +87,41 @@ describe('BoardContentSidebar', () => { }); it('confirms we render GlDrawer', () => { - expect(wrapper.find(GlDrawer).exists()).toBe(true); + expect(wrapper.findComponent(GlDrawer).exists()).toBe(true); }); it('does not render GlDrawer when isSidebarOpen is false', () => { createStore({ mockGetters: { isSidebarOpen: () => false } }); createComponent(); - expect(wrapper.find(GlDrawer).exists()).toBe(false); + expect(wrapper.findComponent(GlDrawer).exists()).toBe(false); }); it('applies an open attribute', () => { - expect(wrapper.find(GlDrawer).props('open')).toBe(true); + expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true); }); it('renders BoardSidebarLabelsSelect', () => { - expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true); + expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true); }); it('renders BoardSidebarTitle', () => { - expect(wrapper.find(BoardSidebarTitle).exists()).toBe(true); + expect(wrapper.findComponent(BoardSidebarTitle).exists()).toBe(true); }); - it('renders BoardSidebarDueDate', () => { - expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true); + it('renders SidebarDateWidget', () => { + expect(wrapper.findComponent(SidebarDateWidget).exists()).toBe(true); }); it('renders BoardSidebarSubscription', () => { - expect(wrapper.find(SidebarSubscriptionsWidget).exists()).toBe(true); + expect(wrapper.findComponent(SidebarSubscriptionsWidget).exists()).toBe(true); }); - it('renders BoardSidebarMilestoneSelect', () => { - expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true); + it('renders SidebarDropdownWidget for milestones', () => { + expect(wrapper.findComponent(SidebarDropdownWidget).exists()).toBe(true); + expect(wrapper.findComponent(SidebarDropdownWidget).props('issuableAttribute')).toEqual( + 'milestone', + ); }); describe('when we emit close', () => { @@ -128,7 +134,7 @@ describe('BoardContentSidebar', () => { }); it('calls toggleBoardItem with correct parameters', async () => { - wrapper.find(GlDrawer).vm.$emit('close'); + wrapper.findComponent(GlDrawer).vm.$emit('close'); expect(toggleBoardItem).toHaveBeenCalledTimes(1); expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index e27badca9de..6ac5d16e5a3 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -105,9 +105,9 @@ describe('BoardFilteredSearch', () => { beforeEach(() => { store = createStore(); - jest.spyOn(store, 'dispatch'); - createComponent(); + + jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(); }); it('sets the url params to the correct results', async () => { diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 24fcdd528d5..80d740458dc 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -9,14 +9,12 @@ import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql' import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql'; import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql'; import { createStore } from '~/boards/stores'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), stripFinalUrlSegment: jest.requireActual('~/lib/utils/url_utility').stripFinalUrlSegment, })); -jest.mock('~/flash'); const currentBoard = { id: 1, @@ -194,9 +192,11 @@ describe('BoardForm', () => { expect(visitUrl).toHaveBeenCalledWith('test-path'); }); - it('shows an error flash if GraphQL mutation fails', async () => { + it('shows a GlAlert if GraphQL mutation fails', async () => { mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); createComponent({ canAdminBoard: true, currentPage: formType.new }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); + fillForm(); await waitForPromises(); @@ -205,7 +205,7 @@ describe('BoardForm', () => { await waitForPromises(); expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); }); @@ -290,9 +290,11 @@ describe('BoardForm', () => { expect(visitUrl).toHaveBeenCalledWith('test-path?group_by=epic'); }); - it('shows an error flash if GraphQL mutation fails', async () => { + it('shows a GlAlert if GraphQL mutation fails', async () => { mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); createComponent({ canAdminBoard: true, currentPage: formType.edit }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); + findInput().trigger('keyup.enter', { metaKey: true }); await waitForPromises(); @@ -301,7 +303,7 @@ describe('BoardForm', () => { await waitForPromises(); expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); @@ -335,9 +337,11 @@ describe('BoardForm', () => { expect(visitUrl).toHaveBeenCalledWith('root'); }); - it('shows an error flash if GraphQL mutation fails', async () => { + it('dispatches `setError` action when GraphQL mutation fails', async () => { mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); createComponent({ canAdminBoard: true, currentPage: formType.delete }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); + findModal().vm.$emit('primary'); await waitForPromises(); @@ -346,7 +350,7 @@ describe('BoardForm', () => { await waitForPromises(); expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_deprecated_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js index fdc7cd2b1d4..db79e67fe78 100644 --- a/spec/frontend/boards/components/board_list_header_deprecated_spec.js +++ b/spec/frontend/boards/components/board_list_header_deprecated_spec.js @@ -31,6 +31,7 @@ describe('Board List Header Component', () => { listType = ListType.backlog, collapsed = false, withLocalStorage = true, + currentUserId = 1, } = {}) => { const boardId = '1'; @@ -62,6 +63,7 @@ describe('Board List Header Component', () => { }, provide: { boardId, + currentUserId, }, }); }; @@ -100,10 +102,12 @@ describe('Board List Header Component', () => { }); }); - it('does render when logged out', () => { - createComponent(); + it('does not render when logged out', () => { + createComponent({ + currentUserId: null, + }); - expect(findAddIssueButton().exists()).toBe(true); + expect(findAddIssueButton().exists()).toBe(false); }); }); @@ -143,7 +147,6 @@ describe('Board List Header Component', () => { it("when logged in it calls list update and doesn't set localStorage", () => { jest.spyOn(List.prototype, 'update'); - window.gon.current_user_id = 1; createComponent({ withLocalStorage: false }); @@ -158,7 +161,7 @@ describe('Board List Header Component', () => { it("when logged out it doesn't call list update and sets localStorage", () => { jest.spyOn(List.prototype, 'update'); - createComponent(); + createComponent({ currentUserId: null }); findCaret().vm.$emit('click'); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index d2dfb4148b3..0abb00e0fa5 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -28,7 +28,7 @@ describe('Board List Header Component', () => { listType = ListType.backlog, collapsed = false, withLocalStorage = true, - currentUserId = null, + currentUserId = 1, } = {}) => { const boardId = '1'; @@ -109,10 +109,12 @@ describe('Board List Header Component', () => { }); }); - it('does render when logged out', () => { - createComponent(); + it('does not render when logged out', () => { + createComponent({ + currentUserId: null, + }); - expect(findAddIssueButton().exists()).toBe(true); + expect(findAddIssueButton().exists()).toBe(false); }); }); @@ -153,7 +155,9 @@ describe('Board List Header Component', () => { }); it("when logged out it doesn't call list update and sets localStorage", async () => { - createComponent(); + createComponent({ + currentUserId: null, + }); findCaret().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js deleted file mode 100644 index 8fd178a0856..00000000000 --- a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js +++ /dev/null @@ -1,137 +0,0 @@ -import { GlDatepicker } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; -import { createStore } from '~/boards/stores'; -import createFlash from '~/flash'; - -const TEST_DUE_DATE = '2020-02-20'; -const TEST_FORMATTED_DUE_DATE = 'Feb 20, 2020'; -const TEST_PARSED_DATE = new Date(2020, 1, 20); -const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, dueDate: null, referencePath: 'h/b#2' }; - -jest.mock('~/flash'); - -describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { - let wrapper; - let store; - - afterEach(() => { - wrapper.destroy(); - store = null; - wrapper = null; - }); - - const createWrapper = ({ dueDate = null } = {}) => { - store = createStore(); - store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } }; - store.state.activeId = TEST_ISSUE.id; - - wrapper = shallowMount(BoardSidebarDueDate, { - store, - provide: { - canUpdate: true, - }, - stubs: { - 'board-editable-item': BoardEditableItem, - }, - }); - }; - - const findDatePicker = () => wrapper.find(GlDatepicker); - const findResetButton = () => wrapper.find('[data-testid="reset-button"]'); - const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); - - it('renders "None" when no due date is set', () => { - createWrapper(); - - expect(findCollapsed().text()).toBe('None'); - expect(findResetButton().exists()).toBe(false); - }); - - it('renders formatted due date with reset button when set', () => { - createWrapper({ dueDate: TEST_DUE_DATE }); - - expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); - expect(findResetButton().exists()).toBe(true); - }); - - describe('when due date is submitted', () => { - beforeEach(async () => { - createWrapper(); - - jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].dueDate = TEST_DUE_DATE; - }); - findDatePicker().vm.$emit('input', TEST_PARSED_DATE); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders formatted due date with reset button', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); - expect(findResetButton().exists()).toBe(true); - }); - - it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalledWith({ - dueDate: TEST_DUE_DATE, - projectPath: 'h/b', - }); - }); - }); - - describe('when due date is cleared', () => { - beforeEach(async () => { - createWrapper(); - - jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].dueDate = null; - }); - findDatePicker().vm.$emit('clear'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders "None"', () => { - expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled(); - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toBe('None'); - }); - }); - - describe('when due date is resetted', () => { - beforeEach(async () => { - createWrapper({ dueDate: TEST_DUE_DATE }); - - jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].dueDate = null; - }); - findResetButton().vm.$emit('click'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders "None"', () => { - expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled(); - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toBe('None'); - }); - }); - - describe('when the mutation fails', () => { - beforeEach(async () => { - createWrapper({ dueDate: TEST_DUE_DATE }); - - jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - throw new Error(['failed mutation']); - }); - findDatePicker().vm.$emit('input', 'Invalid date'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders former issue due date', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); - expect(createFlash).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index ad682774ee6..8992a5780f3 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -9,11 +9,8 @@ import { import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import { createStore } from '~/boards/stores'; -import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -jest.mock('~/flash'); - const TEST_LABELS_PAYLOAD = TEST_LABELS.map((label) => ({ ...label, set: true })); const TEST_LABELS_TITLES = TEST_LABELS.map((label) => label.title); @@ -154,6 +151,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => { throw new Error(['failed mutation']); }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]); await wrapper.vm.$nextTick(); }); @@ -161,7 +159,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { it('collapses sidebar and renders former issue weight', () => { expect(findCollapsed().isVisible()).toBe(true); expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js deleted file mode 100644 index 8706424a296..00000000000 --- a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js +++ /dev/null @@ -1,178 +0,0 @@ -import { GlLoadingIcon, GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { mockMilestone as TEST_MILESTONE } from 'jest/boards/mock_data'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; -import { createStore } from '~/boards/stores'; -import createFlash from '~/flash'; - -const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, referencePath: 'h/b#2' }; - -jest.mock('~/flash'); - -describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => { - let wrapper; - let store; - - afterEach(() => { - wrapper.destroy(); - store = null; - wrapper = null; - }); - - const createWrapper = ({ milestone = null, loading = false } = {}) => { - store = createStore(); - store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } }; - store.state.activeId = TEST_ISSUE.id; - - wrapper = shallowMount(BoardSidebarMilestoneSelect, { - store, - provide: { - canUpdate: true, - }, - data: () => ({ - milestones: [TEST_MILESTONE], - }), - stubs: { - 'board-editable-item': BoardEditableItem, - }, - mocks: { - $apollo: { - loading, - }, - }, - }); - }; - - const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); - const findLoader = () => wrapper.find(GlLoadingIcon); - const findDropdown = () => wrapper.find(GlDropdown); - const findBoardEditableItem = () => wrapper.find(BoardEditableItem); - const findDropdownItem = () => wrapper.find('[data-testid="milestone-item"]'); - const findUnsetMilestoneItem = () => wrapper.find('[data-testid="no-milestone-item"]'); - const findNoMilestonesFoundItem = () => wrapper.find('[data-testid="no-milestones-found"]'); - - describe('when not editing', () => { - it('opens the milestone dropdown on clicking edit', async () => { - createWrapper(); - wrapper.vm.$refs.dropdown.show = jest.fn(); - - await findBoardEditableItem().vm.$emit('open'); - - expect(wrapper.vm.$refs.dropdown.show).toHaveBeenCalledTimes(1); - }); - }); - - describe('when editing', () => { - beforeEach(() => { - createWrapper(); - jest.spyOn(wrapper.vm.$refs.sidebarItem, 'collapse'); - }); - - it('collapses BoardEditableItem on clicking edit', async () => { - await findBoardEditableItem().vm.$emit('close'); - - expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1); - }); - - it('collapses BoardEditableItem on hiding dropdown', async () => { - await findDropdown().vm.$emit('hide'); - - expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1); - }); - }); - - it('renders "None" when no milestone is selected', () => { - createWrapper(); - - expect(findCollapsed().text()).toBe('None'); - }); - - it('renders milestone title when set', () => { - createWrapper({ milestone: TEST_MILESTONE }); - - expect(findCollapsed().text()).toContain(TEST_MILESTONE.title); - }); - - it('shows loader while Apollo is loading', async () => { - createWrapper({ milestone: TEST_MILESTONE, loading: true }); - - expect(findLoader().exists()).toBe(true); - }); - - it('shows message when error or no milestones found', async () => { - createWrapper(); - - await wrapper.setData({ milestones: [] }); - - expect(findNoMilestonesFoundItem().text()).toBe('No milestones found'); - }); - - describe('when milestone is selected', () => { - beforeEach(async () => { - createWrapper(); - - jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].milestone = TEST_MILESTONE; - }); - findDropdownItem().vm.$emit('click'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders selected milestone', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toContain(TEST_MILESTONE.title); - }); - - it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueMilestone).toHaveBeenCalledWith({ - milestoneId: TEST_MILESTONE.id, - projectPath: 'h/b', - }); - }); - }); - - describe('when milestone is set to "None"', () => { - beforeEach(async () => { - createWrapper({ milestone: TEST_MILESTONE }); - - jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].milestone = null; - }); - findUnsetMilestoneItem().vm.$emit('click'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders "None"', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toBe('None'); - }); - - it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueMilestone).toHaveBeenCalledWith({ - milestoneId: null, - projectPath: 'h/b', - }); - }); - }); - - describe('when the mutation fails', () => { - const testMilestone = { id: '1', title: 'Former milestone' }; - - beforeEach(async () => { - createWrapper({ milestone: testMilestone }); - - jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - throw new Error(['failed mutation']); - }); - findDropdownItem().vm.$emit('click'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders former milestone', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toContain(testMilestone.title); - expect(createFlash).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js index 7976e73ff2f..8847f626c1f 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js @@ -5,11 +5,8 @@ import Vuex from 'vuex'; import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; import { createStore } from '~/boards/stores'; import * as types from '~/boards/stores/mutation_types'; -import createFlash from '~/flash'; import { mockActiveIssue } from '../../mock_data'; -jest.mock('~/flash.js'); - Vue.use(Vuex); describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => { @@ -153,13 +150,15 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => { throw new Error(); }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); findToggle().trigger('click'); await wrapper.vm.$nextTick(); - expect(createFlash).toHaveBeenNthCalledWith(1, { - message: wrapper.vm.$options.i18n.updateSubscribedErrorMessage, - }); + expect(wrapper.vm.setError).toHaveBeenCalled(); + expect(wrapper.vm.setError.mock.calls[0][0].message).toBe( + wrapper.vm.$options.i18n.updateSubscribedErrorMessage, + ); }); }); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js index 03924bfa8d3..74441e147cf 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js @@ -26,6 +26,7 @@ describe('BoardSidebarTimeTracker', () => { store = createStore(); store.state.boardItems = { 1: { + iid: 1, timeEstimate: 3600, totalTimeSpent: 1800, humanTimeEstimate: '1h', @@ -46,12 +47,16 @@ describe('BoardSidebarTimeTracker', () => { createComponent({ provide: { timeTrackingLimitToHours } }); expect(wrapper.find(IssuableTimeTracker).props()).toEqual({ - timeEstimate: 3600, - timeSpent: 1800, - humanTimeEstimate: '1h', - humanTimeSpent: '30min', limitToHours: timeTrackingLimitToHours, showCollapsed: false, + issuableIid: '1', + fullPath: '', + initialTimeTracking: { + timeEstimate: 3600, + totalTimeSpent: 1800, + humanTimeEstimate: '1h', + humanTotalTimeSpent: '30min', + }, }); }, ); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js index c8ccd4c88a5..4a8eda298f2 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js @@ -3,7 +3,6 @@ import { shallowMount } from '@vue/test-utils'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { createStore } from '~/boards/stores'; -import createFlash from '~/flash'; const TEST_TITLE = 'New item title'; const TEST_ISSUE_A = { @@ -19,8 +18,6 @@ const TEST_ISSUE_B = { referencePath: 'h/b#2', }; -jest.mock('~/flash'); - describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { let wrapper; let store; @@ -168,6 +165,7 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => { throw new Error(['failed mutation']); }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); findFormInput().vm.$emit('input', 'Invalid title'); findForm().vm.$emit('submit', { preventDefault: () => {} }); await wrapper.vm.$nextTick(); @@ -176,7 +174,7 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { it('collapses sidebar and renders former item title', () => { expect(findCollapsed().isVisible()).toBe(true); expect(findTitle().text()).toContain(TEST_ISSUE_B.title); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js index 37f519ef5b9..4494de43083 100644 --- a/spec/frontend/boards/project_select_deprecated_spec.js +++ b/spec/frontend/boards/project_select_deprecated_spec.js @@ -5,7 +5,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import ProjectSelect from '~/boards/components/project_select_deprecated.vue'; import { ListType } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import httpStatus from '~/lib/utils/http_status'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; @@ -237,8 +237,10 @@ describe('ProjectSelect component', () => { await searchForProject('foobar'); - expect(flash).toHaveBeenCalledTimes(1); - expect(flash).toHaveBeenCalledWith('Something went wrong while fetching projects'); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while fetching projects', + }); }); describe('with non-empty search result', () => { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 09343b5704f..b28412f2127 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -15,6 +15,7 @@ import { formatIssueInput, formatIssue, getMoveData, + updateListPosition, } from '~/boards/boards_util'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; @@ -29,13 +30,13 @@ import { mockIssue2, rawIssue, mockIssues, - mockMilestone, labels, mockActiveIssue, mockGroupProjects, mockMoveIssueParams, mockMoveState, mockMoveData, + mockList, } from '../mock_data'; jest.mock('~/flash'); @@ -70,27 +71,28 @@ describe('setFilters', () => { [ 'with correct filters as payload', { - filters: { labelName: 'label' }, - updatedFilters: { labelName: 'label', not: {} }, + filters: { labelName: 'label', foobar: 'not-a-filter', search: 'quick brown fox' }, + filterVariables: { labelName: 'label', search: 'quick brown fox', not: {} }, }, ], [ - 'and updates assigneeWildcardId', + "and use 'assigneeWildcardId' as filter variable for 'assigneId' param", { filters: { assigneeId: 'None' }, - updatedFilters: { assigneeWildcardId: 'NONE', not: {} }, + filterVariables: { assigneeWildcardId: 'NONE', not: {} }, }, ], - ])('should commit mutation SET_FILTERS %s', (_, { filters, updatedFilters }) => { + ])('should commit mutation SET_FILTERS %s', (_, { filters, filterVariables }) => { const state = { filters: {}, + issuableType: issuableTypes.issue, }; testAction( actions.setFilters, filters, state, - [{ type: types.SET_FILTERS, payload: updatedFilters }], + [{ type: types.SET_FILTERS, payload: filterVariables }], [], ); }); @@ -373,6 +375,24 @@ describe('createIssueList', () => { }); }); +describe('addList', () => { + const getters = { + getListByTitle: jest.fn().mockReturnValue(mockList), + }; + + it('should commit RECEIVE_ADD_LIST_SUCCESS mutation and dispatch fetchItemsForList action', () => { + testAction({ + action: actions.addList, + payload: mockLists[1], + state: { ...getters }, + expectedMutations: [ + { type: types.RECEIVE_ADD_LIST_SUCCESS, payload: updateListPosition(mockLists[1]) }, + ], + expectedActions: [{ type: 'fetchItemsForList', payload: { listId: mockList.id } }], + }); + }); +}); + describe('fetchLabels', () => { it('should commit mutation RECEIVE_LABELS_SUCCESS on success', async () => { const queryResponse = { @@ -520,7 +540,8 @@ describe('toggleListCollapsed', () => { describe('removeList', () => { let state; - const list = mockLists[0]; + let getters; + const list = mockLists[1]; const listId = list.id; const mutationVariables = { mutation: destroyBoardListMutation, @@ -534,6 +555,9 @@ describe('removeList', () => { boardLists: mockListsById, issuableType: issuableTypes.issue, }; + getters = { + getListByTitle: jest.fn().mockReturnValue(mockList), + }; }); afterEach(() => { @@ -543,13 +567,15 @@ describe('removeList', () => { it('optimistically deletes the list', () => { const commit = jest.fn(); - actions.removeList({ commit, state }, listId); + actions.removeList({ commit, state, getters, dispatch: () => {} }, listId); expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]); }); it('keeps the updated list if remove succeeds', async () => { const commit = jest.fn(); + const dispatch = jest.fn(); + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { destroyBoardList: { @@ -558,17 +584,18 @@ describe('removeList', () => { }, }); - await actions.removeList({ commit, state }, listId); + await actions.removeList({ commit, state, getters, dispatch }, listId); expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]); + expect(dispatch.mock.calls).toEqual([['fetchItemsForList', { listId: mockList.id }]]); }); it('restores the list if update fails', async () => { const commit = jest.fn(); jest.spyOn(gqlClient, 'mutate').mockResolvedValue(Promise.reject()); - await actions.removeList({ commit, state }, listId); + await actions.removeList({ commit, state, getters, dispatch: () => {} }, listId); expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); expect(commit.mock.calls).toEqual([ @@ -587,7 +614,7 @@ describe('removeList', () => { }, }); - await actions.removeList({ commit, state }, listId); + await actions.removeList({ commit, state, getters, dispatch: () => {} }, listId); expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); expect(commit.mock.calls).toEqual([ @@ -648,6 +675,10 @@ describe('fetchItemsForList', () => { { listId }, state, [ + { + type: types.RESET_ITEMS_FOR_LIST, + payload: listId, + }, { type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, @@ -670,6 +701,10 @@ describe('fetchItemsForList', () => { { listId }, state, [ + { + type: types.RESET_ITEMS_FOR_LIST, + payload: listId, + }, { type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, @@ -1114,6 +1149,7 @@ describe('addListItem', () => { listId: mockLists[0].id, itemId: mockIssue.id, atIndex: 0, + inProgress: false, }, }, { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, @@ -1244,8 +1280,9 @@ describe('addListNewIssue', () => { type: 'addListItem', payload: { list: fakeList, - item: formatIssue({ ...mockIssue, id: 'tmp' }), + item: formatIssue({ ...mockIssue, id: 'tmp', isLoading: true }), position: 0, + inProgress: true, }, }, { type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } }, @@ -1286,8 +1323,9 @@ describe('addListNewIssue', () => { type: 'addListItem', payload: { list: fakeList, - item: formatIssue({ ...mockIssue, id: 'tmp' }), + item: formatIssue({ ...mockIssue, id: 'tmp', isLoading: true }), position: 0, + inProgress: true, }, }, { type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } }, @@ -1348,57 +1386,6 @@ describe('setActiveIssueLabels', () => { }); }); -describe('setActiveIssueDueDate', () => { - const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeBoardItem: mockIssue }; - const testDueDate = '2020-02-20'; - const input = { - dueDate: testDueDate, - projectPath: 'h/b', - }; - - it('should commit due date after setting the issue', (done) => { - jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ - data: { - updateIssue: { - issue: { - dueDate: testDueDate, - }, - errors: [], - }, - }, - }); - - const payload = { - itemId: getters.activeBoardItem.id, - prop: 'dueDate', - value: testDueDate, - }; - - testAction( - actions.setActiveIssueDueDate, - input, - { ...state, ...getters }, - [ - { - type: types.UPDATE_BOARD_ITEM_BY_ID, - payload, - }, - ], - [], - done, - ); - }); - - it('throws error if fails', async () => { - jest - .spyOn(gqlClient, 'mutate') - .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); - - await expect(actions.setActiveIssueDueDate({ getters }, input)).rejects.toThrow(Error); - }); -}); - describe('setActiveItemSubscribed', () => { const state = { boardItems: { @@ -1456,60 +1443,6 @@ describe('setActiveItemSubscribed', () => { }); }); -describe('setActiveIssueMilestone', () => { - const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeBoardItem: mockIssue }; - const testMilestone = { - ...mockMilestone, - id: 'gid://gitlab/Milestone/1', - }; - const input = { - milestoneId: testMilestone.id, - projectPath: 'h/b', - }; - - it('should commit milestone after setting the issue', (done) => { - jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ - data: { - updateIssue: { - issue: { - milestone: testMilestone, - }, - errors: [], - }, - }, - }); - - const payload = { - itemId: getters.activeBoardItem.id, - prop: 'milestone', - value: testMilestone, - }; - - testAction( - actions.setActiveIssueMilestone, - input, - { ...state, ...getters }, - [ - { - type: types.UPDATE_BOARD_ITEM_BY_ID, - payload, - }, - ], - [], - done, - ); - }); - - it('throws error if fails', async () => { - jest - .spyOn(gqlClient, 'mutate') - .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); - - await expect(actions.setActiveIssueMilestone({ getters }, input)).rejects.toThrow(Error); - }); -}); - describe('setActiveItemTitle', () => { const state = { boardItems: { [mockIssue.id]: mockIssue }, diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index d89abcc79ae..5b38f04e77b 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -273,6 +273,53 @@ describe('Board Store Mutations', () => { }); }); + describe('RESET_ITEMS_FOR_LIST', () => { + it('should remove issues from boardItemsByListId state', () => { + const listId = 'gid://gitlab/List/1'; + const boardItemsByListId = { + [listId]: [mockIssue.id], + }; + + state = { + ...state, + boardItemsByListId, + }; + + mutations[types.RESET_ITEMS_FOR_LIST](state, listId); + + expect(state.boardItemsByListId[listId]).toEqual([]); + }); + }); + + describe('REQUEST_ITEMS_FOR_LIST', () => { + const listId = 'gid://gitlab/List/1'; + const boardItemsByListId = { + [listId]: [mockIssue.id], + }; + + it.each` + fetchNext | isLoading | isLoadingMore + ${true} | ${undefined} | ${true} + ${false} | ${true} | ${undefined} + `( + 'sets isLoading to $isLoading and isLoadingMore to $isLoadingMore when fetchNext is $fetchNext', + ({ fetchNext, isLoading, isLoadingMore }) => { + state = { + ...state, + boardItemsByListId, + listsFlags: { + [listId]: {}, + }, + }; + + mutations[types.REQUEST_ITEMS_FOR_LIST](state, { listId, fetchNext }); + + expect(state.listsFlags[listId].isLoading).toBe(isLoading); + expect(state.listsFlags[listId].isLoadingMore).toBe(isLoadingMore); + }, + ); + }); + describe('RECEIVE_ITEMS_FOR_LIST_SUCCESS', () => { it('updates boardItemsByListId and issues on state', () => { const listIssues = { diff --git a/spec/frontend/branches/components/delete_branch_button_spec.js b/spec/frontend/branches/components/delete_branch_button_spec.js new file mode 100644 index 00000000000..acbc83a9bdc --- /dev/null +++ b/spec/frontend/branches/components/delete_branch_button_spec.js @@ -0,0 +1,96 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DeleteBranchButton from '~/branches/components/delete_branch_button.vue'; +import eventHub from '~/branches/event_hub'; + +let wrapper; +let findDeleteButton; + +const createComponent = (props = {}) => { + wrapper = shallowMount(DeleteBranchButton, { + propsData: { + branchName: 'test', + deletePath: '/path/to/branch', + defaultBranchName: 'main', + ...props, + }, + }); +}; + +describe('Delete branch button', () => { + let eventHubSpy; + + beforeEach(() => { + findDeleteButton = () => wrapper.findComponent(GlButton); + eventHubSpy = jest.spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the button with default tooltip, style, and icon', () => { + createComponent(); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'Delete branch', + variant: 'danger', + icon: 'remove', + }); + }); + + it('renders a different tooltip for a protected branch', () => { + createComponent({ isProtectedBranch: true }); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'Delete protected branch', + variant: 'danger', + icon: 'remove', + }); + }); + + it('renders a different protected tooltip when it is both protected and disabled', () => { + createComponent({ isProtectedBranch: true, disabled: true }); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'Only a project maintainer or owner can delete a protected branch', + variant: 'default', + }); + }); + + it('emits the data to eventHub when button is clicked', () => { + createComponent({ merged: true }); + + findDeleteButton().vm.$emit('click'); + + expect(eventHubSpy).toHaveBeenCalledWith('openModal', { + branchName: 'test', + defaultBranchName: 'main', + deletePath: '/path/to/branch', + isProtectedBranch: false, + merged: true, + }); + }); + + describe('#disabled', () => { + it('does not disable the button by default when mounted', () => { + createComponent(); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'Delete branch', + variant: 'danger', + }); + }); + + // Used for unallowed users and for the default branch. + it('disables the button when mounted for a disabled modal', () => { + createComponent({ disabled: true, tooltip: 'The default branch cannot be deleted' }); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'The default branch cannot be deleted', + disabled: 'true', + variant: 'default', + }); + }); + }); +}); diff --git a/spec/frontend/branches/components/delete_branch_modal_spec.js b/spec/frontend/branches/components/delete_branch_modal_spec.js new file mode 100644 index 00000000000..0c6111bda9e --- /dev/null +++ b/spec/frontend/branches/components/delete_branch_modal_spec.js @@ -0,0 +1,157 @@ +import { GlButton, GlModal, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import DeleteBranchModal from '~/branches/components/delete_branch_modal.vue'; +import eventHub from '~/branches/event_hub'; + +let wrapper; + +const branchName = 'test_modal'; +const defaultBranchName = 'default'; +const deletePath = '/path/to/branch'; +const merged = false; +const isProtectedBranch = false; + +const createComponent = (data = {}) => { + wrapper = extendedWrapper( + shallowMount(DeleteBranchModal, { + data() { + return { + branchName, + deletePath, + defaultBranchName, + merged, + isProtectedBranch, + ...data, + }; + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '
', + }), + GlButton, + GlFormInput, + GlSprintf, + }, + }), + ); +}; + +const findModal = () => wrapper.findComponent(GlModal); +const findModalMessage = () => wrapper.findByTestId('modal-message'); +const findDeleteButton = () => wrapper.findByTestId('delete-branch-confirmation-button'); +const findCancelButton = () => wrapper.findByTestId('delete-branch-cancel-button'); +const findFormInput = () => wrapper.findComponent(GlFormInput); +const findForm = () => wrapper.find('form'); + +describe('Delete branch modal', () => { + const expectedUnmergedWarning = + 'This branch hasn’t been merged into default. To avoid data loss, consider merging this branch before deleting it.'; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Deleting a regular branch', () => { + const expectedTitle = 'Delete branch. Are you ABSOLUTELY SURE?'; + const expectedWarning = "You're about to permanently delete the branch test_modal."; + const expectedMessage = `${expectedWarning} ${expectedUnmergedWarning}`; + + beforeEach(() => { + createComponent(); + }); + + it('renders the modal correctly', () => { + expect(findModal().props('title')).toBe(expectedTitle); + expect(findModalMessage().text()).toMatchInterpolatedText(expectedMessage); + expect(findCancelButton().text()).toBe('Cancel, keep branch'); + expect(findDeleteButton().text()).toBe('Yes, delete branch'); + expect(findForm().attributes('action')).toBe(deletePath); + }); + + it('submits the form when the delete button is clicked', () => { + const submitFormSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit'); + + findDeleteButton().trigger('click'); + + expect(findForm().attributes('action')).toBe(deletePath); + expect(submitFormSpy).toHaveBeenCalled(); + }); + + it('calls show on the modal when a `openModal` event is received through the event hub', async () => { + const showSpy = jest.spyOn(wrapper.vm.$refs.modal, 'show'); + + eventHub.$emit('openModal', { + isProtectedBranch, + branchName, + defaultBranchName, + deletePath, + merged, + }); + + expect(showSpy).toHaveBeenCalled(); + }); + + it('calls hide on the modal when cancel button is clicked', () => { + const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); + + findCancelButton().trigger('click'); + + expect(closeModalSpy).toHaveBeenCalled(); + }); + }); + + describe('Deleting a protected branch (for owner or maintainer)', () => { + const expectedTitleProtected = 'Delete protected branch. Are you ABSOLUTELY SURE?'; + const expectedWarningProtected = + "You're about to permanently delete the protected branch test_modal."; + const expectedMessageProtected = `${expectedWarningProtected} ${expectedUnmergedWarning}`; + const expectedConfirmationText = + 'Once you confirm and press Yes, delete protected branch, it cannot be undone or recovered. Please type the following to confirm: test_modal'; + + beforeEach(() => { + createComponent({ isProtectedBranch: true }); + }); + + describe('rendering the modal correctly for a protected branch', () => { + it('sets the modal title for a protected branch', () => { + expect(findModal().props('title')).toBe(expectedTitleProtected); + }); + + it('renders the correct text in the modal message', () => { + expect(findModalMessage().text()).toMatchInterpolatedText(expectedMessageProtected); + }); + + it('renders the protected branch name confirmation form with expected text and action', () => { + expect(findForm().text()).toMatchInterpolatedText(expectedConfirmationText); + expect(findForm().attributes('action')).toBe(deletePath); + }); + + it('renders the buttons with the correct button text', () => { + expect(findCancelButton().text()).toBe('Cancel, keep branch'); + expect(findDeleteButton().text()).toBe('Yes, delete protected branch'); + }); + }); + + it('opens with the delete button disabled and enables it when branch name is confirmed', async () => { + expect(findDeleteButton().props('disabled')).toBe(true); + + findFormInput().vm.$emit('input', branchName); + + await waitForPromises(); + + expect(findDeleteButton().props('disabled')).not.toBe(true); + }); + }); + + describe('Deleting a merged branch', () => { + it('does not include the unmerged branch warning when merged is true', () => { + createComponent({ merged: true }); + + expect(findModalMessage().html()).not.toContain(expectedUnmergedWarning); + }); + }); +}); diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index be3640936dc..426e6cae8fb 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -5,7 +5,7 @@ import * as actions from '~/ci_variable_list/store/actions'; import * as types from '~/ci_variable_list/store/mutation_types'; import getInitialState from '~/ci_variable_list/store/state'; import { prepareDataForDisplay, prepareEnvironments } from '~/ci_variable_list/store/utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import mockData from '../services/mock_data'; @@ -240,7 +240,9 @@ describe('CI variable list store actions', () => { mock.onGet(state.endpoint).reply(500); testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }], () => { - expect(createFlash).toHaveBeenCalledWith('There was an error fetching the variables.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the variables.', + }); done(); }); }); @@ -278,9 +280,9 @@ describe('CI variable list store actions', () => { [], [{ type: 'requestEnvironments' }], () => { - expect(createFlash).toHaveBeenCalledWith( - 'There was an error fetching the environments information.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the environments information.', + }); done(); }, ); diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index 6047b404197..e5e336eb3d5 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -62,6 +62,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ aria-hidden="true" class="gl-icon s16 gl-new-dropdown-item-check-icon gl-mt-3 gl-align-self-start" data-testid="dropdown-item-checkbox" + role="img" >
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index 79ad5ad1bb9..2a976c04319 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -13,6 +13,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
- - - - Save changes - -
`; diff --git a/spec/frontend/incidents_settings/components/alerts_form_spec.js b/spec/frontend/incidents_settings/components/alerts_form_spec.js deleted file mode 100644 index 2516e8afdfa..00000000000 --- a/spec/frontend/incidents_settings/components/alerts_form_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import AlertsSettingsForm from '~/incidents_settings/components/alerts_form.vue'; - -describe('Alert integration settings form', () => { - let wrapper; - const service = { updateSettings: jest.fn().mockResolvedValue() }; - - const findForm = () => wrapper.find({ ref: 'settingsForm' }); - - beforeEach(() => { - wrapper = shallowMount(AlertsSettingsForm, { - provide: { - service, - alertSettings: { - issueTemplateKey: 'selecte_tmpl', - createIssue: true, - sendEmail: false, - templates: [], - autoCloseIncident: true, - }, - }, - }); - }); - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } - }); - - describe('default state', () => { - it('should match the default snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('form', () => { - it('should call service `updateSettings` on submit', () => { - findForm().trigger('submit'); - expect(service.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ - create_issue: wrapper.vm.createIssueEnabled, - issue_template_key: wrapper.vm.issueTemplate, - send_email: wrapper.vm.sendEmailEnabled, - auto_close_incident: wrapper.vm.autoCloseIncident, - }), - ); - }); - }); -}); diff --git a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js index 5476e895c68..f4342c56f98 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js @@ -1,5 +1,5 @@ import AxiosMockAdapter from 'axios-mock-adapter'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { ERROR_MSG } from '~/incidents_settings/constants'; import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service'; import axios from '~/lib/utils/axios_utils'; @@ -37,7 +37,10 @@ describe('IncidentsSettingsService', () => { mock.onPatch().reply(httpStatusCodes.BAD_REQUEST); return service.updateSettings({}).then(() => { - expect(createFlash).toHaveBeenCalledWith(expect.stringContaining(ERROR_MSG), 'alert'); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringContaining(ERROR_MSG), + type: 'alert', + }); }); }); }); diff --git a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js index 2ffd1292ddc..d2b591d427d 100644 --- a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js +++ b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js @@ -1,5 +1,5 @@ -import { GlAlert, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlModal, GlToggle } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PagerDutySettingsForm from '~/incidents_settings/components/pagerduty_form.vue'; @@ -8,13 +8,13 @@ describe('Alert integration settings form', () => { const resetWebhookUrl = jest.fn(); const service = { updateSettings: jest.fn().mockResolvedValue(), resetWebhookUrl }; - const findForm = () => wrapper.find({ ref: 'settingsForm' }); - const findWebhookInput = () => wrapper.find('[data-testid="webhook-url"]'); - const findModal = () => wrapper.find(GlModal); - const findAlert = () => wrapper.find(GlAlert); + const findWebhookInput = () => wrapper.findByTestId('webhook-url'); + const findFormToggle = () => wrapper.findComponent(GlToggle); + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => { - wrapper = shallowMount(PagerDutySettingsForm, { + wrapper = shallowMountExtended(PagerDutySettingsForm, { provide: { service, pagerDutySettings: { @@ -27,18 +27,15 @@ describe('Alert integration settings form', () => { }); afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); it('should match the default snapshot', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('should call service `updateSettings` on form submit', () => { - findForm().trigger('submit'); + it('should call service `updateSettings` on toggle change', () => { + findFormToggle().vm.$emit('change', true); expect(service.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ pagerduty_active: wrapper.vm.active }), ); diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js index 0e56fb6454e..df7ffd19747 100644 --- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js +++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js @@ -1,5 +1,6 @@ import { GlFormCheckbox } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; + import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import { createStore } from '~/integrations/edit/store'; diff --git a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js index 1c126f60c37..805d3971994 100644 --- a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js +++ b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js @@ -1,5 +1,6 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; + import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; import { createStore } from '~/integrations/edit/store'; @@ -13,13 +14,10 @@ describe('ConfirmationModal', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findGlModal = () => wrapper.find(GlModal); + const findGlModal = () => wrapper.findComponent(GlModal); describe('template', () => { beforeEach(() => { diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index 2ebb3333c0f..8784b3c2b00 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -1,5 +1,6 @@ import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; + import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; describe('DynamicField', () => { @@ -24,17 +25,14 @@ describe('DynamicField', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findGlFormGroup = () => wrapper.find(GlFormGroup); - const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox); - const findGlFormInput = () => wrapper.find(GlFormInput); - const findGlFormSelect = () => wrapper.find(GlFormSelect); - const findGlFormTextarea = () => wrapper.find(GlFormTextarea); + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); + const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findGlFormInput = () => wrapper.findComponent(GlFormInput); + const findGlFormSelect = () => wrapper.findComponent(GlFormSelect); + const findGlFormTextarea = () => wrapper.findComponent(GlFormTextarea); describe('template', () => { describe.each([ diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index c015fd0b9e0..cbce26762b1 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,6 +1,6 @@ -import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture } from 'helpers/fixtures'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import { mockIntegrationProps } from 'jest/integrations/edit/mock_data'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; @@ -23,42 +23,37 @@ describe('IntegrationForm', () => { initialState = {}, props = {}, } = {}) => { - wrapper = extendedWrapper( - shallowMount(IntegrationForm, { - propsData: { ...props }, - store: createStore({ - customState: { ...mockIntegrationProps, ...customStateProps }, - ...initialState, - }), - stubs: { - OverrideDropdown, - ActiveCheckbox, - ConfirmationModal, - JiraTriggerFields, - TriggerFields, - }, - provide: { - glFeatures: featureFlags, - }, + wrapper = shallowMountExtended(IntegrationForm, { + propsData: { ...props }, + store: createStore({ + customState: { ...mockIntegrationProps, ...customStateProps }, + ...initialState, }), - ); + stubs: { + OverrideDropdown, + ActiveCheckbox, + ConfirmationModal, + JiraTriggerFields, + TriggerFields, + }, + provide: { + glFeatures: featureFlags, + }, + }); }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findOverrideDropdown = () => wrapper.find(OverrideDropdown); - const findActiveCheckbox = () => wrapper.find(ActiveCheckbox); - const findConfirmationModal = () => wrapper.find(ConfirmationModal); - const findResetConfirmationModal = () => wrapper.find(ResetConfirmationModal); - const findResetButton = () => wrapper.find('[data-testid="reset-button"]'); - const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields); - const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields); - const findTriggerFields = () => wrapper.find(TriggerFields); + const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown); + const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox); + const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal); + const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal); + const findResetButton = () => wrapper.findByTestId('reset-button'); + const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields); + const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields); + const findTriggerFields = () => wrapper.findComponent(TriggerFields); describe('template', () => { describe('showActive is true', () => { @@ -286,7 +281,7 @@ describe('IntegrationForm', () => {
`); - it('renders `helpHtml`', async () => { + it('renders `helpHtml`', () => { const mockHelpHtml = document.querySelector(`[data-testid="${mockTestId}"]`); createComponent({ diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index f121a148f27..eb5f7e9fe40 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -1,10 +1,13 @@ import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import JiraUpgradeCta from '~/integrations/edit/components/jira_upgrade_cta.vue'; import eventHub from '~/integrations/edit/event_hub'; +import { createStore } from '~/integrations/edit/store'; describe('JiraIssuesFields', () => { + let store; let wrapper; const defaultProps = { @@ -13,25 +16,29 @@ describe('JiraIssuesFields', () => { showJiraVulnerabilitiesIntegration: true, }; - const createComponent = ({ props, ...options } = {}) => { - wrapper = mount(JiraIssuesFields, { + const createComponent = ({ isInheriting = false, props, ...options } = {}) => { + store = createStore({ + defaultState: isInheriting ? {} : undefined, + }); + + wrapper = mountExtended(JiraIssuesFields, { propsData: { ...defaultProps, ...props }, + store, stubs: ['jira-issue-creation-vulnerabilities'], ...options, }); }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); const findEnableCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findEnableCheckboxDisabled = () => + findEnableCheckbox().find('[type=checkbox]').attributes('disabled'); const findProjectKey = () => wrapper.findComponent(GlFormInput); const findJiraUpgradeCta = () => wrapper.findComponent(JiraUpgradeCta); - const findJiraForVulnerabilities = () => wrapper.find('[data-testid="jira-for-vulnerabilities"]'); + const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities'); const setEnableCheckbox = async (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); @@ -79,6 +86,19 @@ describe('JiraIssuesFields', () => { createComponent({ props: { initialProjectKey: '' } }); }); + it('renders enabled checkbox', () => { + expect(findEnableCheckbox().exists()).toBe(true); + expect(findEnableCheckboxDisabled()).toBeUndefined(); + }); + + it('renders disabled project_key input', () => { + const projectKey = findProjectKey(); + + expect(projectKey.exists()).toBe(true); + expect(projectKey.attributes('disabled')).toBe('disabled'); + expect(projectKey.attributes('required')).toBeUndefined(); + }); + it('does not show upgrade banner', () => { expect(findJiraUpgradeCta().exists()).toBe(false); }); @@ -89,24 +109,20 @@ describe('JiraIssuesFields', () => { expect(wrapper.find('input[name="service[issues_enabled]"]').exists()).toBe(true); }); - it('disables project_key input', () => { - expect(findProjectKey().attributes('disabled')).toBe('disabled'); - }); + describe('when isInheriting = true', () => { + it('disables checkbox and sets input as readonly', () => { + createComponent({ isInheriting: true }); - it('does not require project_key', () => { - expect(findProjectKey().attributes('required')).toBeUndefined(); + expect(findEnableCheckboxDisabled()).toBe('disabled'); + expect(findProjectKey().attributes('readonly')).toBe('readonly'); + }); }); describe('on enable issues', () => { - it('enables project_key input', async () => { + it('enables project_key input as required', async () => { await setEnableCheckbox(true); expect(findProjectKey().attributes('disabled')).toBeUndefined(); - }); - - it('requires project_key input', async () => { - await setEnableCheckbox(true); - expect(findProjectKey().attributes('required')).toBe('required'); }); }); diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js index 5c04add61a1..9e01371f542 100644 --- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js @@ -1,5 +1,6 @@ import { GlFormCheckbox } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; describe('JiraTriggerFields', () => { @@ -12,7 +13,7 @@ describe('JiraTriggerFields', () => { }; const createComponent = (props, isInheriting = false) => { - wrapper = mount(JiraTriggerFields, { + wrapper = mountExtended(JiraTriggerFields, { propsData: { ...defaultProps, ...props }, computed: { isInheriting: () => isInheriting, @@ -21,18 +22,15 @@ describe('JiraTriggerFields', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findCommentSettings = () => wrapper.find('[data-testid="comment-settings"]'); - const findCommentDetail = () => wrapper.find('[data-testid="comment-detail"]'); - const findCommentSettingsCheckbox = () => findCommentSettings().find(GlFormCheckbox); + const findCommentSettings = () => wrapper.findByTestId('comment-settings'); + const findCommentDetail = () => wrapper.findByTestId('comment-detail'); + const findCommentSettingsCheckbox = () => findCommentSettings().findComponent(GlFormCheckbox); const findIssueTransitionEnabled = () => wrapper.find('[data-testid="issue-transition-enabled"] input[type="checkbox"]'); - const findIssueTransitionMode = () => wrapper.find('[data-testid="issue-transition-mode"]'); + const findIssueTransitionMode = () => wrapper.findByTestId('issue-transition-mode'); const findIssueTransitionModeRadios = () => findIssueTransitionMode().findAll('input[type="radio"]'); const findIssueTransitionIdsField = () => diff --git a/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js b/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js index e49a1619627..e90e9a5d2ac 100644 --- a/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js +++ b/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; + import JiraUpgradeCta from '~/integrations/edit/components/jira_upgrade_cta.vue'; describe('JiraUpgradeCta', () => { @@ -18,13 +19,13 @@ describe('JiraUpgradeCta', () => { it('displays the correct message for premium and lower users', () => { createComponent({ showPremiumMessage: true }); - expect(wrapper.html()).toContain('This is a Premium feature'); - expect(wrapper.html()).toContain(contentMessage); + expect(wrapper.text()).toContain('This is a Premium feature'); + expect(wrapper.text()).toContain(contentMessage); }); it('displays the correct message for ultimate and lower users', () => { createComponent({ showUltimateMessage: true }); - expect(wrapper.html()).toContain('This is an Ultimate feature'); - expect(wrapper.html()).toContain(contentMessage); + expect(wrapper.text()).toContain('This is an Ultimate feature'); + expect(wrapper.text()).toContain(contentMessage); }); }); diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js index 592f4514e45..eb43d940f5e 100644 --- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js +++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js @@ -1,5 +1,6 @@ import { GlDropdown, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; + import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/edit/constants'; import { createStore } from '~/integrations/edit/store'; @@ -26,14 +27,11 @@ describe('OverrideDropdown', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findGlLink = () => wrapper.find(GlLink); - const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlLink = () => wrapper.findComponent(GlLink); + const findGlDropdown = () => wrapper.findComponent(GlDropdown); describe('template', () => { describe('override prop is true', () => { diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js index b9d16464e72..a0816682741 100644 --- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js @@ -1,5 +1,6 @@ import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; describe('TriggerFields', () => { @@ -10,7 +11,7 @@ describe('TriggerFields', () => { }; const createComponent = (props, isInheriting = false) => { - wrapper = mount(TriggerFields, { + wrapper = mountExtended(TriggerFields, { propsData: { ...defaultProps, ...props }, computed: { isInheriting: () => isInheriting, @@ -19,21 +20,19 @@ describe('TriggerFields', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); + const findTriggerLabel = () => wrapper.findByTestId('trigger-fields-group').find('label'); const findAllGlFormGroups = () => wrapper.find('#trigger-fields').findAll(GlFormGroup); - const findAllGlFormCheckboxes = () => wrapper.findAll(GlFormCheckbox); - const findAllGlFormInputs = () => wrapper.findAll(GlFormInput); + const findAllGlFormCheckboxes = () => wrapper.findAllComponents(GlFormCheckbox); + const findAllGlFormInputs = () => wrapper.findAllComponents(GlFormInput); describe.each([true, false])('template, isInheriting = `%p`', (isInheriting) => { it('renders a label with text "Trigger"', () => { createComponent(); - const triggerLabel = wrapper.find('[data-testid="trigger-fields-group"]').find('label'); + const triggerLabel = findTriggerLabel(); expect(triggerLabel.exists()).toBe(true); expect(triggerLabel.text()).toBe('Trigger'); }); @@ -68,7 +67,7 @@ describe('TriggerFields', () => { }); it('renders GlFormInput with description for each event', () => { - const groups = wrapper.find('#trigger-fields').findAll(GlFormGroup); + const groups = findAllGlFormGroups(); expect(groups).toHaveLength(2); groups.wrappers.forEach((group, index) => { @@ -138,11 +137,11 @@ describe('TriggerFields', () => { const expectedResults = [ { name: 'service[push_channel]', - placeholder: 'general, development', + placeholder: '#general, #development', }, { name: 'service[merge_request_channel]', - placeholder: 'general, development', + placeholder: '#general, #development', }, ]; diff --git a/spec/frontend/integrations/index/components/integrations_list_spec.js b/spec/frontend/integrations/index/components/integrations_list_spec.js index 94fd7fc84ee..ee54a5fd359 100644 --- a/spec/frontend/integrations/index/components/integrations_list_spec.js +++ b/spec/frontend/integrations/index/components/integrations_list_spec.js @@ -1,5 +1,5 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import IntegrationsList from '~/integrations/index/components/integrations_list.vue'; import { mockActiveIntegrations, mockInactiveIntegrations } from '../mock_data'; @@ -10,7 +10,7 @@ describe('IntegrationsList', () => { const findInactiveIntegrationsTable = () => wrapper.findByTestId('inactive-integrations-table'); const createComponent = (propsData = {}) => { - wrapper = extendedWrapper(shallowMount(IntegrationsList, { propsData })); + wrapper = shallowMountExtended(IntegrationsList, { propsData }); }; afterEach(() => { diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js index 2a6985de136..2ef8fe07650 100644 --- a/spec/frontend/invite_members/components/group_select_spec.js +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -1,22 +1,22 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlAvatarLabeled, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import Api from '~/api'; +import * as groupsApi from '~/api/groups_api'; import GroupSelect from '~/invite_members/components/group_select.vue'; const createComponent = () => { return mount(GroupSelect, {}); }; -const group1 = { id: 1, full_name: 'Group One' }; -const group2 = { id: 2, full_name: 'Group Two' }; +const group1 = { id: 1, full_name: 'Group One', avatar_url: 'test' }; +const group2 = { id: 2, full_name: 'Group Two', avatar_url: 'test' }; const allGroups = [group1, group2]; describe('GroupSelect', () => { let wrapper; beforeEach(() => { - jest.spyOn(Api, 'groups').mockResolvedValue(allGroups); + jest.spyOn(groupsApi, 'getGroups').mockResolvedValue(allGroups); wrapper = createComponent(); }); @@ -29,10 +29,10 @@ describe('GroupSelect', () => { const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="true"]'); - const findDropdownItemByText = (text) => + const findAvatarByLabel = (text) => wrapper - .findAllComponents(GlDropdownItem) - .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text); + .findAllComponents(GlAvatarLabeled) + .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.props('label') === text); it('renders GlSearchBoxByType with default attributes', () => { expect(findSearchBoxByType().exists()).toBe(true); @@ -45,7 +45,7 @@ describe('GroupSelect', () => { let resolveApiRequest; beforeEach(() => { - jest.spyOn(Api, 'groups').mockImplementation( + jest.spyOn(groupsApi, 'getGroups').mockImplementation( () => new Promise((resolve) => { resolveApiRequest = resolve; @@ -58,7 +58,7 @@ describe('GroupSelect', () => { it('calls the API', () => { resolveApiRequest({ data: allGroups }); - expect(Api.groups).toHaveBeenCalledWith(group1.name, { + expect(groupsApi.getGroups).toHaveBeenCalledWith(group1.name, { active: true, exclude_internal: true, }); @@ -74,9 +74,20 @@ describe('GroupSelect', () => { }); }); + describe('avatar label', () => { + it('includes the correct attributes with name and avatar_url', () => { + expect(findAvatarByLabel(group1.full_name).attributes()).toMatchObject({ + src: group1.avatar_url, + 'entity-id': `${group1.id}`, + 'entity-name': group1.full_name, + size: '32', + }); + }); + }); + describe('when group is selected from the dropdown', () => { beforeEach(() => { - findDropdownItemByText(group1.full_name).vm.$emit('click'); + findAvatarByLabel(group1.full_name).trigger('click'); }); it('emits `input` event used by `v-model`', () => { 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 7ed18775693..eabbea84234 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -15,6 +15,7 @@ const isProject = false; const inviteeType = 'members'; const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; const defaultAccessLevel = 10; +const inviteSource = 'unknown'; const helpLink = 'https://example.com'; const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; @@ -173,6 +174,7 @@ describe('InviteMembersModal', () => { user_id: '1', access_level: defaultAccessLevel, expires_at: undefined, + invite_source: inviteSource, format: 'json', }; @@ -245,6 +247,7 @@ describe('InviteMembersModal', () => { access_level: defaultAccessLevel, expires_at: undefined, email: 'email@example.com', + invite_source: inviteSource, format: 'json', }; @@ -293,6 +296,7 @@ describe('InviteMembersModal', () => { const postData = { access_level: defaultAccessLevel, expires_at: undefined, + invite_source: inviteSource, format: 'json', }; @@ -308,20 +312,39 @@ describe('InviteMembersModal', () => { jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); jest.spyOn(wrapper.vm, 'trackInvite'); - - clickInviteButton(); }); - it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData); - }); + describe('when triggered from regular mounting', () => { + beforeEach(() => { + clickInviteButton(); + }); - it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData); + it('calls Api inviteGroupMembersByEmail with the correct params', () => { + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData); + }); + + it('calls Api addGroupMembersByUserId with the correct params', () => { + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); }); - it('displays the successful toastMessage', () => { - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + it('calls Apis with the invite source passed through to openModal', () => { + wrapper.vm.openModal({ inviteeType: 'members', source: '_invite_source_' }); + + clickInviteButton(); + + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, { + ...emailPostData, + invite_source: '_invite_source_', + }); + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, { + ...idPostData, + invite_source: '_invite_source_', + }); }); }); @@ -403,18 +426,11 @@ describe('InviteMembersModal', () => { }); describe('tracking', () => { - const postData = { - user_id: '1', - access_level: defaultAccessLevel, - expires_at: undefined, - format: 'json', - }; - beforeEach(() => { wrapper = createComponent({ newUsersToInvite: [user3] }); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); }); it('tracks the invite', () => { diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index b569b6286e0..f57af61ad5b 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -7,6 +7,8 @@ import eventHub from '~/invite_members/event_hub'; jest.mock('~/experimentation/experiment_tracking'); const displayText = 'Invite team members'; +const triggerSource = '_trigger_source_'; + let wrapper; let triggerProps; let findButton; @@ -26,7 +28,7 @@ const createComponent = (props = {}) => { }; describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => { - triggerProps = { triggerElement }; + triggerProps = { triggerElement, triggerSource }; findButton = () => wrapper.findComponent(triggerComponent[triggerElement]); afterEach(() => { @@ -48,22 +50,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement spy = jest.spyOn(eventHub, '$emit'); }); - it('emits openModal from an unknown source', () => { - createComponent(); - - findButton().vm.$emit('click'); - - expect(spy).toHaveBeenCalledWith('openModal', { inviteeType: 'members', source: 'unknown' }); - }); - it('emits openModal from a named source', () => { - createComponent({ triggerSource: '_trigger_source_' }); + createComponent(); findButton().vm.$emit('click'); expect(spy).toHaveBeenCalledWith('openModal', { inviteeType: 'members', - source: '_trigger_source_', + source: triggerSource, }); }); }); diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index 7eb85a946ae..34094d22e68 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -1,7 +1,6 @@ import { GlModal, GlIcon, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; describe('CsvExportModal', () => { @@ -9,26 +8,24 @@ describe('CsvExportModal', () => { function createComponent(options = {}) { const { injectedProperties = {}, props = {} } = options; - return extendedWrapper( - mount(CsvExportModal, { - propsData: { - modalId: 'csv-export-modal', - exportCsvPath: 'export/csv/path', - issuableCount: 1, - ...props, - }, - provide: { - issuableType: 'issues', - ...injectedProperties, - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: - '
', - }), - }, - }), - ); + return mount(CsvExportModal, { + propsData: { + modalId: 'csv-export-modal', + exportCsvPath: 'export/csv/path', + issuableCount: 1, + ...props, + }, + provide: { + issuableType: 'issues', + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '
', + }), + }, + }); } afterEach(() => { @@ -61,14 +58,13 @@ describe('CsvExportModal', () => { describe('issuable count info text', () => { it('displays the info text when issuableCount is > -1', () => { wrapper = createComponent({ props: { issuableCount: 10 } }); - expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(true); - expect(wrapper.findByTestId('issuable-count-note').text()).toContain('10 issues selected'); + expect(wrapper.text()).toContain('10 issues selected'); expect(findIcon().exists()).toBe(true); }); it("doesn't display the info text when issuableCount is -1", () => { wrapper = createComponent({ props: { issuableCount: -1 } }); - expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false); + expect(wrapper.text()).not.toContain('issues selected'); }); }); diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js index 2fe8d28a333..118c12d968b 100644 --- a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js +++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js @@ -1,6 +1,6 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlDropdown } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; @@ -14,35 +14,33 @@ describe('CsvImportExportButtons', () => { function createComponent(injectedProperties = {}) { glModalDirective = jest.fn(); - return extendedWrapper( - shallowMount(CsvImportExportButtons, { - directives: { - GlTooltip: createMockDirective(), - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, + return mountExtended(CsvImportExportButtons, { + directives: { + GlTooltip: createMockDirective(), + glModal: { + bind(_, { value }) { + glModalDirective(value); }, }, - provide: { - ...injectedProperties, - }, - propsData: { - exportCsvPath, - issuableCount, - }, - }), - ); + }, + provide: { + ...injectedProperties, + }, + propsData: { + exportCsvPath, + issuableCount, + }, + }); } afterEach(() => { wrapper.destroy(); }); - const findExportCsvButton = () => wrapper.findByTestId('export-csv-button'); - const findImportDropdown = () => wrapper.findByTestId('import-csv-dropdown'); - const findImportCsvButton = () => wrapper.findByTestId('import-csv-dropdown'); - const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link'); + const findExportCsvButton = () => wrapper.findComponent(GlButton); + const findImportDropdown = () => wrapper.findComponent(GlDropdown); + const findImportCsvButton = () => wrapper.findByRole('menuitem', { name: 'Import CSV' }); + const findImportFromJiraLink = () => wrapper.findByRole('menuitem', { name: 'Import from Jira' }); const findExportCsvModal = () => wrapper.findComponent(CsvExportModal); const findImportCsvModal = () => wrapper.findComponent(CsvImportModal); @@ -97,7 +95,7 @@ describe('CsvImportExportButtons', () => { expect(findImportDropdown().exists()).toBe(true); }); - it('renders the import button', () => { + it('renders the import csv menu item', () => { expect(findImportCsvButton().exists()).toBe(true); }); @@ -106,8 +104,11 @@ describe('CsvImportExportButtons', () => { wrapper = createComponent({ showImportButton: true, showLabel: false }); }); - it('does not have a button text', () => { - expect(findImportCsvButton().props('text')).toBe(null); + it('hides button text', () => { + expect(findImportDropdown().props()).toMatchObject({ + text: 'Import issues', + textSrOnly: true, + }); }); it('import button has a tooltip', () => { @@ -124,7 +125,10 @@ describe('CsvImportExportButtons', () => { }); it('displays a button text', () => { - expect(findImportCsvButton().props('text')).toBe('Import issues'); + expect(findImportDropdown().props()).toMatchObject({ + text: 'Import issues', + textSrOnly: false, + }); }); it('import button has no tooltip', () => { diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js index ce9d738f77b..0c88b6b1283 100644 --- a/spec/frontend/issuable/components/csv_import_modal_spec.js +++ b/spec/frontend/issuable/components/csv_import_modal_spec.js @@ -1,8 +1,6 @@ -import { GlModal } from '@gitlab/ui'; -import { getByRole, getByLabelText } from '@testing-library/dom'; -import { mount } from '@vue/test-utils'; +import { GlButton, GlModal } from '@gitlab/ui'; import { stubComponent } from 'helpers/stub_component'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -13,23 +11,21 @@ describe('CsvImportModal', () => { function createComponent(options = {}) { const { injectedProperties = {}, props = {} } = options; - return extendedWrapper( - mount(CsvImportModal, { - propsData: { - modalId: 'csv-import-modal', - ...props, - }, - provide: { - issuableType: 'issues', - ...injectedProperties, - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: '
', - }), - }, - }), - ); + return mountExtended(CsvImportModal, { + propsData: { + modalId: 'csv-import-modal', + ...props, + }, + provide: { + issuableType: 'issues', + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: '
', + }), + }, + }); } beforeEach(() => { @@ -41,9 +37,9 @@ describe('CsvImportModal', () => { }); const findModal = () => wrapper.findComponent(GlModal); - const findPrimaryButton = () => getByRole(wrapper.element, 'button', { name: 'Import issues' }); - const findForm = () => wrapper.findByTestId('import-csv-form'); - const findFileInput = () => getByLabelText(wrapper.element, 'Upload CSV file'); + const findPrimaryButton = () => wrapper.findComponent(GlButton); + const findForm = () => wrapper.find('form'); + const findFileInput = () => wrapper.findByLabelText('Upload CSV file'); const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); describe('template', () => { @@ -76,8 +72,8 @@ describe('CsvImportModal', () => { expect(findPrimaryButton()).toExist(); }); - it('submits the form when the primary action is clicked', async () => { - findPrimaryButton().click(); + it('submits the form when the primary action is clicked', () => { + findPrimaryButton().trigger('click'); expect(formSubmitSpy).toHaveBeenCalled(); }); diff --git a/spec/frontend/issuable/components/issuable_by_email_spec.js b/spec/frontend/issuable/components/issuable_by_email_spec.js index 08a99f29479..f11c41fe25d 100644 --- a/spec/frontend/issuable/components/issuable_by_email_spec.js +++ b/spec/frontend/issuable/components/issuable_by_email_spec.js @@ -58,10 +58,11 @@ describe('IssuableByEmail', () => { mockAxios.restore(); }); - const findFormInputGroup = () => wrapper.find(GlFormInputGroup); + const findButton = () => wrapper.findComponent(GlButton); + const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); const clickResetEmail = async () => { - wrapper.findByTestId('incoming-email-token-reset').vm.$emit('click'); + wrapper.findAllComponents(GlButton).at(2).trigger('click'); await waitForPromises(); }; @@ -75,14 +76,14 @@ describe('IssuableByEmail', () => { 'renders a link with "$buttonText" when type is "$issuableType"', ({ issuableType, buttonText }) => { wrapper = createComponent({ issuableType }); - expect(wrapper.findByTestId('issuable-email-modal-btn').text()).toBe(buttonText); + expect(findButton().text()).toBe(buttonText); }, ); it('opens the modal when the user clicks the button', () => { wrapper = createComponent(); - wrapper.findByTestId('issuable-email-modal-btn').vm.$emit('click'); + findButton().trigger('click'); expect(glModalDirective).toHaveBeenCalled(); }); @@ -105,7 +106,7 @@ describe('IssuableByEmail', () => { initialEmail, }); - expect(wrapper.findByTestId('mail-to-btn').attributes('href')).toBe( + expect(wrapper.findAllComponents(GlButton).at(1).attributes('href')).toBe( `mailto:${initialEmail}?subject=${subject}&body=${body}`, ); }); diff --git a/spec/frontend/issuable/components/status_box_spec.js b/spec/frontend/issuable/components/status_box_spec.js index 990fac67f7e..9cbf023dbd6 100644 --- a/spec/frontend/issuable/components/status_box_spec.js +++ b/spec/frontend/issuable/components/status_box_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import StatusBox from '~/issuable/components/status_box.vue'; @@ -64,7 +64,7 @@ describe('Merge request status box component', () => { initialState: testCase.state, }); - expect(wrapper.find('[data-testid="status-icon"]').props('name')).toBe(testCase.icon); + expect(wrapper.findComponent(GlIcon).props('name')).toBe(testCase.icon); }); }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js index e5e3478dc59..3099e0b639b 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -6,7 +6,7 @@ import { issuable1, issuable2, } from 'jest/vue_shared/components/issue/related_issuable_mock_data'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; import { linkedIssueTypesMap } from '~/related_issues/constants'; @@ -195,7 +195,9 @@ describe('RelatedIssuesRoot', () => { wrapper.vm.onPendingFormSubmit(input); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith(message); + expect(createFlash).toHaveBeenCalledWith({ + message, + }); }); }); }); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index e324f071966..ea36d59ff83 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -336,7 +336,7 @@ describe('IssuableItem', () => { const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]'); expect(createdAtEl.exists()).toBe(true); - expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm GMT+0000'); + expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm UTC'); expect(createdAtEl.text()).toBe(wrapper.vm.createdAt); }); @@ -450,7 +450,7 @@ describe('IssuableItem', () => { const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); expect(updatedAtEl.exists()).toBe(true); - expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am GMT+0000'); + expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt); }); diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index b8860e93a22..4c06f2dca1b 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -1,6 +1,7 @@ import { GlIntersectionObserver } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import '~/behaviors/markdown/render_gfm'; import IssuableApp from '~/issue_show/components/app.vue'; @@ -17,7 +18,7 @@ import { publishedIncidentUrl, secondRequest, zoomMeetingUrl, -} from '../mock_data'; +} from '../mock_data/mock_data'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); @@ -36,12 +37,11 @@ describe('Issuable output', () => { let wrapper; const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); - const findLockedBadge = () => wrapper.find('[data-testid="locked"]'); - const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]'); + const findAlert = () => wrapper.find('.alert'); - const mountComponent = (props = {}, options = {}) => { + const mountComponent = (props = {}, options = {}, data = {}) => { wrapper = mount(IssuableApp, { propsData: { ...appProps, ...props }, provide: { @@ -53,6 +53,11 @@ describe('Issuable output', () => { HighlightBar: true, IncidentTabs: true, }, + data() { + return { + ...data, + }; + }, ...options, }); }; @@ -91,10 +96,8 @@ describe('Issuable output', () => { afterEach(() => { mock.restore(); realtimeRequestCount = 0; - wrapper.vm.poll.stop(); wrapper.destroy(); - wrapper = null; }); it('should render a title/description/edited and update title/description/edited on update', () => { @@ -115,7 +118,7 @@ describe('Issuable output', () => { expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/); expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/); expect(editedText.find('time').text()).toBeTruthy(); - expect(wrapper.vm.state.lock_version).toEqual(1); + expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version); }) .then(() => { wrapper.vm.poll.makeRequest(); @@ -133,7 +136,9 @@ describe('Issuable output', () => { expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/); expect(editedText.find('time').text()).toBeTruthy(); - expect(wrapper.vm.state.lock_version).toEqual(2); + // As the lock_version value does not differ from the server, + // we should not see an alert + expect(findAlert().exists()).toBe(false); }); }); @@ -172,7 +177,7 @@ describe('Issuable output', () => { ${'zoomMeetingUrl'} | ${zoomMeetingUrl} ${'publishedIncidentUrl'} | ${publishedIncidentUrl} `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => { - expect(wrapper.vm[prop]).toEqual(value); + expect(wrapper.vm[prop]).toBe(value); expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value); }); }); @@ -374,9 +379,9 @@ describe('Issuable output', () => { }); }) .then(() => { - expect(wrapper.vm.formState.lockedWarningVisible).toEqual(true); - expect(wrapper.vm.formState.lock_version).toEqual(1); - expect(wrapper.find('.alert').exists()).toBe(true); + expect(wrapper.vm.formState.lockedWarningVisible).toBe(true); + expect(wrapper.vm.formState.lock_version).toBe(1); + expect(findAlert().exists()).toBe(true); }); }); }); @@ -530,7 +535,7 @@ describe('Issuable output', () => { `('$title', async ({ state }) => { wrapper.setProps({ issuableStatus: state }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findStickyHeader().text()).toContain(IssuableStatusText[state]); }); @@ -542,7 +547,7 @@ describe('Issuable output', () => { `('$title', async ({ isConfidential }) => { wrapper.setProps({ isConfidential }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findConfidentialBadge().exists()).toBe(isConfidential); }); @@ -554,7 +559,7 @@ describe('Issuable output', () => { `('$title', async ({ isLocked }) => { wrapper.setProps({ isLocked }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findLockedBadge().exists()).toBe(isLocked); }); @@ -562,9 +567,9 @@ describe('Issuable output', () => { }); describe('Composable description component', () => { - const findIncidentTabs = () => wrapper.find(IncidentTabs); - const findDescriptionComponent = () => wrapper.find(DescriptionComponent); - const findPinnedLinks = () => wrapper.find(PinnedLinks); + const findIncidentTabs = () => wrapper.findComponent(IncidentTabs); + const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); + const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; describe('when using description component', () => { diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js index 70c04280675..cdf06ecc31f 100644 --- a/spec/frontend/issue_show/components/description_spec.js +++ b/spec/frontend/issue_show/components/description_spec.js @@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import mountComponent from 'helpers/vue_mount_component_helper'; import Description from '~/issue_show/components/description.vue'; import TaskList from '~/task_list'; -import { descriptionProps as props } from '../mock_data'; +import { descriptionProps as props } from '../mock_data/mock_data'; jest.mock('~/task_list'); diff --git a/spec/frontend/issue_show/components/edit_actions_spec.js b/spec/frontend/issue_show/components/edit_actions_spec.js index 54707879f63..50c27cb5bda 100644 --- a/spec/frontend/issue_show/components/edit_actions_spec.js +++ b/spec/frontend/issue_show/components/edit_actions_spec.js @@ -1,113 +1,163 @@ -import Vue from 'vue'; -import editActions from '~/issue_show/components/edit_actions.vue'; +import { GlButton, GlModal } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +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 IssuableEditActions from '~/issue_show/components/edit_actions.vue'; import eventHub from '~/issue_show/event_hub'; -import Store from '~/issue_show/stores'; -describe('Edit Actions components', () => { - let vm; +import { + getIssueStateQueryResponse, + updateIssueStateQueryResponse, +} from '../mock_data/apollo_mock'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Edit Actions component', () => { + let wrapper; + let fakeApollo; + let mockIssueStateData; + + const mockResolvers = { + Query: { + issueState() { + return { + __typename: 'IssueState', + rawData: mockIssueStateData(), + }; + }, + }, + }; - beforeEach((done) => { - const Component = Vue.extend(editActions); - const store = new Store({ - titleHtml: '', - descriptionHtml: '', - issuableRef: '', - }); - store.formState.title = 'test'; + const modalId = 'delete-issuable-modal-1'; - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + const createComponent = ({ props, data } = {}) => { + fakeApollo = createMockApollo([], mockResolvers); - vm = new Component({ + wrapper = shallowMountExtended(IssuableEditActions, { + apolloProvider: fakeApollo, propsData: { + formState: { + title: 'GitLab Issue', + }, canDestroy: true, - formState: store.formState, issuableType: 'issue', + ...props, }, - }).$mount(); + data() { + return { + issueState: {}, + modalId, + ...data, + }; + }, + }); + }; - Vue.nextTick(done); - }); + async function deleteIssuable(localWrapper) { + localWrapper.findComponent(GlModal).vm.$emit('primary'); + } - it('renders all buttons as enabled', () => { - expect(vm.$el.querySelectorAll('.disabled').length).toBe(0); + const findModal = () => wrapper.findComponent(GlModal); + const findEditButtons = () => wrapper.findAllComponents(GlButton); + const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button'); + const findSaveButton = () => wrapper.findByTestId('issuable-save-button'); + const findCancelButton = () => wrapper.findByTestId('issuable-cancel-button'); - expect(vm.$el.querySelectorAll('[disabled]').length).toBe(0); + beforeEach(() => { + mockIssueStateData = jest.fn(); + createComponent(); }); - it('does not render delete button if canUpdate is false', (done) => { - vm.canDestroy = false; - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-danger')).toBeNull(); + afterEach(() => { + wrapper.destroy(); + }); - done(); + it('renders all buttons as enabled', () => { + const buttons = findEditButtons().wrappers; + buttons.forEach((button) => { + expect(button.attributes('disabled')).toBeFalsy(); }); }); - it('disables submit button when title is blank', (done) => { - vm.formState.title = ''; + it('does not render the delete button if canDestroy is false', () => { + createComponent({ props: { canDestroy: false } }); + expect(findDeleteButton().exists()).toBe(false); + }); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled'); + it('disables save button when title is blank', () => { + createComponent({ props: { formState: { title: '', issue_type: '' } } }); - done(); - }); + expect(findSaveButton().attributes('disabled')).toBe('true'); }); - it('should not show delete button if showDeleteButton is false', (done) => { - vm.showDeleteButton = false; + it('does not render the delete button if showDeleteButton is false', () => { + createComponent({ props: { showDeleteButton: false } }); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-danger')).toBeNull(); - done(); - }); + expect(findDeleteButton().exists()).toBe(false); }); describe('updateIssuable', () => { - it('sends update.issauble event when clicking save button', () => { - vm.$el.querySelector('.btn-confirm').click(); - - expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - it('disabled button after clicking save button', (done) => { - vm.$el.querySelector('.btn-confirm').click(); - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled'); + it('sends update.issauble event when clicking save button', () => { + findSaveButton().vm.$emit('click', { preventDefault: jest.fn() }); - done(); - }); + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); }); }); describe('closeForm', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + it('emits close.form when clicking cancel', () => { - vm.$el.querySelector('.btn-default').click(); + findCancelButton().vm.$emit('click'); expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); }); }); - describe('deleteIssuable', () => { - it('sends delete.issuable event when clicking save button', () => { - jest.spyOn(window, 'confirm').mockReturnValue(true); - vm.$el.querySelector('.btn-danger').click(); + describe('renders create modal with the correct information', () => { + it('renders correct modal id', () => { + expect(findModal().attributes('modalid')).toBe(modalId); + }); + }); - expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); + describe('deleteIssuable', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - it('does no actions when confirm is false', (done) => { - jest.spyOn(window, 'confirm').mockReturnValue(false); - vm.$el.querySelector('.btn-danger').click(); + it('does not send the `delete.issuable` event when clicking delete button', () => { + findDeleteButton().vm.$emit('click'); + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); - Vue.nextTick(() => { - expect(eventHub.$emit).not.toHaveBeenCalledWith('delete.issuable'); + it('sends the `delete.issuable` event when clicking the delete confirm button', async () => { + expect(eventHub.$emit).toHaveBeenCalledTimes(0); + await deleteIssuable(wrapper); + expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); + expect(eventHub.$emit).toHaveBeenCalledTimes(1); + }); + }); - expect(vm.$el.querySelector('.btn-danger .fa')).toBeNull(); + describe('with Apollo cache mock', () => { + it('renders the right delete button text per apollo cache type', async () => { + mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); + await waitForPromises(); + expect(findDeleteButton().text()).toBe('Delete issue'); + }); - done(); - }); + it('should not change the delete button text per apollo cache mutation', async () => { + mockIssueStateData.mockResolvedValue(updateIssueStateQueryResponse); + await waitForPromises(); + expect(findDeleteButton().text()).toBe('Delete issue'); }); }); }); diff --git a/spec/frontend/issue_show/components/fields/type_spec.js b/spec/frontend/issue_show/components/fields/type_spec.js new file mode 100644 index 00000000000..0c8af60d50d --- /dev/null +++ b/spec/frontend/issue_show/components/fields/type_spec.js @@ -0,0 +1,84 @@ +import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import IssueTypeField, { i18n } from '~/issue_show/components/fields/type.vue'; +import { IssuableTypes } from '~/issue_show/constants'; +import { + getIssueStateQueryResponse, + updateIssueStateQueryResponse, +} from '../../mock_data/apollo_mock'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Issue type field component', () => { + let wrapper; + let fakeApollo; + let mockIssueStateData; + + const mockResolvers = { + Query: { + issueState() { + return { + __typename: 'IssueState', + rawData: mockIssueStateData(), + }; + }, + }, + Mutation: { + updateIssueState: jest.fn().mockResolvedValue(updateIssueStateQueryResponse), + }, + }; + + const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup); + const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown); + const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem); + + const createComponent = ({ data } = {}) => { + fakeApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMount(IssueTypeField, { + localVue, + apolloProvider: fakeApollo, + data() { + return { + issueState: {}, + ...data, + }; + }, + }); + }; + + beforeEach(() => { + mockIssueStateData = jest.fn(); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a form group with the correct label', () => { + expect(findTypeFromGroup().attributes('label')).toBe(i18n.label); + }); + + it('renders a form select with the `issue_type` value', () => { + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + }); + + describe('with Apollo cache mock', () => { + it('renders the selected issueType', async () => { + mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); + await waitForPromises(); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + }); + + it('updates the `issue_type` in the apollo cache when the value is changed', async () => { + findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident); + await wrapper.vm.$nextTick(); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js index 6d4807c4261..28498cb90ec 100644 --- a/spec/frontend/issue_show/components/form_spec.js +++ b/spec/frontend/issue_show/components/form_spec.js @@ -2,6 +2,7 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Autosave from '~/autosave'; import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue'; +import IssueTypeField from '~/issue_show/components/fields/type.vue'; import formComponent from '~/issue_show/components/form.vue'; import LockedWarning from '~/issue_show/components/locked_warning.vue'; import eventHub from '~/issue_show/event_hub'; @@ -39,6 +40,7 @@ describe('Inline edit form component', () => { }; const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate); + const findIssuableTypeField = () => wrapper.findComponent(IssueTypeField); const findLockedWarning = () => wrapper.findComponent(LockedWarning); const findAlert = () => wrapper.findComponent(GlAlert); @@ -68,6 +70,21 @@ describe('Inline edit form component', () => { expect(findDescriptionTemplate().exists()).toBe(true); }); + it.each` + issuableType | value + ${'issue'} | ${true} + ${'epic'} | ${false} + `( + 'when `issue_type` is set to "$issuableType" rendering the type select will be "$value"', + ({ issuableType, value }) => { + createComponent({ + issuableType, + }); + + expect(findIssuableTypeField().exists()).toBe(value); + }, + ); + it('hides locked warning by default', () => { createComponent(); diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js index f46b6ba6f54..6b9f5b17e99 100644 --- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js @@ -9,7 +9,7 @@ import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; import INVALID_URL from '~/lib/utils/invalid_url'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; -import { descriptionProps } from '../../mock_data'; +import { descriptionProps } from '../../mock_data/mock_data'; const mockAlert = { __typename: 'AlertManagementAlert', diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js index 9cb7059dd7f..d043693b863 100644 --- a/spec/frontend/issue_show/issue_spec.js +++ b/spec/frontend/issue_show/issue_spec.js @@ -5,7 +5,7 @@ import { initIssuableApp } from '~/issue_show/issue'; import * as parseData from '~/issue_show/utils/parse_data'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; -import { appProps } from './mock_data'; +import { appProps } from './mock_data/mock_data'; const mock = new MockAdapter(axios); mock.onGet().reply(200); diff --git a/spec/frontend/issue_show/mock_data.js b/spec/frontend/issue_show/mock_data.js deleted file mode 100644 index fd08c95b454..00000000000 --- a/spec/frontend/issue_show/mock_data.js +++ /dev/null @@ -1,59 +0,0 @@ -import { TEST_HOST } from 'helpers/test_constants'; - -export const initialRequest = { - title: '

this is a title

', - title_text: 'this is a title', - description: '

this is a description!

', - description_text: 'this is a description', - task_status: '2 of 4 completed', - updated_at: '2015-05-15T12:31:04.428Z', - updated_by_name: 'Some User', - updated_by_path: '/some_user', - lock_version: 1, -}; - -export const secondRequest = { - title: '

2

', - title_text: '2', - description: '

42

', - description_text: '42', - task_status: '0 of 0 completed', - updated_at: '2016-05-15T12:31:04.428Z', - updated_by_name: 'Other User', - updated_by_path: '/other_user', - lock_version: 2, -}; - -export const descriptionProps = { - canUpdate: true, - descriptionHtml: 'test', - descriptionText: 'test', - taskStatus: '', - updateUrl: TEST_HOST, -}; - -export const publishedIncidentUrl = 'https://status.com/'; - -export const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811'; - -export const appProps = { - canUpdate: true, - canDestroy: true, - endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', - updateEndpoint: TEST_HOST, - issuableRef: '#1', - issuableStatus: 'opened', - initialTitleHtml: '', - initialTitleText: '', - initialDescriptionHtml: 'test', - initialDescriptionText: 'test', - lockVersion: 1, - markdownPreviewPath: '/', - markdownDocsPath: '/', - projectNamespace: '/', - projectPath: '/', - projectId: 1, - issuableTemplateNamesPath: '/issuable-templates-path', - zoomMeetingUrl, - publishedIncidentUrl, -}; diff --git a/spec/frontend/issue_show/mock_data/apollo_mock.js b/spec/frontend/issue_show/mock_data/apollo_mock.js new file mode 100644 index 00000000000..bfd31e74393 --- /dev/null +++ b/spec/frontend/issue_show/mock_data/apollo_mock.js @@ -0,0 +1,9 @@ +export const getIssueStateQueryResponse = { + issueType: 'issue', + isDirty: false, +}; + +export const updateIssueStateQueryResponse = { + issueType: 'incident', + isDirty: true, +}; diff --git a/spec/frontend/issue_show/mock_data/mock_data.js b/spec/frontend/issue_show/mock_data/mock_data.js new file mode 100644 index 00000000000..a73826954c3 --- /dev/null +++ b/spec/frontend/issue_show/mock_data/mock_data.js @@ -0,0 +1,60 @@ +import { TEST_HOST } from 'helpers/test_constants'; + +export const initialRequest = { + title: '

this is a title

', + title_text: 'this is a title', + description: '

this is a description!

', + description_text: 'this is a description', + task_status: '2 of 4 completed', + updated_at: '2015-05-15T12:31:04.428Z', + updated_by_name: 'Some User', + updated_by_path: '/some_user', + lock_version: 1, +}; + +export const secondRequest = { + title: '

2

', + title_text: '2', + description: '

42

', + description_text: '42', + task_status: '0 of 0 completed', + updated_at: '2016-05-15T12:31:04.428Z', + updated_by_name: 'Other User', + updated_by_path: '/other_user', + lock_version: 2, +}; + +export const descriptionProps = { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + taskStatus: '', + updateUrl: TEST_HOST, +}; + +export const publishedIncidentUrl = 'https://status.com/'; + +export const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811'; + +export const appProps = { + canUpdate: true, + canDestroy: true, + endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', + updateEndpoint: TEST_HOST, + issuableRef: '#1', + issuableStatus: 'opened', + initialTitleHtml: '', + initialTitleText: '', + initialDescriptionHtml: 'test', + initialDescriptionText: 'test', + lockVersion: 1, + issueType: 'issue', + markdownPreviewPath: '/', + markdownDocsPath: '/', + projectNamespace: '/', + projectPath: '/', + projectId: 1, + issuableTemplateNamesPath: '/issuable-templates-path', + zoomMeetingUrl, + publishedIncidentUrl, +}; diff --git a/spec/frontend/issues_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js index fe3d2114463..a7f3dd81517 100644 --- a/spec/frontend/issues_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js @@ -302,7 +302,6 @@ describe('Issuables list component', () => { my_reaction_emoji: 'airplane', scope: 'all', state: 'opened', - utf8: '✓', weight: '0', milestone: 'v3.0', labels: 'Aquapod,Astro', @@ -312,7 +311,7 @@ describe('Issuables list component', () => { describe('when page is not present in params', () => { const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0¬[label_name][]=Afterpod¬[milestone_title][]=13'; + '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0¬[label_name][]=Afterpod¬[milestone_title][]=13'; beforeEach(() => { setUrl(query); @@ -356,7 +355,7 @@ describe('Issuables list component', () => { describe('when page is present in the param', () => { const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0&page=3'; + '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&page=3'; beforeEach(() => { setUrl(query); 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 5d83bf0142f..d78a436c618 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -18,6 +18,15 @@ import { PAGE_SIZE_MANUAL, PARAM_DUE_DATE, RELATIVE_POSITION_DESC, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_EPIC, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_WEIGHT, urlSortParams, } from '~/issues_list/constants'; import eventHub from '~/issues_list/eventhub'; @@ -39,8 +48,8 @@ describe('IssuesListApp component', () => { endpoint: 'api/endpoint', exportCsvPath: 'export/csv/path', hasBlockedIssuesFeature: true, - hasIssues: true, hasIssueWeightsFeature: true, + hasProjectIssues: true, isSignedIn: false, issuesPath: 'path/to/issues', jiraIntegrationPath: 'jira/integration/path', @@ -320,7 +329,7 @@ describe('IssuesListApp component', () => { beforeEach(async () => { global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` }); - wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); await waitForPromises(); }); @@ -336,7 +345,7 @@ describe('IssuesListApp component', () => { describe('when "Open" tab has no issues', () => { beforeEach(async () => { - wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); await waitForPromises(); }); @@ -356,7 +365,7 @@ describe('IssuesListApp component', () => { url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST), }); - wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); await waitForPromises(); }); @@ -374,7 +383,7 @@ describe('IssuesListApp component', () => { describe('when user is logged in', () => { beforeEach(() => { wrapper = mountComponent({ - provide: { hasIssues: false, isSignedIn: true }, + provide: { hasProjectIssues: false, isSignedIn: true }, mountFn: mount, }); }); @@ -413,7 +422,7 @@ describe('IssuesListApp component', () => { describe('when user is logged out', () => { beforeEach(() => { wrapper = mountComponent({ - provide: { hasIssues: false, isSignedIn: false }, + provide: { hasProjectIssues: false, isSignedIn: false }, }); }); @@ -430,6 +439,119 @@ describe('IssuesListApp component', () => { }); }); + describe('tokens', () => { + const mockCurrentUser = { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'avatar/url', + }; + + describe('when user is signed out', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + isSignedIn: false, + }, + }); + }); + + it('does not render My-Reaction or Confidential tokens', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_CONFIDENTIAL }, + ]); + }); + }); + + describe('when iterations are not available', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + projectIterationsPath: '', + }, + }); + }); + + it('does not render Iteration token', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_ITERATION }, + ]); + }); + }); + + describe('when epics are not available', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + groupEpicsPath: '', + }, + }); + }); + + it('does not render Epic token', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_EPIC }, + ]); + }); + }); + + describe('when weights are not available', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + groupEpicsPath: '', + }, + }); + }); + + it('does not render Weight token', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_WEIGHT }, + ]); + }); + }); + + describe('when all tokens are available', () => { + const originalGon = window.gon; + + beforeEach(() => { + window.gon = { + ...originalGon, + current_user_id: mockCurrentUser.id, + current_user_fullname: mockCurrentUser.name, + current_username: mockCurrentUser.username, + current_user_avatar_url: mockCurrentUser.avatar_url, + }; + + wrapper = mountComponent({ + provide: { + isSignedIn: true, + projectIterationsPath: 'project/iterations/path', + groupEpicsPath: 'group/epics/path', + hasIssueWeightsFeature: true, + }, + }); + }); + + it('renders all tokens', () => { + expect(findIssuableList().props('searchTokens')).toMatchObject([ + { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_MILESTONE }, + { type: TOKEN_TYPE_LABEL }, + { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_CONFIDENTIAL }, + { type: TOKEN_TYPE_ITERATION }, + { type: TOKEN_TYPE_EPIC }, + { type: TOKEN_TYPE_WEIGHT }, + ]); + }); + }); + }); + describe('events', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index ce2880d177a..99267fb6e31 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -21,8 +21,8 @@ export const locationSearch = [ 'confidential=no', 'iteration_title=season:+%234', 'not[iteration_title]=season:+%2320', - 'epic_id=12', - 'not[epic_id]=34', + 'epic_id=gitlab-org%3A%3A%2612', + 'not[epic_id]=gitlab-org%3A%3A%2634', 'weight=1', 'not[weight]=3', ].join('&'); @@ -53,8 +53,8 @@ export const filteredTokens = [ { type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } }, - { type: 'epic_id', value: { data: '12', operator: OPERATOR_IS } }, - { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, + { type: 'epic_id', value: { data: 'gitlab-org::&12', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: 'gitlab-org::&34', operator: OPERATOR_IS_NOT } }, { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, { type: 'filtered-search-term', value: { data: 'find' } }, @@ -84,7 +84,7 @@ export const apiParams = { iteration_title: 'season: #4', 'not[iteration_title]': 'season: #20', epic_id: '12', - 'not[epic_id]': '34', + 'not[epic_id]': 'gitlab-org::&34', weight: '1', 'not[weight]': '3', }; @@ -111,8 +111,8 @@ export const urlParams = { confidential: 'no', iteration_title: 'season: #4', 'not[iteration_title]': 'season: #20', - epic_id: '12', - 'not[epic_id]': '34', + epic_id: 'gitlab-org%3A%3A%2612', + 'not[epic_id]': 'gitlab-org::&34', weight: '1', 'not[weight]': '3', }; diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues_list/utils_spec.js index 17127753972..e377c35a0aa 100644 --- a/spec/frontend/issues_list/utils_spec.js +++ b/spec/frontend/issues_list/utils_spec.js @@ -82,7 +82,10 @@ describe('getFilterTokens', () => { describe('convertToParams', () => { it('returns api params given filtered tokens', () => { - expect(convertToParams(filteredTokens, API_PARAM)).toEqual(apiParams); + expect(convertToParams(filteredTokens, API_PARAM)).toEqual({ + ...apiParams, + epic_id: 'gitlab-org::&12', + }); }); it('returns api params given filtered tokens with special values', () => { @@ -92,7 +95,10 @@ describe('convertToParams', () => { }); it('returns url params given filtered tokens', () => { - expect(convertToParams(filteredTokens, URL_PARAM)).toEqual(urlParams); + expect(convertToParams(filteredTokens, URL_PARAM)).toEqual({ + ...urlParams, + epic_id: 'gitlab-org::&12', + }); }); it('returns url params given filtered tokens with special values', () => { diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index 9f49cb4007a..172b6e4831c 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -73,6 +73,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-label="Will be mapped to" class="gl-icon s16" data-testid="arrow-right-icon" + role="img" > { }); it('shows the time of import', () => { - expect(getParagraphText()).toContain('Time of import: Apr 8, 2020 12:17pm GMT+0000'); + expect(getParagraphText()).toContain('Time of import: Apr 8, 2020 12:17pm UTC'); }); it('shows the project key of the import', () => { 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 9d1135e26c8..482d0df4e9a 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 } from '@gitlab/ui'; +import { GlSkeletonLoader, GlAlert, GlEmptyState, GlPagination } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -25,6 +25,10 @@ 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 createMockApolloProvider = (handler) => { const requestHandlers = [[getJobsQuery, handler]]; @@ -32,8 +36,17 @@ describe('Job table app', () => { return createMockApollo(requestHandlers); }; - const createComponent = (handler = successHandler, mountFn = shallowMount) => { + const createComponent = ({ + handler = successHandler, + mountFn = shallowMount, + data = {}, + } = {}) => { wrapper = mountFn(JobsTableApp, { + data() { + return { + ...data, + }; + }, provide: { projectPath, }, @@ -52,6 +65,7 @@ describe('Job table app', () => { expect(findSkeletonLoader().exists()).toBe(true); expect(findTable().exists()).toBe(false); + expect(findPagination().exists()).toBe(false); }); }); @@ -65,9 +79,10 @@ 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 retfech jobs query on fetchJobsByStatus event', async () => { + it('should refetch jobs query on fetchJobsByStatus event', async () => { jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); @@ -78,9 +93,72 @@ describe('Job table app', () => { }); }); + describe('pagination', () => { + it('should disable the next page button on the last page', async () => { + createComponent({ + handler: successHandler, + mountFn: mount, + data: { + pagination: { + currentPage: 3, + }, + jobs: { + pageInfo: { + hasPreviousPage: true, + startCursor: 'abc', + endCursor: 'bcd', + }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + wrapper.setData({ + jobs: { + pageInfo: { + hasNextPage: false, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + 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: successHandler, + mountFn: mount, + data: { + pagination: { + currentPage: 1, + }, + jobs: { + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'abc', + endCursor: 'bcd', + }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(findPrevious().exists()).toBe(true); + expect(findPrevious().classes('disabled')).toBe(true); + expect(findNext().exists()).toBe(true); + }); + }); + describe('error state', () => { it('should show an alert if there is an error fetching the data', async () => { - createComponent(failedHandler); + createComponent({ handler: failedHandler }); await waitForPromises(); @@ -90,7 +168,7 @@ describe('Job table app', () => { describe('empty state', () => { it('should display empty state if there are no jobs and tab scope is null', async () => { - createComponent(emptyHandler, mount); + createComponent({ handler: emptyHandler, mountFn: mount }); await waitForPromises(); @@ -99,7 +177,7 @@ describe('Job table app', () => { }); it('should not display empty state if there are jobs and tab scope is not null', async () => { - createComponent(successHandler, mount); + createComponent({ handler: successHandler, mountFn: mount }); await waitForPromises(); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 6180cd8e94d..df0ccb19cb7 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -101,13 +101,13 @@ describe('Date time utils', () => { it('should format date properly', () => { const formattedDate = datetimeUtility.formatDate(new Date('07/23/2016')); - expect(formattedDate).toBe('Jul 23, 2016 12:00am GMT+0000'); + expect(formattedDate).toBe('Jul 23, 2016 12:00am UTC'); }); it('should format ISO date properly', () => { const formattedDate = datetimeUtility.formatDate('2016-07-23T00:00:00.559Z'); - expect(formattedDate).toBe('Jul 23, 2016 12:00am GMT+0000'); + expect(formattedDate).toBe('Jul 23, 2016 12:00am UTC'); }); it('should throw an error if date is invalid', () => { @@ -878,7 +878,7 @@ describe('localTimeAgo', () => { it.each` timeagoArg | title ${false} | ${'some time'} - ${true} | ${'Feb 18, 2020 10:22pm GMT+0000'} + ${true} | ${'Feb 18, 2020 10:22pm UTC'} `('converts $seconds seconds to $approximation', ({ timeagoArg, title }) => { const element = document.querySelector('time'); datetimeUtility.localTimeAgo($(element), timeagoArg); @@ -889,17 +889,6 @@ describe('localTimeAgo', () => { }); }); -describe('dateFromParams', () => { - it('returns the expected date object', () => { - const expectedDate = new Date('2019-07-17T00:00:00.000Z'); - const date = datetimeUtility.dateFromParams(2019, 6, 17); - - expect(date.getYear()).toBe(expectedDate.getYear()); - expect(date.getMonth()).toBe(expectedDate.getMonth()); - expect(date.getDate()).toBe(expectedDate.getDate()); - }); -}); - describe('differenceInSeconds', () => { const startDateTime = new Date('2019-07-17T00:00:00.000Z'); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index f4483f5098b..e743678ea90 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -80,18 +80,22 @@ describe('Number Utils', () => { describe('numberToHumanSize', () => { it('should return bytes', () => { expect(numberToHumanSize(654)).toEqual('654 bytes'); + expect(numberToHumanSize(-654)).toEqual('-654 bytes'); }); it('should return KiB', () => { expect(numberToHumanSize(1079)).toEqual('1.05 KiB'); + expect(numberToHumanSize(-1079)).toEqual('-1.05 KiB'); }); it('should return MiB', () => { expect(numberToHumanSize(10485764)).toEqual('10.00 MiB'); + expect(numberToHumanSize(-10485764)).toEqual('-10.00 MiB'); }); it('should return GiB', () => { expect(numberToHumanSize(10737418240)).toEqual('10.00 GiB'); + expect(numberToHumanSize(-10737418240)).toEqual('-10.00 GiB'); }); }); diff --git a/spec/frontend/lib/utils/table_utility_spec.js b/spec/frontend/lib/utils/table_utility_spec.js new file mode 100644 index 00000000000..75b9252aa40 --- /dev/null +++ b/spec/frontend/lib/utils/table_utility_spec.js @@ -0,0 +1,11 @@ +import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; +import * as tableUtils from '~/lib/utils/table_utility'; + +describe('table_utility', () => { + describe('thWidthClass', () => { + it('returns the width class including default table header classes', () => { + const width = 50; + expect(tableUtils.thWidthClass(width)).toBe(`gl-w-${width}p ${DEFAULT_TH_CLASSES}`); + }); + }); +}); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index e12cd8b0e37..305d3de3c53 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -471,6 +471,7 @@ describe('URL utility', () => { ${'notaurl'} | ${false} ${'../relative_url'} | ${false} ${''} | ${false} + ${'//other-host.test'} | ${false} `('returns $valid for $url', ({ url, valid }) => { expect(urlUtils.isRootRelative(url)).toBe(valid); }); @@ -650,45 +651,24 @@ describe('URL utility', () => { }); describe('queryToObject', () => { - it('converts search query into an object', () => { - const searchQuery = '?one=1&two=2'; - - expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' }); - }); - - it('removes undefined values from the search query', () => { - const searchQuery = '?one=1&two=2&three'; - - expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' }); - }); - - describe('with gatherArrays=false', () => { - it('overwrites values with the same array-key and does not change the key', () => { - const searchQuery = '?one[]=1&one[]=2&two=2&two=3'; - - expect(urlUtils.queryToObject(searchQuery)).toEqual({ 'one[]': '2', two: '3' }); - }); - }); - - describe('with gatherArrays=true', () => { - const options = { gatherArrays: true }; - it('gathers only values with the same array-key and strips `[]` from the key', () => { - const searchQuery = '?one[]=1&one[]=2&two=2&two=3'; - - expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: ['1', '2'], two: '3' }); - }); - - it('overwrites values with the same array-key name', () => { - const searchQuery = '?one=1&one[]=2&two=2&two=3'; - - expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: ['2'], two: '3' }); - }); - - it('overwrites values with the same key name', () => { - const searchQuery = '?one[]=1&one=2&two=2&two=3'; - - expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: '2', two: '3' }); - }); + it.each` + case | query | options | result + ${'converts query'} | ${'?one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'converts query without ?'} | ${'one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'removes undefined values'} | ${'?one=1&two=2&three'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'overwrites values with same key and does not change key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${undefined} | ${{ 'one[]': '2', two: '3' }} + ${'gathers values with the same array-key, strips `[]` from key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['1', '2'], two: '3' }} + ${'overwrites values with the same array-key name'} | ${'?one=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['2'], two: '3' }} + ${'overwrites values with the same key name'} | ${'?one[]=1&one=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: '2', two: '3' }} + ${'ignores plus symbols'} | ${'?search=a+b'} | ${{ legacySpacesDecode: true }} | ${{ search: 'a+b' }} + ${'ignores plus symbols in keys'} | ${'?search+term=a'} | ${{ legacySpacesDecode: true }} | ${{ 'search+term': 'a' }} + ${'ignores plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true, legacySpacesDecode: true }} | ${{ search: ['a+b'] }} + ${'replaces plus symbols with spaces'} | ${'?search=a+b'} | ${undefined} | ${{ search: 'a b' }} + ${'replaces plus symbols in keys with spaces'} | ${'?search+term=a'} | ${undefined} | ${{ 'search term': 'a' }} + ${'replaces plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true }} | ${{ search: ['a b'] }} + ${'replaces plus symbols when gathering arrays for values with same key'} | ${'?search[]=a+b&search[]=c+d'} | ${{ gatherArrays: true }} | ${{ search: ['a b', 'c d'] }} + `('$case', ({ query, options, result }) => { + expect(urlUtils.queryToObject(query, options)).toEqual(result); }); }); @@ -798,15 +778,29 @@ describe('URL utility', () => { ); }); - it('handles arrays properly', () => { + it('adds parameters from arrays', () => { const url = 'https://gitlab.com/test'; - expect(urlUtils.setUrlParams({ label_name: ['foo', 'bar'] }, url)).toEqual( - 'https://gitlab.com/test?label_name=foo&label_name=bar', + expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url)).toEqual( + 'https://gitlab.com/test?labels=foo&labels=bar', ); }); - it('handles arrays properly when railsArraySyntax=true', () => { + 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'); + }); + + 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( + 'https://gitlab.com/test?unrelated=unrelated', + ); + }); + + it('adds parameters from arrays when railsArraySyntax=true', () => { const url = 'https://gitlab.com/test'; expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true)).toEqual( @@ -814,6 +808,14 @@ 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( + 'https://gitlab.com/test', + ); + }); + it('decodes URI when decodeURI=true', () => { const url = 'https://gitlab.com/test'; diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js index b40d9d7d5e2..b107708ac2c 100644 --- a/spec/frontend/logs/components/environment_logs_spec.js +++ b/spec/frontend/logs/components/environment_logs_spec.js @@ -12,7 +12,6 @@ import { mockTrace, mockEnvironmentsEndpoint, mockDocumentationPath, - mockManagedAppsEndpoint, } from '../mock_data'; jest.mock('~/lib/utils/scroll_utils'); @@ -35,7 +34,7 @@ describe('EnvironmentLogs', () => { environmentName: mockEnvName, environmentsPath: mockEnvironmentsEndpoint, clusterApplicationsDocumentationPath: mockDocumentationPath, - clustersPath: mockManagedAppsEndpoint, + clustersPath: '/gitlab-org', }; const updateControlBtnsMock = jest.fn(); diff --git a/spec/frontend/logs/mock_data.js b/spec/frontend/logs/mock_data.js index 3fabab4bc59..14c8f7a2ba2 100644 --- a/spec/frontend/logs/mock_data.js +++ b/spec/frontend/logs/mock_data.js @@ -7,8 +7,6 @@ export const mockDocumentationPath = '/documentation.md'; export const mockLogsEndpoint = '/dummy_logs_path.json'; export const mockCursor = 'MOCK_CURSOR'; export const mockNextCursor = 'MOCK_NEXT_CURSOR'; -export const mockManagedAppName = 'kubernetes-cluster-1'; -export const mockManagedAppsEndpoint = `${mockProjectPath}/clusters.json`; const makeMockEnvironment = (id, name, advancedQuerying) => ({ id, @@ -25,31 +23,6 @@ export const mockEnvironments = [ makeMockEnvironment(102, 'review/a-feature', false), ]; -export const mockManagedApps = [ - { - cluster_type: 'project_type', - enabled: true, - environment_scope: '*', - name: 'kubernetes-cluster-1', - provider_type: 'user', - status: 'connected', - path: '/root/autodevops-deploy/-/clusters/15', - gitlab_managed_apps_logs_path: '/root/autodevops-deploy/-/logs?cluster_id=15', - enable_advanced_logs_querying: true, - }, - { - cluster_type: 'project_type', - enabled: true, - environment_scope: '*', - name: 'kubernetes-cluster-2', - provider_type: 'user', - status: 'connected', - path: '/root/autodevops-deploy/-/clusters/16', - gitlab_managed_apps_logs_path: null, - enable_advanced_logs_querying: false, - }, -]; - export const mockPodName = 'production-764c58d697-aaaaa'; export const mockPods = [ mockPodName, diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js index d5118bbde8c..9307a3b62fb 100644 --- a/spec/frontend/logs/stores/actions_spec.js +++ b/spec/frontend/logs/stores/actions_spec.js @@ -11,7 +11,6 @@ import { fetchEnvironments, fetchLogs, fetchMoreLogsPrepend, - fetchManagedApps, } from '~/logs/stores/actions'; import * as types from '~/logs/stores/mutation_types'; import logsPageState from '~/logs/stores/state'; @@ -31,8 +30,6 @@ import { mockResponse, mockCursor, mockNextCursor, - mockManagedApps, - mockManagedAppsEndpoint, } from '../mock_data'; jest.mock('~/flash'); @@ -219,30 +216,6 @@ describe('Logs Store actions', () => { }); }); - describe('fetchManagedApps', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - it('should commit RECEIVE_MANAGED_APPS_DATA_SUCCESS mutation on succesful fetch', () => { - mock.onGet(mockManagedAppsEndpoint).replyOnce(200, { clusters: mockManagedApps }); - return testAction(fetchManagedApps, mockManagedAppsEndpoint, state, [ - { type: types.RECEIVE_MANAGED_APPS_DATA_SUCCESS, payload: mockManagedApps }, - ]); - }); - - it('should commit RECEIVE_MANAGED_APPS_DATA_ERROR on wrong data', () => { - mock.onGet(mockManagedAppsEndpoint).replyOnce(500); - return testAction( - fetchManagedApps, - mockManagedAppsEndpoint, - state, - [{ type: types.RECEIVE_MANAGED_APPS_DATA_ERROR }], - [], - ); - }); - }); - describe('when the backend responds succesfully', () => { let expectedMutations; let expectedActions; diff --git a/spec/frontend/logs/stores/getters_spec.js b/spec/frontend/logs/stores/getters_spec.js index bca1ce4ca92..9d213d8c01f 100644 --- a/spec/frontend/logs/stores/getters_spec.js +++ b/spec/frontend/logs/stores/getters_spec.js @@ -1,14 +1,7 @@ import { trace, showAdvancedFilters } from '~/logs/stores/getters'; import logsPageState from '~/logs/stores/state'; -import { - mockLogsResult, - mockTrace, - mockEnvName, - mockEnvironments, - mockManagedApps, - mockManagedAppName, -} from '../mock_data'; +import { mockLogsResult, mockTrace, mockEnvName, mockEnvironments } from '../mock_data'; describe('Logs Store getters', () => { let state; @@ -79,43 +72,4 @@ describe('Logs Store getters', () => { }); }); }); - - describe('when no managedApps are set', () => { - beforeEach(() => { - state.environments.current = null; - state.environments.options = []; - state.managedApps.current = mockManagedAppName; - state.managedApps.options = []; - }); - - it('returns false', () => { - expect(showAdvancedFilters(state)).toBe(false); - }); - }); - - describe('when the managedApp supports filters', () => { - beforeEach(() => { - state.environments.current = null; - state.environments.options = mockEnvironments; - state.managedApps.current = mockManagedAppName; - state.managedApps.options = mockManagedApps; - }); - - it('returns true', () => { - expect(showAdvancedFilters(state)).toBe(true); - }); - }); - - describe('when the managedApp does not support filters', () => { - beforeEach(() => { - state.environments.current = null; - state.environments.options = mockEnvironments; - state.managedApps.options = mockManagedApps; - state.managedApps.current = mockManagedApps[1].name; - }); - - it('returns false', () => { - expect(showAdvancedFilters(state)).toBe(false); - }); - }); }); diff --git a/spec/frontend/logs/stores/mutations_spec.js b/spec/frontend/logs/stores/mutations_spec.js index 111c795ba52..988197a8350 100644 --- a/spec/frontend/logs/stores/mutations_spec.js +++ b/spec/frontend/logs/stores/mutations_spec.js @@ -11,8 +11,6 @@ import { mockSearch, mockCursor, mockNextCursor, - mockManagedApps, - mockManagedAppName, } from '../mock_data'; describe('Logs Store Mutations', () => { @@ -32,15 +30,6 @@ describe('Logs Store Mutations', () => { it('sets the environment', () => { mutations[types.SET_PROJECT_ENVIRONMENT](state, mockEnvName); expect(state.environments.current).toEqual(mockEnvName); - expect(state.managedApps.current).toBe(null); - }); - }); - - describe('SET_MANAGED_APP', () => { - it('sets the managed app', () => { - mutations[types.SET_MANAGED_APP](state, mockManagedAppName); - expect(state.managedApps.current).toBe(mockManagedAppName); - expect(state.environments.current).toBe(null); }); }); @@ -265,29 +254,4 @@ describe('Logs Store Mutations', () => { ); }); }); - - describe('RECEIVE_MANAGED_APPS_DATA_SUCCESS', () => { - it('receives managed apps data success', () => { - expect(state.managedApps.options).toEqual([]); - - mutations[types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, mockManagedApps); - - expect(state.managedApps.options.length).toEqual(1); - expect(state.managedApps.options).toEqual([mockManagedApps[0]]); - expect(state.managedApps.isLoading).toBe(false); - }); - }); - - describe('RECEIVE_MANAGED_APPS_DATA_ERROR', () => { - it('received managed apps data error', () => { - mutations[types.RECEIVE_MANAGED_APPS_DATA_ERROR](state); - - expect(state.managedApps).toEqual({ - options: [], - isLoading: false, - current: null, - fetchError: true, - }); - }); - }); }); diff --git a/spec/frontend/members/components/app_spec.js b/spec/frontend/members/components/app_spec.js index 05933e36b52..b9fdf8792fd 100644 --- a/spec/frontend/members/components/app_spec.js +++ b/spec/frontend/members/components/app_spec.js @@ -33,7 +33,7 @@ describe('MembersApp', () => { wrapper = shallowMount(MembersApp, { localVue, - provide: { + propsData: { namespace: MEMBER_TYPES.user, }, store, diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js index 28614b52706..6f1a6d0c223 100644 --- a/spec/frontend/members/components/members_tabs_spec.js +++ b/spec/frontend/members/components/members_tabs_spec.js @@ -6,7 +6,7 @@ import MembersTabs from '~/members/components/members_tabs.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { pagination } from '../mock_data'; -describe('MembersApp', () => { +describe('MembersTabs', () => { Vue.use(Vuex); let wrapper; @@ -111,10 +111,10 @@ describe('MembersApp', () => { const membersApps = wrapper.findAllComponents(MembersApp).wrappers; - expect(membersApps[0].attributes('namespace')).toBe(MEMBER_TYPES.user); - expect(membersApps[1].attributes('namespace')).toBe(MEMBER_TYPES.group); - expect(membersApps[2].attributes('namespace')).toBe(MEMBER_TYPES.invite); - expect(membersApps[3].attributes('namespace')).toBe(MEMBER_TYPES.accessRequest); + expect(membersApps[0].props('namespace')).toBe(MEMBER_TYPES.user); + expect(membersApps[1].props('namespace')).toBe(MEMBER_TYPES.group); + expect(membersApps[2].props('namespace')).toBe(MEMBER_TYPES.invite); + expect(membersApps[3].props('namespace')).toBe(MEMBER_TYPES.accessRequest); }); }); diff --git a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js index 01279581c55..313c237f51c 100644 --- a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js @@ -109,6 +109,6 @@ describe('RemoveGroupLinkModal', () => { it('modal does not show when `removeGroupLinkModalVisible` is `false`', () => { createComponent({ removeGroupLinkModalVisible: false }); - expect(findModal().vm.$attrs.visible).toBe(false); + expect(findModal().props().visible).toBe(false); }); }); diff --git a/spec/frontend/members/components/table/expires_at_spec.js b/spec/frontend/members/components/table/expires_at_spec.js index 02fe3c6d684..2b8e6ab8f2a 100644 --- a/spec/frontend/members/components/table/expires_at_spec.js +++ b/spec/frontend/members/components/table/expires_at_spec.js @@ -54,7 +54,7 @@ describe('ExpiresAt', () => { const tooltipDirective = getTooltipDirective(expiredText); expect(tooltipDirective).not.toBeUndefined(); - expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000'); + expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am UTC'); }); }); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index c8b6bead450..a4a4c620921 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -88,7 +88,7 @@ describe('RoleDropdown', () => { }); it('renders dropdown header', () => { - expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true); + expect(getByTextInDropdownMenu('Change role').exists()).toBe(true); }); it('sets dropdown toggle and checks selected role', () => { diff --git a/spec/frontend/members/index_spec.js b/spec/frontend/members/index_spec.js index b07534ae4ed..efabe54f238 100644 --- a/spec/frontend/members/index_spec.js +++ b/spec/frontend/members/index_spec.js @@ -1,5 +1,5 @@ import { createWrapper } from '@vue/test-utils'; -import MembersApp from '~/members/components/app.vue'; +import MembersTabs from '~/members/components/members_tabs.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { initMembersApp } from '~/members/index'; import { members, pagination, dataAttribute } from './mock_data'; @@ -11,12 +11,13 @@ describe('initMembersApp', () => { const setup = () => { vm = initMembersApp(el, { - namespace: MEMBER_TYPES.user, - tableFields: ['account'], - tableAttrs: { table: { 'data-qa-selector': 'members_list' } }, - tableSortableFields: ['account'], - requestFormatter: () => ({}), - filteredSearchBar: { show: false }, + [MEMBER_TYPES.user]: { + tableFields: ['account'], + tableAttrs: { table: { 'data-qa-selector': 'members_list' } }, + tableSortableFields: ['account'], + requestFormatter: () => ({}), + filteredSearchBar: { show: false }, + }, }); wrapper = createWrapper(vm); }; @@ -35,10 +36,10 @@ describe('initMembersApp', () => { wrapper = null; }); - it('renders `MembersApp`', () => { + it('renders `MembersTabs`', () => { setup(); - expect(wrapper.find(MembersApp).exists()).toBe(true); + expect(wrapper.find(MembersTabs).exists()).toBe(true); }); it('parses and sets `members` in Vuex store', () => { diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index d0a7c36349b..4275db5fa9f 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -1,3 +1,5 @@ +import { MEMBER_TYPES } from '~/members/constants'; + export const member = { requestedAt: null, canUpdate: false, @@ -28,6 +30,7 @@ export const member = { usingLicense: false, groupSso: false, groupManagedAccount: false, + provisionedByThisGroup: false, validRoles: { Guest: 10, Reporter: 20, @@ -97,10 +100,12 @@ export const pagination = { }; export const dataAttribute = JSON.stringify({ - members, - pagination: paginationData, + [MEMBER_TYPES.user]: { + members, + pagination: paginationData, + member_path: '/groups/foo-bar/-/group_members/:id', + ldap_override_path: '/groups/ldap-group/-/group_members/:id/override', + }, source_id: 234, can_manage_members: true, - member_path: '/groups/foo-bar/-/group_members/:id', - ldap_override_path: '/groups/ldap-group/-/group_members/:id/override', }); diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 72696979722..9740e1c2edb 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -1,4 +1,4 @@ -import { DEFAULT_SORT } from '~/members/constants'; +import { DEFAULT_SORT, MEMBER_TYPES } from '~/members/constants'; import { generateBadges, isGroup, @@ -268,11 +268,13 @@ describe('Members Utils', () => { it('correctly parses the data attribute', () => { expect(parseDataAttributes(el)).toMatchObject({ - members, - pagination, + [MEMBER_TYPES.user]: { + members, + pagination, + memberPath: '/groups/foo-bar/-/group_members/:id', + }, sourceId: 234, canManageMembers: true, - memberPath: '/groups/foo-bar/-/group_members/:id', }); }); }); diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js index 1f0597bac67..9bf9e8ad7cc 100644 --- a/spec/frontend/monitoring/alert_widget_spec.js +++ b/spec/frontend/monitoring/alert_widget_spec.js @@ -1,7 +1,7 @@ import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import AlertWidget from '~/monitoring/components/alert_widget.vue'; const mockReadAlert = jest.fn(); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 98503636d33..08f9e07244f 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -7,7 +7,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` environmentstate="available" metricsdashboardbasepath="/monitoring/monitor-project/-/environments/1/metrics" metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" - prometheusstatus="" > diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index afa63bcff29..754ddd96c9b 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -208,7 +208,7 @@ describe('Time series component', () => { }); it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (GMT+0000)'); + expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); }); it('formats tooltip content', () => { @@ -282,7 +282,7 @@ describe('Time series component', () => { }); it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (GMT+0000)'); + expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); }); it('formats tooltip sha', () => { @@ -301,7 +301,7 @@ describe('Time series component', () => { }); it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (GMT+0000)'); + expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); }); it('formats tooltip sha', () => { @@ -334,7 +334,7 @@ describe('Time series component', () => { it('formats tooltip title and sets tooltip content', () => { const formattedTooltipData = wrapper.vm.formatAnnotationsTooltipText(mockMarkPoint); - expect(formattedTooltipData.title).toBe('19 Feb 2020, 10:01AM (GMT+0000)'); + expect(formattedTooltipData.title).toBe('19 Feb 2020, 10:01AM (UTC)'); expect(formattedTooltipData.content).toBe(annotationsMetadata.tooltipData.content); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index a72dbbd0f41..c8951dff9ed 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -778,5 +778,31 @@ describe('Dashboard Panel', () => { expect(findRunbookLinks().at(0).attributes('href')).toBe(invalidUrl); }); }); + + describe('managed alert deprecation feature flag', () => { + beforeEach(() => { + setMetricsSavedToDb([metricId]); + }); + + it('shows alerts when alerts are not deprecated', () => { + createWrapper( + { alertsEndpoint: '/endpoint', prometheusAlertsAvailable: true }, + { provide: { glFeatures: { managedAlertsDeprecation: false } } }, + ); + + expect(findAlertsWidget().exists()).toBe(true); + expect(findMenuItemByText('Alerts').exists()).toBe(true); + }); + + it('hides alerts when alerts are deprecated', () => { + createWrapper( + { alertsEndpoint: '/endpoint', prometheusAlertsAvailable: true }, + { provide: { glFeatures: { managedAlertsDeprecation: true } } }, + ); + + expect(findAlertsWidget().exists()).toBe(false); + expect(findMenuItemByText('Alerts').exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 0c2f85c7298..7ca1b97d849 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,9 +1,8 @@ -import { GlAlert } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import VueDraggable from 'vuedraggable'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { ESC_KEY } from '~/lib/utils/keys'; import { objectToQuery } from '~/lib/utils/url_utility'; @@ -17,7 +16,6 @@ import LinksSection from '~/monitoring/components/links_section.vue'; import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; -import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import { metricsDashboardViewModel, metricsDashboardPanelCount, @@ -41,7 +39,7 @@ describe('Dashboard', () => { let mock; const createShallowWrapper = (props = {}, options = {}) => { - wrapper = shallowMount(Dashboard, { + wrapper = shallowMountExtended(Dashboard, { propsData: { ...dashboardProps, ...props }, store, stubs: { @@ -53,7 +51,7 @@ describe('Dashboard', () => { }; const createMountedWrapper = (props = {}, options = {}) => { - wrapper = mount(Dashboard, { + wrapper = mountExtended(Dashboard, { propsData: { ...dashboardProps, ...props }, store, stubs: { @@ -818,24 +816,28 @@ describe('Dashboard', () => { }); }); - describe('deprecation notice', () => { + describe('alerts deprecation', () => { beforeEach(() => { setupStoreWithData(store); }); - const findDeprecationNotice = () => - wrapper.find(AlertDeprecationWarning).findComponent(GlAlert); - - it('shows the deprecation notice when available', () => { - createMountedWrapper({}, { provide: { hasManagedPrometheus: true } }); - - expect(findDeprecationNotice().exists()).toBe(true); - }); - - it('hides the deprecation notice when not available', () => { - createMountedWrapper(); - - expect(findDeprecationNotice().exists()).toBe(false); - }); + const findDeprecationNotice = () => wrapper.findByTestId('alerts-deprecation-warning'); + + it.each` + managedAlertsDeprecation | hasManagedPrometheus | isVisible + ${false} | ${false} | ${false} + ${false} | ${true} | ${true} + ${true} | ${false} | ${false} + ${true} | ${true} | ${false} + `( + 'when the deprecation feature flag is $managedAlertsDeprecation and has managed prometheus is $hasManagedPrometheus', + ({ hasManagedPrometheus, managedAlertsDeprecation, isVisible }) => { + createMountedWrapper( + {}, + { provide: { hasManagedPrometheus, glFeatures: { managedAlertsDeprecation } } }, + ); + expect(findDeprecationNotice().exists()).toBe(isVisible); + }, + ); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 090613b0f1e..bea263f143a 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { queryToObject, diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index b7f741c449f..f60c531e3f6 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { backoffMockImplementation } from 'helpers/backoff_helper'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; import statusCodes from '~/lib/utils/http_status'; @@ -257,9 +257,9 @@ describe('Monitoring store actions', () => { 'receiveMetricsDashboardFailure', new Error('Request failed with status code 500'), ); - expect(createFlash).toHaveBeenCalledWith( - expect.stringContaining(mockDashboardsErrorResponse.message), - ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringContaining(mockDashboardsErrorResponse.message), + }); done(); }) .catch(done.fail); @@ -1148,9 +1148,9 @@ describe('Monitoring store actions', () => { return testAction(fetchVariableMetricLabelValues, { defaultQueryParams }, state, [], []).then( () => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - expect.stringContaining('error getting options for variable "label1"'), - ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringContaining('error getting options for variable "label1"'), + }); }, ); }); diff --git a/spec/frontend/nav/components/responsive_app_spec.js b/spec/frontend/nav/components/responsive_app_spec.js new file mode 100644 index 00000000000..7221ea2c5cd --- /dev/null +++ b/spec/frontend/nav/components/responsive_app_spec.js @@ -0,0 +1,173 @@ +import { shallowMount } from '@vue/test-utils'; +import ResponsiveApp from '~/nav/components/responsive_app.vue'; +import ResponsiveHeader from '~/nav/components/responsive_header.vue'; +import ResponsiveHome from '~/nav/components/responsive_home.vue'; +import TopNavContainerView from '~/nav/components/top_nav_container_view.vue'; +import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '~/nav/event_hub'; +import { resetMenuItemsActive } from '~/nav/utils/reset_menu_items_active'; +import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; +import { TEST_NAV_DATA } from '../mock_data'; + +const HTML_HEADER_CONTENT = '
'; +const HTML_MENU_EXPANDED = ''; +const HTML_HEADER_WITH_MENU_EXPANDED = + '
'; + +describe('~/nav/components/responsive_app.vue', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(ResponsiveApp, { + propsData: { + navData: TEST_NAV_DATA, + }, + stubs: { + KeepAliveSlots, + }, + }); + }; + const triggerResponsiveToggle = () => eventHub.$emit(EVENT_RESPONSIVE_TOGGLE); + + const findHome = () => wrapper.findComponent(ResponsiveHome); + const findMobileOverlay = () => wrapper.find('[data-testid="mobile-overlay"]'); + const findSubviewHeader = () => wrapper.findComponent(ResponsiveHeader); + const findSubviewContainer = () => wrapper.findComponent(TopNavContainerView); + const hasBodyResponsiveOpen = () => document.body.classList.contains('top-nav-responsive-open'); + const hasMobileOverlayVisible = () => findMobileOverlay().classes('mobile-nav-open'); + + beforeEach(() => { + document.body.innerHTML = ''; + // Add test class to reset state + assert that we're adding classes correctly + document.body.className = 'test-class'; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows home by default', () => { + expect(findHome().isVisible()).toBe(true); + expect(findHome().props()).toEqual({ + navData: resetMenuItemsActive(TEST_NAV_DATA), + }); + }); + + it.each` + bodyHtml | expectation + ${''} | ${false} + ${HTML_HEADER_CONTENT} | ${false} + ${HTML_MENU_EXPANDED} | ${false} + ${HTML_HEADER_WITH_MENU_EXPANDED} | ${true} + `( + 'with responsive toggle event and html set to $bodyHtml, responsive open = $expectation', + ({ bodyHtml, expectation }) => { + document.body.innerHTML = bodyHtml; + + triggerResponsiveToggle(); + + expect(hasBodyResponsiveOpen()).toBe(expectation); + }, + ); + + it.each` + events | expectation + ${[]} | ${false} + ${['bv::dropdown::show']} | ${true} + ${['bv::dropdown::show', 'bv::dropdown::hide']} | ${false} + `( + 'with root events $events, movile overlay visible = $expectation', + async ({ events, expectation }) => { + // `await...reduce(async` is like doing an `forEach(async (...))` excpet it works + await events.reduce(async (acc, evt) => { + await acc; + + wrapper.vm.$root.$emit(evt); + + await wrapper.vm.$nextTick(); + }, Promise.resolve()); + + expect(hasMobileOverlayVisible()).toBe(expectation); + }, + ); + }); + + describe('with menu expanded in body', () => { + beforeEach(() => { + document.body.innerHTML = HTML_HEADER_WITH_MENU_EXPANDED; + createComponent(); + }); + + it('sets the body responsive open', () => { + expect(hasBodyResponsiveOpen()).toBe(true); + }); + }); + + const projectsContainerProps = { + containerClass: 'gl-px-3', + frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.namespace, + frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.vuexModule, + linksPrimary: TEST_NAV_DATA.views.projects.linksPrimary, + linksSecondary: TEST_NAV_DATA.views.projects.linksSecondary, + }; + const groupsContainerProps = { + containerClass: 'gl-px-3', + frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_GROUPS.namespace, + frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_GROUPS.vuexModule, + linksPrimary: TEST_NAV_DATA.views.groups.linksPrimary, + linksSecondary: TEST_NAV_DATA.views.groups.linksSecondary, + }; + + describe.each` + view | header | containerProps + ${'projects'} | ${'Projects'} | ${projectsContainerProps} + ${'groups'} | ${'Groups'} | ${groupsContainerProps} + `('when menu item with $view is clicked', ({ view, header, containerProps }) => { + beforeEach(async () => { + createComponent(); + + findHome().vm.$emit('menu-item-click', { view }); + + await wrapper.vm.$nextTick(); + }); + + it('shows header', () => { + expect(findSubviewHeader().text()).toBe(header); + }); + + it('shows container subview', () => { + expect(findSubviewContainer().props()).toEqual(containerProps); + }); + + it('hides home', () => { + expect(findHome().isVisible()).toBe(false); + }); + + describe('when header back button is clicked', () => { + beforeEach(() => { + findSubviewHeader().vm.$emit('menu-item-click', { view: 'home' }); + }); + + it('shows home', () => { + expect(findHome().isVisible()).toBe(true); + }); + }); + }); + + describe('when destroyed', () => { + beforeEach(() => { + createComponent(); + wrapper.destroy(); + }); + + it('responsive toggle event does nothing', () => { + triggerResponsiveToggle(); + + expect(hasBodyResponsiveOpen()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/nav/components/responsive_header_spec.js b/spec/frontend/nav/components/responsive_header_spec.js new file mode 100644 index 00000000000..937c44727c7 --- /dev/null +++ b/spec/frontend/nav/components/responsive_header_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ResponsiveHeader from '~/nav/components/responsive_header.vue'; +import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; + +const TEST_SLOT_CONTENT = 'Test slot content'; + +describe('~/nav/components/top_nav_menu_sections.vue', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(ResponsiveHeader, { + slots: { + default: TEST_SLOT_CONTENT, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findMenuItem = () => wrapper.findComponent(TopNavMenuItem); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders slot', () => { + expect(wrapper.text()).toBe(TEST_SLOT_CONTENT); + }); + + it('renders back button', () => { + const button = findMenuItem(); + + const tooltip = getBinding(button.element, 'gl-tooltip').value.title; + + expect(tooltip).toBe('Go back'); + expect(button.props()).toEqual({ + menuItem: { + id: 'home', + view: 'home', + icon: 'angle-left', + }, + iconOnly: true, + }); + }); + + it('emits nothing', () => { + expect(wrapper.emitted()).toEqual({}); + }); + + describe('when back button is clicked', () => { + beforeEach(() => { + findMenuItem().vm.$emit('click'); + }); + + it('emits menu-item-click', () => { + expect(wrapper.emitted()).toEqual({ + 'menu-item-click': [[{ id: 'home', view: 'home', icon: 'angle-left' }]], + }); + }); + }); +}); diff --git a/spec/frontend/nav/components/responsive_home_spec.js b/spec/frontend/nav/components/responsive_home_spec.js new file mode 100644 index 00000000000..8f198d92747 --- /dev/null +++ b/spec/frontend/nav/components/responsive_home_spec.js @@ -0,0 +1,137 @@ +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ResponsiveHome from '~/nav/components/responsive_home.vue'; +import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; +import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; +import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue'; +import { TEST_NAV_DATA } from '../mock_data'; + +const TEST_SEARCH_MENU_ITEM = { + id: 'search', + title: 'search', + icon: 'search', + href: '/search', +}; + +const TEST_NEW_DROPDOWN_VIEW_MODEL = { + title: 'new', + menu_sections: [], +}; + +describe('~/nav/components/responsive_home.vue', () => { + let wrapper; + let menuItemClickListener; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ResponsiveHome, { + propsData: { + navData: TEST_NAV_DATA, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + listeners: { + 'menu-item-click': menuItemClickListener, + }, + }); + }; + + const findSearchMenuItem = () => wrapper.findComponent(TopNavMenuItem); + const findNewDropdown = () => wrapper.findComponent(TopNavNewDropdown); + const findMenuSections = () => wrapper.findComponent(TopNavMenuSections); + + beforeEach(() => { + menuItemClickListener = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + desc | fn + ${'does not show search menu item'} | ${findSearchMenuItem} + ${'does not show new dropdown'} | ${findNewDropdown} + `('$desc', ({ fn }) => { + expect(fn().exists()).toBe(false); + }); + + it('shows menu sections', () => { + expect(findMenuSections().props('sections')).toEqual([ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, + { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, + ]); + }); + + it('emits when menu sections emits', () => { + expect(menuItemClickListener).not.toHaveBeenCalled(); + + findMenuSections().vm.$emit('menu-item-click', TEST_NAV_DATA.primary[0]); + + expect(menuItemClickListener).toHaveBeenCalledWith(TEST_NAV_DATA.primary[0]); + }); + }); + + describe('without secondary', () => { + beforeEach(() => { + createComponent({ navData: { ...TEST_NAV_DATA, secondary: null } }); + }); + + it('shows menu sections', () => { + expect(findMenuSections().props('sections')).toEqual([ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, + ]); + }); + }); + + describe('with search view', () => { + beforeEach(() => { + createComponent({ + navData: { + ...TEST_NAV_DATA, + views: { search: TEST_SEARCH_MENU_ITEM }, + }, + }); + }); + + it('shows search menu item', () => { + expect(findSearchMenuItem().props()).toEqual({ + menuItem: TEST_SEARCH_MENU_ITEM, + iconOnly: true, + }); + }); + + it('shows tooltip for search', () => { + const tooltip = getBinding(findSearchMenuItem().element, 'gl-tooltip'); + expect(tooltip.value).toEqual({ title: TEST_SEARCH_MENU_ITEM.title }); + }); + }); + + describe('with new view', () => { + beforeEach(() => { + createComponent({ + navData: { + ...TEST_NAV_DATA, + views: { new: TEST_NEW_DROPDOWN_VIEW_MODEL }, + }, + }); + }); + + it('shows new dropdown', () => { + expect(findNewDropdown().props()).toEqual({ + viewModel: TEST_NEW_DROPDOWN_VIEW_MODEL, + }); + }); + + it('shows tooltip for new dropdown', () => { + const tooltip = getBinding(findNewDropdown().element, 'gl-tooltip'); + expect(tooltip.value).toEqual({ title: TEST_NEW_DROPDOWN_VIEW_MODEL.title }); + }); + }); +}); diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js index 06700ce748e..1d6ea99155b 100644 --- a/spec/frontend/nav/components/top_nav_app_spec.js +++ b/spec/frontend/nav/components/top_nav_app_spec.js @@ -1,5 +1,5 @@ -import { GlNavItemDropdown, GlTooltip } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { GlNavItemDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import TopNavApp from '~/nav/components/top_nav_app.vue'; import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; import { TEST_NAV_DATA } from '../mock_data'; @@ -7,8 +7,8 @@ import { TEST_NAV_DATA } from '../mock_data'; describe('~/nav/components/top_nav_app.vue', () => { let wrapper; - const createComponent = (mountFn = shallowMount) => { - wrapper = mountFn(TopNavApp, { + const createComponent = () => { + wrapper = shallowMount(TopNavApp, { propsData: { navData: TEST_NAV_DATA, }, @@ -17,7 +17,6 @@ describe('~/nav/components/top_nav_app.vue', () => { const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown); const findMenu = () => wrapper.findComponent(TopNavDropdownMenu); - const findTooltip = () => wrapper.findComponent(GlTooltip); afterEach(() => { wrapper.destroy(); @@ -31,7 +30,7 @@ describe('~/nav/components/top_nav_app.vue', () => { it('renders nav item dropdown', () => { expect(findNavItemDropdown().attributes('href')).toBeUndefined(); expect(findNavItemDropdown().attributes()).toMatchObject({ - icon: 'dot-grid', + icon: 'hamburger', text: TEST_NAV_DATA.activeTitle, 'no-flip': '', }); @@ -44,25 +43,5 @@ describe('~/nav/components/top_nav_app.vue', () => { views: TEST_NAV_DATA.views, }); }); - - it('renders tooltip', () => { - expect(findTooltip().attributes()).toMatchObject({ - 'boundary-padding': '0', - placement: 'right', - title: TopNavApp.TOOLTIP, - }); - }); - }); - - describe('when full mounted', () => { - beforeEach(() => { - createComponent(mount); - }); - - it('has dropdown toggle as tooltip target', () => { - const targetFn = findTooltip().props('target'); - - expect(targetFn()).toBe(wrapper.find('.js-top-nav-dropdown-toggle').element); - }); }); }); diff --git a/spec/frontend/nav/components/top_nav_container_view_spec.js b/spec/frontend/nav/components/top_nav_container_view_spec.js index b08d75f36ce..06d2179b859 100644 --- a/spec/frontend/nav/components/top_nav_container_view_spec.js +++ b/spec/frontend/nav/components/top_nav_container_view_spec.js @@ -4,7 +4,7 @@ import FrequentItemsApp from '~/frequent_items/components/app.vue'; import { FREQUENT_ITEMS_PROJECTS } from '~/frequent_items/constants'; import eventHub from '~/frequent_items/event_hub'; import TopNavContainerView from '~/nav/components/top_nav_container_view.vue'; -import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; +import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; import { TEST_NAV_DATA } from '../mock_data'; @@ -13,39 +13,39 @@ const DEFAULT_PROPS = { frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule, linksPrimary: TEST_NAV_DATA.primary, linksSecondary: TEST_NAV_DATA.secondary, + containerClass: 'test-frequent-items-container-class', }; const TEST_OTHER_PROPS = { namespace: 'projects', - currentUserName: '', - currentItem: {}, + currentUserName: 'test-user', + currentItem: { id: 'test' }, }; describe('~/nav/components/top_nav_container_view.vue', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = (props = {}, options = {}) => { wrapper = shallowMount(TopNavContainerView, { propsData: { ...DEFAULT_PROPS, ...TEST_OTHER_PROPS, ...props, }, + ...options, }); }; - const findMenuItems = (parent = wrapper) => parent.findAll(TopNavMenuItem); - const findMenuItemsModel = (parent = wrapper) => - findMenuItems(parent).wrappers.map((x) => x.props()); - const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]'); - const findMenuItemGroupsModel = () => findMenuItemGroups().wrappers.map(findMenuItemsModel); + const findMenuSections = () => wrapper.findComponent(TopNavMenuSections); const findFrequentItemsApp = () => { const parent = wrapper.findComponent(VuexModuleProvider); return { vuexModule: parent.props('vuexModule'), props: parent.findComponent(FrequentItemsApp).props(), + attributes: parent.findComponent(FrequentItemsApp).attributes(), }; }; + const findFrequentItemsContainer = () => wrapper.find('[data-testid="frequent-items-container"]'); afterEach(() => { wrapper.destroy(); @@ -67,34 +67,40 @@ describe('~/nav/components/top_nav_container_view.vue', () => { ); describe('default', () => { + const EXTRA_ATTRS = { 'data-test-attribute': 'foo' }; + beforeEach(() => { - createComponent(); + createComponent({}, { attrs: EXTRA_ATTRS }); + }); + + it('does not inherit extra attrs', () => { + expect(wrapper.attributes()).toEqual({ + class: expect.any(String), + }); }); it('renders frequent items app', () => { expect(findFrequentItemsApp()).toEqual({ vuexModule: DEFAULT_PROPS.frequentItemsVuexModule, - props: TEST_OTHER_PROPS, + props: expect.objectContaining(TEST_OTHER_PROPS), + attributes: expect.objectContaining(EXTRA_ATTRS), }); }); - it('renders menu item groups', () => { - expect(findMenuItemGroupsModel()).toEqual([ - TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })), - TEST_NAV_DATA.secondary.map((menuItem) => ({ menuItem })), - ]); - }); - - it('only the first group does not have margin top', () => { - expect(findMenuItemGroups().wrappers.map((x) => x.classes('gl-mt-3'))).toEqual([false, true]); + it('renders given container class', () => { + expect(findFrequentItemsContainer().classes(DEFAULT_PROPS.containerClass)).toBe(true); }); - it('only the first menu item does not have margin top', () => { - const actual = findMenuItems(findMenuItemGroups().at(1)).wrappers.map((x) => - x.classes('gl-mt-1'), - ); + it('renders menu sections', () => { + const sections = [ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, + { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, + ]; - expect(actual).toEqual([false, ...TEST_NAV_DATA.secondary.slice(1).fill(true)]); + expect(findMenuSections().props()).toEqual({ + sections, + withTopBorder: true, + }); }); }); @@ -106,8 +112,8 @@ describe('~/nav/components/top_nav_container_view.vue', () => { }); it('renders one menu item group', () => { - expect(findMenuItemGroupsModel()).toEqual([ - TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })), + expect(findMenuSections().props('sections')).toEqual([ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, ]); }); }); diff --git a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js index d9bba22238a..70df05a2781 100644 --- a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js +++ b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js @@ -1,67 +1,62 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; +import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; +import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; import { TEST_NAV_DATA } from '../mock_data'; -const SECONDARY_GROUP_CLASSES = TopNavDropdownMenu.SECONDARY_GROUP_CLASS.split(' '); - describe('~/nav/components/top_nav_dropdown_menu.vue', () => { let wrapper; - const createComponent = (props = {}) => { - wrapper = shallowMount(TopNavDropdownMenu, { + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(TopNavDropdownMenu, { propsData: { primary: TEST_NAV_DATA.primary, secondary: TEST_NAV_DATA.secondary, views: TEST_NAV_DATA.views, ...props, }, + stubs: { + // Stub the keep-alive-slots so we don't render frequent items which uses a store + KeepAliveSlots: true, + }, }); }; - const findMenuItems = (parent = wrapper) => parent.findAll('[data-testid="menu-item"]'); - const findMenuItemsModel = (parent = wrapper) => - findMenuItems(parent).wrappers.map((x) => ({ - menuItem: x.props('menuItem'), - isActive: x.classes('active'), - })); - const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]'); - const findMenuItemGroupsModel = () => - findMenuItemGroups().wrappers.map((x) => ({ - classes: x.classes(), - items: findMenuItemsModel(x), - })); + const findMenuItems = () => wrapper.findAllComponents(TopNavMenuItem); + const findMenuSections = () => wrapper.find(TopNavMenuSections); const findMenuSidebar = () => wrapper.find('[data-testid="menu-sidebar"]'); const findMenuSubview = () => wrapper.findComponent(KeepAliveSlots); const hasFullWidthMenuSidebar = () => findMenuSidebar().classes('gl-w-full'); - const createItemsGroupModelExpectation = ({ - primary = TEST_NAV_DATA.primary, - secondary = TEST_NAV_DATA.secondary, - activeIndex = -1, - } = {}) => [ - { - classes: [], - items: primary.map((menuItem, index) => ({ isActive: index === activeIndex, menuItem })), - }, - { - classes: SECONDARY_GROUP_CLASSES, - items: secondary.map((menuItem) => ({ isActive: false, menuItem })), - }, - ]; + const withActiveIndex = (menuItems, activeIndex) => + menuItems.map((x, idx) => ({ + ...x, + active: idx === activeIndex, + })); afterEach(() => { wrapper.destroy(); }); + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(); + }); + describe('default', () => { beforeEach(() => { createComponent(); }); - it('renders menu item groups', () => { - expect(findMenuItemGroupsModel()).toEqual(createItemsGroupModelExpectation()); + it('renders menu sections', () => { + expect(findMenuSections().props()).toEqual({ + sections: [ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, + { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, + ], + withTopBorder: false, + }); }); it('has full width menu sidebar', () => { @@ -74,36 +69,25 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => { expect(subview.isVisible()).toBe(false); expect(subview.props()).toEqual({ slotKey: '' }); }); - - it('the first menu item in a group does not render margin top', () => { - const actual = findMenuItems(findMenuItemGroups().at(0)).wrappers.map((x) => - x.classes('gl-mt-1'), - ); - - expect(actual).toEqual([false, ...TEST_NAV_DATA.primary.slice(1).fill(true)]); - }); }); describe('with pre-initialized active view', () => { - const primaryWithActive = [ - TEST_NAV_DATA.primary[0], - { - ...TEST_NAV_DATA.primary[1], - active: true, - }, - ...TEST_NAV_DATA.primary.slice(2), - ]; - beforeEach(() => { - createComponent({ - primary: primaryWithActive, - }); + // We opt for a small integration test, to make sure the event is handled correctly + // as it would in prod. + createComponent( + { + primary: withActiveIndex(TEST_NAV_DATA.primary, 1), + }, + mount, + ); }); - it('renders menu item groups', () => { - expect(findMenuItemGroupsModel()).toEqual( - createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 1 }), - ); + it('renders menu sections', () => { + expect(findMenuSections().props('sections')).toStrictEqual([ + { id: 'primary', menuItems: withActiveIndex(TEST_NAV_DATA.primary, 1) }, + { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, + ]); }); it('does not have full width menu sidebar', () => { @@ -114,11 +98,11 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => { const subview = findMenuSubview(); expect(subview.isVisible()).toBe(true); - expect(subview.props('slotKey')).toBe(primaryWithActive[1].view); + expect(subview.props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view); }); it('does not change view if non-view menu item is clicked', async () => { - const secondaryLink = findMenuItems().at(primaryWithActive.length); + const secondaryLink = findMenuItems().at(TEST_NAV_DATA.primary.length); // Ensure this doesn't have a view expect(secondaryLink.props('menuItem').view).toBeUndefined(); @@ -127,10 +111,10 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => { await nextTick(); - expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[1].view); + expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view); }); - describe('when other view menu item is clicked', () => { + describe('when menu item is clicked', () => { let primaryLink; beforeEach(async () => { @@ -144,13 +128,20 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => { }); it('changes active view', () => { - expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[0].view); + expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[0].view); }); it('changes active status on menu item', () => { - expect(findMenuItemGroupsModel()).toStrictEqual( - createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 0 }), - ); + expect(findMenuSections().props('sections')).toStrictEqual([ + { + id: 'primary', + menuItems: withActiveIndex(TEST_NAV_DATA.primary, 0), + }, + { + id: 'secondary', + menuItems: withActiveIndex(TEST_NAV_DATA.secondary, -1), + }, + ]); }); }); }); diff --git a/spec/frontend/nav/components/top_nav_menu_item_spec.js b/spec/frontend/nav/components/top_nav_menu_item_spec.js index 579af13d08a..fd2b4d3b056 100644 --- a/spec/frontend/nav/components/top_nav_menu_item_spec.js +++ b/spec/frontend/nav/components/top_nav_menu_item_spec.js @@ -7,6 +7,7 @@ const TEST_MENU_ITEM = { icon: 'search', href: '/pretty/good/burger', view: 'burger-view', + data: { qa_selector: 'not-a-real-selector', method: 'post', testFoo: 'test' }, }; describe('~/nav/components/top_nav_menu_item.vue', () => { @@ -29,7 +30,10 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { const findButtonIcons = () => findButton() .findAllComponents(GlIcon) - .wrappers.map((x) => x.props('name')); + .wrappers.map((x) => ({ + name: x.props('name'), + classes: x.classes(), + })); beforeEach(() => { listener = jest.fn(); @@ -47,6 +51,16 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { expect(button.text()).toBe(TEST_MENU_ITEM.title); }); + it('renders button data attributes', () => { + const button = findButton(); + + expect(button.attributes()).toMatchObject({ + 'data-qa-selector': TEST_MENU_ITEM.data.qa_selector, + 'data-method': TEST_MENU_ITEM.data.method, + 'data-test-foo': TEST_MENU_ITEM.data.testFoo, + }); + }); + it('passes listeners to button', () => { expect(listener).not.toHaveBeenCalled(); @@ -54,11 +68,42 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { expect(listener).toHaveBeenCalledWith('TEST'); }); + + it('renders expected icons', () => { + expect(findButtonIcons()).toEqual([ + { + name: TEST_MENU_ITEM.icon, + classes: ['gl-mr-2!'], + }, + { + name: 'chevron-right', + classes: ['gl-ml-auto'], + }, + ]); + }); + }); + + describe('with icon-only', () => { + beforeEach(() => { + createComponent({ iconOnly: true }); + }); + + it('does not render title or view icon', () => { + expect(wrapper.text()).toBe(''); + }); + + it('only renders menuItem icon', () => { + expect(findButtonIcons()).toEqual([ + { + name: TEST_MENU_ITEM.icon, + classes: [], + }, + ]); + }); }); describe.each` desc | menuItem | expectedIcons - ${'default'} | ${TEST_MENU_ITEM} | ${[TEST_MENU_ITEM.icon, 'chevron-right']} ${'with no icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']} ${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]} ${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]} @@ -68,7 +113,32 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { }); it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => { - expect(findButtonIcons()).toEqual(expectedIcons); + expect(findButtonIcons().map((x) => x.name)).toEqual(expectedIcons); + }); + }); + + describe.each` + desc | active | cssClass | expectedClasses + ${'default'} | ${false} | ${''} | ${[]} + ${'with css class'} | ${false} | ${'test-css-class testing-123'} | ${['test-css-class', 'testing-123']} + ${'with css class & active'} | ${true} | ${'test-css-class'} | ${['test-css-class', ...TopNavMenuItem.ACTIVE_CLASS.split(' ')]} + `('$desc', ({ active, cssClass, expectedClasses }) => { + beforeEach(() => { + createComponent({ + menuItem: { + ...TEST_MENU_ITEM, + active, + css_class: cssClass, + }, + }); + }); + + it('renders expected classes', () => { + expect(wrapper.classes()).toStrictEqual([ + 'top-nav-menu-item', + 'gl-display-block', + ...expectedClasses, + ]); }); }); }); diff --git a/spec/frontend/nav/components/top_nav_menu_sections_spec.js b/spec/frontend/nav/components/top_nav_menu_sections_spec.js new file mode 100644 index 00000000000..d56542fe572 --- /dev/null +++ b/spec/frontend/nav/components/top_nav_menu_sections_spec.js @@ -0,0 +1,107 @@ +import { shallowMount } from '@vue/test-utils'; +import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; + +const TEST_SECTIONS = [ + { + id: 'primary', + menuItems: [{ id: 'test', href: '/test/href' }, { id: 'foo' }, { id: 'bar' }], + }, + { + id: 'secondary', + menuItems: [{ id: 'lorem' }, { id: 'ipsum' }], + }, +]; + +describe('~/nav/components/top_nav_menu_sections.vue', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TopNavMenuSections, { + propsData: { + sections: TEST_SECTIONS, + ...props, + }, + }); + }; + + const findMenuItemModels = (parent) => + parent.findAll('[data-testid="menu-item"]').wrappers.map((x) => ({ + menuItem: x.props('menuItem'), + classes: x.classes(), + })); + const findSectionModels = () => + wrapper.findAll('[data-testid="menu-section"]').wrappers.map((x) => ({ + classes: x.classes(), + menuItems: findMenuItemModels(x), + })); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders sections with menu items', () => { + expect(findSectionModels()).toEqual([ + { + classes: [], + menuItems: [ + { + menuItem: TEST_SECTIONS[0].menuItems[0], + classes: ['gl-w-full'], + }, + ...TEST_SECTIONS[0].menuItems.slice(1).map((menuItem) => ({ + menuItem, + classes: ['gl-w-full', 'gl-mt-1'], + })), + ], + }, + { + classes: [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-mt-3'], + menuItems: [ + { + menuItem: TEST_SECTIONS[1].menuItems[0], + classes: ['gl-w-full'], + }, + ...TEST_SECTIONS[1].menuItems.slice(1).map((menuItem) => ({ + menuItem, + classes: ['gl-w-full', 'gl-mt-1'], + })), + ], + }, + ]); + }); + + it('when clicked menu item with href, does nothing', () => { + const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(0); + + menuItem.vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({}); + }); + + it('when clicked menu item without href, emits "menu-item-click"', () => { + const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(1); + + menuItem.vm.$emit('click'); + + expect(wrapper.emitted('menu-item-click')).toEqual([[TEST_SECTIONS[0].menuItems[1]]]); + }); + }); + + describe('with withTopBorder=true', () => { + beforeEach(() => { + createComponent({ withTopBorder: true }); + }); + + it('renders border classes for top section', () => { + expect(findSectionModels().map((x) => x.classes)).toEqual([ + [...TopNavMenuSections.BORDER_CLASSES.split(' ')], + [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-mt-3'], + ]); + }); + }); +}); diff --git a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js new file mode 100644 index 00000000000..18210658b89 --- /dev/null +++ b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js @@ -0,0 +1,122 @@ +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue'; + +const TEST_VIEW_MODEL = { + title: 'Dropdown', + menu_sections: [ + { + title: 'Section 1', + menu_items: [ + { id: 'foo-1', title: 'Foo 1', href: '/foo/1' }, + { id: 'foo-2', title: 'Foo 2', href: '/foo/2' }, + { id: 'foo-3', title: 'Foo 3', href: '/foo/3' }, + ], + }, + { + title: 'Section 2', + menu_items: [ + { id: 'bar-1', title: 'Bar 1', href: '/bar/1' }, + { id: 'bar-2', title: 'Bar 2', href: '/bar/2' }, + ], + }, + ], +}; + +describe('~/nav/components/top_nav_menu_sections.vue', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TopNavNewDropdown, { + propsData: { + viewModel: TEST_VIEW_MODEL, + ...props, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownContents = () => + findDropdown() + .findAll('[data-testid]') + .wrappers.map((child) => { + const type = child.attributes('data-testid'); + + if (type === 'divider') { + return { type }; + } else if (type === 'header') { + return { type, text: child.text() }; + } + + return { + type, + text: child.text(), + href: child.attributes('href'), + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders dropdown parent', () => { + expect(findDropdown().props()).toMatchObject({ + text: TEST_VIEW_MODEL.title, + textSrOnly: true, + icon: 'plus', + }); + }); + + it('renders dropdown content', () => { + expect(findDropdownContents()).toEqual([ + { + type: 'header', + text: TEST_VIEW_MODEL.menu_sections[0].title, + }, + ...TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({ + type: 'item', + href, + text: title, + })), + { + type: 'divider', + }, + { + type: 'header', + text: TEST_VIEW_MODEL.menu_sections[1].title, + }, + ...TEST_VIEW_MODEL.menu_sections[1].menu_items.map(({ title, href }) => ({ + type: 'item', + href, + text: title, + })), + ]); + }); + }); + + describe('with only 1 section', () => { + beforeEach(() => { + createComponent({ + viewModel: { + ...TEST_VIEW_MODEL, + menu_sections: TEST_VIEW_MODEL.menu_sections.slice(0, 1), + }, + }); + }); + + it('renders dropdown content without headers and dividers', () => { + expect(findDropdownContents()).toEqual( + TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({ + type: 'item', + href, + text: title, + })), + ); + }); + }); +}); diff --git a/spec/frontend/nav/mock_data.js b/spec/frontend/nav/mock_data.js index 2987d8deb16..c2ad86a4605 100644 --- a/spec/frontend/nav/mock_data.js +++ b/spec/frontend/nav/mock_data.js @@ -25,11 +25,15 @@ export const TEST_NAV_DATA = { namespace: 'projects', currentUserName: '', currentItem: {}, + linksPrimary: [{ id: 'project-link', href: '/path/to/projects', title: 'Project Link' }], + linksSecondary: [], }, groups: { namespace: 'groups', currentUserName: '', currentItem: {}, + linksPrimary: [], + linksSecondary: [{ id: 'group-link', href: '/path/to/groups', title: 'Group Link' }], }, }, }; diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index b140eea9439..537622b7918 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -328,20 +328,45 @@ describe('issue_comment_form component', () => { mountComponent({ mountFunction: mount }); }); - it('should save note when cmd+enter is pressed', () => { - jest.spyOn(wrapper.vm, 'handleSave'); + describe('when no draft exists', () => { + it('should save note when cmd+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); - findTextArea().trigger('keydown.enter', { metaKey: true }); + findTextArea().trigger('keydown.enter', { metaKey: true }); - expect(wrapper.vm.handleSave).toHaveBeenCalled(); + expect(wrapper.vm.handleSave).toHaveBeenCalledWith(); + }); + + it('should save note when ctrl+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); + + findTextArea().trigger('keydown.enter', { ctrlKey: true }); + + expect(wrapper.vm.handleSave).toHaveBeenCalledWith(); + }); }); - it('should save note when ctrl+enter is pressed', () => { - jest.spyOn(wrapper.vm, 'handleSave'); + describe('when a draft exists', () => { + beforeEach(() => { + store.registerModule('batchComments', batchComments()); + store.state.batchComments.drafts = [{ note: 'A' }]; + }); + + it('should save note draft when cmd+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSaveDraft'); + + findTextArea().trigger('keydown.enter', { metaKey: true }); + + expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); + }); + + it('should save note draft when ctrl+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSaveDraft'); - findTextArea().trigger('keydown.enter', { ctrlKey: true }); + findTextArea().trigger('keydown.enter', { ctrlKey: true }); - expect(wrapper.vm.handleSave).toHaveBeenCalled(); + expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); + }); }); }); }); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index c6a7d7ead98..925dbcc09ec 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -20,7 +20,7 @@ const createUnallowedNote = () => describe('DiscussionActions', () => { let wrapper; - const createComponentFactory = (shallow = true) => (props) => { + const createComponentFactory = (shallow = true) => (props, options) => { const store = createStore(); const mountFn = shallow ? shallowMount : mount; @@ -34,6 +34,7 @@ describe('DiscussionActions', () => { shouldShowJumpToNextDiscussion: true, ...props, }, + ...options, }); }; @@ -90,17 +91,17 @@ describe('DiscussionActions', () => { describe('events handling', () => { const createComponent = createComponentFactory(false); - beforeEach(() => { - createComponent(); - }); - it('emits showReplyForm event when clicking on reply placeholder', () => { + createComponent({}, { attachTo: document.body }); + jest.spyOn(wrapper.vm, '$emit'); wrapper.find(ReplyPlaceholder).find('textarea').trigger('focus'); expect(wrapper.vm.$emit).toHaveBeenCalledWith('showReplyForm'); }); it('emits resolve event when clicking on resolve button', () => { + createComponent(); + jest.spyOn(wrapper.vm, '$emit'); wrapper.find(ResolveDiscussionButton).find('button').trigger('click'); expect(wrapper.vm.$emit).toHaveBeenCalledWith('resolve'); diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js index 2a4cd0df0c7..3932f818c4e 100644 --- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js +++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js @@ -6,31 +6,34 @@ const placeholderText = 'Test Button Text'; describe('ReplyPlaceholder', () => { let wrapper; - const findTextarea = () => wrapper.find({ ref: 'textarea' }); - - beforeEach(() => { + const createComponent = ({ options = {} } = {}) => { wrapper = shallowMount(ReplyPlaceholder, { propsData: { placeholderText, }, + ...options, }); - }); + }; + + const findTextarea = () => wrapper.find({ ref: 'textarea' }); afterEach(() => { wrapper.destroy(); }); - it('emits focus event on button click', () => { - findTextarea().trigger('focus'); + it('emits focus event on button click', async () => { + createComponent({ options: { attachTo: document.body } }); + + await findTextarea().trigger('focus'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted()).toEqual({ - focus: [[]], - }); + expect(wrapper.emitted()).toEqual({ + focus: [[]], }); }); it('should render reply button', () => { + createComponent(); + expect(findTextarea().attributes('placeholder')).toEqual(placeholderText); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 735bc2b70dd..a364a524e7b 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -56,6 +56,18 @@ describe('noteable_discussion component', () => { expect(wrapper.find('.discussion-header').exists()).toBe(true); }); + it('should hide actions when diff refs do not exists', async () => { + const discussion = { ...discussionMock }; + discussion.diff_file = { ...mockDiffFile, diff_refs: null }; + discussion.diff_discussion = true; + discussion.expanded = false; + + wrapper.setProps({ discussion }); + await nextTick(); + + expect(wrapper.vm.canShowReplyActions).toBe(false); + }); + describe('actions', () => { it('should toggle reply form', async () => { await nextTick(); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 9b7456d54bc..7eef2017dfb 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -25,7 +25,19 @@ import { } from '../mock_data'; const TEST_ERROR_MESSAGE = 'Test error message'; -jest.mock('~/flash'); +const mockFlashClose = jest.fn(); +jest.mock('~/flash', () => { + const flash = jest.fn().mockImplementation(() => { + return { + close: mockFlashClose, + }; + }); + + return { + createFlash: flash, + deprecatedCreateFlash: flash, + }; +}); describe('Actions Notes Store', () => { let commit; @@ -254,42 +266,144 @@ describe('Actions Notes Store', () => { }); describe('poll', () => { - beforeEach((done) => { - axiosMock - .onGet(notesDataMock.notesPath) - .reply(200, { notes: [], last_fetched_at: '123456' }, { 'poll-interval': '1000' }); + const pollInterval = 6000; + const pollResponse = { notes: [], last_fetched_at: '123456' }; + const pollHeaders = { 'poll-interval': `${pollInterval}` }; + const successMock = () => + axiosMock.onGet(notesDataMock.notesPath).reply(200, pollResponse, pollHeaders); + const failureMock = () => axiosMock.onGet(notesDataMock.notesPath).reply(500); + const advanceAndRAF = async (time) => { + if (time) { + jest.advanceTimersByTime(time); + } + + return new Promise((resolve) => requestAnimationFrame(resolve)); + }; + const advanceXMoreIntervals = async (number) => { + const timeoutLength = pollInterval * number; + return advanceAndRAF(timeoutLength); + }; + const startPolling = async () => { + await store.dispatch('poll'); + await advanceAndRAF(2); + }; + const cleanUp = async () => { + jest.clearAllTimers(); + + return store.dispatch('stopPolling'); + }; + + beforeEach((done) => { store.dispatch('setNotesData', notesDataMock).then(done).catch(done.fail); }); - it('calls service with last fetched state', (done) => { - store - .dispatch('poll') - .then(() => { - jest.advanceTimersByTime(2); - }) - .then(() => new Promise((resolve) => requestAnimationFrame(resolve))) - .then(() => { - expect(store.state.lastFetchedAt).toBe('123456'); - - jest.advanceTimersByTime(1500); - }) - .then( - () => - new Promise((resolve) => { - requestAnimationFrame(resolve); - }), - ) - .then(() => { - const expectedGetRequests = 2; - expect(axiosMock.history.get.length).toBe(expectedGetRequests); - expect(axiosMock.history.get[expectedGetRequests - 1].headers).toMatchObject({ - 'X-Last-Fetched-At': '123456', - }); - }) - .then(() => store.dispatch('stopPolling')) - .then(done) - .catch(done.fail); + afterEach(() => { + return cleanUp(); + }); + + it('calls service with last fetched state', async () => { + successMock(); + + await startPolling(); + + expect(store.state.lastFetchedAt).toBe('123456'); + + await advanceXMoreIntervals(1); + + expect(axiosMock.history.get).toHaveLength(2); + expect(axiosMock.history.get[1].headers).toMatchObject({ + 'X-Last-Fetched-At': '123456', + }); + }); + + describe('polling side effects', () => { + it('retries twice', async () => { + failureMock(); + + await startPolling(); + + // This is the first request, not a retry + expect(axiosMock.history.get).toHaveLength(1); + + await advanceXMoreIntervals(1); + + // Retry #1 + expect(axiosMock.history.get).toHaveLength(2); + + await advanceXMoreIntervals(1); + + // Retry #2 + expect(axiosMock.history.get).toHaveLength(3); + + await advanceXMoreIntervals(10); + + // There are no more retries + expect(axiosMock.history.get).toHaveLength(3); + }); + + it('shows the error display on the second failure', async () => { + failureMock(); + + await startPolling(); + + expect(axiosMock.history.get).toHaveLength(1); + expect(Flash).not.toHaveBeenCalled(); + + await advanceXMoreIntervals(1); + + expect(axiosMock.history.get).toHaveLength(2); + expect(Flash).toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledTimes(1); + }); + + it('resets the failure counter on success', async () => { + // We can't get access to the actual counter in the polling closure. + // So we can infer that it's reset by ensuring that the error is only + // shown when we cause two failures in a row - no successes between + + axiosMock + .onGet(notesDataMock.notesPath) + .replyOnce(500) // cause one error + .onGet(notesDataMock.notesPath) + .replyOnce(200, pollResponse, pollHeaders) // then a success + .onGet(notesDataMock.notesPath) + .reply(500); // and then more errors + + await startPolling(); // Failure #1 + await advanceXMoreIntervals(1); // Success #1 + await advanceXMoreIntervals(1); // Failure #2 + + // That was the first failure AFTER a success, so we should NOT see the error displayed + expect(Flash).not.toHaveBeenCalled(); + + // Now we'll allow another failure + await advanceXMoreIntervals(1); // Failure #3 + + // Since this is the second failure in a row, the error should happen + expect(Flash).toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledTimes(1); + }); + + it('hides the error display if it exists on success', async () => { + jest.mock(); + failureMock(); + + await startPolling(); + await advanceXMoreIntervals(2); + + // After two errors, the error should be displayed + expect(Flash).toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledTimes(1); + + axiosMock.reset(); + successMock(); + + await advanceXMoreIntervals(1); + + expect(mockFlashClose).toHaveBeenCalled(); + expect(mockFlashClose).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js index 272e9b71f67..5eecfd395e2 100644 --- a/spec/frontend/operation_settings/components/metrics_settings_spec.js +++ b/spec/frontend/operation_settings/components/metrics_settings_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlLink, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { timezones } from '~/monitoring/format_date'; @@ -56,7 +56,7 @@ describe('operation settings external dashboard component', () => { it('renders header text', () => { mountComponent(); - expect(wrapper.find('.js-section-header').text()).toBe('Metrics dashboard'); + expect(wrapper.find('.js-section-header').text()).toBe('Metrics'); }); describe('expand/collapse button', () => { @@ -77,13 +77,13 @@ describe('operation settings external dashboard component', () => { }); it('renders descriptive text', () => { - expect(subHeader.text()).toContain('Manage Metrics Dashboard settings.'); + expect(subHeader.text()).toContain('Manage metrics dashboard settings.'); }); it('renders help page link', () => { const link = subHeader.find(GlLink); - expect(link.text()).toBe('Learn more'); + expect(link.text()).toBe('Learn more.'); expect(link.attributes().href).toBe(helpPage); }); }); @@ -203,10 +203,10 @@ describe('operation settings external dashboard component', () => { .$nextTick() .then(jest.runAllTicks) .then(() => - expect(createFlash).toHaveBeenCalledWith( - `There was an error saving your changes. ${message}`, - 'alert', - ), + expect(createFlash).toHaveBeenCalledWith({ + message: `There was an error saving your changes. ${message}`, + type: 'alert', + }), ); }); }); diff --git a/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap new file mode 100644 index 00000000000..881d441e116 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileSha renders 1`] = ` +
+ + + +
+ + bar: + foo + + +
+
+
+`; diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js index 11dad7ba34d..3132ec61942 100644 --- a/spec/frontend/packages/details/components/app_spec.js +++ b/spec/frontend/packages/details/components/app_spec.js @@ -1,5 +1,6 @@ -import { GlEmptyState, GlModal } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import stubChildren from 'helpers/stub_children'; @@ -34,6 +35,7 @@ describe('PackagesApp', () => { let store; const fetchPackageVersions = jest.fn(); const deletePackage = jest.fn(); + const deletePackageFile = jest.fn(); const defaultProjectName = 'bar'; const { location } = window; @@ -59,6 +61,7 @@ describe('PackagesApp', () => { actions: { deletePackage, fetchPackageVersions, + deletePackageFile, }, getters, }); @@ -82,8 +85,8 @@ describe('PackagesApp', () => { const packageTitle = () => wrapper.find(PackageTitle); const emptyState = () => wrapper.find(GlEmptyState); const deleteButton = () => wrapper.find('.js-delete-button'); - const deleteModal = () => wrapper.find(GlModal); - const modalDeleteButton = () => wrapper.find({ ref: 'modal-delete-button' }); + const findDeleteModal = () => wrapper.find({ ref: 'deleteModal' }); + const findDeleteFileModal = () => wrapper.find({ ref: 'deleteFileModal' }); const versionsTab = () => wrapper.find('.js-versions-tab > a'); const packagesLoader = () => wrapper.find(PackagesListLoader); const packagesVersionRows = () => wrapper.findAll(PackageListRow); @@ -107,10 +110,12 @@ describe('PackagesApp', () => { window.location = location; }); - it('renders the app and displays the package title', () => { + it('renders the app and displays the package title', async () => { createComponent(); - expect(packageTitle()).toExist(); + await nextTick(); + + expect(packageTitle().exists()).toBe(true); }); it('renders an empty state component when no an invalid package is passed as a prop', () => { @@ -118,7 +123,7 @@ describe('PackagesApp', () => { packageEntity: {}, }); - expect(emptyState()).toExist(); + expect(emptyState().exists()).toBe(true); }); it('package history has the right props', () => { @@ -152,7 +157,16 @@ describe('PackagesApp', () => { }); it('shows the delete confirmation modal when delete is clicked', () => { - expect(deleteModal()).toExist(); + expect(findDeleteModal().exists()).toBe(true); + }); + }); + + describe('deleting package files', () => { + it('shows the delete confirmation modal when delete is clicked', () => { + createComponent(); + findPackageFiles().vm.$emit('delete-file', mavenFiles[0]); + + expect(findDeleteFileModal().exists()).toBe(true); }); }); @@ -228,13 +242,7 @@ describe('PackagesApp', () => { }); describe('tracking and delete', () => { - const doDelete = async () => { - deleteButton().trigger('click'); - await wrapper.vm.$nextTick(); - modalDeleteButton().trigger('click'); - }; - - describe('delete', () => { + describe('delete package', () => { const originalReferrer = document.referrer; const setReferrer = (value = defaultProjectName) => { Object.defineProperty(document, 'referrer', { @@ -250,9 +258,9 @@ describe('PackagesApp', () => { }); }); - it('calls the proper vuex action', async () => { + it('calls the proper vuex action', () => { createComponent({ packageEntity: npmPackage }); - await doDelete(); + findDeleteModal().vm.$emit('primary'); expect(deletePackage).toHaveBeenCalled(); }); @@ -260,7 +268,7 @@ describe('PackagesApp', () => { setReferrer(); deletePackage.mockResolvedValue(); createComponent({ packageEntity: npmPackage }); - await doDelete(); + findDeleteModal().vm.$emit('primary'); await deletePackage(); expect(window.location.replace).toHaveBeenCalledWith( 'project_url?showSuccessDeleteAlert=true', @@ -271,7 +279,7 @@ describe('PackagesApp', () => { setReferrer('baz'); deletePackage.mockResolvedValue(); createComponent({ packageEntity: npmPackage }); - await doDelete(); + findDeleteModal().vm.$emit('primary'); await deletePackage(); expect(window.location.replace).toHaveBeenCalledWith( 'group_url?showSuccessDeleteAlert=true', @@ -279,6 +287,17 @@ describe('PackagesApp', () => { }); }); + describe('delete file', () => { + it('calls the proper vuex action', () => { + createComponent({ packageEntity: npmPackage }); + + findPackageFiles().vm.$emit('delete-file', mavenFiles[0]); + findDeleteFileModal().vm.$emit('primary'); + + expect(deletePackageFile).toHaveBeenCalled(); + }); + }); + describe('tracking', () => { let eventSpy; let utilSpy; @@ -295,9 +314,9 @@ describe('PackagesApp', () => { expect(utilSpy).toHaveBeenCalledWith('conan'); }); - it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, async () => { + it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => { createComponent({ packageEntity: npmPackage }); - await doDelete(); + findDeleteModal().vm.$emit('primary'); expect(eventSpy).toHaveBeenCalledWith( category, TrackingActions.DELETE_PACKAGE, @@ -305,6 +324,56 @@ describe('PackagesApp', () => { ); }); + it(`canceling a package deletion tracks ${TrackingActions.CANCEL_DELETE_PACKAGE}`, () => { + createComponent({ packageEntity: npmPackage }); + + findDeleteModal().vm.$emit('canceled'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.CANCEL_DELETE_PACKAGE, + expect.any(Object), + ); + }); + + it(`request a file deletion tracks ${TrackingActions.REQUEST_DELETE_PACKAGE_FILE}`, () => { + createComponent({ packageEntity: npmPackage }); + + findPackageFiles().vm.$emit('delete-file', mavenFiles[0]); + + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.REQUEST_DELETE_PACKAGE_FILE, + expect.any(Object), + ); + }); + + it(`confirming a file deletion tracks ${TrackingActions.DELETE_PACKAGE_FILE}`, () => { + createComponent({ packageEntity: npmPackage }); + + findPackageFiles().vm.$emit('delete-file', npmPackage); + findDeleteFileModal().vm.$emit('primary'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.REQUEST_DELETE_PACKAGE_FILE, + expect.any(Object), + ); + }); + + it(`canceling a file deletion tracks ${TrackingActions.CANCEL_DELETE_PACKAGE_FILE}`, () => { + createComponent({ packageEntity: npmPackage }); + + findPackageFiles().vm.$emit('delete-file', npmPackage); + findDeleteFileModal().vm.$emit('canceled'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.CANCEL_DELETE_PACKAGE_FILE, + expect.any(Object), + ); + }); + it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { createComponent({ packageEntity: conanPackage }); diff --git a/spec/frontend/packages/details/components/file_sha_spec.js b/spec/frontend/packages/details/components/file_sha_spec.js new file mode 100644 index 00000000000..7bfcf78baab --- /dev/null +++ b/spec/frontend/packages/details/components/file_sha_spec.js @@ -0,0 +1,33 @@ +import { shallowMount } from '@vue/test-utils'; + +import FileSha from '~/packages/details/components/file_sha.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +describe('FileSha', () => { + let wrapper; + + const defaultProps = { sha: 'foo', title: 'bar' }; + + function createComponent() { + wrapper = shallowMount(FileSha, { + propsData: { + ...defaultProps, + }, + stubs: { + ClipboardButton, + DetailsRow, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages/details/components/installations_commands_spec.js b/spec/frontend/packages/details/components/installations_commands_spec.js index 065bf503585..164f9f69741 100644 --- a/spec/frontend/packages/details/components/installations_commands_spec.js +++ b/spec/frontend/packages/details/components/installations_commands_spec.js @@ -7,6 +7,7 @@ import MavenInstallation from '~/packages/details/components/maven_installation. import NpmInstallation from '~/packages/details/components/npm_installation.vue'; import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; import { conanPackage, @@ -15,6 +16,7 @@ import { nugetPackage, pypiPackage, composerPackage, + terraformModule, } from '../../mock_data'; describe('InstallationCommands', () => { @@ -32,6 +34,7 @@ describe('InstallationCommands', () => { const nugetInstallation = () => wrapper.find(NugetInstallation); const pypiInstallation = () => wrapper.find(PypiInstallation); const composerInstallation = () => wrapper.find(ComposerInstallation); + const terraformInstallation = () => wrapper.findComponent(TerraformInstallation); afterEach(() => { wrapper.destroy(); @@ -46,6 +49,7 @@ describe('InstallationCommands', () => { ${nugetPackage} | ${nugetInstallation} ${pypiPackage} | ${pypiInstallation} ${composerPackage} | ${composerInstallation} + ${terraformModule} | ${terraformInstallation} `('renders', ({ packageEntity, selector }) => { it(`${packageEntity.package_type} instructions exist`, () => { createComponent({ packageEntity }); diff --git a/spec/frontend/packages/details/components/package_files_spec.js b/spec/frontend/packages/details/components/package_files_spec.js index bcf1b6d56f0..e8e5a24d3a3 100644 --- a/spec/frontend/packages/details/components/package_files_spec.js +++ b/spec/frontend/packages/details/components/package_files_spec.js @@ -1,4 +1,6 @@ +import { GlDropdown, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue/'; import stubChildren from 'helpers/stub_children'; import component from '~/packages/details/components/package_files.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -12,16 +14,21 @@ describe('Package Files', () => { const findAllRows = () => wrapper.findAll('[data-testid="file-row"'); const findFirstRow = () => findAllRows().at(0); const findSecondRow = () => findAllRows().at(1); - const findFirstRowDownloadLink = () => findFirstRow().find('[data-testid="download-link"'); - const findFirstRowCommitLink = () => findFirstRow().find('[data-testid="commit-link"'); - const findSecondRowCommitLink = () => findSecondRow().find('[data-testid="commit-link"'); + const findFirstRowDownloadLink = () => findFirstRow().find('[data-testid="download-link"]'); + const findFirstRowCommitLink = () => findFirstRow().find('[data-testid="commit-link"]'); + const findSecondRowCommitLink = () => findSecondRow().find('[data-testid="commit-link"]'); const findFirstRowFileIcon = () => findFirstRow().find(FileIcon); const findFirstRowCreatedAt = () => findFirstRow().find(TimeAgoTooltip); + const findFirstActionMenu = () => findFirstRow().findComponent(GlDropdown); + const findActionMenuDelete = () => findFirstActionMenu().find('[data-testid="delete-file"]'); + const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton); + const findFirstRowShaComponent = (id) => wrapper.find(`[data-testid="${id}"]`); - const createComponent = (packageFiles = npmFiles) => { + const createComponent = ({ packageFiles = npmFiles, canDelete = true } = {}) => { wrapper = mount(component, { propsData: { packageFiles, + canDelete, }, stubs: { ...stubChildren(component), @@ -43,7 +50,7 @@ describe('Package Files', () => { }); it('renders multiple files for a package that contains more than one file', () => { - createComponent(mavenFiles); + createComponent({ packageFiles: mavenFiles }); expect(findAllRows()).toHaveLength(2); }); @@ -123,7 +130,7 @@ describe('Package Files', () => { }); describe('when package file has no pipeline associated', () => { it('does not exist', () => { - createComponent(mavenFiles); + createComponent({ packageFiles: mavenFiles }); expect(findFirstRowCommitLink().exists()).toBe(false); }); @@ -131,11 +138,122 @@ describe('Package Files', () => { describe('when only one file lacks an associated pipeline', () => { it('renders the commit when it exists and not otherwise', () => { - createComponent([npmFiles[0], mavenFiles[0]]); + createComponent({ packageFiles: [npmFiles[0], mavenFiles[0]] }); expect(findFirstRowCommitLink().exists()).toBe(true); expect(findSecondRowCommitLink().exists()).toBe(false); }); }); + + describe('action menu', () => { + describe('when the user can delete', () => { + it('exists', () => { + createComponent(); + + expect(findFirstActionMenu().exists()).toBe(true); + }); + + describe('menu items', () => { + describe('delete file', () => { + it('exists', () => { + createComponent(); + + expect(findActionMenuDelete().exists()).toBe(true); + }); + + it('emits a delete event when clicked', () => { + createComponent(); + + findActionMenuDelete().vm.$emit('click'); + + const [[{ id }]] = wrapper.emitted('delete-file'); + expect(id).toBe(npmFiles[0].id); + }); + }); + }); + }); + + describe('when the user can not delete', () => { + const canDelete = false; + + it('does not exist', () => { + createComponent({ canDelete }); + + expect(findFirstActionMenu().exists()).toBe(false); + }); + }); + }); + }); + + describe('additional details', () => { + describe('details toggle button', () => { + it('exists', () => { + createComponent(); + + expect(findFirstToggleDetailsButton().exists()).toBe(true); + }); + + it('is hidden when no details is present', () => { + const [{ ...noShaFile }] = npmFiles; + noShaFile.file_sha256 = null; + noShaFile.file_md5 = null; + noShaFile.file_sha1 = null; + createComponent({ packageFiles: [noShaFile] }); + + expect(findFirstToggleDetailsButton().exists()).toBe(false); + }); + + it('toggles the details row', async () => { + createComponent(); + + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + + findFirstToggleDetailsButton().vm.$emit('click'); + await nextTick(); + + expect(findFirstRowShaComponent('sha-256').exists()).toBe(true); + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-up'); + + findFirstToggleDetailsButton().vm.$emit('click'); + await nextTick(); + + expect(findFirstRowShaComponent('sha-256').exists()).toBe(false); + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + }); + }); + + describe('file shas', () => { + const showShaFiles = () => { + findFirstToggleDetailsButton().vm.$emit('click'); + return nextTick(); + }; + + it.each` + selector | title | sha + ${'sha-256'} | ${'SHA-256'} | ${'file_sha256'} + ${'md5'} | ${'MD5'} | ${'file_md5'} + ${'sha-1'} | ${'SHA-1'} | ${'file_sha1'} + `('has a $title row', async ({ selector, title, sha }) => { + createComponent(); + + await showShaFiles(); + + expect(findFirstRowShaComponent(selector).props()).toMatchObject({ + title, + sha, + }); + }); + + it('does not display a row when the data is missing', async () => { + const [{ ...missingMd5 }] = npmFiles; + missingMd5.file_md5 = null; + + createComponent({ packageFiles: [missingMd5] }); + + await showShaFiles(); + + expect(findFirstRowShaComponent('md5').exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages/details/store/actions_spec.js index d11ee548b72..b16e50debc4 100644 --- a/spec/frontend/packages/details/store/actions_spec.js +++ b/spec/frontend/packages/details/store/actions_spec.js @@ -1,10 +1,18 @@ import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants'; -import { fetchPackageVersions, deletePackage } from '~/packages/details/store/actions'; +import { + fetchPackageVersions, + deletePackage, + deletePackageFile, +} from '~/packages/details/store/actions'; import * as types from '~/packages/details/store/mutation_types'; -import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; +import { + DELETE_PACKAGE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, +} from '~/packages/shared/constants'; import { npmPackage as packageEntity } from '../../mock_data'; jest.mock('~/flash.js'); @@ -74,7 +82,10 @@ describe('Actions Package details store', () => { packageEntity.project_id, packageEntity.id, ); - expect(createFlash).toHaveBeenCalledWith(FETCH_PACKAGE_VERSIONS_ERROR); + expect(createFlash).toHaveBeenCalledWith({ + message: FETCH_PACKAGE_VERSIONS_ERROR, + type: 'warning', + }); done(); }, ); @@ -96,7 +107,48 @@ describe('Actions Package details store', () => { Api.deleteProjectPackage = jest.fn().mockRejectedValue(); testAction(deletePackage, undefined, { packageEntity }, [], [], () => { - expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + type: 'warning', + }); + done(); + }); + }); + }); + + describe('deletePackageFile', () => { + const fileId = 'a_file_id'; + + it('should call Api.deleteProjectPackageFile and commit the right data', (done) => { + const packageFiles = [{ id: 'foo' }, { id: fileId }]; + Api.deleteProjectPackageFile = jest.fn().mockResolvedValue(); + testAction( + deletePackageFile, + fileId, + { packageEntity, packageFiles }, + [{ type: types.UPDATE_PACKAGE_FILES, payload: [{ id: 'foo' }] }], + [], + () => { + expect(Api.deleteProjectPackageFile).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + fileId, + ); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + type: 'success', + }); + done(); + }, + ); + }); + it('should create flash on API error', (done) => { + Api.deleteProjectPackageFile = jest.fn().mockRejectedValue(); + testAction(deletePackageFile, fileId, { packageEntity }, [], [], () => { + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + type: 'warning', + }); done(); }); }); diff --git a/spec/frontend/packages/details/store/mutations_spec.js b/spec/frontend/packages/details/store/mutations_spec.js index 6bc5fb7241f..296ed02d786 100644 --- a/spec/frontend/packages/details/store/mutations_spec.js +++ b/spec/frontend/packages/details/store/mutations_spec.js @@ -28,4 +28,13 @@ describe('Mutations package details Store', () => { expect(mockState.packageEntity.versions).toEqual(fakeVersions); }); }); + describe('UPDATE_PACKAGE_FILES', () => { + it('should update the packageFiles', () => { + const files = [1, 2, 3]; + + mutations[types.UPDATE_PACKAGE_FILES](mockState, files); + + expect(mockState.packageFiles).toEqual(files); + }); + }); }); diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js index 52966c1be5e..adccb7436e1 100644 --- a/spec/frontend/packages/list/stores/actions_spec.js +++ b/spec/frontend/packages/list/stores/actions_spec.js @@ -2,7 +2,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { MISSING_DELETE_PATH_ERROR } from '~/packages/list/constants'; import * as actions from '~/packages/list/stores/actions'; import * as types from '~/packages/list/stores/mutation_types'; @@ -241,7 +241,9 @@ describe('Actions Package list store', () => { `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => { testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => { expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR)); - expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + }); done(); }); }); diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js index 06009daba54..33b47cca68b 100644 --- a/spec/frontend/packages/mock_data.js +++ b/spec/frontend/packages/mock_data.js @@ -79,6 +79,9 @@ export const npmFiles = [ pipelines: [ { id: 1, project: { commit_url: 'http://foo.bar' }, git_commit_message: 'foo bar baz?' }, ], + file_sha256: 'file_sha256', + file_md5: 'file_md5', + file_sha1: 'file_sha1', }, ]; @@ -175,6 +178,20 @@ export const composerPackage = { version: '1.0.0', }; +export const terraformModule = { + created_at: '2015-12-10', + id: 2, + name: 'Test/system-22', + package_type: 'terraform_module', + project_path: 'foo/bar/baz', + projectPathName: 'foo/bar/baz', + project_id: 1, + updated_at: '2015-12-10', + version: '0.1', + versions: [], + _links, +}; + export const mockTags = [ { name: 'foo-1', diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap index f4e617ecafe..b576f1b2553 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -11,7 +11,7 @@ exports[`packages_list_row renders 1`] = `
+

+ Provision instructions +

+ + + +

+ Registry setup +

+ + \\" +}" + label="To authorize access to the Terraform registry:" + multiline="true" + trackingaction="" + trackinglabel="" + /> + + +
+`; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js new file mode 100644 index 00000000000..87e0059344c --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js @@ -0,0 +1,93 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { terraformModule, mavenFiles, npmPackage } from 'jest/packages/mock_data'; +import component from '~/packages_and_registries/infrastructure_registry/components/details_title.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PackageTitle', () => { + let wrapper; + let store; + + function createComponent({ packageFiles = mavenFiles, packageEntity = terraformModule } = {}) { + store = new Vuex.Store({ + state: { + packageEntity, + packageFiles, + }, + getters: { + packagePipeline: ({ packageEntity: { pipeline = null } }) => pipeline, + }, + }); + + wrapper = shallowMount(component, { + localVue, + store, + stubs: { + TitleArea, + }, + }); + return wrapper.vm.$nextTick(); + } + + const findTitleArea = () => wrapper.findComponent(TitleArea); + const packageSize = () => wrapper.find('[data-testid="package-size"]'); + const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]'); + const packageRef = () => wrapper.find('[data-testid="package-ref"]'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('module title', () => { + it('is correctly bound', async () => { + await createComponent(); + + expect(findTitleArea().props('title')).toBe(terraformModule.name); + }); + }); + + describe('calculates the package size', () => { + it('correctly calculates the size', async () => { + await createComponent(); + + expect(packageSize().props('text')).toBe('300 bytes'); + }); + }); + + describe('package ref', () => { + it('does not display the ref if missing', async () => { + await createComponent(); + + expect(packageRef().exists()).toBe(false); + }); + + it('correctly shows the package ref if there is one', async () => { + await createComponent({ packageEntity: npmPackage }); + expect(packageRef().props()).toMatchObject({ + text: npmPackage.pipeline.ref, + icon: 'branch', + }); + }); + }); + + describe('pipeline project', () => { + it('does not display the project if missing', async () => { + await createComponent(); + + expect(pipelineProject().exists()).toBe(false); + }); + + it('correctly shows the pipeline project if there is one', async () => { + await createComponent({ packageEntity: npmPackage }); + + expect(pipelineProject().props()).toMatchObject({ + text: npmPackage.pipeline.project.name, + icon: 'review-list', + link: npmPackage.pipeline.project.web_url, + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js new file mode 100644 index 00000000000..7a129794d54 --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js @@ -0,0 +1,61 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { terraformModule as packageEntity } from 'jest/packages/mock_data'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('TerraformInstallation', () => { + let wrapper; + + const store = new Vuex.Store({ + state: { + packageEntity, + projectPath: 'foo', + }, + }); + + const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); + + function createComponent() { + wrapper = shallowMount(TerraformInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct command', () => { + expect(findCodeInstructions().at(0).props('instruction')).toMatchInlineSnapshot(` + "module \\"Test/system-22\\" { + source = \\"foo/Test/system-22\\" + version = \\"0.1\\" + }" + `); + }); + }); + + describe('setup commands', () => { + it('renders the correct command', () => { + expect(findCodeInstructions().at(1).props('instruction')).toMatchInlineSnapshot(` + "credentials \\"gitlab.com\\" { + token = \\"\\" + }" + `); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index a725941f7f6..8266f9bee89 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -10,6 +10,8 @@ import { UNAVAILABLE_USER_FEATURE_TEXT, } from '~/packages_and_registries/settings/project/constants'; import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; +import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import { expirationPolicyPayload, @@ -28,15 +30,19 @@ describe('Registry Settings App', () => { isAdmin: false, adminSettingsPath: 'settingsPath', enableHistoricEntries: false, + helpPagePath: 'helpPagePath', + showCleanupPolicyOnAlert: false, }; const findSettingsComponent = () => wrapper.find(SettingsForm); const findAlert = () => wrapper.find(GlAlert); + const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); const mountComponent = (provide = defaultProvidedValues, config) => { wrapper = shallowMount(component, { stubs: { GlSprintf, + SettingsBlock, }, mocks: { $toast: { @@ -66,6 +72,26 @@ describe('Registry Settings App', () => { wrapper.destroy(); }); + describe('cleanup is on alert', () => { + it('exist when showCleanupPolicyOnAlert is true and has the correct props', () => { + mountComponent({ + ...defaultProvidedValues, + showCleanupPolicyOnAlert: true, + }); + + expect(findCleanupAlert().exists()).toBe(true); + expect(findCleanupAlert().props()).toMatchObject({ + projectPath: 'path', + }); + }); + + it('is hidden when showCleanupPolicyOnAlert is false', async () => { + mountComponent(); + + expect(findCleanupAlert().exists()).toBe(false); + }); + }); + describe('isEdited status', () => { it.each` description | apiResponse | workingCopy | result diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap new file mode 100644 index 00000000000..2cded2ead2e --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CleanupPolicyEnabledAlert renders 1`] = ` + + + +`; diff --git a/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js b/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js new file mode 100644 index 00000000000..269e087f5ac --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js @@ -0,0 +1,49 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import component from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +describe('CleanupPolicyEnabledAlert', () => { + let wrapper; + + const defaultProps = { + projectPath: 'foo', + cleanupPoliciesSettingsPath: 'label-bar', + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + const mountComponent = (props) => { + wrapper = shallowMount(component, { + stubs: { + LocalStorageSync, + }, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('when dismissed is not visible', async () => { + mountComponent(); + + expect(findAlert().exists()).toBe(true); + findAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findAlert().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js index d22e0474e06..4280a78c202 100644 --- a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; -import * as flash from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import PromoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; @@ -103,7 +103,7 @@ describe('Promote milestone modal', () => { wrapper.findComponent(GlModal).vm.$emit('primary'); await waitForPromises(); - expect(flash.deprecatedCreateFlash).toHaveBeenCalledWith(dummyError); + expect(createFlash).toHaveBeenCalledWith({ message: dummyError }); }); }); }); 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 e1820606704..a7b4b9c42bd 100644 --- a/spec/frontend/pages/projects/forks/new/components/app_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/app_spec.js @@ -13,6 +13,7 @@ describe('App component', () => { projectPath: 'project-name', projectDescription: 'some project description', projectVisibility: 'private', + restrictedVisibilityLevels: [], }; const createComponent = (props = {}) => { 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 6d853120232..c80ccfa8256 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 @@ -1,4 +1,5 @@ -import { GlFormInputGroup, GlFormInput, GlForm } from '@gitlab/ui'; +import { GlFormInputGroup, GlFormInput, GlForm, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +import { getByRole, getAllByRole } from '@testing-library/dom'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; @@ -15,6 +16,13 @@ describe('ForkForm component', () => { let wrapper; let axiosMock; + const PROJECT_VISIBILITY_TYPE = { + private: + 'Private Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', + internal: 'Internal The project can be accessed by any logged in user.', + public: 'Public The project can be accessed without any authentication.', + }; + const GON_GITLAB_URL = 'https://gitlab.com'; const GON_API_VERSION = 'v7'; @@ -37,6 +45,7 @@ describe('ForkForm component', () => { projectPath: 'project-name', projectDescription: 'some project description', projectVisibility: 'private', + restrictedVisibilityLevels: [], }; const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { @@ -61,6 +70,8 @@ describe('ForkForm component', () => { stubs: { GlFormInputGroup, GlFormInput, + GlFormRadioGroup, + GlFormRadio, }, }); }; @@ -81,6 +92,7 @@ describe('ForkForm component', () => { axiosMock.restore(); }); + const findFormSelectOptions = () => wrapper.find('select[name="namespace"]').findAll('option'); const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]'); const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]'); const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]'); @@ -203,24 +215,145 @@ describe('ForkForm component', () => { }); describe('visibility level', () => { + it('displays the correct description', () => { + mockGetRequest(); + createComponent(); + + const formRadios = wrapper.findAll(GlFormRadio); + + Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibilityType, index) => { + expect(formRadios.at(index).text()).toBe(PROJECT_VISIBILITY_TYPE[visibilityType]); + }); + }); + + it('displays all 3 visibility levels', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.findAll(GlFormRadio)).toHaveLength(3); + }); + + describe('when the namespace is changed', () => { + const namespaces = [ + { + visibility: 'private', + }, + { + visibility: 'internal', + }, + { + visibility: 'public', + }, + ]; + + beforeEach(() => { + mockGetRequest(); + }); + + it('resets the visibility to default "private"', async () => { + createFullComponent({ projectVisibility: 'public' }, { namespaces }); + + expect(wrapper.vm.form.fields.visibility.value).toBe('public'); + await findFormSelectOptions().at(1).setSelected(); + + await wrapper.vm.$nextTick(); + + expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true); + }); + + it('sets the visibility to be null when restrictedVisibilityLevels is set', async () => { + createFullComponent({ restrictedVisibilityLevels: [10] }, { namespaces }); + + await findFormSelectOptions().at(1).setSelected(); + + await wrapper.vm.$nextTick(); + + const container = getByRole(wrapper.element, 'radiogroup', { name: /visibility/i }); + const visibilityRadios = getAllByRole(container, 'radio'); + expect(visibilityRadios.filter((e) => e.checked)).toHaveLength(0); + }); + }); + + it.each` + project | restrictedVisibilityLevels + ${'private'} | ${[]} + ${'internal'} | ${[]} + ${'public'} | ${[]} + ${'private'} | ${[0]} + ${'private'} | ${[10]} + ${'private'} | ${[20]} + ${'private'} | ${[0, 10]} + ${'private'} | ${[0, 20]} + ${'private'} | ${[10, 20]} + ${'private'} | ${[0, 10, 20]} + ${'internal'} | ${[0]} + ${'internal'} | ${[10]} + ${'internal'} | ${[20]} + ${'internal'} | ${[0, 10]} + ${'internal'} | ${[0, 20]} + ${'internal'} | ${[10, 20]} + ${'internal'} | ${[0, 10, 20]} + ${'public'} | ${[0]} + ${'public'} | ${[10]} + ${'public'} | ${[0, 10]} + ${'public'} | ${[0, 20]} + ${'public'} | ${[10, 20]} + ${'public'} | ${[0, 10, 20]} + `('checks the correct radio button', async ({ project, restrictedVisibilityLevels }) => { + mockGetRequest(); + createFullComponent({ + projectVisibility: project, + restrictedVisibilityLevels, + }); + + if (restrictedVisibilityLevels.length === 0) { + expect(wrapper.find('[name="visibility"]:checked').attributes('value')).toBe(project); + } else { + expect(wrapper.find('[name="visibility"]:checked').exists()).toBe(false); + } + }); + it.each` - project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled - ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} - ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} - ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} - ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} - ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} - ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} - ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} - ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} - ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined} + project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled | restrictedVisibilityLevels + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} | ${[]} + ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} | ${[]} + ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} | ${[]} + ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined} | ${[]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[0]} + ${'internal'} | ${'internal'} | ${'true'} | ${undefined} | ${'true'} | ${[0]} + ${'public'} | ${'public'} | ${'true'} | ${undefined} | ${undefined} | ${[0]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[10]} + ${'internal'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} | ${[10]} + ${'public'} | ${'public'} | ${undefined} | ${'true'} | ${undefined} | ${[10]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[20]} + ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} | ${[20]} + ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} | ${[20]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[10, 20]} + ${'internal'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} | ${[10, 20]} + ${'public'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[10, 20]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]} + ${'internal'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]} + ${'public'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]} `( 'sets appropriate radio button disabled state', - async ({ project, namespace, privateIsDisabled, internalIsDisabled, publicIsDisabled }) => { + async ({ + project, + namespace, + privateIsDisabled, + internalIsDisabled, + publicIsDisabled, + restrictedVisibilityLevels, + }) => { mockGetRequest(); createComponent( { projectVisibility: project, + restrictedVisibilityLevels, }, { form: { fields: { namespace: { value: { visibility: namespace } } } }, @@ -235,7 +368,7 @@ describe('ForkForm component', () => { }); describe('onSubmit', () => { - beforeEach(() => { + const setupComponent = (fields = {}) => { jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); mockGetRequest(); @@ -245,9 +378,14 @@ describe('ForkForm component', () => { namespaces: MOCK_NAMESPACES_RESPONSE, form: { state: true, + ...fields, }, }, ); + }; + + beforeEach(() => { + setupComponent(); }); const selectedMockNamespaceIndex = 1; @@ -279,6 +417,22 @@ describe('ForkForm component', () => { expect(urlUtility.redirectTo).not.toHaveBeenCalled(); }); + + it('does not make POST request if no visbility is checked', async () => { + jest.spyOn(axios, 'post'); + + setupComponent({ + fields: { + visibility: { + value: null, + }, + }, + }); + + await submitForm(); + + expect(axios.post).not.toHaveBeenCalled(); + }); }); describe('with valid form', () => { @@ -330,7 +484,7 @@ describe('ForkForm component', () => { expect(urlUtility.redirectTo).not.toHaveBeenCalled(); expect(createFlash).toHaveBeenCalledWith({ - message: dummyError, + message: 'An error occurred while forking the project. Please try again.', }); }); }); 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 index e7ac837a4c8..9f8dbf3d542 100644 --- 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 @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +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'; diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index c4c48ea7517..4ba9120d196 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -66,7 +66,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`]
" `; + +exports[`Links Inner component with same stage needs matches snapshot and has expected path 1`] = ` +"
+ + +
" +`; diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index bb1f0965469..8f39c8c2405 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -10,6 +10,7 @@ import { pipelineData, pipelineDataWithNoNeeds, rootRect, + sameStageNeeds, } from '../pipeline_graph/mock_data'; describe('Links Inner component', () => { @@ -40,7 +41,7 @@ describe('Links Inner component', () => { // We create fixture so that each job has an empty div that represent // the JobPill in the DOM. Each `JobPill` would have different coordinates, - // so we increment their coordinates on each iteration to simulat different positions. + // so we increment their coordinates on each iteration to simulate different positions. const setFixtures = ({ stages }) => { const jobs = createJobsHash(stages); const arrayOfJobs = Object.keys(jobs); @@ -81,7 +82,6 @@ describe('Links Inner component', () => { afterEach(() => { jest.restoreAllMocks(); wrapper.destroy(); - wrapper = null; }); describe('basic SVG creation', () => { @@ -160,6 +160,25 @@ describe('Links Inner component', () => { }); }); + describe('with same stage needs', () => { + beforeEach(() => { + setFixtures(sameStageNeeds); + createComponent({ pipelineData: sameStageNeeds.stages }); + }); + + it('renders the correct number of links', () => { + expect(findAllLinksPath()).toHaveLength(2); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + describe('with a large number of needs', () => { beforeEach(() => { setFixtures(largePipelineData); diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index 57d846c53c8..31f0e72c279 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -7,7 +7,9 @@ import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline. import { mockCancelledPipelineHeader, mockFailedPipelineHeader, + mockFailedPipelineNoPermissions, mockRunningPipelineHeader, + mockRunningPipelineNoPermissions, mockSuccessfulPipelineHeader, } from './mock_data'; @@ -168,5 +170,19 @@ describe('Pipeline details header', () => { }); }); }); + + describe('Permissions', () => { + it('should not display the cancel action if user does not have permission', () => { + wrapper = createComponent(mockRunningPipelineNoPermissions); + + expect(findCancelButton().exists()).toBe(false); + }); + + it('should not display the retry action if user does not have permission', () => { + wrapper = createComponent(mockFailedPipelineNoPermissions); + + expect(findRetryButton().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 16f15b20824..7e3c3727c9d 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -10,6 +10,7 @@ export const mockPipelineHeader = { id: 123, userPermissions: { destroyPipeline: true, + updatePipeline: true, }, createdAt: threeWeeksAgo.toISOString(), user: { @@ -34,6 +35,31 @@ export const mockFailedPipelineHeader = { }, }; +export const mockFailedPipelineNoPermissions = { + id: 123, + userPermissions: { + destroyPipeline: false, + updatePipeline: false, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, + status: PIPELINE_RUNNING, + retryable: true, + cancelable: false, + detailedStatus: { + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + export const mockRunningPipelineHeader = { ...mockPipelineHeader, status: PIPELINE_RUNNING, @@ -48,6 +74,31 @@ export const mockRunningPipelineHeader = { }, }; +export const mockRunningPipelineNoPermissions = { + id: 123, + userPermissions: { + destroyPipeline: false, + updatePipeline: false, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, + status: PIPELINE_RUNNING, + retryable: false, + cancelable: true, + detailedStatus: { + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + export const mockCancelledPipelineHeader = { ...mockPipelineHeader, status: PIPELINE_CANCELED, diff --git a/spec/frontend/pipelines/notification/pipeline_notification_spec.js b/spec/frontend/pipelines/notification/pipeline_notification_spec.js deleted file mode 100644 index 79aa337ba9d..00000000000 --- a/spec/frontend/pipelines/notification/pipeline_notification_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import { GlBanner } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import PipelineNotification from '~/pipelines/components/notification/pipeline_notification.vue'; -import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql'; - -describe('Pipeline notification', () => { - const localVue = createLocalVue(); - - let wrapper; - const dagDocPath = 'my/dag/path'; - - const createWrapper = (apolloProvider) => { - return shallowMount(PipelineNotification, { - localVue, - provide: { - dagDocPath, - }, - apolloProvider, - }); - }; - - const createWrapperWithApollo = async ({ callouts = [], isLoading = false } = {}) => { - localVue.use(VueApollo); - - const mappedCallouts = callouts.map((callout) => { - return { featureName: callout, __typename: 'UserCallout' }; - }); - - const mockCalloutsResponse = { - data: { - currentUser: { - id: 45, - __typename: 'User', - callouts: { - id: 5, - __typename: 'UserCalloutConnection', - nodes: mappedCallouts, - }, - }, - }, - }; - const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse); - const requestHandlers = [[getUserCallouts, getUserCalloutsHandler]]; - - const apolloWrapper = createWrapper(createMockApollo(requestHandlers)); - if (!isLoading) { - await nextTick(); - } - - return apolloWrapper; - }; - - const findBanner = () => wrapper.findComponent(GlBanner); - - afterEach(() => { - wrapper.destroy(); - }); - - it('shows the banner if the user has never seen it', async () => { - wrapper = await createWrapperWithApollo({ callouts: ['random'] }); - - expect(findBanner().exists()).toBe(true); - }); - - it('does not show the banner while the user callout query is loading', async () => { - wrapper = await createWrapperWithApollo({ callouts: ['random'], isLoading: true }); - - expect(findBanner().exists()).toBe(false); - }); - - it('does not show the banner if the user has previously dismissed it', async () => { - wrapper = await createWrapperWithApollo({ callouts: ['pipeline_needs_banner'.toUpperCase()] }); - - expect(findBanner().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/pipelines/parsing_utils_spec.js b/spec/frontend/pipelines/parsing_utils_spec.js index 96748ae9e5c..074009ae056 100644 --- a/spec/frontend/pipelines/parsing_utils_spec.js +++ b/spec/frontend/pipelines/parsing_utils_spec.js @@ -10,7 +10,7 @@ import { getMaxNodes, } from '~/pipelines/components/parsing_utils'; -import { mockParsedGraphQLNodes } from './components/dag/mock_data'; +import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data'; import { generateResponse, mockPipelineResponse } from './graph/mock_data'; describe('DAG visualization parsing utilities', () => { @@ -24,6 +24,12 @@ describe('DAG visualization parsing utilities', () => { expect(unfilteredLinks[0]).toHaveProperty('target', 'test_a'); expect(unfilteredLinks[0]).toHaveProperty('value', 10); }); + + it('does not generate a link for non-existing jobs', () => { + const sources = unfilteredLinks.map(({ source }) => source); + + expect(sources.includes(missingJob)).toBe(false); + }); }); describe('filterByAncestors', () => { @@ -88,7 +94,7 @@ describe('DAG visualization parsing utilities', () => { These lengths are determined by the mock data. If the data changes, the numbers may also change. */ - expect(parsed.nodes).toHaveLength(21); + expect(parsed.nodes).toHaveLength(mockParsedGraphQLNodes.length); expect(cleanedNodes).toHaveLength(12); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index a79917bfd48..db77e0a0573 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -162,6 +162,38 @@ export const parallelNeedData = { ], }; +export const sameStageNeeds = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build', name: 'build_1' }], + }, + ], + }, + { + name: 'build', + groups: [ + { + name: 'build_2', + jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_1'] }], + }, + ], + }, + { + name: 'build', + groups: [ + { + name: 'build_3', + jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_2'] }], + }, + ], + }, + ], +}; + export const largePipelineData = { stages: [ { diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js index 070d3bf7dac..5816bc06fe3 100644 --- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js @@ -111,6 +111,28 @@ describe('utils functions', () => { }); }); + it('removes needs which are not in the data', () => { + const inexistantJobName = 'job5'; + const jobsWithNeeds = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: { + name: jobName4, + script: 'echo deploy', + stage: 'deploy', + needs: [inexistantJobName], + }, + }; + + expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ + [jobName1]: [], + [jobName2]: [], + [jobName3]: [jobName1, jobName2], + [jobName4]: [], + }); + }); + it('handles parallel jobs by adding the group name as a need', () => { const size = 3; const jobOptimize1 = 'optimize_1'; diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index f9b59c5dc48..874ecbccf82 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -7,8 +7,8 @@ import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; -import { getExperimentVariant } from '~/experimentation/utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +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'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; @@ -23,6 +23,7 @@ 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'), })); @@ -48,6 +49,7 @@ describe('Pipelines', () => { 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`, }; const noPermissions = { @@ -349,7 +351,7 @@ describe('Pipelines', () => { it('displays a warning message if raw text search is used', () => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(RAW_TEXT_WARNING, 'warning'); + expect(createFlash).toHaveBeenCalledWith({ message: RAW_TEXT_WARNING, type: 'warning' }); }); it('should update browser bar', () => { @@ -563,6 +565,7 @@ describe('Pipelines', () => { describe('when the code_quality_walkthrough experiment is active', () => { beforeAll(() => { + getExperimentData.mockImplementation((name) => name === 'code_quality_walkthrough'); getExperimentVariant.mockReturnValue('candidate'); }); @@ -574,6 +577,29 @@ describe('Pipelines', () => { }); }); + describe('when the ci_runner_templates experiment is active', () => { + beforeAll(() => { + getExperimentData.mockImplementation((name) => name === 'ci_runner_templates'); + 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/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index 6258b08dfbb..e931ddb8496 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/pipelines/stores/test_reports/actions'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index a3d7b63373c..42adefcd0bb 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -2,7 +2,7 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import UpdateUsername from '~/profile/account/components/update_username.vue'; @@ -146,7 +146,9 @@ describe('UpdateUsername component', () => { await expect(wrapper.vm.onConfirm()).rejects.toThrow(); - expect(createFlash).toBeCalledWith('Invalid username'); + expect(createFlash).toBeCalledWith({ + message: 'Invalid username', + }); }); it("shows a fallback error message if the error response doesn't have a `message` property", async () => { @@ -156,9 +158,9 @@ describe('UpdateUsername component', () => { await expect(wrapper.vm.onConfirm()).rejects.toThrow(); - expect(createFlash).toBeCalledWith( - 'An error occurred while updating your username, please try again.', - ); + expect(createFlash).toBeCalledWith({ + message: 'An error occurred while updating your username, please try again.', + }); }); }); }); diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js index 9688cb47799..0c8089430d0 100644 --- a/spec/frontend/projects/commit/components/form_modal_spec.js +++ b/spec/frontend/projects/commit/components/form_modal_spec.js @@ -159,12 +159,7 @@ describe('CommitFormModal', () => { }); it('Changes the target_project_id input value', async () => { - createComponent( - shallowMount, - {}, - { glFeatures: { pickIntoProject: true } }, - { isCherryPick: true }, - ); + createComponent(shallowMount, {}, {}, { isCherryPick: true }); findProjectsDropdown().vm.$emit('selectProject', '_changed_project_value_'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js index e2c993b8395..fdb12640b26 100644 --- a/spec/frontend/projects/commits/store/actions_spec.js +++ b/spec/frontend/projects/commits/store/actions_spec.js @@ -1,7 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import actions from '~/projects/commits/store/actions'; import * as types from '~/projects/commits/store/mutation_types'; import createState from '~/projects/commits/store/state'; @@ -39,7 +39,9 @@ describe('Project commits actions', () => { actions.receiveAuthorsError(mockDispatchContext); expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith('An error occurred fetching the project authors.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred fetching the project authors.', + }); }); }); diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index 40e31e24a14..b41b5028736 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit'; @@ -69,7 +69,7 @@ describe('ProtectedBranchEdit', () => { expect(mock.history.patch).toHaveLength(1); expect(toggle).not.toBeDisabled(); - expect(flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); })); }); @@ -81,7 +81,7 @@ describe('ProtectedBranchEdit', () => { it('flashes error', () => axios.waitForAll().then(() => { - expect(flash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); })); }); }); diff --git a/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap index 4be4fce1abf..f80e2ce6ecc 100644 --- a/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ b/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -26,6 +26,7 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` aria-hidden="true" class="gl-icon s8" data-testid="angle-right-icon" + role="img" > { }); describe.each` - name | finderFunction | text | icon | clipboard - ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 GMT+0000 on 2020-11-03'} | ${'clock'} | ${false} - ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} - ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} + name | finderFunction | text | icon | clipboard + ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 UTC on 2020-11-03'} | ${'clock'} | ${false} + ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} + ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} `('$name details row', ({ finderFunction, text, icon, clipboard }) => { it(`has ${text} as text`, async () => { mountComponent(); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index 48acc06792d..b58a53f0af2 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; +import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import DeleteImage from '~/registry/explorer/components/delete_image.vue'; import CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue'; @@ -43,21 +44,22 @@ describe('List Page', () => { let wrapper; let apolloProvider; - const findDeleteModal = () => wrapper.find(GlModal); - const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findEmptyState = () => wrapper.find(GlEmptyState); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); - const findCliCommands = () => wrapper.find(CliCommands); - const findProjectEmptyState = () => wrapper.find(ProjectEmptyState); - const findGroupEmptyState = () => wrapper.find(GroupEmptyState); - const findRegistryHeader = () => wrapper.find(RegistryHeader); + const findCliCommands = () => wrapper.findComponent(CliCommands); + const findProjectEmptyState = () => wrapper.findComponent(ProjectEmptyState); + const findGroupEmptyState = () => wrapper.findComponent(GroupEmptyState); + const findRegistryHeader = () => wrapper.findComponent(RegistryHeader); - const findDeleteAlert = () => wrapper.find(GlAlert); - const findImageList = () => wrapper.find(ImageList); - const findRegistrySearch = () => wrapper.find(RegistrySearch); + const findDeleteAlert = () => wrapper.findComponent(GlAlert); + const findImageList = () => wrapper.findComponent(ImageList); + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); - const findDeleteImage = () => wrapper.find(DeleteImage); + const findDeleteImage = () => wrapper.findComponent(DeleteImage); + const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); const waitForApolloRequestRender = async () => { jest.runOnlyPendingTimers(); @@ -560,4 +562,33 @@ describe('List Page', () => { }, ); }); + + describe('cleanup is on alert', () => { + it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => { + mountComponent({ + config: { + showCleanupPolicyOnAlert: true, + projectPath: 'foo', + isGroupPage: false, + cleanupPoliciesSettingsPath: 'bar', + }, + }); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(true); + expect(findCleanupAlert().props()).toMatchObject({ + projectPath: 'foo', + cleanupPoliciesSettingsPath: 'bar', + }); + }); + + it('is hidden when showCleanupPolicyOnAlert is false', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/related_merge_requests/store/actions_spec.js b/spec/frontend/related_merge_requests/store/actions_spec.js index a14096388e6..3bd07c34b6f 100644 --- a/spec/frontend/related_merge_requests/store/actions_spec.js +++ b/spec/frontend/related_merge_requests/store/actions_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/related_merge_requests/store/actions'; import * as types from '~/related_merge_requests/store/mutation_types'; @@ -100,7 +100,9 @@ describe('RelatedMergeRequest store actions', () => { [{ type: 'requestData' }, { type: 'receiveDataError' }], () => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(expect.stringMatching('Something went wrong')); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), + }); done(); }, diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index cad593b76ea..e0a1343c39c 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -3,6 +3,58 @@ exports[`releases/util.js convertAllReleasesGraphQLResponse matches snapshot 1`] = ` Object { "data": Array [ + Object { + "_links": Object { + "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=closed", + "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=closed", + "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2/edit", + "mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=merged", + "openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=opened", + "openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=opened", + "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.2", + "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2", + }, + "assets": Object { + "count": 4, + "links": Array [], + "sources": Array [ + Object { + "format": "zip", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.zip", + }, + Object { + "format": "tar.gz", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.gz", + }, + Object { + "format": "tar.bz2", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.bz2", + }, + Object { + "format": "tar", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar", + }, + ], + }, + "author": Object { + "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "username": "administrator", + "webUrl": "http://localhost/administrator", + }, + "commit": Object { + "shortId": "b83d6e39", + "title": "Merge branch 'branch-merged' into 'master'", + }, + "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", + "descriptionHtml": "

An okay release 🤷

", + "evidences": Array [], + "milestones": Array [], + "name": "The second release", + "releasedAt": "2019-01-10T00:00:00Z", + "tagName": "v1.2", + "tagPath": "/releases-namespace/releases-project/-/tags/v1.2", + "upcomingRelease": true, + }, Object { "_links": Object { "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed", @@ -121,10 +173,10 @@ Object { }, ], "paginationInfo": Object { - "endCursor": "eyJpZCI6IjEifQ", + "endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9", "hasNextPage": false, "hasPreviousPage": false, - "startCursor": "eyJpZCI6IjEifQ", + "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMiJ9", }, } `; diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js new file mode 100644 index 00000000000..002d8939058 --- /dev/null +++ b/spec/frontend/releases/components/app_index_apollo_client_spec.js @@ -0,0 +1,394 @@ +import { cloneDeep } from 'lodash'; +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 createFlash from '~/flash'; +import { historyPushState } from '~/lib/utils/common_utils'; +import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue'; +import ReleaseBlock from '~/releases/components/release_block.vue'; +import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; +import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; +import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; +import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; +import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; +import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +let mockQueryParams; +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + getParameterByName: jest + .fn() + .mockImplementation((parameterName) => mockQueryParams[parameterName]), + historyPushState: jest.fn(), +})); + +describe('app_index_apollo_client.vue', () => { + const originalAllReleasesQueryResponse = getJSONFixture( + 'graphql/releases/graphql/queries/all_releases.query.graphql.json', + ); + const projectPath = 'project/path'; + const newReleasePath = 'path/to/new/release/page'; + const before = 'beforeCursor'; + const after = 'afterCursor'; + + let wrapper; + let allReleases; + let singleRelease; + let noReleases; + let queryMock; + + const createComponent = ({ + singleResponse = Promise.resolve(singleRelease), + fullResponse = Promise.resolve(allReleases), + } = {}) => { + const apolloProvider = createMockApollo([ + [ + allReleasesQuery, + queryMock.mockImplementation((vars) => { + return vars.first === 1 ? singleResponse : fullResponse; + }), + ], + ]); + + wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, { + apolloProvider, + provide: { + newReleasePath, + projectPath, + }, + }); + }; + + beforeEach(() => { + mockQueryParams = {}; + + allReleases = cloneDeep(originalAllReleasesQueryResponse); + + singleRelease = cloneDeep(originalAllReleasesQueryResponse); + singleRelease.data.project.releases.nodes.splice( + 1, + singleRelease.data.project.releases.nodes.length, + ); + + noReleases = cloneDeep(originalAllReleasesQueryResponse); + noReleases.data.project.releases.nodes = []; + + queryMock = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + // Finders + const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader); + const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState); + const findNewReleaseButton = () => + wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease); + const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); + const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient); + const findSort = () => wrapper.findComponent(ReleasesSortApolloClient); + + // Tests + describe('component states', () => { + // These need to be defined as functions, since `singleRelease` and + // `allReleases` are generated in a `beforeEach`, and therefore + // aren't available at test definition time. + const getInProgressResponse = () => new Promise(() => {}); + const getErrorResponse = () => Promise.reject(new Error('Oops!')); + const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease); + const getFullRequestLoadedResponse = () => Promise.resolve(allReleases); + const getLoadedEmptyResponse = () => Promise.resolve(noReleases); + + const toDescription = (bool) => (bool ? 'does' : 'does not'); + + describe.each` + description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination + ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false} + ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false} + ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false} + ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false} + ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + `( + '$description', + ({ + singleResponseFn, + fullResponseFn, + loadingIndicator, + emptyState, + flashMessage, + releaseCount, + pagination, + }) => { + beforeEach(() => { + createComponent({ + singleResponse: singleResponseFn(), + fullResponse: fullResponseFn(), + }); + }); + + it(`${toDescription(loadingIndicator)} render a loading indicator`, () => { + expect(findLoadingIndicator().exists()).toBe(loadingIndicator); + }); + + it(`${toDescription(emptyState)} render an empty state`, () => { + expect(findEmptyState().exists()).toBe(emptyState); + }); + + it(`${toDescription(flashMessage)} show a flash message`, () => { + if (flashMessage) { + expect(createFlash).toHaveBeenCalledWith({ + message: ReleasesIndexApolloClientApp.i18n.errorMessage, + captureError: true, + error: expect.any(Error), + }); + } else { + expect(createFlash).not.toHaveBeenCalled(); + } + }); + + it(`renders ${releaseCount} release(s)`, () => { + expect(findAllReleaseBlocks()).toHaveLength(releaseCount); + }); + + it(`${toDescription(pagination)} render the pagination controls`, () => { + expect(findPagination().exists()).toBe(pagination); + }); + + it('does render the "New release" button', () => { + expect(findNewReleaseButton().exists()).toBe(true); + }); + + it('does render the sort controls', () => { + expect(findSort().exists()).toBe(true); + }); + }, + ); + }); + + describe('URL parameters', () => { + describe('when the URL contains no query parameters', () => { + beforeEach(() => { + createComponent(); + }); + + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + + describe('when the URL contains a "before" query parameter', () => { + beforeEach(() => { + mockQueryParams = { before }; + createComponent(); + }); + + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(1); + + expect(queryMock).toHaveBeenCalledWith({ + before, + last: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + + describe('when the URL contains an "after" query parameter', () => { + beforeEach(() => { + mockQueryParams = { after }; + createComponent(); + }); + + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + + describe('when the URL contains both "before" and "after" query parameters', () => { + beforeEach(() => { + mockQueryParams = { before, after }; + createComponent(); + }); + + it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + }); + + describe('New release button', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the new release button with the correct href', () => { + expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); + }); + }); + + describe('pagination', () => { + beforeEach(() => { + mockQueryParams = { before }; + createComponent(); + }); + + it('requeries the GraphQL endpoint when a pagination button is clicked', async () => { + expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]); + + mockQueryParams = { after }; + findPagination().vm.$emit('next', after); + + await wrapper.vm.$nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ before })], + [expect.objectContaining({ after })], + [expect.objectContaining({ after })], + ]); + }); + }); + + describe('sorting', () => { + beforeEach(() => { + createComponent(); + }); + + it(`sorts by ${DEFAULT_SORT} by default`, () => { + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); + }); + + it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => { + findSort().vm.$emit('input', CREATED_ASC); + + await wrapper.vm.$nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: CREATED_ASC })], + [expect.objectContaining({ sort: CREATED_ASC })], + ]); + + // URL manipulation is tested in more detail in the `describe` block below + expect(historyPushState).toHaveBeenCalled(); + }); + + it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => { + findSort().vm.$emit('input', DEFAULT_SORT); + + await wrapper.vm.$nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); + + expect(historyPushState).not.toHaveBeenCalled(); + }); + }); + + describe('sorting + pagination interaction', () => { + const nonPaginationQueryParam = 'nonPaginationQueryParam'; + + beforeEach(() => { + historyPushState.mockImplementation((newUrl) => { + mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams); + }); + }); + + describe.each` + queryParamsBefore | paramName | paramInitialValue + ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before} + ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after} + `( + 'when the URL contains a "$paramName" pagination cursor', + ({ queryParamsBefore, paramName, paramInitialValue }) => { + beforeEach(async () => { + mockQueryParams = queryParamsBefore; + createComponent(); + + findSort().vm.$emit('input', CREATED_ASC); + + await wrapper.vm.$nextTick(); + }); + + it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => { + const firstRequestVariables = queryMock.mock.calls[0][0]; + // Might be request #2 or #3, depending on the pagination direction + const mostRecentRequestVariables = + queryMock.mock.calls[queryMock.mock.calls.length - 1][0]; + + expect(firstRequestVariables[paramName]).toBe(paramInitialValue); + expect(mostRecentRequestVariables[paramName]).toBeUndefined(); + }); + + it(`updates the URL to not include the "${paramName}" URL query parameter`, () => { + expect(historyPushState).toHaveBeenCalledTimes(1); + + const updatedUrlQueryParams = Object.fromEntries( + new URL(historyPushState.mock.calls[0][0]).searchParams, + ); + + expect(updatedUrlQueryParams[paramName]).toBeUndefined(); + }); + }, + ); + }); +}); diff --git a/spec/frontend/releases/components/releases_empty_state_spec.js b/spec/frontend/releases/components/releases_empty_state_spec.js new file mode 100644 index 00000000000..495e6d863f7 --- /dev/null +++ b/spec/frontend/releases/components/releases_empty_state_spec.js @@ -0,0 +1,56 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; + +describe('releases_empty_state.vue', () => { + const documentationPath = 'path/to/releases/documentation'; + const illustrationPath = 'path/to/releases/empty/state/illustration'; + + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ReleasesEmptyState, { + provide: { + documentationPath, + illustrationPath, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a GlEmptyState and provides it with the correct props', () => { + const emptyStateProps = wrapper.findComponent(GlEmptyState).props(); + + expect(emptyStateProps).toEqual( + expect.objectContaining({ + title: ReleasesEmptyState.i18n.emptyStateTitle, + svgPath: illustrationPath, + }), + ); + }); + + it('renders the empty state text', () => { + expect(wrapper.findByText(ReleasesEmptyState.i18n.emptyStateText).exists()).toBe(true); + }); + + it('renders a link to the documentation', () => { + const documentationLink = wrapper.findByText(ReleasesEmptyState.i18n.moreInformation); + + expect(documentationLink.exists()).toBe(true); + + expect(documentationLink.attributes()).toEqual( + expect.objectContaining({ + 'aria-label': ReleasesEmptyState.i18n.releasesDocumentation, + href: documentationPath, + target: '_blank', + }), + ); + }); +}); diff --git a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js new file mode 100644 index 00000000000..a538afd5d38 --- /dev/null +++ b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js @@ -0,0 +1,126 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { historyPushState } from '~/lib/utils/common_utils'; +import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; + +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + historyPushState: jest.fn(), +})); + +describe('releases_pagination_apollo_client.vue', () => { + const startCursor = 'startCursor'; + const endCursor = 'endCursor'; + let wrapper; + let onPrev; + let onNext; + + const createComponent = (pageInfo) => { + onPrev = jest.fn(); + onNext = jest.fn(); + + wrapper = mountExtended(ReleasesPaginationApolloClient, { + propsData: { + pageInfo, + }, + listeners: { + prev: onPrev, + next: onNext, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const singlePageInfo = { + hasPreviousPage: false, + hasNextPage: false, + startCursor, + endCursor, + }; + + const onlyNextPageInfo = { + hasPreviousPage: false, + hasNextPage: true, + startCursor, + endCursor, + }; + + const onlyPrevPageInfo = { + hasPreviousPage: true, + hasNextPage: false, + startCursor, + endCursor, + }; + + const prevAndNextPageInfo = { + hasPreviousPage: true, + hasNextPage: true, + startCursor, + endCursor, + }; + + const findPrevButton = () => wrapper.findByTestId('prevButton'); + const findNextButton = () => wrapper.findByTestId('nextButton'); + + describe.each` + description | pageInfo | prevEnabled | nextEnabled + ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} + ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} + ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} + ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} + `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => { + describe(description, () => { + beforeEach(() => { + createComponent(pageInfo); + }); + + it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled'); + }); + + it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled'); + }); + }); + }); + + describe('button behavior', () => { + beforeEach(() => { + createComponent(prevAndNextPageInfo); + }); + + describe('next button behavior', () => { + beforeEach(() => { + findNextButton().trigger('click'); + }); + + it('emits an "next" event with the "after" cursor', () => { + expect(onNext.mock.calls).toEqual([[endCursor]]); + }); + + it('calls historyPushState with the new URL', () => { + expect(historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?after=${endCursor}`)], + ]); + }); + }); + + describe('prev button behavior', () => { + beforeEach(() => { + findPrevButton().trigger('click'); + }); + + it('emits an "prev" event with the "before" cursor', () => { + expect(onPrev.mock.calls).toEqual([[startCursor]]); + }); + + it('calls historyPushState with the new URL', () => { + expect(historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?before=${startCursor}`)], + ]); + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js new file mode 100644 index 00000000000..d93a932af01 --- /dev/null +++ b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js @@ -0,0 +1,103 @@ +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; +import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; + +describe('releases_sort_apollo_client.vue', () => { + let wrapper; + + const createComponent = (valueProp = RELEASED_AT_ASC) => { + wrapper = shallowMountExtended(ReleasesSortApolloClient, { + propsData: { + value: valueProp, + }, + stubs: { + GlSortingItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSorting = () => wrapper.findComponent(GlSorting); + const findSortingItems = () => wrapper.findAllComponents(GlSortingItem); + const findReleasedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Released date'); + const findCreatedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Created date'); + const getSortingItemsInfo = () => + findSortingItems().wrappers.map((item) => ({ + label: item.text(), + active: item.attributes().active === 'true', + })); + + describe.each` + valueProp | text | isAscending | items + ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + `('component states', ({ valueProp, text, isAscending, items }) => { + beforeEach(() => { + createComponent(valueProp); + }); + + it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => { + expect(findSorting().props()).toEqual( + expect.objectContaining({ + text, + isAscending, + }), + ); + }); + + it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => { + expect(getSortingItemsInfo()).toEqual(items); + }); + }); + + const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click'); + const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click'); + const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange'); + + const releasedAtDropdownItemDescription = 'released at dropdown item'; + const createdAtDropdownItemDescription = 'created at dropdown item'; + const sortDirectionButtonDescription = 'sort direction button'; + + describe.each` + initialValueProp | itemClickFn | itemToClickDescription | emittedEvent + ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC} + ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC} + ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC} + ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC} + ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC} + ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC} + `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => { + beforeEach(() => { + createComponent(initialValueProp); + itemClickFn(); + }); + + it(`emits ${ + emittedEvent || 'nothing' + } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => { + expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent); + }); + }); + + describe('prop validation', () => { + it('validates that the `value` prop is one of the expected sort strings', () => { + expect(() => { + createComponent('not a valid value'); + }).toThrow('Invalid prop: custom validator check failed'); + }); + }); +}); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 688ec4c0a50..6504a09df2f 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash'; import { getJSONFixture } from 'helpers/fixtures'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { ASSET_LINK_TYPE } from '~/releases/constants'; import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql'; @@ -151,9 +151,9 @@ describe('Release edit/new actions', () => { it(`shows a flash message`, () => { return actions.fetchRelease({ commit: jest.fn(), state, rootState: state }).then(() => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while getting the release details.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while getting the release details.', + }); }); }); }); @@ -352,9 +352,9 @@ describe('Release edit/new actions', () => { .createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} }) .then(() => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while creating a new release.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while creating a new release.', + }); }); }); }); @@ -483,9 +483,9 @@ describe('Release edit/new actions', () => { await actions.updateRelease({ commit, dispatch, state, getters }); expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while saving the release details.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while saving the release details.', + }); }); }); @@ -503,9 +503,9 @@ describe('Release edit/new actions', () => { await actions.updateRelease({ commit, dispatch, state, getters }); expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while saving the release details.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while saving the release details.', + }); }); }; diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js index 1b83d071d17..9dda024bffd 100644 --- a/spec/frontend/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/reports/codequality_report/store/actions_spec.js @@ -20,6 +20,9 @@ describe('Codequality Reports actions', () => { it('should commit SET_PATHS mutation', (done) => { const paths = { basePath: 'basePath', + headPath: 'headPath', + baseBlobPath: 'baseBlobPath', + headBlobPath: 'headBlobPath', reportsPath: 'reportsPath', helpPath: 'codequalityHelpPath', }; diff --git a/spec/frontend/reports/codequality_report/store/mutations_spec.js b/spec/frontend/reports/codequality_report/store/mutations_spec.js index 9d4c05afd36..8bc6bb26c2a 100644 --- a/spec/frontend/reports/codequality_report/store/mutations_spec.js +++ b/spec/frontend/reports/codequality_report/store/mutations_spec.js @@ -13,16 +13,25 @@ describe('Codequality Reports mutations', () => { describe('SET_PATHS', () => { it('sets paths to given values', () => { const basePath = 'base.json'; + const headPath = 'head.json'; + const baseBlobPath = 'base/blob/path/'; + const headBlobPath = 'head/blob/path/'; const reportsPath = 'reports.json'; const helpPath = 'help.html'; mutations.SET_PATHS(localState, { basePath, + headPath, + baseBlobPath, + headBlobPath, reportsPath, helpPath, }); expect(localState.basePath).toEqual(basePath); + expect(localState.headPath).toEqual(headPath); + expect(localState.baseBlobPath).toEqual(baseBlobPath); + expect(localState.headBlobPath).toEqual(headBlobPath); expect(localState.reportsPath).toEqual(reportsPath); expect(localState.helpPath).toEqual(helpPath); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index f03df8cf2ac..495039b4ccb 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -5,6 +5,7 @@ import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue'; +import BlobReplace from '~/repository/components/blob_replace.vue'; let wrapper; const simpleMockData = { @@ -75,10 +76,11 @@ const factory = createFactory(shallowMount); const fullFactory = createFactory(mount); describe('Blob content viewer component', () => { - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findBlobHeader = () => wrapper.find(BlobHeader); - const findBlobHeaderEdit = () => wrapper.find(BlobHeaderEdit); - const findBlobContent = () => wrapper.find(BlobContent); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findBlobHeader = () => wrapper.findComponent(BlobHeader); + const findBlobHeaderEdit = () => wrapper.findComponent(BlobHeaderEdit); + const findBlobContent = () => wrapper.findComponent(BlobContent); + const findBlobReplace = () => wrapper.findComponent(BlobReplace); afterEach(() => { wrapper.destroy(); @@ -162,15 +164,23 @@ describe('Blob content viewer component', () => { }); describe('BlobHeader action slot', () => { - it('renders BlobHeaderEdit button in simple viewer', async () => { + const { ideEditPath, editBlobPath } = simpleMockData; + + it('renders BlobHeaderEdit buttons in simple viewer', async () => { fullFactory({ mockData: { blobInfo: simpleMockData }, stubs: { BlobContent: true, + BlobReplace: true, }, }); + await nextTick(); - expect(findBlobHeaderEdit().props('editPath')).toEqual('some_file.js/edit'); + + expect(findBlobHeaderEdit().props()).toMatchObject({ + editPath: editBlobPath, + webIdePath: ideEditPath, + }); }); it('renders BlobHeaderEdit button in rich viewer', async () => { @@ -178,10 +188,55 @@ describe('Blob content viewer component', () => { mockData: { blobInfo: richMockData }, stubs: { BlobContent: true, + BlobReplace: true, }, }); + await nextTick(); - expect(findBlobHeaderEdit().props('editPath')).toEqual('some_file.js/edit'); + + expect(findBlobHeaderEdit().props()).toMatchObject({ + editPath: editBlobPath, + webIdePath: ideEditPath, + }); + }); + + describe('BlobReplace', () => { + const { name, path } = simpleMockData; + + it('renders component', async () => { + window.gon.current_user_id = 1; + + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobReplace().props()).toMatchObject({ + name, + path, + }); + }); + + it('does not render if not logged in', async () => { + window.gon.current_user_id = null; + + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobReplace().exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/repository/components/blob_header_edit_spec.js b/spec/frontend/repository/components/blob_header_edit_spec.js new file mode 100644 index 00000000000..c0eb7c523c4 --- /dev/null +++ b/spec/frontend/repository/components/blob_header_edit_spec.js @@ -0,0 +1,82 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import BlobHeaderEdit from '~/repository/components/blob_header_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', +}; + +describe('BlobHeaderEdit component', () => { + let wrapper; + + const createComponent = (consolidatedEditButton = false, props = {}) => { + wrapper = shallowMount(BlobHeaderEdit, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: { + glFeatures: { + consolidatedEditButton, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findButtons = () => wrapper.findAll(GlButton); + const findEditButton = () => findButtons().at(0); + const findWebIdeButton = () => findButtons().at(1); + 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().attributes('href')).toBe(DEFAULT_PROPS.editPath); + expect(findEditButton().text()).toBe('Edit'); + expect(findEditButton()).not.toBeDisabled(); + }); + + it('renders the Web IDE button', () => { + createComponent(); + + expect(findWebIdeButton().attributes('href')).toBe(DEFAULT_PROPS.webIdePath); + expect(findWebIdeButton().text()).toBe('Web IDE'); + expect(findWebIdeButton()).not.toBeDisabled(); + }); + + it('renders WebIdeLink component', () => { + createComponent(true); + + const { editPath: editUrl, webIdePath: webIdeUrl } = DEFAULT_PROPS; + + expect(findWebIdeLink().props()).toMatchObject({ + editUrl, + webIdeUrl, + isBlob: true, + }); + }); +}); diff --git a/spec/frontend/repository/components/blob_replace_spec.js b/spec/frontend/repository/components/blob_replace_spec.js new file mode 100644 index 00000000000..4a6f147da22 --- /dev/null +++ b/spec/frontend/repository/components/blob_replace_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import BlobReplace from '~/repository/components/blob_replace.vue'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; + +const DEFAULT_PROPS = { + name: 'some name', + path: 'some/path', + canPushCode: true, + replacePath: 'some/replace/path', +}; + +const DEFAULT_INJECT = { + targetBranch: 'master', + originalBranch: 'master', +}; + +describe('BlobReplace component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(BlobReplace, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: { + ...DEFAULT_INJECT, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); + + it('renders component', () => { + createComponent(); + + const { name, path } = DEFAULT_PROPS; + + expect(wrapper.props()).toMatchObject({ + name, + path, + }); + }); + + it('renders UploadBlobModal', () => { + createComponent(); + + const { targetBranch, originalBranch } = DEFAULT_INJECT; + const { name, path, canPushCode, replacePath } = DEFAULT_PROPS; + const title = `Replace ${name}`; + + expect(findUploadBlobModal().props()).toMatchObject({ + modalTitle: title, + commitMessage: title, + targetBranch, + originalBranch, + canPushCode, + path, + replacePath, + primaryBtnText: 'Replace file', + }); + }); +}); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 6ba6f993db1..da28c9873d9 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -1,5 +1,6 @@ import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import TableRow from '~/repository/components/table/row.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; @@ -18,6 +19,10 @@ function factory(propsData = {}) { name: propsData.path, projectPath: 'gitlab-org/gitlab-ce', url: `https://test.com`, + totalEntries: 10, + }, + directives: { + GlHoverLoad: createMockDirective(), }, provide: { glFeatures: { refactorBlobViewer: true }, @@ -34,6 +39,8 @@ function factory(propsData = {}) { } describe('Repository table row component', () => { + const findRouterLink = () => vm.find(RouterLinkStub); + afterEach(() => { vm.destroy(); }); @@ -81,6 +88,21 @@ describe('Repository table row component', () => { }); }); + it('renders a gl-hover-load directive', () => { + factory({ + id: '1', + sha: '123', + path: 'test', + type: 'blob', + currentPath: '/', + }); + + const hoverLoadDirective = getBinding(findRouterLink().element, 'gl-hover-load'); + + expect(hoverLoadDirective).not.toBeUndefined(); + expect(hoverLoadDirective.value).toBeInstanceOf(Function); + }); + it.each` type | component | componentName ${'tree'} | ${RouterLinkStub} | ${'RouterLink'} diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 2930e39df8a..d397bc185e2 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -1,7 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; -import TreeContent, { INITIAL_FETCH_COUNT } from '~/repository/components/tree_content.vue'; +import TreeContent from '~/repository/components/tree_content.vue'; +import { TREE_INITIAL_FETCH_COUNT } from '~/repository/constants'; let vm; let $apollo; @@ -128,7 +129,7 @@ describe('Repository table component', () => { it('has limit of 1000 files on initial load', () => { factory('/'); - expect(INITIAL_FETCH_COUNT * vm.vm.pageSize).toBe(1000); + expect(TREE_INITIAL_FETCH_COUNT * vm.vm.pageSize).toBe(1000); }); }); }); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index ec85d5666fb..d93b1d7e5f1 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -200,4 +200,84 @@ describe('UploadBlobModal', () => { }); }, ); + + describe('blob file submission type', () => { + const submitForm = async () => { + wrapper.vm.uploadFile = jest.fn(); + wrapper.vm.replaceFile = jest.fn(); + wrapper.vm.submitForm(); + await wrapper.vm.$nextTick(); + }; + + const submitRequest = async () => { + mock = new MockAdapter(axios); + findModal().vm.$emit('primary', mockEvent); + await waitForPromises(); + }; + + describe('upload blob file', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the default "Upload New File" modal title ', () => { + expect(findModal().props('title')).toBe('Upload New File'); + }); + + it('display the defaul primary button text', () => { + expect(findModal().props('actionPrimary').text).toBe('Upload file'); + }); + + it('calls the default uploadFile when the form submit', async () => { + await submitForm(); + + expect(wrapper.vm.uploadFile).toHaveBeenCalled(); + expect(wrapper.vm.replaceFile).not.toHaveBeenCalled(); + }); + + it('makes a POST request', async () => { + await submitRequest(); + + expect(mock.history.put).toHaveLength(0); + expect(mock.history.post).toHaveLength(1); + }); + }); + + describe('replace blob file', () => { + const modalTitle = 'Replace foo.js'; + const replacePath = 'replace-path'; + const primaryBtnText = 'Replace file'; + + beforeEach(() => { + createComponent({ + modalTitle, + replacePath, + primaryBtnText, + }); + }); + + it('displays the passed modal title', () => { + expect(findModal().props('title')).toBe(modalTitle); + }); + + it('display the passed primary button text', () => { + expect(findModal().props('actionPrimary').text).toBe(primaryBtnText); + }); + + it('calls the replaceFile when the form submit', async () => { + await submitForm(); + + expect(wrapper.vm.replaceFile).toHaveBeenCalled(); + expect(wrapper.vm.uploadFile).not.toHaveBeenCalled(); + }); + + it('makes a PUT request', async () => { + await submitRequest(); + + expect(mock.history.put).toHaveLength(1); + expect(mock.history.post).toHaveLength(0); + expect(mock.history.put[0].url).toBe(replacePath); + }); + }); + }); }); diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index a842053caad..8cabf902a4f 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -69,6 +69,21 @@ describe('fetchLogsTree', () => { mock.restore(); }); + it('persists the offset for a given page if offset is larger than maximum offset', async () => { + await fetchLogsTree(client, 'path', '1000', resolver, 900).then(() => {}); + + await fetchLogsTree(client, 'path', '1100', resolver, 1200).then(() => { + expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/-/refs/main/logs_tree/path', { + params: { format: 'json', offset: 975 }, + }); + }); + }); + + it('does not call axios get if offset is larger than the maximum offset', () => + fetchLogsTree(client, '', '1000', resolver, 900).then(() => { + expect(axios.get).not.toHaveBeenCalled(); + })); + it('calls axios get', () => fetchLogsTree(client, '', '0', resolver).then(() => { expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/-/refs/main/logs_tree/', { diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js new file mode 100644 index 00000000000..12651a82a0c --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -0,0 +1,201 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; +import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql'; +import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; +import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; + +const mockId = '1'; + +const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; + +describe('RunnerTypeCell', () => { + let wrapper; + let mutate; + + const findEditBtn = () => wrapper.findByTestId('edit-runner'); + const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); + const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); + + const createComponent = ({ active = true } = {}, options) => { + wrapper = extendedWrapper( + shallowMount(RunnerActionCell, { + propsData: { + runner: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + active, + }, + }, + mocks: { + $apollo: { + mutate, + }, + }, + ...options, + }), + ); + }; + + beforeEach(() => { + mutate = jest.fn(); + }); + + afterEach(() => { + mutate.mockReset(); + wrapper.destroy(); + }); + + it('Displays the runner edit link with the correct href', () => { + createComponent(); + + expect(findEditBtn().attributes('href')).toBe('/admin/runners/1'); + }); + + describe.each` + state | label | icon | isActive | newActiveValue + ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false} + ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true} + `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => { + beforeEach(() => { + mutate.mockResolvedValue({ + data: { + runnerUpdate: { + runner: { + id: `gid://gitlab/Ci::Runner/1`, + __typename: 'CiRunner', + }, + }, + }, + }); + + createComponent({ active: isActive }); + }); + + it(`Displays a ${icon} button`, () => { + expect(findToggleActiveBtn().props('loading')).toBe(false); + expect(findToggleActiveBtn().props('icon')).toBe(icon); + expect(findToggleActiveBtn().attributes('title')).toBe(label); + expect(findToggleActiveBtn().attributes('aria-label')).toBe(label); + }); + + it(`After clicking the ${icon} button, the button has a loading state`, async () => { + await findToggleActiveBtn().vm.$emit('click'); + + expect(findToggleActiveBtn().props('loading')).toBe(true); + }); + + it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => { + await findToggleActiveBtn().vm.$emit('click'); + + expect(findToggleActiveBtn().attributes('title')).toBe(''); + expect(findToggleActiveBtn().attributes('aria-label')).toBe(''); + }); + + describe(`When clicking on the ${icon} button`, () => { + beforeEach(async () => { + await findToggleActiveBtn().vm.$emit('click'); + await waitForPromises(); + }); + + it(`The apollo mutation to set active to ${newActiveValue} is called`, () => { + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + mutation: runnerUpdateMutation, + variables: { + input: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + active: newActiveValue, + }, + }, + }); + }); + + it('The button does not have a loading state', () => { + expect(findToggleActiveBtn().props('loading')).toBe(false); + }); + }); + }); + + describe('When the user clicks a runner', () => { + beforeEach(() => { + createComponent(); + + mutate.mockResolvedValue({ + data: { + runnerDelete: { + runner: { + id: `gid://gitlab/Ci::Runner/1`, + __typename: 'CiRunner', + }, + }, + }, + }); + + jest.spyOn(window, 'confirm'); + }); + + describe('When the user confirms deletion', () => { + beforeEach(async () => { + window.confirm.mockReturnValue(true); + await findDeleteBtn().vm.$emit('click'); + }); + + it('The user sees a confirmation alert', async () => { + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(window.confirm).toHaveBeenCalledWith(expect.any(String)); + }); + + it('The delete mutation is called correctly', () => { + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + mutation: deleteRunnerMutation, + variables: { + input: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + }, + }, + awaitRefetchQueries: true, + refetchQueries: [getRunnersQueryName], + }); + }); + + it('The delete button does not have a loading state', () => { + expect(findDeleteBtn().props('loading')).toBe(false); + expect(findDeleteBtn().attributes('title')).toBe('Remove'); + }); + + it('After the delete button is clicked, loading state is shown', async () => { + await findDeleteBtn().vm.$emit('click'); + + expect(findDeleteBtn().props('loading')).toBe(true); + }); + + it('After the delete button is clicked, stale tooltip is removed', async () => { + await findDeleteBtn().vm.$emit('click'); + + expect(findDeleteBtn().attributes('title')).toBe(''); + }); + }); + + describe('When the user does not confirm deletion', () => { + beforeEach(async () => { + window.confirm.mockReturnValue(false); + await findDeleteBtn().vm.$emit('click'); + }); + + it('The user sees a confirmation alert', () => { + expect(window.confirm).toHaveBeenCalledTimes(1); + }); + + it('The delete mutation is not called', () => { + expect(mutate).toHaveBeenCalledTimes(0); + }); + + it('The delete button does not have a loading state', () => { + expect(findDeleteBtn().props('loading')).toBe(false); + expect(findDeleteBtn().attributes('title')).toBe('Remove'); + }); + }); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_name_cell_spec.js b/spec/frontend/runner/components/cells/runner_name_cell_spec.js new file mode 100644 index 00000000000..26055fc0faf --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_name_cell_spec.js @@ -0,0 +1,42 @@ +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue'; + +const mockId = '1'; +const mockShortSha = '2P6oDVDm'; +const mockDescription = 'runner-1'; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = () => { + wrapper = mount(RunnerNameCell, { + propsData: { + runner: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + shortSha: mockShortSha, + description: mockDescription, + }, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays the runner link with id and short token', () => { + expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`); + expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`); + }); + + it('Displays the runner description', () => { + expect(wrapper.text()).toContain(mockDescription); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_type_cell_spec.js b/spec/frontend/runner/components/cells/runner_type_cell_spec.js new file mode 100644 index 00000000000..48958a282fc --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_type_cell_spec.js @@ -0,0 +1,48 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue'; +import { INSTANCE_TYPE } from '~/runner/constants'; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findBadges = () => wrapper.findAllComponents(GlBadge); + + const createComponent = ({ runner = {} } = {}) => { + wrapper = mount(RunnerTypeCell, { + propsData: { + runner: { + runnerType: INSTANCE_TYPE, + active: true, + locked: false, + ...runner, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays the runner type', () => { + createComponent(); + + expect(findBadges()).toHaveLength(1); + expect(findBadges().at(0).text()).toBe('shared'); + }); + + it('Displays locked and paused states', () => { + createComponent({ + runner: { + active: false, + locked: true, + }, + }); + + expect(findBadges()).toHaveLength(3); + expect(findBadges().at(0).text()).toBe('shared'); + expect(findBadges().at(1).text()).toBe('locked'); + expect(findBadges().at(2).text()).toBe('paused'); + }); +}); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js new file mode 100644 index 00000000000..61a8f821b30 --- /dev/null +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -0,0 +1,137 @@ +import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE } from '~/runner/constants'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +describe('RunnerList', () => { + let wrapper; + + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); + const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); + + const mockDefaultSort = 'CREATED_DESC'; + const mockOtherSort = 'CONTACTED_DESC'; + const mockFilters = [ + { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }, + { type: 'filtered-search-term', value: { data: '' } }, + ]; + + const createComponent = ({ props = {}, options = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(RunnerFilteredSearchBar, { + propsData: { + value: { + filters: [], + sort: mockDefaultSort, + }, + ...props, + }, + attrs: { namespace: 'runners' }, + stubs: { + FilteredSearch, + GlFilteredSearch, + GlDropdown, + GlDropdownItem, + }, + ...options, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('binds a namespace to the filtered search', () => { + expect(findFilteredSearch().props('namespace')).toBe('runners'); + }); + + it('sets sorting options', () => { + const SORT_OPTIONS_COUNT = 2; + + expect(findSortOptions()).toHaveLength(SORT_OPTIONS_COUNT); + expect(findSortOptions().at(0).text()).toBe('Created date'); + expect(findSortOptions().at(1).text()).toBe('Last contact'); + }); + + it('sets tokens', () => { + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + ]); + }); + + it('fails validation for v-model with the wrong shape', () => { + expect(() => { + createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } }); + }).toThrow('Invalid prop: custom validator check failed'); + + expect(() => { + createComponent({ props: { value: { sort: 'sort' } } }); + }).toThrow('Invalid prop: custom validator check failed'); + }); + + describe('when a search is preselected', () => { + beforeEach(() => { + createComponent({ + props: { + value: { + sort: mockOtherSort, + filters: mockFilters, + }, + }, + }); + }); + + it('filter values are shown', () => { + expect(findGlFilteredSearch().props('value')).toEqual(mockFilters); + }); + + it('sort option is selected', () => { + expect( + findSortOptions() + .filter((w) => w.props('isChecked')) + .at(0) + .text(), + ).toEqual('Last contact'); + }); + }); + + it('when the user sets a filter, the "search" is emitted with filters', () => { + findGlFilteredSearch().vm.$emit('input', mockFilters); + findGlFilteredSearch().vm.$emit('submit'); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + filters: mockFilters, + sort: mockDefaultSort, + pagination: { page: 1 }, + }, + ]); + }); + + it('when the user sets a sorting method, the "search" is emitted with the sort', () => { + findSortOptions().at(1).vm.$emit('click'); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + filters: [], + sort: mockOtherSort, + pagination: { page: 1 }, + }, + ]); + }); +}); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js new file mode 100644 index 00000000000..d88d7b3fbee --- /dev/null +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -0,0 +1,130 @@ +import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerList from '~/runner/components/runner_list.vue'; +import { runnersData } from '../mock_data'; + +const mockRunners = runnersData.data.runners.nodes; +const mockActiveRunnersCount = mockRunners.length; + +describe('RunnerList', () => { + let wrapper; + + const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findTable = () => wrapper.findComponent(GlTable); + 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 = shallowMount) => { + wrapper = extendedWrapper( + mountFn(RunnerList, { + propsData: { + runners: mockRunners, + activeRunnersCount: mockActiveRunnersCount, + ...props, + }, + }), + ); + }; + + beforeEach(() => { + createComponent({}, mount); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays active runner count', () => { + expect(findActiveRunnersMessage().text()).toBe( + `Runners currently online: ${mockActiveRunnersCount}`, + ); + }); + + it('Displays a large active runner count', () => { + createComponent({ props: { activeRunnersCount: 2000 } }); + + expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000'); + }); + + it('Displays headers', () => { + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + + expect(headerLabels).toEqual([ + 'Type/State', + 'Runner', + 'Version', + 'IP Address', + 'Projects', + 'Jobs', + 'Tags', + 'Last contact', + '', // actions has no label + ]); + }); + + it('Displays a list of runners', () => { + expect(findRows()).toHaveLength(3); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('Displays details of a runner', () => { + const { id, description, version, ipAddress, shortSha } = mockRunners[0]; + + // Badges + expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('specific paused'); + + // Runner identifier + expect(findCell({ fieldKey: 'name' }).text()).toContain( + `#${getIdFromGraphQLId(id)} (${shortSha})`, + ); + expect(findCell({ fieldKey: 'name' }).text()).toContain(description); + + // Other fields: some cells are empty in the first iteration + // See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features + expect(findCell({ fieldKey: 'version' }).text()).toBe(version); + expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); + expect(findCell({ fieldKey: 'projectCount' }).text()).toBe(''); + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe(''); + expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); + expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); + + // Actions + const actions = findCell({ fieldKey: 'actions' }); + + expect(actions.findByTestId('edit-runner').exists()).toBe(true); + expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); + }); + + it('Links to the runner page', () => { + const { id } = mockRunners[0]; + + expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe( + `/admin/runners/${getIdFromGraphQLId(id)}`, + ); + }); + + describe('When data is loading', () => { + it('shows a busy state', () => { + createComponent({ props: { runners: [], loading: true } }); + expect(findTable().attributes('busy')).toBeTruthy(); + }); + + it('when there are no runners, shows an skeleton loader', () => { + createComponent({ props: { runners: [], loading: true } }, mount); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('when there are runners, shows a busy indicator skeleton loader', () => { + createComponent({ props: { loading: true } }, mount); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js new file mode 100644 index 00000000000..ca5c88f6e28 --- /dev/null +++ b/spec/frontend/runner/components/runner_manual_setup_help_spec.js @@ -0,0 +1,84 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; + +const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/'; + +describe('RunnerManualSetupHelp', () => { + let wrapper; + let originalGon; + + const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions); + const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton); + const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title'); + const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url'); + const findRegistrationToken = () => wrapper.findByTestId('registration-token'); + const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(RunnerManualSetupHelp, { + provide: { + runnerInstallHelpPage: mockRunnerInstallHelpPage, + }, + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, + stubs: { + GlSprintf, + }, + }), + ); + }; + + beforeAll(() => { + originalGon = global.gon; + global.gon = { gitlab_url: TEST_HOST }; + }); + + afterAll(() => { + global.gon = originalGon; + }); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Title contains the default runner type', () => { + expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually'); + }); + + it('Title contains the group runner type', () => { + createComponent({ props: { typeName: 'group' } }); + + expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually'); + }); + + it('Runner Install Page link', () => { + expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage); + }); + + it('Displays the coordinator URL token', () => { + expect(findCoordinatorUrl().text()).toBe(TEST_HOST); + expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST); + }); + + it('Displays the registration token', () => { + expect(findRegistrationToken().text()).toBe(mockRegistrationToken); + expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken); + }); + + it('Displays the runner instructions', () => { + expect(findRunnerInstructions().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/runner/components/runner_pagination_spec.js b/spec/frontend/runner/components/runner_pagination_spec.js new file mode 100644 index 00000000000..59feb32dd2a --- /dev/null +++ b/spec/frontend/runner/components/runner_pagination_spec.js @@ -0,0 +1,160 @@ +import { GlPagination } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; + +const mockStartCursor = 'START_CURSOR'; +const mockEndCursor = 'END_CURSOR'; + +describe('RunnerPagination', () => { + let wrapper; + + const findPagination = () => wrapper.findComponent(GlPagination); + + const createComponent = ({ page = 1, hasPreviousPage = false, hasNextPage = true } = {}) => { + wrapper = mount(RunnerPagination, { + propsData: { + value: { + page, + }, + pageInfo: { + hasPreviousPage, + hasNextPage, + startCursor: mockStartCursor, + endCursor: mockEndCursor, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When on the first page', () => { + beforeEach(() => { + createComponent({ + page: 1, + hasPreviousPage: false, + hasNextPage: true, + }); + }); + + it('Contains the current page information', () => { + expect(findPagination().props('value')).toBe(1); + expect(findPagination().props('prevPage')).toBe(null); + 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); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + after: mockEndCursor, + page: 2, + }, + ]); + }); + }); + + describe('When in between pages', () => { + beforeEach(() => { + createComponent({ + page: 2, + hasPreviousPage: true, + hasNextPage: true, + }); + }); + + it('Contains the current page information', () => { + expect(findPagination().props('value')).toBe(2); + expect(findPagination().props('prevPage')).toBe(1); + expect(findPagination().props('nextPage')).toBe(3); + }); + + it('Shows the next and previous pages', () => { + const links = findPagination().findAll('a'); + + expect(links).toHaveLength(2); + expect(links.at(0).text()).toBe('Prev'); + expect(links.at(1).text()).toBe('Next'); + }); + + it('Goes to the last page', () => { + findPagination().vm.$emit('input', 3); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + after: mockEndCursor, + page: 3, + }, + ]); + }); + + it('Goes to the first page', () => { + findPagination().vm.$emit('input', 1); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + before: mockStartCursor, + page: 1, + }, + ]); + }); + }); + + describe('When in the last page', () => { + beforeEach(() => { + createComponent({ + page: 3, + hasPreviousPage: true, + hasNextPage: false, + }); + }); + + it('Contains the current page', () => { + expect(findPagination().props('value')).toBe(3); + 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', () => { + beforeEach(() => { + createComponent({ + page: 1, + hasPreviousPage: false, + hasNextPage: false, + }); + }); + + it('does not display pagination', () => { + expect(wrapper.html()).toBe(''); + }); + + it('Contains the current page', () => { + expect(findPagination().props('value')).toBe(1); + }); + + it('Shows no more page buttons', () => { + expect(findPagination().props('prevPage')).toBe(null); + expect(findPagination().props('nextPage')).toBe(null); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_tags_spec.js b/spec/frontend/runner/components/runner_tags_spec.js new file mode 100644 index 00000000000..7bb3f65e4ba --- /dev/null +++ b/spec/frontend/runner/components/runner_tags_spec.js @@ -0,0 +1,64 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTags from '~/runner/components/runner_tags.vue'; + +describe('RunnerTags', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTags, { + propsData: { + tagList: ['tag1', 'tag2'], + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays tags text', () => { + expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2'); + + expect(findBadgesAt(0).text()).toBe('tag1'); + expect(findBadgesAt(1).text()).toBe('tag2'); + }); + + it('Displays tags with correct style', () => { + expect(findBadge().props('size')).toBe('md'); + expect(findBadge().props('variant')).toBe('info'); + }); + + it('Displays tags with small size', () => { + createComponent({ + props: { size: 'sm' }, + }); + + expect(findBadge().props('size')).toBe('sm'); + }); + + it('Displays tags with a variant', () => { + createComponent({ + props: { variant: 'warning' }, + }); + + expect(findBadge().props('variant')).toBe('warning'); + }); + + it('Is empty when there are no tags', () => { + createComponent({ + props: { tagList: null }, + }); + + expect(wrapper.text()).toBe(''); + expect(findBadge().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js new file mode 100644 index 00000000000..5b136a77eeb --- /dev/null +++ b/spec/frontend/runner/components/runner_type_alert_spec.js @@ -0,0 +1,61 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeAlert from '~/runner/components/runner_type_alert.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +describe('RunnerTypeAlert', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTypeAlert, { + propsData: { + type: INSTANCE_TYPE, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + type | exampleText | anchor | variant + ${INSTANCE_TYPE} | ${'Shared runners are available to every project'} | ${'#shared-runners'} | ${'success'} + ${GROUP_TYPE} | ${'Use Group runners when you want all projects in a group'} | ${'#group-runners'} | ${'success'} + ${PROJECT_TYPE} | ${'You can set up a specific runner to be used by multiple projects'} | ${'#specific-runners'} | ${'info'} + `('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => { + beforeEach(() => { + createComponent({ props: { type } }); + }); + + it('Describes runner type', () => { + expect(wrapper.text()).toMatch(exampleText); + }); + + it(`Shows a ${variant} variant`, () => { + expect(findAlert().props('variant')).toBe(variant); + }); + + it(`Links to anchor "${anchor}"`, () => { + expect(findLink().attributes('href')).toBe(`/help/ci/runners/runners_scope${anchor}`); + }); + }); + + describe('When runner type is not correct', () => { + it('Does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); + + expect(wrapper.html()).toBe(''); + }); + + it('Validation fails for an incorrect type', () => { + expect(() => { + createComponent({ props: { type: 'NOT_A_TYPE' } }); + }).toThrow(); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js index 8e52d3398bd..ab5ccf6390f 100644 --- a/spec/frontend/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/runner/components/runner_type_badge_spec.js @@ -32,8 +32,14 @@ describe('RunnerTypeBadge', () => { expect(findBadge().props('variant')).toBe(variant); }); - it('does not display a badge when type is unknown', () => { - createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + it('validation fails for an incorrect type', () => { + expect(() => { + createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + }).toThrow(); + }); + + it('does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); expect(findBadge().exists()).toBe(false); }); diff --git a/spec/frontend/runner/components/runner_type_help_spec.js b/spec/frontend/runner/components/runner_type_help_spec.js new file mode 100644 index 00000000000..f0d03282f8e --- /dev/null +++ b/spec/frontend/runner/components/runner_type_help_spec.js @@ -0,0 +1,32 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; + +describe('RunnerTypeHelp', () => { + let wrapper; + + const findBadges = () => wrapper.findAllComponents(GlBadge); + + const createComponent = () => { + wrapper = mount(RunnerTypeHelp); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays each of the runner types', () => { + expect(findBadges().at(0).text()).toBe('shared'); + expect(findBadges().at(1).text()).toBe('group'); + expect(findBadges().at(2).text()).toBe('specific'); + }); + + it('Displays runner states', () => { + expect(findBadges().at(3).text()).toBe('locked'); + expect(findBadges().at(4).text()).toBe('paused'); + }); +}); diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js new file mode 100644 index 00000000000..6333ed7118a --- /dev/null +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -0,0 +1,263 @@ +import { GlForm } from '@gitlab/ui'; +import { createLocalVue, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import RunnerUpdateForm from '~/runner/components/runner_update_form.vue'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + ACCESS_LEVEL_REF_PROTECTED, + ACCESS_LEVEL_NOT_PROTECTED, +} from '~/runner/constants'; +import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; +import { runnerData } from '../mock_data'; + +jest.mock('~/flash'); + +const mockRunner = runnerData.data.runner; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerUpdateForm', () => { + let wrapper; + let runnerUpdateHandler; + + const findForm = () => wrapper.findComponent(GlForm); + const findPausedCheckbox = () => wrapper.findByTestId('runner-field-paused'); + const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected'); + const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged'); + const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked'); + + const findIpInput = () => wrapper.findByTestId('runner-field-ip-address').find('input'); + + const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input'); + const findMaxJobTimeoutInput = () => + wrapper.findByTestId('runner-field-max-timeout').find('input'); + const findTagsInput = () => wrapper.findByTestId('runner-field-tags').find('input'); + + const findSubmit = () => wrapper.find('[type="submit"]'); + const findSubmitDisabledAttr = () => findSubmit().attributes('disabled'); + const submitForm = () => findForm().trigger('submit'); + const submitFormAndWait = () => submitForm().then(waitForPromises); + + const getFieldsModel = () => ({ + active: !findPausedCheckbox().element.checked, + accessLevel: findProtectedCheckbox().element.checked + ? ACCESS_LEVEL_REF_PROTECTED + : ACCESS_LEVEL_NOT_PROTECTED, + runUntagged: findRunUntaggedCheckbox().element.checked, + locked: findLockedCheckbox().element.checked, + ipAddress: findIpInput().element.value, + maximumTimeout: findMaxJobTimeoutInput().element.value || null, + tagList: findTagsInput().element.value.split(',').filter(Boolean), + }); + + const createComponent = ({ props } = {}) => { + wrapper = extendedWrapper( + mount(RunnerUpdateForm, { + localVue, + propsData: { + runner: mockRunner, + ...props, + }, + apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]), + }), + ); + }; + + const expectToHaveSubmittedRunnerContaining = (submittedRunner) => { + expect(runnerUpdateHandler).toHaveBeenCalledTimes(1); + expect(runnerUpdateHandler).toHaveBeenCalledWith({ + input: expect.objectContaining(submittedRunner), + }); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: expect.stringContaining('saved'), + type: FLASH_TYPES.SUCCESS, + }); + + expect(findSubmitDisabledAttr()).toBeUndefined(); + }; + + beforeEach(() => { + runnerUpdateHandler = jest.fn().mockImplementation(({ input }) => { + return Promise.resolve({ + data: { + runnerUpdate: { + runner: { + ...mockRunner, + ...input, + }, + errors: [], + }, + }, + }); + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Form has a submit button', () => { + expect(findSubmit().exists()).toBe(true); + }); + + it('Form fields match data', () => { + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + + it('Form prevent multiple submissions', async () => { + await submitForm(); + + expect(findSubmitDisabledAttr()).toBe('disabled'); + }); + + it('Updates runner with no changes', async () => { + await submitFormAndWait(); + + // Some fields are not submitted + const { ipAddress, runnerType, ...submitted } = mockRunner; + + expectToHaveSubmittedRunnerContaining(submitted); + }); + + describe('When data is being loaded', () => { + beforeEach(() => { + createComponent({ props: { runner: null } }); + }); + + it('Form cannot be submitted', () => { + expect(findSubmit().props('loading')).toBe(true); + }); + + it('Form is updated when data loads', async () => { + wrapper.setProps({ + runner: mockRunner, + }); + + await nextTick(); + + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + }); + + it.each` + runnerType | attrDisabled | outcome + ${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'} + ${GROUP_TYPE} | ${'disabled'} | ${'disabled'} + ${PROJECT_TYPE} | ${undefined} | ${'enabled'} + `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => { + const runner = { ...mockRunner, runnerType }; + createComponent({ props: { runner } }); + + expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled); + }); + + describe('On submit, runner gets updated', () => { + it.each` + test | initialValue | findCheckbox | checked | submitted + ${'pauses'} | ${{ active: true }} | ${findPausedCheckbox} | ${true} | ${{ active: false }} + ${'activates'} | ${{ active: false }} | ${findPausedCheckbox} | ${false} | ${{ active: true }} + ${'unprotects'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} | ${findProtectedCheckbox} | ${true} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} + ${'protects'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} | ${findProtectedCheckbox} | ${false} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} + ${'"runs untagged jobs"'} | ${{ runUntagged: true }} | ${findRunUntaggedCheckbox} | ${false} | ${{ runUntagged: false }} + ${'"runs tagged jobs"'} | ${{ runUntagged: false }} | ${findRunUntaggedCheckbox} | ${true} | ${{ runUntagged: true }} + ${'locks'} | ${{ runnerType: PROJECT_TYPE, locked: true }} | ${findLockedCheckbox} | ${false} | ${{ locked: false }} + ${'unlocks'} | ${{ runnerType: PROJECT_TYPE, locked: false }} | ${findLockedCheckbox} | ${true} | ${{ locked: true }} + `('Checkbox $test runner', async ({ initialValue, findCheckbox, checked, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findCheckbox().setChecked(checked); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + test | initialValue | findInput | value | submitted + ${'description'} | ${{ description: 'Desc. 1' }} | ${findDescriptionInput} | ${'Desc. 2'} | ${{ description: 'Desc. 2' }} + ${'max timeout'} | ${{ maximumTimeout: 36000 }} | ${findMaxJobTimeoutInput} | ${'40000'} | ${{ maximumTimeout: 40000 }} + ${'tags'} | ${{ tagList: ['tag1'] }} | ${findTagsInput} | ${'tag2, tag3'} | ${{ tagList: ['tag2', 'tag3'] }} + `("Field updates runner's $test", async ({ initialValue, findInput, value, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + value | submitted + ${''} | ${{ tagList: [] }} + ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} + ${'with spaces'} | ${{ tagList: ['with spaces'] }} + ${',,,,, commas'} | ${{ tagList: ['commas'] }} + ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }} + ${' trimmed , trimmed2 '} | ${{ tagList: ['trimmed', 'trimmed2'] }} + `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => { + const runner = { ...mockRunner, tagList: ['tag1'] }; + createComponent({ props: { runner } }); + + await findTagsInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + }); + + describe('On error', () => { + beforeEach(() => { + createComponent(); + }); + + it('On network error, error message is shown', async () => { + runnerUpdateHandler.mockRejectedValue(new Error('Something went wrong')); + + await submitFormAndWait(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: 'Network error: Something went wrong', + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + }); + + it('On validation error, error message is shown', async () => { + runnerUpdateHandler.mockResolvedValue({ + data: { + runnerUpdate: { + runner: mockRunner, + errors: ['A value is invalid'], + }, + }, + }); + + await submitFormAndWait(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: 'A value is invalid', + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js new file mode 100644 index 00000000000..8f551feca6e --- /dev/null +++ b/spec/frontend/runner/mock_data.js @@ -0,0 +1,6 @@ +// Fixtures generated by: spec/frontend/fixtures/runner.rb +export const runnersData = getJSONFixture('graphql/runner/get_runners.query.graphql.json'); +export const runnersDataPaginated = getJSONFixture( + 'graphql/runner/get_runners.query.graphql.paginated.json', +); +export const runnerData = getJSONFixture('graphql/runner/get_runner.query.graphql.json'); diff --git a/spec/frontend/runner/runner_detail/runner_details_app_spec.js b/spec/frontend/runner/runner_detail/runner_details_app_spec.js index c61cb647ae6..d0bd701458d 100644 --- a/spec/frontend/runner/runner_detail/runner_details_app_spec.js +++ b/spec/frontend/runner/runner_detail/runner_details_app_spec.js @@ -3,12 +3,15 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; -import { INSTANCE_TYPE } from '~/runner/constants'; import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; -const mockRunnerId = '55'; +import { runnerData } from '../mock_data'; + +const mockRunnerGraphqlId = runnerData.data.runner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -35,15 +38,7 @@ describe('RunnerDetailsApp', () => { }; beforeEach(async () => { - mockRunnerQuery = jest.fn().mockResolvedValue({ - data: { - runner: { - id: `gid://gitlab/Ci::Runner/${mockRunnerId}`, - runnerType: INSTANCE_TYPE, - __typename: 'CiRunner', - }, - }, - }); + mockRunnerQuery = jest.fn().mockResolvedValue(runnerData); }); afterEach(() => { @@ -54,13 +49,13 @@ describe('RunnerDetailsApp', () => { it('expect GraphQL ID to be requested', async () => { await createComponentWithApollo(); - expect(mockRunnerQuery).toHaveBeenCalledWith({ id: `gid://gitlab/Ci::Runner/${mockRunnerId}` }); + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); it('displays the runner id', async () => { await createComponentWithApollo(); - expect(wrapper.text()).toContain('Runner #55'); + expect(wrapper.text()).toContain(`Runner #${mockRunnerId}`); }); it('displays the runner type', async () => { diff --git a/spec/frontend/runner/runner_list/runner_list_app_spec.js b/spec/frontend/runner/runner_list/runner_list_app_spec.js new file mode 100644 index 00000000000..dd913df7143 --- /dev/null +++ b/spec/frontend/runner/runner_list/runner_list_app_spec.js @@ -0,0 +1,232 @@ +import * as Sentry from '@sentry/browser'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import { updateHistory } from '~/lib/utils/url_utility'; + +import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; +import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; + +import { + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + INSTANCE_TYPE, + PARAM_KEY_STATUS, + STATUS_ACTIVE, + RUNNER_PAGE_SIZE, +} from '~/runner/constants'; +import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; +import RunnerListApp from '~/runner/runner_list/runner_list_app.vue'; + +import { runnersData, runnersDataPaginated } from '../mock_data'; + +const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockActiveRunnersCount = 2; + +jest.mock('@sentry/browser'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerListApp', () => { + let wrapper; + let mockRunnersQuery; + let originalLocation; + + const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); + const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + + const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getRunnersQuery, mockRunnersQuery]]; + + wrapper = mountFn(RunnerListApp, { + localVue, + apolloProvider: createMockApollo(handlers), + propsData: { + activeRunnersCount: mockActiveRunnersCount, + registrationToken: mockRegistrationToken, + ...props, + }, + }); + }; + + const setQuery = (query) => { + window.location.href = `${TEST_HOST}/admin/runners/${query}`; + window.location.search = query; + }; + + beforeAll(() => { + originalLocation = window.location; + Object.defineProperty(window, 'location', { writable: true, value: { href: '', search: '' } }); + }); + + afterAll(() => { + window.location = originalLocation; + }); + + beforeEach(async () => { + setQuery(''); + + Sentry.withScope.mockImplementation((fn) => { + const scope = { setTag: jest.fn() }; + fn(scope); + }); + + mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); + createComponentWithApollo(); + await waitForPromises(); + }); + + afterEach(() => { + mockRunnersQuery.mockReset(); + wrapper.destroy(); + }); + + it('shows the runners list', () => { + expect(runnersData.data.runners.nodes).toMatchObject(findRunnerList().props('runners')); + }); + + it('requests the runners with no filters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: undefined, + type: undefined, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('shows the runner type help', () => { + expect(findRunnerTypeHelp().exists()).toBe(true); + }); + + it('shows the runner setup instructions', () => { + expect(findRunnerManualSetupHelp().exists()).toBe(true); + expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + window.location.search = `?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`; + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + filters: [ + { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, + ], + sort: 'CREATED_DESC', + pagination: { page: 1 }, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: STATUS_ACTIVE, + type: INSTANCE_TYPE, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(() => { + findRunnerFilteredSearchBar().vm.$emit('input', { + filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }], + sort: CREATED_ASC, + }); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: 'http://test.host/admin/runners/?status[]=ACTIVE&sort=CREATED_ASC', + }); + }); + + it('requests the runners with filters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: STATUS_ACTIVE, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } }); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('shows a message for no results', async () => { + expect(wrapper.text()).toContain('No runners found'); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponentWithApollo(); + expect(findRunnerList().props('loading')).toBe(true); + }); + + describe('when runners query fails', () => { + beforeEach(async () => { + mockRunnersQuery = jest.fn().mockRejectedValue(new Error()); + createComponentWithApollo(); + + await waitForPromises(); + }); + + it('error is reported to sentry', async () => { + expect(Sentry.withScope).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); + + createComponentWithApollo({ mountFn: mount }); + }); + + it('more pages can be selected', () => { + expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next'); + }); + + it('cannot navigate to the previous page', () => { + expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev'); + }); + + it('navigates to the next page', async () => { + const nextPageBtn = findRunnerPagination().find('a'); + expect(nextPageBtn.text()).toBe('Next'); + + await nextPageBtn.trigger('click'); + + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: runnersDataPaginated.data.runners.pageInfo.endCursor, + }); + }); + }); +}); diff --git a/spec/frontend/runner/runner_list/runner_search_utils_spec.js b/spec/frontend/runner/runner_list/runner_search_utils_spec.js new file mode 100644 index 00000000000..a1f33e9c880 --- /dev/null +++ b/spec/frontend/runner/runner_list/runner_search_utils_spec.js @@ -0,0 +1,239 @@ +import { RUNNER_PAGE_SIZE } from '~/runner/constants'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, +} from '~/runner/runner_list/runner_search_utils'; + +describe('search_params.js', () => { + const examples = [ + { + name: 'a default query', + urlQuery: '', + search: { filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, + graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a single status', + urlQuery: '?status[]=ACTIVE', + search: { + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a single term text search', + urlQuery: '?search=something', + search: { + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a two terms text search', + urlQuery: '?search=something+else', + search: { + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: 'else' }, + }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'single instance type', + urlQuery: '?runner_type[]=INSTANCE_TYPE', + search: { + filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'multiple runner status', + urlQuery: '?status[]=ACTIVE&status[]=PAUSED', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'status', value: { data: 'PAUSED', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'multiple status, a single instance type and a non default sort', + urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + sort: 'CREATED_ASC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'the next page', + urlQuery: '?page=2&after=AFTER_CURSOR', + search: { filters: [], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC' }, + graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'the previous page', + urlQuery: '?page=2&before=BEFORE_CURSOR', + search: { + filters: [], + pagination: { page: 2, before: 'BEFORE_CURSOR' }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE }, + }, + { + name: + 'the next page filtered by multiple status, a single instance type and a non default sort', + urlQuery: + '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, + ], + pagination: { page: 2, after: 'AFTER_CURSOR' }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + sort: 'CREATED_ASC', + after: 'AFTER_CURSOR', + first: RUNNER_PAGE_SIZE, + }, + }, + ]; + + describe('fromUrlQueryToSearch', () => { + examples.forEach(({ name, urlQuery, search }) => { + it(`Converts ${name} to a search object`, () => { + expect(fromUrlQueryToSearch(urlQuery)).toEqual(search); + }); + }); + + it('When search params appear as array, they are concatenated', () => { + expect(fromUrlQueryToSearch('?search[]=my&search[]=text').filters).toEqual([ + { type: 'filtered-search-term', value: { data: 'my' } }, + { type: 'filtered-search-term', value: { data: 'text' } }, + ]); + }); + + it('When a page cannot be parsed as a number, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=NONSENSE&after=AFTER_CURSOR').pagination).toEqual({ + page: 1, + }); + }); + + it('When a page is less than 1, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=0&after=AFTER_CURSOR').pagination).toEqual({ + page: 1, + }); + }); + + it('When a page with no cursor is given, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=2').pagination).toEqual({ + page: 1, + }); + }); + }); + + describe('fromSearchToUrl', () => { + examples.forEach(({ name, urlQuery, search }) => { + it(`Converts ${name} to a url`, () => { + expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`); + }); + }); + + it.each([ + 'http://test.host/?status[]=ACTIVE', + 'http://test.host/?runner_type[]=INSTANCE_TYPE', + 'http://test.host/?search=my_text', + ])('When a filter is removed, it is removed from the URL', (initalUrl) => { + const search = { filters: [], sort: 'CREATED_DESC' }; + const expectedUrl = `http://test.host/`; + + expect(fromSearchToUrl(search, initalUrl)).toEqual(expectedUrl); + }); + + it('When unrelated search parameter is present, it does not get removed', () => { + const initialUrl = `http://test.host/?unrelated=UNRELATED&status[]=ACTIVE`; + const search = { filters: [], sort: 'CREATED_DESC' }; + const expectedUrl = `http://test.host/?unrelated=UNRELATED`; + + expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl); + }); + }); + + describe('fromSearchToVariables', () => { + examples.forEach(({ name, graphqlVariables, search }) => { + it(`Converts ${name} to a GraphQL query variables object`, () => { + expect(fromSearchToVariables(search)).toEqual(graphqlVariables); + }); + }); + + it('When a search param is empty, it gets removed', () => { + expect( + fromSearchToVariables({ + filters: [ + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }), + ).toMatchObject({ + search: '', + }); + + expect( + fromSearchToVariables({ + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }), + ).toMatchObject({ + search: 'something', + }); + }); + }); +}); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index d076997b04a..fbe01f372b0 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -2,47 +2,49 @@ export const MOCK_QUERY = { scope: 'issues', state: 'all', confidential: null, - group_id: 'test_1', + group_id: 1, }; export const MOCK_GROUP = { name: 'test group', - full_name: 'full name test group', - id: 'test_1', + full_name: 'full name / test group', + id: 1, }; export const MOCK_GROUPS = [ { + avatar_url: null, name: 'test group', - full_name: 'full name test group', - id: 'test_1', + full_name: 'full name / test group', + id: 1, }, { + avatar_url: 'https://avatar.com', name: 'test group 2', - full_name: 'full name test group 2', - id: 'test_2', + full_name: 'full name / test group 2', + id: 2, }, ]; export const MOCK_PROJECT = { name: 'test project', namespace: MOCK_GROUP, - nameWithNamespace: 'test group test project', - id: 'test_1', + nameWithNamespace: 'test group / test project', + id: 1, }; export const MOCK_PROJECTS = [ { name: 'test project', namespace: MOCK_GROUP, - name_with_namespace: 'test group test project', - id: 'test_1', + name_with_namespace: 'test group / test project', + id: 1, }, { name: 'test project 2', namespace: MOCK_GROUP, - name_with_namespace: 'test group test project 2', - id: 'test_2', + name_with_namespace: 'test group / test project 2', + id: 2, }, ]; diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index ab622c53387..634661c5843 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -20,9 +20,8 @@ describe('Global Search Store Actions', () => { let mock; let state; - const noCallback = () => {}; - const flashCallback = () => { - expect(createFlash).toHaveBeenCalledTimes(1); + const flashCallback = (callCount) => { + expect(createFlash).toHaveBeenCalledTimes(callCount); createFlash.mockClear(); }; @@ -37,19 +36,21 @@ describe('Global Search Store Actions', () => { }); describe.each` - action | axiosMock | type | expectedMutations | callback - ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback} - ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback} - ${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${noCallback} - ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${flashCallback} - `(`axios calls`, ({ action, axiosMock, type, expectedMutations, callback }) => { + action | axiosMock | type | expectedMutations | flashCallCount + ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${0} + ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${1} + ${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${0} + ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${2} + `(`axios calls`, ({ action, axiosMock, type, expectedMutations, flashCallCount }) => { describe(action.name, () => { describe(`on ${type}`, () => { beforeEach(() => { mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res); }); it(`should dispatch the correct mutations`, () => { - return testAction({ action, state, expectedMutations }).then(() => callback()); + return testAction({ action, state, expectedMutations }).then(() => + flashCallback(flashCallCount), + ); }); }); }); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js new file mode 100644 index 00000000000..e51fe9a4cf9 --- /dev/null +++ b/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js @@ -0,0 +1,97 @@ +import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { MOCK_GROUPS } from 'jest/search/mock_data'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import SearchableDropdownItem from '~/search/topbar/components/searchable_dropdown_item.vue'; +import { GROUP_DATA } from '~/search/topbar/constants'; + +describe('Global Search Searchable Dropdown Item', () => { + let wrapper; + + const defaultProps = { + item: MOCK_GROUPS[0], + selectedItem: MOCK_GROUPS[0], + name: GROUP_DATA.name, + fullName: GROUP_DATA.fullName, + }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(SearchableDropdownItem, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findGlAvatar = () => wrapper.findComponent(GlAvatar); + const findDropdownTitle = () => wrapper.findByTestId('item-title'); + const findDropdownSubtitle = () => wrapper.findByTestId('item-namespace'); + + describe('template', () => { + describe('always', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlDropdownItem', () => { + expect(findGlDropdownItem().exists()).toBe(true); + }); + + it('renders GlAvatar', () => { + expect(findGlAvatar().exists()).toBe(true); + }); + + it('renders Dropdown Title correctly', () => { + const titleEl = findDropdownTitle(); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.text()).toBe(MOCK_GROUPS[0][GROUP_DATA.name]); + }); + + it('renders Dropdown Subtitle correctly', () => { + const subtitleEl = findDropdownSubtitle(); + + expect(subtitleEl.exists()).toBe(true); + expect(subtitleEl.text()).toBe(truncateNamespace(MOCK_GROUPS[0][GROUP_DATA.fullName])); + }); + }); + + describe('when item === selectedItem', () => { + beforeEach(() => { + createComponent({ item: MOCK_GROUPS[0], selectedItem: MOCK_GROUPS[0] }); + }); + + it('marks the dropdown as checked', () => { + expect(findGlDropdownItem().attributes('ischecked')).toBe('true'); + }); + }); + + describe('when item !== selectedItem', () => { + beforeEach(() => { + createComponent({ item: MOCK_GROUPS[0], selectedItem: MOCK_GROUPS[1] }); + }); + + it('marks the dropdown as not checked', () => { + expect(findGlDropdownItem().attributes('ischecked')).toBeUndefined(); + }); + }); + }); + + describe('actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('clicking the dropdown item $emits change with the item', () => { + findGlDropdownItem().vm.$emit('click'); + + expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); + }); + }); +}); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js index 5de948592d4..10d779f0f90 100644 --- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js +++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js @@ -1,20 +1,21 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; -import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; +import SearchableDropdownItem from '~/search/topbar/components/searchable_dropdown_item.vue'; import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('Global Search Searchable Dropdown', () => { let wrapper; const defaultProps = { headerText: GROUP_DATA.headerText, - selectedDisplayValue: GROUP_DATA.selectedDisplayValue, - itemsDisplayValue: GROUP_DATA.itemsDisplayValue, + name: GROUP_DATA.name, + fullName: GROUP_DATA.fullName, loading: false, selectedItem: ANY_OPTION, items: [], @@ -29,7 +30,6 @@ describe('Global Search Searchable Dropdown', () => { }); wrapper = mountFn(SearchableDropdown, { - localVue, store, propsData: { ...defaultProps, @@ -40,17 +40,16 @@ describe('Global Search Searchable Dropdown', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findGlDropdown = () => wrapper.find(GlDropdown); - const findGlDropdownSearch = () => findGlDropdown().find(GlSearchBoxByType); + const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlDropdownSearch = () => findGlDropdown().findComponent(GlSearchBoxByType); const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text'); - const findDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); - const findDropdownItemsText = () => findDropdownItems().wrappers.map((w) => w.text()); - const findAnyDropdownItem = () => findDropdownItems().at(0); - const findFirstGroupDropdownItem = () => findDropdownItems().at(1); - const findLoader = () => wrapper.find(GlSkeletonLoader); + const findSearchableDropdownItems = () => + findGlDropdown().findAllComponents(SearchableDropdownItem); + const findAnyDropdownItem = () => findGlDropdown().findComponent(GlDropdownItem); + const findFirstGroupDropdownItem = () => findSearchableDropdownItems().at(0); + const findLoader = () => wrapper.findComponent(GlSkeletonLoader); describe('template', () => { beforeEach(() => { @@ -93,9 +92,12 @@ describe('Global Search Searchable Dropdown', () => { expect(findLoader().exists()).toBe(false); }); - it('renders an instance for each namespace', () => { - const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map((n) => n.full_name)); - expect(findDropdownItemsText()).toStrictEqual(resultsIncludeAny); + it('renders the Any Dropdown', () => { + expect(findAnyDropdownItem().exists()).toBe(true); + }); + + it('renders SearchableDropdownItem for each item', () => { + expect(findSearchableDropdownItems()).toHaveLength(MOCK_GROUPS.length); }); }); @@ -108,18 +110,12 @@ describe('Global Search Searchable Dropdown', () => { expect(findLoader().exists()).toBe(true); }); - it('renders only Any in dropdown', () => { - expect(findDropdownItemsText()).toStrictEqual(['Any']); - }); - }); - - describe('when item is selected', () => { - beforeEach(() => { - createComponent({}, { items: MOCK_GROUPS, selectedItem: MOCK_GROUPS[0] }); + it('renders the Any Dropdown', () => { + expect(findAnyDropdownItem().exists()).toBe(true); }); - it('marks the dropdown as checked', () => { - expect(findFirstGroupDropdownItem().attributes('ischecked')).toBe('true'); + it('does not render SearchableDropdownItem', () => { + expect(findSearchableDropdownItems()).toHaveLength(0); }); }); }); @@ -140,8 +136,8 @@ describe('Global Search Searchable Dropdown', () => { createComponent({}, { selectedItem: MOCK_GROUP }, mount); }); - it('sets dropdown text to the selectedItem selectedDisplayValue', () => { - expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.selectedDisplayValue]); + it('sets dropdown text to the selectedItem name', () => { + expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.name]); }); }); }); @@ -158,8 +154,8 @@ describe('Global Search Searchable Dropdown', () => { expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]); }); - it('clicking result dropdown item $emits @change with result', () => { - findFirstGroupDropdownItem().vm.$emit('click'); + it('on SearchableDropdownItem @change, the wrapper $emits change with the item', () => { + findFirstGroupDropdownItem().vm.$emit('change', MOCK_GROUPS[0]); expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); }); diff --git a/spec/frontend/security_configuration/components/redesigned_app_spec.js b/spec/frontend/security_configuration/components/redesigned_app_spec.js new file mode 100644 index 00000000000..7e27a3e1108 --- /dev/null +++ b/spec/frontend/security_configuration/components/redesigned_app_spec.js @@ -0,0 +1,232 @@ +import { GlTab } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + SAST_NAME, + SAST_SHORT_NAME, + SAST_DESCRIPTION, + SAST_HELP_PATH, + SAST_CONFIG_HELP_PATH, + LICENSE_COMPLIANCE_NAME, + LICENSE_COMPLIANCE_DESCRIPTION, + LICENSE_COMPLIANCE_HELP_PATH, +} from '~/security_configuration/components/constants'; +import FeatureCard from '~/security_configuration/components/feature_card.vue'; +import RedesignedSecurityConfigurationApp, { + i18n, +} from '~/security_configuration/components/redesigned_app.vue'; +import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; +import { + REPORT_TYPE_LICENSE_COMPLIANCE, + REPORT_TYPE_SAST, +} from '~/vue_shared/security_reports/constants'; + +const upgradePath = '/upgrade'; + +describe('redesigned App component', () => { + let wrapper; + let userCalloutDismissSpy; + + const createComponent = ({ shouldShowCallout = true, ...propsData }) => { + userCalloutDismissSpy = jest.fn(); + + wrapper = extendedWrapper( + mount(RedesignedSecurityConfigurationApp, { + propsData, + provide: { + upgradePath, + }, + stubs: { + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, + }), + ); + }; + + const findMainHeading = () => wrapper.find('h1'); + const findTab = () => wrapper.findComponent(GlTab); + const findTabs = () => wrapper.findAllComponents(GlTab); + const findByTestId = (id) => wrapper.findByTestId(id); + const findFeatureCards = () => wrapper.findAllComponents(FeatureCard); + const findComplianceViewHistoryLink = () => findByTestId('compliance-view-history-link'); + const findSecurityViewHistoryLink = () => findByTestId('security-view-history-link'); + const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner); + + const securityFeaturesMock = [ + { + name: SAST_NAME, + shortName: SAST_SHORT_NAME, + description: SAST_DESCRIPTION, + helpPath: SAST_HELP_PATH, + configurationHelpPath: SAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST, + available: true, + }, + ]; + + const complianceFeaturesMock = [ + { + name: LICENSE_COMPLIANCE_NAME, + description: LICENSE_COMPLIANCE_DESCRIPTION, + helpPath: LICENSE_COMPLIANCE_HELP_PATH, + type: REPORT_TYPE_LICENSE_COMPLIANCE, + configurationHelpPath: LICENSE_COMPLIANCE_HELP_PATH, + }, + ]; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('basic structure', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + }); + }); + + it('renders main-heading with correct text', () => { + const mainHeading = findMainHeading(); + expect(mainHeading).toExist(); + expect(mainHeading.text()).toContain('Security Configuration'); + }); + + it('renders GlTab Component ', () => { + expect(findTab()).toExist(); + }); + + it('renders right amount of tabs with correct title ', () => { + expect(findTabs()).toHaveLength(2); + }); + + it('renders security-testing tab', () => { + expect(findByTestId('security-testing-tab').exists()).toBe(true); + }); + + it('renders compliance-testing tab', () => { + expect(findByTestId('compliance-testing-tab').exists()).toBe(true); + }); + + it('renders right amount of feature cards for given props with correct props', () => { + const cards = findFeatureCards(); + expect(cards).toHaveLength(2); + expect(cards.at(0).props()).toEqual({ feature: securityFeaturesMock[0] }); + expect(cards.at(1).props()).toEqual({ feature: complianceFeaturesMock[0] }); + }); + + it('should not show latest pipeline link when latestPipelinePath is not defined', () => { + expect(findByTestId('latest-pipeline-info').exists()).toBe(false); + }); + + it('should not show configuration History Link when gitlabCiPresent & gitlabCiHistoryPath are not defined', () => { + expect(findComplianceViewHistoryLink().exists()).toBe(false); + expect(findSecurityViewHistoryLink().exists()).toBe(false); + }); + }); + + describe('upgrade banner', () => { + const makeAvailable = (available) => (feature) => ({ ...feature, available }); + + describe('given at least one unavailable feature', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)), + }); + }); + + it('renders the banner', () => { + expect(findUpgradeBanner().exists()).toBe(true); + }); + + it('calls the dismiss callback when closing the banner', () => { + expect(userCalloutDismissSpy).not.toHaveBeenCalled(); + + findUpgradeBanner().vm.$emit('close'); + + expect(userCalloutDismissSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('given at least one unavailable feature, but banner is already dismissed', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)), + shouldShowCallout: false, + }); + }); + + it('does not render the banner', () => { + expect(findUpgradeBanner().exists()).toBe(false); + }); + }); + + describe('given all features are available', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock.map(makeAvailable(true)), + augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(true)), + }); + }); + + it('does not render the banner', () => { + expect(findUpgradeBanner().exists()).toBe(false); + }); + }); + }); + + describe('when given latestPipelinePath props', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + latestPipelinePath: 'test/path', + }); + }); + + it('should show latest pipeline info on the security tab with correct link when latestPipelinePath is defined', () => { + const latestPipelineInfoSecurity = findByTestId('latest-pipeline-info-security'); + + expect(latestPipelineInfoSecurity.exists()).toBe(true); + expect(latestPipelineInfoSecurity.text()).toMatchInterpolatedText( + i18n.securityTestingDescription, + ); + expect(latestPipelineInfoSecurity.find('a').attributes('href')).toBe('test/path'); + }); + + it('should show latest pipeline info on the compliance tab with correct link when latestPipelinePath is defined', () => { + const latestPipelineInfoCompliance = findByTestId('latest-pipeline-info-compliance'); + + expect(latestPipelineInfoCompliance.exists()).toBe(true); + expect(latestPipelineInfoCompliance.text()).toMatchInterpolatedText( + i18n.securityTestingDescription, + ); + expect(latestPipelineInfoCompliance.find('a').attributes('href')).toBe('test/path'); + }); + }); + + describe('given gitlabCiPresent & gitlabCiHistoryPath props', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + gitlabCiPresent: true, + gitlabCiHistoryPath: 'test/historyPath', + }); + }); + + it('should show configuration History Link', () => { + expect(findComplianceViewHistoryLink().exists()).toBe(true); + expect(findSecurityViewHistoryLink().exists()).toBe(true); + + expect(findComplianceViewHistoryLink().attributes('href')).toBe('test/historyPath'); + expect(findSecurityViewHistoryLink().attributes('href')).toBe('test/historyPath'); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/section_layout_spec.js b/spec/frontend/security_configuration/components/section_layout_spec.js new file mode 100644 index 00000000000..75da380bbb8 --- /dev/null +++ b/spec/frontend/security_configuration/components/section_layout_spec.js @@ -0,0 +1,49 @@ +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import SectionLayout from '~/security_configuration/components/section_layout.vue'; + +describe('Section Layout component', () => { + let wrapper; + + const createComponent = (propsData) => { + wrapper = extendedWrapper( + mount(SectionLayout, { + propsData, + scopedSlots: { + description: 'foo', + features: 'bar', + }, + }), + ); + }; + + const findHeading = () => wrapper.find('h2'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('basic structure', () => { + beforeEach(() => { + createComponent({ heading: 'testheading' }); + }); + + const slots = { + description: 'foo', + features: 'bar', + }; + + it('should render heading when passed in as props', () => { + expect(findHeading().exists()).toBe(true); + expect(findHeading().text()).toBe('testheading'); + }); + + Object.keys(slots).forEach((slot) => { + it('renders the slots', () => { + const slotContent = slots[slot]; + createComponent({ heading: '' }); + expect(wrapper.text()).toContain(slotContent); + }); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/upgrade_banner_spec.js b/spec/frontend/security_configuration/components/upgrade_banner_spec.js new file mode 100644 index 00000000000..cf7945343af --- /dev/null +++ b/spec/frontend/security_configuration/components/upgrade_banner_spec.js @@ -0,0 +1,60 @@ +import { GlBanner } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; + +const upgradePath = '/upgrade'; + +describe('UpgradeBanner component', () => { + let wrapper; + let closeSpy; + + const createComponent = (propsData) => { + closeSpy = jest.fn(); + + wrapper = shallowMountExtended(UpgradeBanner, { + provide: { + upgradePath, + }, + propsData, + listeners: { + close: closeSpy, + }, + }); + }; + + const findGlBanner = () => wrapper.findComponent(GlBanner); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('passes the expected props to GlBanner', () => { + expect(findGlBanner().props()).toMatchObject({ + title: UpgradeBanner.i18n.title, + buttonText: UpgradeBanner.i18n.buttonText, + buttonLink: upgradePath, + }); + }); + + it('renders the list of benefits', () => { + const wrapperText = wrapper.text(); + + expect(wrapperText).toContain('GitLab Ultimate checks your application'); + expect(wrapperText).toContain('statistics in the merge request'); + expect(wrapperText).toContain('statistics across projects'); + expect(wrapperText).toContain('Runtime security metrics'); + expect(wrapperText).toContain('risk analysis and remediation'); + }); + + it(`re-emits GlBanner's close event`, () => { + expect(closeSpy).not.toHaveBeenCalled(); + + wrapper.findComponent(GlBanner).vm.$emit('close'); + + expect(closeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js new file mode 100644 index 00000000000..6ad167cadda --- /dev/null +++ b/spec/frontend/security_configuration/utils_spec.js @@ -0,0 +1,81 @@ +import { augmentFeatures } from '~/security_configuration/utils'; + +const mockSecurityFeatures = [ + { + name: 'SAST', + type: 'SAST', + }, +]; + +const mockComplianceFeatures = [ + { + name: 'LICENSE_COMPLIANCE', + type: 'LICENSE_COMPLIANCE', + }, +]; + +const mockFeaturesWithSecondary = [ + { + name: 'DAST', + type: 'DAST', + secondary: { + type: 'DAST PROFILES', + name: 'DAST PROFILES', + }, + }, +]; + +const mockInvalidCustomFeature = [ + { + foo: 'bar', + }, +]; + +const mockValidCustomFeature = [ + { + name: 'SAST', + type: 'SAST', + customfield: 'customvalue', + }, +]; + +const expectedOutputDefault = { + augmentedSecurityFeatures: mockSecurityFeatures, + augmentedComplianceFeatures: mockComplianceFeatures, +}; + +const expectedOutputSecondary = { + augmentedSecurityFeatures: mockSecurityFeatures, + augmentedComplianceFeatures: mockFeaturesWithSecondary, +}; + +const expectedOutputCustomFeature = { + augmentedSecurityFeatures: mockValidCustomFeature, + augmentedComplianceFeatures: mockComplianceFeatures, +}; + +describe('returns an object with augmentedSecurityFeatures and augmentedComplianceFeatures when', () => { + it('given an empty array', () => { + expect(augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, [])).toEqual( + expectedOutputDefault, + ); + }); + + it('given an invalid populated array', () => { + expect( + augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockInvalidCustomFeature), + ).toEqual(expectedOutputDefault); + }); + + it('features have secondary key', () => { + expect(augmentFeatures(mockSecurityFeatures, mockFeaturesWithSecondary, [])).toEqual( + expectedOutputSecondary, + ); + }); + + it('given a valid populated array', () => { + expect( + augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockValidCustomFeature), + ).toEqual(expectedOutputCustomFeature); + }); +}); diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index 226e580a8e8..523f4e88985 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -45,7 +45,9 @@ exports[`self monitor component When the self monitor project has not been creat Enabling this feature creates a project that can be used to monitor the health of your instance.

- + In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. More information

- Install Knative +
diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js index d5b187452c6..1b93fd784e1 100644 --- a/spec/frontend/serverless/components/missing_prometheus_spec.js +++ b/spec/frontend/serverless/components/missing_prometheus_spec.js @@ -21,7 +21,7 @@ describe('missingPrometheusComponent', () => { const { vm } = wrapper; expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain( - 'Function invocation metrics require Prometheus to be installed first.', + 'Function invocation metrics require the Prometheus cluster integration.', ); expect(wrapper.find(GlButton).attributes('variant')).toBe('success'); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index 82fc06e1166..3ff6d1f9597 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -2,7 +2,8 @@ import { GlModal, GlFormCheckbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { initEmojiMock } from 'helpers/emoji'; import * as UserApi from '~/api/user_api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import EmojiPicker from '~/emoji/components/picker.vue'; +import createFlash from '~/flash'; import SetStatusModalWrapper, { AVAILABILITY_STATUS, } from '~/set_status_modal/set_status_modal_wrapper.vue'; @@ -25,7 +26,7 @@ describe('SetStatusModalWrapper', () => { defaultEmoji, }; - const createComponent = (props = {}) => { + const createComponent = (props = {}, improvedEmojiPicker = false) => { return shallowMount(SetStatusModalWrapper, { propsData: { ...defaultProps, @@ -34,6 +35,9 @@ describe('SetStatusModalWrapper', () => { mocks: { $toast, }, + provide: { + glFeatures: { improvedEmojiPicker }, + }, }); }; @@ -106,6 +110,20 @@ describe('SetStatusModalWrapper', () => { }); }); + describe('improvedEmojiPicker is true', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({}, true); + return initModal(); + }); + + it('sets emojiTag when clicking in emoji picker', async () => { + await wrapper.findComponent(EmojiPicker).vm.$emit('click', 'thumbsup'); + + expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"'); + }); + }); + describe('with no currentMessage set', () => { beforeEach(async () => { mockEmoji = await initEmojiMock(); @@ -271,9 +289,9 @@ describe('SetStatusModalWrapper', () => { findModal().vm.$emit('ok'); await wrapper.vm.$nextTick(); - expect(createFlash).toHaveBeenCalledWith( - "Sorry, we weren't able to set your status. Please try again later.", - ); + expect(createFlash).toHaveBeenCalledWith({ + message: "Sorry, we weren't able to set your status. Please try again later.", + }); }); }); }); diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js index 74dce499999..be27a800418 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/assignees_spec.js @@ -19,7 +19,7 @@ describe('Assignee component', () => { }); }; - const findComponentTextNoUsers = () => wrapper.find('.assign-yourself'); + const findComponentTextNoUsers = () => wrapper.find('[data-testid="no-value"]'); const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *'); afterEach(() => { @@ -64,7 +64,7 @@ describe('Assignee component', () => { }); jest.spyOn(wrapper.vm, '$emit'); - wrapper.find('.assign-yourself .btn-link').trigger('click'); + wrapper.find('[data-testid="assign-yourself"]').trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted('assign-self')).toBeTruthy(); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js index cfbe7227915..b738d931040 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js @@ -4,11 +4,16 @@ import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_ describe('Sidebar invite members component', () => { let wrapper; + const issuableType = 'issue'; const findDirectInviteLink = () => wrapper.findComponent(InviteMembersTrigger); const createComponent = () => { - wrapper = shallowMount(SidebarInviteMembers); + wrapper = shallowMount(SidebarInviteMembers, { + propsData: { + issuableType, + }, + }); }; afterEach(() => { @@ -23,5 +28,9 @@ describe('Sidebar invite members component', () => { it('renders a direct link to project members path', () => { expect(findDirectInviteLink().exists()).toBe(true); }); + + it('has expected attributes on the trigger', () => { + expect(findDirectInviteLink().props('triggerSource')).toBe('issue-assignee-dropdown'); + }); }); }); diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js index 91cbcc6cc27..619e89beb23 100644 --- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js @@ -22,6 +22,10 @@ describe('Sidebar date Widget', () => { let fakeApollo; const date = '2021-04-15'; + window.gon = { + first_day_of_week: 1, + }; + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findPopoverIcon = () => wrapper.find('[data-testid="inherit-date-popover"]'); const findDatePicker = () => wrapper.find(GlDatepicker); @@ -119,11 +123,12 @@ describe('Sidebar date Widget', () => { expect(wrapper.emitted('dueDateUpdated')).toEqual([[date]]); }); - it('uses a correct prop to set the initial date for GlDatePicker', () => { + it('uses a correct prop to set the initial date and first day of the week for GlDatePicker', () => { expect(findDatePicker().props()).toMatchObject({ value: null, autocomplete: 'off', defaultDate: expect.any(Object), + firstDay: window.gon.first_day_of_week, }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js new file mode 100644 index 00000000000..8d58854b013 --- /dev/null +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -0,0 +1,503 @@ +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLink, + GlSearchBoxByType, + GlFormInput, + GlLoadingIcon, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; +import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { IssuableAttributeType } from '~/sidebar/constants'; +import projectIssueMilestoneMutation from '~/sidebar/queries/project_issue_milestone.mutation.graphql'; +import projectIssueMilestoneQuery from '~/sidebar/queries/project_issue_milestone.query.graphql'; +import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; + +import { + mockIssue, + mockProjectMilestonesResponse, + noCurrentMilestoneResponse, + mockMilestoneMutationResponse, + mockMilestone2, + emptyProjectMilestonesResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('SidebarDropdownWidget', () => { + let wrapper; + let mockApollo; + + const promiseData = { issuableSetAttribute: { issue: { attribute: { id: '123' } } } }; + const firstErrorMsg = 'first error'; + const promiseWithErrors = { + ...promiseData, + issuableSetAttribute: { ...promiseData.issuableSetAttribute, errors: [firstErrorMsg] }, + }; + + const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData }); + const mutationError = () => + jest.fn().mockRejectedValue('Failed to set milestone on this issue. Please try again.'); + const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors }); + + const findGlLink = () => wrapper.findComponent(GlLink); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownText = () => wrapper.findComponent(GlDropdownText); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemWithText = (text) => + findAllDropdownItems().wrappers.find((x) => x.text() === text); + + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findEditButton = () => findSidebarEditableItem().find('[data-testid="edit-button"]'); + const findEditableLoadingIcon = () => findSidebarEditableItem().findComponent(GlLoadingIcon); + const findAttributeItems = () => wrapper.findByTestId('milestone-items'); + const findSelectedAttribute = () => wrapper.findByTestId('select-milestone'); + const findNoAttributeItem = () => wrapper.findByTestId('no-milestone-item'); + const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown'); + + const waitForDropdown = async () => { + // BDropdown first changes its `visible` property + // in a requestAnimationFrame callback. + // It then emits `shown` event in a watcher for `visible` + // Hence we need both of these: + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + + const waitForApollo = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + // Used with createComponentWithApollo which uses 'mount' + const clickEdit = async () => { + await findEditButton().trigger('click'); + + await waitForDropdown(); + + // We should wait for attributes list to be fetched. + await waitForApollo(); + }; + + // Used with createComponent which shallow mounts components + const toggleDropdown = async () => { + wrapper.vm.$refs.editable.expand(); + + await waitForDropdown(); + }; + + const createComponentWithApollo = async ({ + requestHandlers = [], + projectMilestonesSpy = jest.fn().mockResolvedValue(mockProjectMilestonesResponse), + currentMilestoneSpy = jest.fn().mockResolvedValue(noCurrentMilestoneResponse), + } = {}) => { + localVue.use(VueApollo); + mockApollo = createMockApollo([ + [projectMilestonesQuery, projectMilestonesSpy], + [projectIssueMilestoneQuery, currentMilestoneSpy], + ...requestHandlers, + ]); + + wrapper = extendedWrapper( + mount(SidebarDropdownWidget, { + localVue, + provide: { canUpdate: true }, + apolloProvider: mockApollo, + propsData: { + workspacePath: mockIssue.projectPath, + attrWorkspacePath: mockIssue.projectPath, + iid: mockIssue.iid, + issuableType: IssuableType.Issue, + issuableAttribute: IssuableAttributeType.Milestone, + }, + attachTo: document.body, + }), + ); + + await waitForApollo(); + }; + + const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(SidebarDropdownWidget, { + provide: { canUpdate: true }, + data() { + return data; + }, + propsData: { + workspacePath: '', + attrWorkspacePath: '', + iid: '', + issuableType: IssuableType.Issue, + issuableAttribute: IssuableAttributeType.Milestone, + }, + mocks: { + $apollo: { + mutate: mutationPromise(), + queries: { + currentAttribute: { loading: false }, + attributesList: { loading: false }, + ...queries, + }, + }, + }, + stubs: { + SidebarEditableItem, + GlSearchBoxByType, + GlDropdown, + }, + }), + ); + + // We need to mock out `showDropdown` which + // invokes `show` method of BDropdown used inside GlDropdown. + jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not editing', () => { + beforeEach(() => { + createComponent({ + data: { + currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl' }, + }, + stubs: { + GlDropdown, + SidebarEditableItem, + }, + }); + }); + + it('shows the current attribute', () => { + expect(findSelectedAttribute().text()).toBe('title'); + }); + + it('links to the current attribute', () => { + expect(findGlLink().attributes().href).toBe('webUrl'); + }); + + it('does not show a loading spinner next to the heading', () => { + expect(findEditableLoadingIcon().exists()).toBe(false); + }); + + it('shows a loading spinner while fetching the current attribute', () => { + createComponent({ + queries: { + currentAttribute: { loading: true }, + }, + }); + + expect(findEditableLoadingIcon().exists()).toBe(true); + }); + + it('shows the loading spinner and the title of the selected attribute while updating', () => { + createComponent({ + data: { + updating: true, + selectedTitle: 'Some milestone title', + }, + queries: { + currentAttribute: { loading: false }, + }, + }); + + expect(findEditableLoadingIcon().exists()).toBe(true); + expect(findSelectedAttribute().text()).toBe('Some milestone title'); + }); + + describe('when current attribute does not exist', () => { + it('renders "None" as the selected attribute title', () => { + createComponent(); + + expect(findSelectedAttribute().text()).toBe('None'); + }); + }); + }); + + describe('when a user can edit', () => { + describe('when user is editing', () => { + describe('when rendering the dropdown', () => { + it('shows a loading spinner while fetching a list of attributes', async () => { + createComponent({ + queries: { + attributesList: { loading: true }, + }, + }); + + await toggleDropdown(); + + expect(findLoadingIconDropdown().exists()).toBe(true); + }); + + describe('GlDropdownItem with the right title and id', () => { + const id = 'id'; + const title = 'title'; + + beforeEach(async () => { + createComponent({ + data: { attributesList: [{ id, title }], currentAttribute: { id, title } }, + }); + + await toggleDropdown(); + }); + + it('does not show a loading spinner', () => { + expect(findLoadingIconDropdown().exists()).toBe(false); + }); + + it('renders title $title', () => { + expect(findDropdownItemWithText(title).exists()).toBe(true); + }); + + it('checks the correct dropdown item', () => { + expect( + findAllDropdownItems() + .filter((w) => w.props('isChecked') === true) + .at(0) + .text(), + ).toBe(title); + }); + }); + + describe('when no data is assigned', () => { + beforeEach(async () => { + createComponent(); + + await toggleDropdown(); + }); + + it('finds GlDropdownItem with "No milestone"', () => { + expect(findNoAttributeItem().text()).toBe('No milestone'); + }); + + it('"No milestone" is checked', () => { + expect(findNoAttributeItem().props('isChecked')).toBe(true); + }); + + it('does not render any dropdown item', () => { + expect(findAttributeItems().exists()).toBe(false); + }); + }); + + describe('when clicking on dropdown item', () => { + describe('when currentAttribute is equal to attribute id', () => { + it('does not call setIssueAttribute mutation', async () => { + createComponent({ + data: { + attributesList: [{ id: 'id', title: 'title' }], + currentAttribute: { id: 'id', title: 'title' }, + }, + }); + + await toggleDropdown(); + + findDropdownItemWithText('title').vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0); + }); + }); + + describe('when currentAttribute is not equal to attribute id', () => { + describe('when error', () => { + const bootstrapComponent = (mutationResp) => { + createComponent({ + data: { + attributesList: [ + { id: '123', title: '123' }, + { id: 'id', title: 'title' }, + ], + currentAttribute: '123', + }, + mutationPromise: mutationResp, + }); + }; + + describe.each` + description | mutationResp | expectedMsg + ${'top-level error'} | ${mutationError} | ${'Failed to set milestone on this issue. Please try again.'} + ${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg} + `(`$description`, ({ mutationResp, expectedMsg }) => { + beforeEach(async () => { + bootstrapComponent(mutationResp); + + await toggleDropdown(); + + findDropdownItemWithText('title').vm.$emit('click'); + }); + + it(`calls createFlash with "${expectedMsg}"`, async () => { + await wrapper.vm.$nextTick(); + expect(createFlash).toHaveBeenCalledWith({ + message: expectedMsg, + captureError: true, + error: expectedMsg, + }); + }); + }); + }); + }); + }); + }); + + describe('when a user is searching', () => { + describe('when search result is not found', () => { + it('renders "No milestone found"', async () => { + createComponent(); + + await toggleDropdown(); + + findSearchBox().vm.$emit('input', 'non existing milestones'); + + await wrapper.vm.$nextTick(); + + expect(findDropdownText().text()).toBe('No milestone found'); + }); + }); + }); + }); + }); + + describe('with mock apollo', () => { + let error; + + beforeEach(() => { + jest.spyOn(Sentry, 'captureException'); + error = new Error('mayday'); + }); + + describe("when issuable type is 'issue'", () => { + describe('when dropdown is expanded and user can edit', () => { + let milestoneMutationSpy; + beforeEach(async () => { + milestoneMutationSpy = jest.fn().mockResolvedValue(mockMilestoneMutationResponse); + + await createComponentWithApollo({ + requestHandlers: [[projectIssueMilestoneMutation, milestoneMutationSpy]], + }); + + await clickEdit(); + }); + + it('renders the dropdown on clicking edit', async () => { + expect(findDropdown().isVisible()).toBe(true); + }); + + it('focuses on the input when dropdown is shown', async () => { + expect(document.activeElement).toEqual(wrapper.findComponent(GlFormInput).element); + }); + + describe('when currentAttribute is not equal to attribute id', () => { + describe('when update is successful', () => { + beforeEach(() => { + findDropdownItemWithText(mockMilestone2.title).vm.$emit('click'); + }); + + it('calls setIssueAttribute mutation', () => { + expect(milestoneMutationSpy).toHaveBeenCalledWith({ + iid: mockIssue.iid, + attributeId: getIdFromGraphQLId(mockMilestone2.id), + fullPath: mockIssue.projectPath, + }); + }); + + it('sets the value returned from the mutation to currentAttribute', async () => { + expect(findSelectedAttribute().text()).toBe(mockMilestone2.title); + }); + }); + }); + + describe('milestones', () => { + let projectMilestonesSpy; + + it('should call createFlash if milestones query fails', async () => { + await createComponentWithApollo({ + projectMilestonesSpy: jest.fn().mockRejectedValue(error), + }); + + await clickEdit(); + + expect(createFlash).toHaveBeenCalledWith({ + message: wrapper.vm.i18n.listFetchError, + captureError: true, + error: expect.any(Error), + }); + }); + + it('only fetches attributes when dropdown is opened', async () => { + projectMilestonesSpy = jest.fn().mockResolvedValueOnce(emptyProjectMilestonesResponse); + await createComponentWithApollo({ projectMilestonesSpy }); + + expect(projectMilestonesSpy).not.toHaveBeenCalled(); + + await clickEdit(); + + expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, { + fullPath: mockIssue.projectPath, + title: '', + state: 'active', + }); + }); + + describe('when a user is searching', () => { + const mockSearchTerm = 'foobar'; + + beforeEach(async () => { + projectMilestonesSpy = jest + .fn() + .mockResolvedValueOnce(emptyProjectMilestonesResponse); + await createComponentWithApollo({ projectMilestonesSpy }); + + await clickEdit(); + }); + + it('sends a projectMilestones query with the entered search term "foo"', async () => { + findSearchBox().vm.$emit('input', mockSearchTerm); + await wrapper.vm.$nextTick(); + + // Account for debouncing + jest.runAllTimers(); + + expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, { + fullPath: mockIssue.projectPath, + title: mockSearchTerm, + state: 'active', + }); + }); + }); + }); + }); + + describe('currentAttributes', () => { + it('should call createFlash if currentAttributes query fails', async () => { + await createComponentWithApollo({ + currentMilestoneSpy: jest.fn().mockRejectedValue(error), + }); + + expect(createFlash).toHaveBeenCalledWith({ + message: wrapper.vm.i18n.currentFetchError, + captureError: true, + error: expect.any(Error), + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index 0aa5aa2f691..710fae8ddf7 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -36,7 +36,7 @@ describe('Issuable Time Tracking Report', () => { issuableId: 1, issuableType, }, - propsData: { limitToHours }, + propsData: { limitToHours, issuableId: '1' }, localVue, apolloProvider: fakeApollo, }); diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index f26cdcb8b20..e08bd80b18e 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -1,7 +1,11 @@ import { mount } from '@vue/test-utils'; + import { stubTransition } from 'helpers/stub_transition'; import { createMockDirective } from 'helpers/vue_mock_directive'; import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; +import SidebarEventHub from '~/sidebar/event_hub'; + +import { issuableTimeTrackingResponse } from '../../mock_data'; describe('Issuable Time Tracker', () => { let wrapper; @@ -13,21 +17,39 @@ describe('Issuable Time Tracker', () => { const findReportLink = () => findByTestId('reportLink'); const defaultProps = { - timeEstimate: 10_000, // 2h 46m - timeSpent: 5_000, // 1h 23m - humanTimeEstimate: '2h 46m', - humanTimeSpent: '1h 23m', limitToHours: false, + fullPath: 'gitlab-org/gitlab-test', + issuableIid: '1', + initialTimeTracking: { + ...issuableTimeTrackingResponse.data.workspace.issuable, + }, }; - const mountComponent = ({ props = {} } = {}) => - mount(TimeTracker, { + const issuableTimeTrackingRefetchSpy = jest.fn(); + + const mountComponent = ({ props = {}, issuableType = 'issue', loading = false } = {}) => { + return mount(TimeTracker, { propsData: { ...defaultProps, ...props }, directives: { GlTooltip: createMockDirective() }, stubs: { transition: stubTransition(), }, + provide: { + issuableType, + }, + mocks: { + $apollo: { + queries: { + issuableTimeTracking: { + loading, + refetch: issuableTimeTrackingRefetchSpy, + query: jest.fn().mockResolvedValue(issuableTimeTrackingResponse), + }, + }, + }, + }, }); + }; afterEach(() => { wrapper.destroy(); @@ -44,13 +66,13 @@ describe('Issuable Time Tracker', () => { it('should correctly render timeEstimate', () => { expect(findByTestId('timeTrackingComparisonPane').html()).toContain( - defaultProps.humanTimeEstimate, + defaultProps.initialTimeTracking.humanTimeEstimate, ); }); - it('should correctly render time_spent', () => { + it('should correctly render totalTimeSpent', () => { expect(findByTestId('timeTrackingComparisonPane').html()).toContain( - defaultProps.humanTimeSpent, + defaultProps.initialTimeTracking.humanTotalTimeSpent, ); }); }); @@ -78,10 +100,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 100_000, // 1d 3h - timeSpent: 5_000, // 1h 23m - humanTimeEstimate: '1d 3h', - humanTimeSpent: '1h 23m', + initialTimeTracking: { + timeEstimate: 100_000, // 1d 3h + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '1d 3h', + humanTotalTimeSpent: '1h 23m', + }, }, }); }); @@ -108,8 +132,11 @@ describe('Issuable Time Tracker', () => { it('should display the remaining meter with the correct background color when over estimate', () => { wrapper = mountComponent({ props: { - timeEstimate: 10_000, // 2h 46m - timeSpent: 20_000_000, // 231 days + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 20_000_000, // 231 days + }, }, }); @@ -122,8 +149,11 @@ describe('Issuable Time Tracker', () => { beforeEach(async () => { wrapper = mountComponent({ props: { - timeEstimate: 100_000, // 1d 3h limitToHours: true, + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + timeEstimate: 100_000, // 1d 3h + }, }, }); }); @@ -140,10 +170,12 @@ describe('Issuable Time Tracker', () => { beforeEach(async () => { wrapper = mountComponent({ props: { - timeEstimate: 10_000, // 2h 46m - timeSpent: 0, - timeEstimateHumanReadable: '2h 46m', - timeSpentHumanReadable: '', + initialTimeTracking: { + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 0, + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '', + }, }, }); await wrapper.vm.$nextTick(); @@ -159,10 +191,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 0, - timeSpent: 5_000, // 1h 23m - timeEstimateHumanReadable: '2h 46m', - timeSpentHumanReadable: '1h 23m', + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '1h 23m', + }, }, }); }); @@ -177,10 +211,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 0, - timeSpent: 0, - timeEstimateHumanReadable: '', - timeSpentHumanReadable: '', + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, }, }); }); @@ -198,8 +234,11 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeSpent: 0, - timeSpentHumanReadable: '', + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + totalTimeSpent: 0, + humanTotalTimeSpent: '', + }, }, }); }); @@ -210,13 +249,20 @@ describe('Issuable Time Tracker', () => { }); describe('When time spent', () => { - beforeEach(() => { + it('link should appear on issue', () => { wrapper = mountComponent(); + expect(findReportLink().exists()).toBe(true); }); - it('link should appear', () => { + it('link should appear on merge request', () => { + wrapper = mountComponent({ issuableType: 'merge_request' }); expect(findReportLink().exists()).toBe(true); }); + + it('link should not appear on milestone', () => { + wrapper = mountComponent({ issuableType: 'milestone' }); + expect(findReportLink().exists()).toBe(false); + }); }); }); @@ -225,7 +271,16 @@ describe('Issuable Time Tracker', () => { const findCloseHelpButton = () => findByTestId('closeHelpButton'); beforeEach(async () => { - wrapper = mountComponent({ props: { timeEstimate: 0, timeSpent: 0 } }); + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, + }, + }); await wrapper.vm.$nextTick(); }); @@ -254,4 +309,14 @@ describe('Issuable Time Tracker', () => { }); }); }); + + describe('Event listeners', () => { + it('refetches issuableTimeTracking query when eventHub emits `timeTracker:refresh` event', async () => { + SidebarEventHub.$emit('timeTracker:refresh'); + + await wrapper.vm.$nextTick(); + + expect(issuableTimeTrackingRefetchSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index b052038661a..d6287b93fb9 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -513,4 +513,100 @@ export const participantsQueryResponse = { }, }; +export const mockGroupPath = 'gitlab-org'; +export const mockProjectPath = `${mockGroupPath}/some-project`; + +export const mockIssue = { + projectPath: mockProjectPath, + iid: '1', + groupPath: mockGroupPath, +}; + +export const mockIssueId = 'gid://gitlab/Issue/1'; + +export const mockMilestone1 = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/1', + title: 'Foobar Milestone', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1', + state: 'active', +}; + +export const mockMilestone2 = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/2', + title: 'Awesome Milestone', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2', + state: 'active', +}; + +export const mockProjectMilestonesResponse = { + data: { + workspace: { + attributes: { + nodes: [mockMilestone1, mockMilestone2], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, +}; + +export const noCurrentMilestoneResponse = { + data: { + workspace: { + issuable: { id: mockIssueId, attribute: null, __typename: 'Issue' }, + __typename: 'Project', + }, + }, +}; + +export const mockMilestoneMutationResponse = { + data: { + issuableSetAttribute: { + errors: [], + issuable: { + id: 'gid://gitlab/Issue/1', + attribute: { + id: 'gid://gitlab/Milestone/2', + title: 'Awesome Milestone', + state: 'active', + __typename: 'Milestone', + }, + __typename: 'Issue', + }, + __typename: 'UpdateIssuePayload', + }, + }, +}; + +export const emptyProjectMilestonesResponse = { + data: { + workspace: { + attributes: { + nodes: [], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, +}; + +export const issuableTimeTrackingResponse = { + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/1', + title: 'Commodi incidunt eos eos libero dicta dolores sed.', + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '1h 23m', + }, + }, + }, +}; + export default mockData; diff --git a/spec/frontend/sidebar/track_invite_members_spec.js b/spec/frontend/sidebar/track_invite_members_spec.js new file mode 100644 index 00000000000..6c96e4cfc76 --- /dev/null +++ b/spec/frontend/sidebar/track_invite_members_spec.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; + +describe('Track user dropdown open', () => { + let trackingSpy; + let dropdownElement; + + beforeEach(() => { + document.body.innerHTML = ` +
+
+
+
+
+
+ `; + + dropdownElement = document.querySelector('.js-sidebar-assignee-dropdown'); + trackingSpy = mockTracking('_category_', dropdownElement, jest.spyOn); + document.body.dataset.page = 'some:page'; + + trackShowInviteMemberLink(dropdownElement); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends a tracking event when the dropdown is opened and contains Invite Members link', () => { + $(dropdownElement).trigger('shown.bs.dropdown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, '_track_event_', { + label: '_track_label_', + }); + }); +}); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index 95da67c2bbf..5df69ffb5f8 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -22,6 +22,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = { it('should call flash', async () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith( - "Can't fetch content for the blob: Error: Request failed with status code 500", - ); + expect(createFlash).toHaveBeenCalledWith({ + message: "Can't fetch content for the blob: Error: Request failed with status code 500", + }); }); }); diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index 17fb3fe788a..1d6245e9dbb 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -7,8 +7,8 @@ import EditDrawer from '~/static_site_editor/components/edit_drawer.vue'; import EditHeader from '~/static_site_editor/components/edit_header.vue'; import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue'; -import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; -import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +import { EDITOR_TYPES } from '~/static_site_editor/rich_content_editor/constants'; +import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue'; import { sourceContentTitle as title, diff --git a/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js b/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js new file mode 100644 index 00000000000..cd0d09c085f --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js @@ -0,0 +1,214 @@ +import buildCustomRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; +import buildHTMLToMarkdownRenderer from '~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'; +import { + generateToolbarItem, + addCustomEventListener, + removeCustomEventListener, + registerHTMLToMarkdownRenderer, + addImage, + insertVideo, + getMarkdown, + getEditorOptions, +} from '~/static_site_editor/rich_content_editor/services/editor_service'; +import sanitizeHTML from '~/static_site_editor/rich_content_editor/services/sanitize_html'; + +jest.mock('~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'); +jest.mock('~/static_site_editor/rich_content_editor/services/build_custom_renderer'); +jest.mock('~/static_site_editor/rich_content_editor/services/sanitize_html'); + +describe('Editor Service', () => { + let mockInstance; + let event; + let handler; + const parseHtml = (str) => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = str; + return wrapper.firstChild; + }; + + beforeEach(() => { + mockInstance = { + eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, + editor: { + exec: jest.fn(), + isWysiwygMode: jest.fn(), + getSquire: jest.fn(), + insertText: jest.fn(), + }, + invoke: jest.fn(), + toMarkOptions: { + renderer: { + constructor: { + factory: jest.fn(), + }, + }, + }, + }; + event = 'someCustomEvent'; + handler = jest.fn(); + }); + + describe('generateToolbarItem', () => { + const config = { + icon: 'bold', + command: 'some-command', + tooltip: 'Some Tooltip', + event: 'some-event', + }; + + const generatedItem = generateToolbarItem(config); + + it('generates the correct command', () => { + expect(generatedItem.options.command).toBe(config.command); + }); + + it('generates the correct event', () => { + expect(generatedItem.options.event).toBe(config.event); + }); + + it('generates a divider when isDivider is set to true', () => { + const isDivider = true; + + expect(generateToolbarItem({ isDivider })).toBe('divider'); + }); + }); + + describe('addCustomEventListener', () => { + it('registers an event type on the instance and adds an event handler', () => { + addCustomEventListener(mockInstance, event, handler); + + expect(mockInstance.eventManager.addEventType).toHaveBeenCalledWith(event); + expect(mockInstance.eventManager.listen).toHaveBeenCalledWith(event, handler); + }); + }); + + describe('removeCustomEventListener', () => { + it('removes an event handler from the instance', () => { + removeCustomEventListener(mockInstance, event, handler); + + expect(mockInstance.eventManager.removeEventHandler).toHaveBeenCalledWith(event, handler); + }); + }); + + describe('addImage', () => { + const file = new File([], 'some-file.jpg'); + const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' }; + + it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => { + jest.spyOn(URL, 'createObjectURL'); + mockInstance.editor.isWysiwygMode.mockReturnValue(true); + mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() }); + + addImage(mockInstance, mockImage, file); + + expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled(); + expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file); + }); + + it('calls the insertText method on the instance when in Markdown mode', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(false); + addImage(mockInstance, mockImage, file); + + expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)'); + }); + }); + + describe('insertVideo', () => { + const mockUrl = 'some/url'; + const htmlString = `
`; + const mockInsertElement = jest.fn(); + + beforeEach(() => + mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }), + ); + + describe('WYSIWYG mode', () => { + it('calls the insertElement method on the squire instance with an iFrame element', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(true); + + insertVideo(mockInstance, mockUrl); + + expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith( + parseHtml(htmlString), + ); + }); + }); + + describe('Markdown mode', () => { + it('calls the insertText method on the editor instance with the iFrame element HTML', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(false); + + insertVideo(mockInstance, mockUrl); + + expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString); + }); + }); + }); + + describe('getMarkdown', () => { + it('calls the invoke method on the instance', () => { + getMarkdown(mockInstance); + + expect(mockInstance.invoke).toHaveBeenCalledWith('getMarkdown'); + }); + }); + + describe('registerHTMLToMarkdownRenderer', () => { + let baseRenderer; + const htmlToMarkdownRenderer = {}; + const extendedRenderer = {}; + + beforeEach(() => { + baseRenderer = mockInstance.toMarkOptions.renderer; + buildHTMLToMarkdownRenderer.mockReturnValueOnce(htmlToMarkdownRenderer); + baseRenderer.constructor.factory.mockReturnValueOnce(extendedRenderer); + + registerHTMLToMarkdownRenderer(mockInstance); + }); + + it('builds a new instance of the HTML to Markdown renderer', () => { + expect(buildHTMLToMarkdownRenderer).toHaveBeenCalledWith(baseRenderer); + }); + + it('extends base renderer with the HTML to Markdown renderer', () => { + expect(baseRenderer.constructor.factory).toHaveBeenCalledWith( + baseRenderer, + htmlToMarkdownRenderer, + ); + }); + + it('replaces the default renderer with extended renderer', () => { + expect(mockInstance.toMarkOptions.renderer).toBe(extendedRenderer); + }); + }); + + describe('getEditorOptions', () => { + const externalOptions = { + customRenderers: {}, + }; + const renderer = {}; + + beforeEach(() => { + buildCustomRenderer.mockReturnValueOnce(renderer); + }); + + it('generates a configuration object with a custom HTML renderer and toolbarItems', () => { + expect(getEditorOptions()).toHaveProp('customHTMLRenderer', renderer); + expect(getEditorOptions()).toHaveProp('toolbarItems'); + }); + + it('passes external renderers to the buildCustomRenderers function', () => { + getEditorOptions(externalOptions); + expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers); + }); + + it('uses the internal sanitizeHTML service for HTML sanitization', () => { + const options = getEditorOptions(); + const html = '
'; + + options.customHTMLSanitizer(html); + + expect(sanitizeHTML).toHaveBeenCalledWith(html); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js new file mode 100644 index 00000000000..86ae016987d --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js @@ -0,0 +1,73 @@ +import { GlModal, GlTabs } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { IMAGE_TABS } from '~/static_site_editor/rich_content_editor/constants'; +import AddImageModal from '~/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue'; +import UploadImageTab from '~/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue'; + +describe('Add Image Modal', () => { + let wrapper; + const propsData = { imageRoot: 'path/to/root/' }; + + const findModal = () => wrapper.find(GlModal); + const findTabs = () => wrapper.find(GlTabs); + const findUploadImageTab = () => wrapper.find(UploadImageTab); + const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); + const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); + + beforeEach(() => { + wrapper = shallowMount(AddImageModal, { propsData }); + }); + + describe('when content is loaded', () => { + it('renders a modal component', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders a Tabs component', () => { + expect(findTabs().exists()).toBe(true); + }); + + it('renders an upload image tab', () => { + expect(findUploadImageTab().exists()).toBe(true); + }); + + it('renders an input to add an image URL', () => { + expect(findUrlInput().exists()).toBe(true); + }); + + it('renders an input to add an image description', () => { + expect(findDescriptionInput().exists()).toBe(true); + }); + }); + + describe('add image', () => { + describe('Upload', () => { + it('validates the file', () => { + const preventDefault = jest.fn(); + const description = 'some description'; + const file = { name: 'some_file.png' }; + + wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() }; + wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB }); + + findModal().vm.$emit('ok', { preventDefault }); + + expect(wrapper.vm.$refs.uploadImageTab.validateFile).toHaveBeenCalled(); + }); + }); + + describe('URL', () => { + it('emits an addImage event when a valid URL is specified', () => { + const preventDefault = jest.fn(); + const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' }; + wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB }); + + findModal().vm.$emit('ok', { preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + expect(wrapper.emitted('addImage')).toEqual([ + [{ imageUrl: mockImage.imageUrl, altText: mockImage.description }], + ]); + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js new file mode 100644 index 00000000000..11b73d58259 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import UploadImageTab from '~/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue'; + +describe('Upload Image Tab', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(UploadImageTab); + }); + + afterEach(() => wrapper.destroy()); + + const triggerInputEvent = (size) => { + const file = { size, name: 'file-name.png' }; + const mockEvent = new Event('input'); + + Object.defineProperty(mockEvent, 'target', { value: { files: [file] } }); + + wrapper.find({ ref: 'fileInput' }).element.dispatchEvent(mockEvent); + + return file; + }; + + describe('onInput', () => { + it.each` + size | fileError + ${2000000000} | ${'Maximum file size is 2MB. Please select a smaller file.'} + ${200} | ${null} + `('validates the file correctly', ({ size, fileError }) => { + triggerInputEvent(size); + + expect(wrapper.vm.fileError).toBe(fileError); + }); + }); + + it('emits input event when file is valid', () => { + const file = triggerInputEvent(200); + + expect(wrapper.emitted('input')).toEqual([[file]]); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js new file mode 100644 index 00000000000..392d31bf039 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js @@ -0,0 +1,44 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import InsertVideoModal from '~/static_site_editor/rich_content_editor/modals/insert_video_modal.vue'; + +describe('Insert Video Modal', () => { + let wrapper; + + const findModal = () => wrapper.find(GlModal); + const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); + + const triggerInsertVideo = (url) => { + const preventDefault = jest.fn(); + findUrlInput().vm.$emit('input', url); + findModal().vm.$emit('primary', { preventDefault }); + }; + + beforeEach(() => { + wrapper = shallowMount(InsertVideoModal); + }); + + afterEach(() => wrapper.destroy()); + + describe('when content is loaded', () => { + it('renders a modal component', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders an input to add a URL', () => { + expect(findUrlInput().exists()).toBe(true); + }); + }); + + describe('insert video', () => { + it.each` + url | emitted + ${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]} + ${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]} + ${'::youtube.com/invalid/url'} | ${undefined} + `('formats the url correctly', ({ url, emitted }) => { + triggerInsertVideo(url); + expect(wrapper.emitted('insertVideo')).toEqual(emitted); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js new file mode 100644 index 00000000000..6c02ec506c6 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js @@ -0,0 +1,69 @@ +import Editor from '@toast-ui/editor'; +import buildMarkdownToHTMLRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; +import { registerHTMLToMarkdownRenderer } from '~/static_site_editor/rich_content_editor/services/editor_service'; + +describe('static_site_editor/rich_content_editor', () => { + let editor; + + const buildEditor = () => { + editor = new Editor({ + el: document.body, + customHTMLRenderer: buildMarkdownToHTMLRenderer(), + }); + + registerHTMLToMarkdownRenderer(editor); + }; + + beforeEach(() => { + buildEditor(); + }); + + describe('HTML to Markdown', () => { + it('uses "-" character list marker in unordered lists', () => { + editor.setHtml('
  • List item 1
  • List item 2
'); + + const markdown = editor.getMarkdown(); + + expect(markdown).toBe('- List item 1\n- List item 2'); + }); + + it('does not increment the list marker in ordered lists', () => { + editor.setHtml('
  1. List item 1
  2. List item 2
'); + + const markdown = editor.getMarkdown(); + + expect(markdown).toBe('1. List item 1\n1. List item 2'); + }); + + it('indents lists using four spaces', () => { + editor.setHtml('
  • List item 1
    • List item 2
'); + + const markdown = editor.getMarkdown(); + + expect(markdown).toBe('- List item 1\n - List item 2'); + }); + + it('uses * for strong and _ for emphasis text', () => { + editor.setHtml('strong textemphasis text'); + + const markdown = editor.getMarkdown(); + + expect(markdown).toBe('**strong text**_emphasis text_'); + }); + }); + + describe('Markdown to HTML', () => { + it.each` + input | output + ${'markdown with _emphasized\ntext_'} | ${'

markdown with emphasized text

\n'} + ${'markdown with **strong\ntext**'} | ${'

markdown with strong text

\n'} + `( + 'does not transform softbreaks inside (_) and strong (**) nodes into
tags', + ({ input, output }) => { + editor.setMarkdown(input); + + expect(editor.getHtml()).toBe(output); + }, + ); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js new file mode 100644 index 00000000000..3b0d2993a5d --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js @@ -0,0 +1,222 @@ +import { Editor, mockEditorApi } from '@toast-ui/vue-editor'; +import { shallowMount } from '@vue/test-utils'; +import { + EDITOR_TYPES, + EDITOR_HEIGHT, + EDITOR_PREVIEW_STYLE, + CUSTOM_EVENTS, +} from '~/static_site_editor/rich_content_editor/constants'; +import AddImageModal from '~/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue'; +import InsertVideoModal from '~/static_site_editor/rich_content_editor/modals/insert_video_modal.vue'; +import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue'; + +import { + addCustomEventListener, + removeCustomEventListener, + addImage, + insertVideo, + registerHTMLToMarkdownRenderer, + getEditorOptions, + getMarkdown, +} from '~/static_site_editor/rich_content_editor/services/editor_service'; + +jest.mock('~/static_site_editor/rich_content_editor/services/editor_service', () => ({ + addCustomEventListener: jest.fn(), + removeCustomEventListener: jest.fn(), + addImage: jest.fn(), + insertVideo: jest.fn(), + registerHTMLToMarkdownRenderer: jest.fn(), + getEditorOptions: jest.fn(), + getMarkdown: jest.fn(), +})); + +describe('Rich Content Editor', () => { + let wrapper; + + const content = '## Some Markdown'; + const imageRoot = 'path/to/root/'; + const findEditor = () => wrapper.find({ ref: 'editor' }); + const findAddImageModal = () => wrapper.find(AddImageModal); + const findInsertVideoModal = () => wrapper.find(InsertVideoModal); + + const buildWrapper = async () => { + wrapper = shallowMount(RichContentEditor, { + propsData: { content, imageRoot }, + stubs: { + ToastEditor: Editor, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when content is loaded', () => { + const editorOptions = {}; + + beforeEach(() => { + getEditorOptions.mockReturnValueOnce(editorOptions); + buildWrapper(); + }); + + it('renders an editor', () => { + expect(findEditor().exists()).toBe(true); + }); + + it('renders the correct content', () => { + expect(findEditor().props().initialValue).toBe(content); + }); + + it('provides options generated by the getEditorOptions service', () => { + expect(findEditor().props().options).toBe(editorOptions); + }); + + it('has the correct preview style', () => { + expect(findEditor().props().previewStyle).toBe(EDITOR_PREVIEW_STYLE); + }); + + it('has the correct initial edit type', () => { + expect(findEditor().props().initialEditType).toBe(EDITOR_TYPES.wysiwyg); + }); + + it('has the correct height', () => { + expect(findEditor().props().height).toBe(EDITOR_HEIGHT); + }); + }); + + describe('when content is changed', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('emits an input event with the changed content', () => { + const changedMarkdown = '## Changed Markdown'; + getMarkdown.mockReturnValueOnce(changedMarkdown); + + findEditor().vm.$emit('change'); + + expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown); + }); + }); + + describe('when content is reset', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('should reset the content via setMarkdown', () => { + const newContent = 'Just the body content excluding the front matter for example'; + const mockInstance = { invoke: jest.fn() }; + wrapper.vm.$refs.editor = mockInstance; + + wrapper.vm.resetInitialValue(newContent); + + expect(mockInstance.invoke).toHaveBeenCalledWith('setMarkdown', newContent); + }); + }); + + describe('when editor is loaded', () => { + const formattedMarkdown = 'formatted markdown'; + + beforeEach(() => { + mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown); + buildWrapper(); + }); + + afterEach(() => { + mockEditorApi.getMarkdown.mockReset(); + }); + + it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { + expect(addCustomEventListener).toHaveBeenCalledWith( + wrapper.vm.editorApi, + CUSTOM_EVENTS.openAddImageModal, + wrapper.vm.onOpenAddImageModal, + ); + }); + + it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { + expect(addCustomEventListener).toHaveBeenCalledWith( + wrapper.vm.editorApi, + CUSTOM_EVENTS.openInsertVideoModal, + wrapper.vm.onOpenInsertVideoModal, + ); + }); + + it('registers HTML to markdown renderer', () => { + expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); + }); + + it('emits load event with the markdown formatted by Toast UI', () => { + mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown); + expect(mockEditorApi.getMarkdown).toHaveBeenCalled(); + expect(wrapper.emitted('load')[0]).toEqual([{ formattedMarkdown }]); + }); + }); + + describe('when editor is destroyed', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { + wrapper.vm.$destroy(); + + expect(removeCustomEventListener).toHaveBeenCalledWith( + wrapper.vm.editorApi, + CUSTOM_EVENTS.openAddImageModal, + wrapper.vm.onOpenAddImageModal, + ); + }); + + it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { + wrapper.vm.$destroy(); + + expect(removeCustomEventListener).toHaveBeenCalledWith( + wrapper.vm.editorApi, + CUSTOM_EVENTS.openInsertVideoModal, + wrapper.vm.onOpenInsertVideoModal, + ); + }); + }); + + describe('add image modal', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders an addImageModal component', () => { + expect(findAddImageModal().exists()).toBe(true); + }); + + it('calls the onAddImage method when the addImage event is emitted', () => { + const mockImage = { imageUrl: 'some/url.png', altText: 'some description' }; + const mockInstance = { exec: jest.fn() }; + wrapper.vm.$refs.editor = mockInstance; + + findAddImageModal().vm.$emit('addImage', mockImage); + expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined); + }); + }); + + describe('insert video modal', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders an insertVideoModal component', () => { + expect(findInsertVideoModal().exists()).toBe(true); + }); + + it('calls the onInsertVideo method when the insertVideo event is emitted', () => { + const mockUrl = 'https://www.youtube.com/embed/someId'; + const mockInstance = { exec: jest.fn() }; + wrapper.vm.$refs.editor = mockInstance; + + findInsertVideoModal().vm.$emit('insertVideo', mockUrl); + expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js new file mode 100644 index 00000000000..202e13e8bff --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js @@ -0,0 +1,32 @@ +import buildCustomHTMLRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; + +describe('Build Custom Renderer Service', () => { + describe('buildCustomHTMLRenderer', () => { + it('should return an object with the default renderer functions when lacking arguments', () => { + expect(buildCustomHTMLRenderer()).toEqual( + expect.objectContaining({ + htmlBlock: expect.any(Function), + htmlInline: expect.any(Function), + heading: expect.any(Function), + item: expect.any(Function), + paragraph: expect.any(Function), + text: expect.any(Function), + softbreak: expect.any(Function), + }), + ); + }); + + it('should return an object with both custom and default renderer functions when passed customRenderers', () => { + const mockHtmlCustomRenderer = jest.fn(); + const customRenderers = { + html: [mockHtmlCustomRenderer], + }; + + expect(buildCustomHTMLRenderer(customRenderers)).toEqual( + expect.objectContaining({ + html: expect.any(Function), + }), + ); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js new file mode 100644 index 00000000000..c9cba3e8689 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -0,0 +1,218 @@ +import buildHTMLToMarkdownRenderer from '~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'; +import { attributeDefinition } from './renderers/mock_data'; + +describe('rich_content_editor/services/html_to_markdown_renderer', () => { + let baseRenderer; + let htmlToMarkdownRenderer; + let fakeNode; + + beforeEach(() => { + baseRenderer = { + trim: jest.fn((input) => `trimmed ${input}`), + getSpaceCollapsedText: jest.fn((input) => `space collapsed ${input}`), + getSpaceControlled: jest.fn((input) => `space controlled ${input}`), + convert: jest.fn(), + }; + + fakeNode = { nodeValue: 'mock_node', dataset: {} }; + }); + + afterEach(() => { + htmlToMarkdownRenderer = null; + }); + + describe('TEXT_NODE visitor', () => { + it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + + expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe( + `space controlled trimmed space collapsed ${fakeNode.nodeValue}`, + ); + }); + }); + + describe('LI OL, LI UL visitor', () => { + const oneLevelNestedList = '\n * List item 1\n * List item 2'; + const twoLevelNestedList = '\n * List item 1\n * List item 2'; + const spaceInContentList = '\n * List item 1\n * List item 2'; + + it.each` + list | indentSpaces | result + ${oneLevelNestedList} | ${2} | ${'\n * List item 1\n * List item 2'} + ${oneLevelNestedList} | ${3} | ${'\n * List item 1\n * List item 2'} + ${oneLevelNestedList} | ${6} | ${'\n * List item 1\n * List item 2'} + ${twoLevelNestedList} | ${4} | ${'\n * List item 1\n * List item 2'} + ${spaceInContentList} | ${1} | ${'\n * List item 1\n * List item 2'} + `('changes the list indentation to $indentSpaces spaces', ({ list, indentSpaces, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + subListIndentSpaces: indentSpaces, + }); + + baseRenderer.convert.mockReturnValueOnce(list); + + expect(htmlToMarkdownRenderer['LI OL, LI UL'](fakeNode, list)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list); + }); + }); + + describe('UL LI visitor', () => { + it.each` + listItem | unorderedListBulletChar | result | bulletChar + ${'* list item'} | ${undefined} | ${'- list item'} | ${'default'} + ${' - list item'} | ${'*'} | ${' * list item'} | ${'*'} + ${' * list item'} | ${'-'} | ${' - list item'} | ${'-'} + `( + 'uses $bulletChar bullet char in unordered list items when $unorderedListBulletChar is set in config', + ({ listItem, unorderedListBulletChar, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + unorderedListBulletChar, + }); + baseRenderer.convert.mockReturnValueOnce(listItem); + + expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem); + }, + ); + + it('detects attribute definitions and attaches them to the list item', () => { + const listItem = '- list item'; + const result = `${listItem}\n${attributeDefinition}\n`; + + fakeNode.dataset.attributeDefinition = attributeDefinition; + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`); + + expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); + }); + }); + + describe('OL LI visitor', () => { + it.each` + listItem | result | incrementListMarker | action + ${'2. list item'} | ${'1. list item'} | ${false} | ${'increments'} + ${' 3. list item'} | ${' 1. list item'} | ${false} | ${'increments'} + ${' 123. list item'} | ${' 1. list item'} | ${false} | ${'increments'} + ${'3. list item'} | ${'3. list item'} | ${true} | ${'does not increment'} + `( + '$action a list item counter when incrementListMaker is $incrementListMarker', + ({ listItem, result, incrementListMarker }) => { + const subContent = null; + + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + incrementListMarker, + }); + baseRenderer.convert.mockReturnValueOnce(listItem); + + expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent); + }, + ); + }); + + describe('STRONG, B visitor', () => { + it.each` + input | strongCharacter | result + ${'**strong text**'} | ${'_'} | ${'__strong text__'} + ${'__strong text__'} | ${'*'} | ${'**strong text**'} + `( + 'converts $input to $result when strong character is $strongCharacter', + ({ input, strongCharacter, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + strong: strongCharacter, + }); + + baseRenderer.convert.mockReturnValueOnce(input); + + expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); + }, + ); + }); + + describe('EM, I visitor', () => { + it.each` + input | emphasisCharacter | result + ${'*strong text*'} | ${'_'} | ${'_strong text_'} + ${'_strong text_'} | ${'*'} | ${'*strong text*'} + `( + 'converts $input to $result when emphasis character is $emphasisCharacter', + ({ input, emphasisCharacter, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + emphasis: emphasisCharacter, + }); + + baseRenderer.convert.mockReturnValueOnce(input); + + expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); + }, + ); + }); + + describe('H1, H2, H3, H4, H5, H6 visitor', () => { + it('detects attribute definitions and attaches them to the heading', () => { + const heading = 'heading text'; + const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`; + + fakeNode.dataset.attributeDefinition = attributeDefinition; + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`); + + expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result); + }); + }); + + describe('PRE CODE', () => { + let node; + const subContent = 'sub content'; + const originalConverterResult = 'base result'; + + beforeEach(() => { + node = document.createElement('PRE'); + + node.innerText = 'reference definition content'; + node.dataset.sseReferenceDefinition = true; + + baseRenderer.convert.mockReturnValueOnce(originalConverterResult); + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + }); + + it('returns raw text when pre node has sse-reference-definitions class', () => { + expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe( + `\n\n${node.innerText}\n\n`, + ); + }); + + it('returns base result when pre node does not have sse-reference-definitions class', () => { + delete node.dataset.sseReferenceDefinition; + + expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); + }); + }); + + describe('IMG', () => { + const originalSrc = 'path/to/image.png'; + const alt = 'alt text'; + let node; + + beforeEach(() => { + node = document.createElement('img'); + node.alt = alt; + node.src = originalSrc; + }); + + it('returns an image with its original src of the `original-src` attribute is preset', () => { + node.dataset.originalSrc = originalSrc; + node.src = 'modified/path/to/image.png'; + + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + + expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); + }); + + it('fallback to `src` if no `original-src` is specified on the image', () => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js new file mode 100644 index 00000000000..ef3ff052cb2 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js @@ -0,0 +1,88 @@ +import { + buildTextToken, + buildUneditableOpenTokens, + buildUneditableCloseToken, + buildUneditableCloseTokens, + buildUneditableBlockTokens, + buildUneditableInlineTokens, + buildUneditableHtmlAsTextTokens, +} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; + +import { + originInlineToken, + originToken, + uneditableOpenTokens, + uneditableCloseToken, + uneditableCloseTokens, + uneditableBlockTokens, + uneditableInlineTokens, + uneditableTokens, +} from './mock_data'; + +describe('Build Uneditable Token renderer helper', () => { + describe('buildTextToken', () => { + it('returns an object literal representing a text token', () => { + const text = originToken.content; + expect(buildTextToken(text)).toStrictEqual(originToken); + }); + }); + + describe('buildUneditableOpenTokens', () => { + it('returns a 2-item array of tokens with the originToken appended to an open token', () => { + const result = buildUneditableOpenTokens(originToken); + + expect(result).toHaveLength(2); + expect(result).toStrictEqual(uneditableOpenTokens); + }); + }); + + describe('buildUneditableCloseToken', () => { + it('returns an object literal representing the uneditable close token', () => { + expect(buildUneditableCloseToken()).toStrictEqual(uneditableCloseToken); + }); + }); + + describe('buildUneditableCloseTokens', () => { + it('returns a 2-item array of tokens with the originToken prepended to a close token', () => { + const result = buildUneditableCloseTokens(originToken); + + expect(result).toHaveLength(2); + expect(result).toStrictEqual(uneditableCloseTokens); + }); + }); + + describe('buildUneditableBlockTokens', () => { + it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => { + const result = buildUneditableBlockTokens(originToken); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableTokens); + }); + }); + + describe('buildUneditableInlineTokens', () => { + it('returns a 3-item array of tokens with the originInlineToken wrapped in the middle of inline tokens', () => { + const result = buildUneditableInlineTokens(originInlineToken); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableInlineTokens); + }); + }); + + describe('buildUneditableHtmlAsTextTokens', () => { + it('returns a 3-item array of tokens with the htmlBlockNode wrapped as a text token in the middle of block tokens', () => { + const htmlBlockNode = { + type: 'htmlBlock', + literal: '

Some header

Some paragraph

', + }; + const result = buildUneditableHtmlAsTextTokens(htmlBlockNode); + const { type, content } = result[1]; + + expect(type).toBe('text'); + expect(content).not.toMatch(/ data-tomark-pass /); + + expect(result).toHaveLength(3); + expect(result).toStrictEqual(uneditableBlockTokens); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js new file mode 100644 index 00000000000..407072fb596 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js @@ -0,0 +1,54 @@ +// Node spec helpers + +export const buildMockTextNode = (literal) => ({ literal, type: 'text' }); + +export const normalTextNode = buildMockTextNode('This is just normal text.'); + +// Token spec helpers + +const buildMockUneditableOpenToken = (type) => { + return { + type: 'openTag', + tagName: type, + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }; +}; + +const buildMockTextToken = (content) => { + return { + type: 'text', + tagName: null, + content, + }; +}; + +const buildMockUneditableCloseToken = (type) => ({ type: 'closeTag', tagName: type }); + +export const originToken = buildMockTextToken('{:.no_toc .hidden-md .hidden-lg}'); +const uneditableOpenToken = buildMockUneditableOpenToken('div'); +export const uneditableOpenTokens = [uneditableOpenToken, originToken]; +export const uneditableCloseToken = buildMockUneditableCloseToken('div'); +export const uneditableCloseTokens = [originToken, uneditableCloseToken]; +export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken]; + +export const originInlineToken = { + type: 'text', + content: 'Inline content', +}; + +export const uneditableInlineTokens = [ + buildMockUneditableOpenToken('a'), + originInlineToken, + buildMockUneditableCloseToken('a'), +]; + +export const uneditableBlockTokens = [ + uneditableOpenToken, + buildMockTextToken('

Some header

Some paragraph

'), + uneditableCloseToken, +]; + +export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}'; diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js new file mode 100644 index 00000000000..6d96dd3bbca --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js @@ -0,0 +1,25 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition'; +import { attributeDefinition } from './mock_data'; + +describe('rich_content_editor/renderers/render_attribute_definition', () => { + describe('canRender', () => { + it.each` + input | result + ${{ literal: attributeDefinition }} | ${true} + ${{ literal: `FOO${attributeDefinition}` }} | ${false} + ${{ literal: `${attributeDefinition}BAR` }} | ${false} + ${{ literal: 'foobar' }} | ${false} + `('returns $result when input is $input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); + }); + }); + + describe('render', () => { + it('returns an empty HTML comment', () => { + expect(renderer.render()).toEqual({ + type: 'html', + content: '', + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js new file mode 100644 index 00000000000..29e2b5b3b16 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js @@ -0,0 +1,24 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text'; +import { renderUneditableLeaf } from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const embeddedRubyTextNode = buildMockTextNode('<%= partial("some/path") %>'); + +describe('Render Embedded Ruby Text renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has embedded ruby syntax', () => { + expect(renderer.canRender(embeddedRubyTextNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks embedded ruby syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + it('should delegate rendering to the renderUneditableLeaf util', () => { + expect(renderer.render).toBe(renderUneditableLeaf); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js new file mode 100644 index 00000000000..0fda847b688 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js @@ -0,0 +1,33 @@ +import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline'; + +import { normalTextNode } from './mock_data'; + +const fontAwesomeInlineHtmlNode = { + firstChild: null, + literal: '', + type: 'html', +}; + +describe('Render Font Awesome Inline HTML renderer', () => { + describe('canRender', () => { + it('should return true when the argument `literal` has font awesome inline html syntax', () => { + expect(renderer.canRender(fontAwesomeInlineHtmlNode)).toBe(true); + }); + + it('should return false when the argument `literal` lacks font awesome inline html syntax', () => { + expect(renderer.canRender(normalTextNode)).toBe(false); + }); + }); + + describe('render', () => { + it('should return uneditable inline tokens', () => { + const token = { type: 'text', tagName: null, content: fontAwesomeInlineHtmlNode.literal }; + const context = { origin: () => token }; + + expect(renderer.render(fontAwesomeInlineHtmlNode, context)).toStrictEqual( + buildUneditableInlineTokens(token), + ); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js new file mode 100644 index 00000000000..cf4a90885df --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js @@ -0,0 +1,12 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_heading'; +import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; + +describe('rich_content_editor/renderers/render_heading', () => { + it('canRender delegates to renderUtils.willAlwaysRender', () => { + expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); + }); + + it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { + expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js new file mode 100644 index 00000000000..9c937ac22f4 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js @@ -0,0 +1,37 @@ +import { buildUneditableHtmlAsTextTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_html_block'; + +describe('rich_content_editor/services/renderers/render_html_block', () => { + const htmlBlockNode = { + literal: '

Heading

Paragraph.

', + type: 'htmlBlock', + }; + + describe('canRender', () => { + it.each` + input | result + ${htmlBlockNode} | ${true} + ${{ literal: '', type: 'htmlBlock' }} | ${true} + ${{ literal: '', type: 'htmlBlock' }} | ${false} + ${{ literal: '', type: 'text' }} | ${false} + `('returns $result when input=$input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); + }); + }); + + describe('render', () => { + const htmlBlockNodeToMark = { + firstChild: null, + literal: '
', + type: 'htmlBlock', + }; + + it.each` + node + ${htmlBlockNode} + ${htmlBlockNodeToMark} + `('should return uneditable tokens wrapping the $node as a token', ({ node }) => { + expect(renderer.render(node)).toStrictEqual(buildUneditableHtmlAsTextTokens(node)); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js new file mode 100644 index 00000000000..15fb2c3a430 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js @@ -0,0 +1,55 @@ +import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text'; + +import { buildMockTextNode, normalTextNode } from './mock_data'; + +const mockTextStart = 'Majority example '; +const mockTextMiddle = '[environment terraform plans][terraform]'; +const mockTextEnd = '.'; +const identifierInstanceStartTextNode = buildMockTextNode(mockTextStart); +const identifierInstanceEndTextNode = buildMockTextNode(mockTextEnd); + +describe('Render Identifier Instance Text renderer', () => { + describe('canRender', () => { + it.each` + node | target + ${normalTextNode} | ${false} + ${identifierInstanceStartTextNode} | ${false} + ${identifierInstanceEndTextNode} | ${false} + ${buildMockTextNode(mockTextMiddle)} | ${true} + ${buildMockTextNode('Minority example [environment terraform plans][]')} | ${true} + ${buildMockTextNode('Minority example [environment terraform plans]')} | ${true} + `( + 'should return $target when the $node validates against identifier instance syntax', + ({ node, target }) => { + expect(renderer.canRender(node)).toBe(target); + }, + ); + }); + + describe('render', () => { + it.each` + start | middle | end + ${mockTextStart} | ${mockTextMiddle} | ${mockTextEnd} + ${mockTextStart} | ${'[environment terraform plans][]'} | ${mockTextEnd} + ${mockTextStart} | ${'[environment terraform plans]'} | ${mockTextEnd} + `( + 'should return inline editable, uneditable, and editable tokens in sequence', + ({ start, middle, end }) => { + const buildMockTextToken = (content) => ({ type: 'text', tagName: null, content }); + + const startToken = buildMockTextToken(start); + const middleToken = buildMockTextToken(middle); + const endToken = buildMockTextToken(end); + + const content = `${start}${middle}${end}`; + const contentToken = buildMockTextToken(content); + const contentNode = buildMockTextNode(content); + const context = { origin: jest.fn().mockReturnValueOnce(contentToken) }; + expect(renderer.render(contentNode, context)).toStrictEqual( + [startToken, buildUneditableInlineTokens(middleToken), endToken].flat(), + ); + }, + ); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js new file mode 100644 index 00000000000..6a2b89a8dcf --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -0,0 +1,84 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph'; + +import { buildMockTextNode } from './mock_data'; + +const buildMockParagraphNode = (literal) => { + return { + firstChild: buildMockTextNode(literal), + type: 'paragraph', + }; +}; + +const normalParagraphNode = buildMockParagraphNode( + 'This is just normal paragraph. It has multiple sentences.', +); +const identifierParagraphNode = buildMockParagraphNode( + `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, +); + +describe('rich_content_editor/renderers_render_identifier_paragraph', () => { + describe('canRender', () => { + it.each` + node | paragraph | target + ${identifierParagraphNode} | ${'[Some text]: https://link.com'} | ${true} + ${normalParagraphNode} | ${'Normal non-identifier text. Another sentence.'} | ${false} + `( + 'should return $target when the $node matches $paragraph syntax', + ({ node, paragraph, target }) => { + const context = { + entering: true, + getChildrenText: jest.fn().mockReturnValueOnce(paragraph), + }; + + expect(renderer.canRender(node, context)).toBe(target); + }, + ); + }); + + describe('render', () => { + let context; + let result; + + beforeEach(() => { + const node = { + firstChild: { + type: 'text', + literal: '[Some text]: https://link.com', + next: { + type: 'linebreak', + next: { + type: 'text', + literal: '[identifier]: http://example1.com "title"', + }, + }, + }, + }; + context = { skipChildren: jest.fn() }; + result = renderer.render(node, context); + }); + + it('renders the reference definitions as a code block', () => { + expect(result).toEqual([ + { + type: 'openTag', + tagName: 'pre', + classNames: ['code-block', 'language-markdown'], + attributes: { + 'data-sse-reference-definition': true, + }, + }, + { type: 'openTag', tagName: 'code' }, + { + type: 'text', + content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"', + }, + { type: 'closeTag', tagName: 'code' }, + { type: 'closeTag', tagName: 'pre' }, + ]); + }); + + it('skips the reference definition node children from rendering', () => { + expect(context.skipChildren).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js new file mode 100644 index 00000000000..1e8e62b9dd2 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js @@ -0,0 +1,12 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_list_item'; +import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; + +describe('rich_content_editor/renderers/render_list_item', () => { + it('canRender delegates to renderUtils.willAlwaysRender', () => { + expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); + }); + + it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { + expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js new file mode 100644 index 00000000000..d8d1e6ff295 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js @@ -0,0 +1,23 @@ +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_softbreak'; + +describe('Render softbreak renderer', () => { + describe('canRender', () => { + it.each` + node | parentType | result + ${{ parent: { type: 'emph' } }} | ${'emph'} | ${true} + ${{ parent: { type: 'strong' } }} | ${'strong'} | ${true} + ${{ parent: { type: 'paragraph' } }} | ${'paragraph'} | ${false} + `('returns $result when node parent type is $parentType ', ({ node, result }) => { + expect(renderer.canRender(node)).toBe(result); + }); + }); + + describe('render', () => { + it('returns text node with a break line', () => { + expect(renderer.render()).toEqual({ + type: 'text', + content: ' ', + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js new file mode 100644 index 00000000000..49b8936a9f7 --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js @@ -0,0 +1,109 @@ +import { + buildUneditableBlockTokens, + buildUneditableOpenTokens, +} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import { + renderUneditableLeaf, + renderUneditableBranch, + renderWithAttributeDefinitions, + willAlwaysRender, +} from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; + +import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data'; + +describe('rich_content_editor/renderers/render_utils', () => { + describe('renderUneditableLeaf', () => { + it('should return uneditable block tokens around an origin token', () => { + const context = { origin: jest.fn().mockReturnValueOnce(originToken) }; + const result = renderUneditableLeaf({}, context); + + expect(result).toStrictEqual(buildUneditableBlockTokens(originToken)); + }); + }); + + describe('renderUneditableBranch', () => { + let origin; + + beforeEach(() => { + origin = jest.fn().mockReturnValueOnce(originToken); + }); + + it('should return uneditable block open token followed by the origin token when entering', () => { + const context = { entering: true, origin }; + const result = renderUneditableBranch({}, context); + + expect(result).toStrictEqual(buildUneditableOpenTokens(originToken)); + }); + + it('should return uneditable block closing token when exiting', () => { + const context = { entering: false, origin }; + const result = renderUneditableBranch({}, context); + + expect(result).toStrictEqual(uneditableCloseToken); + }); + }); + + describe('willAlwaysRender', () => { + it('always returns true', () => { + expect(willAlwaysRender()).toBe(true); + }); + }); + + describe('renderWithAttributeDefinitions', () => { + let openTagToken; + let closeTagToken; + let node; + const attributes = { + 'data-attribute-definition': attributeDefinition, + }; + + beforeEach(() => { + openTagToken = { type: 'openTag' }; + closeTagToken = { type: 'closeTag' }; + node = { + next: { + firstChild: { + literal: attributeDefinition, + }, + }, + }; + }); + + describe('when token type is openTag', () => { + it('attaches attributes when attributes exist in the node’s next sibling', () => { + const context = { origin: () => openTagToken }; + + expect(renderWithAttributeDefinitions(node, context)).toEqual({ + ...openTagToken, + attributes, + }); + }); + + it('attaches attributes when attributes exist in the node’s children', () => { + const context = { origin: () => openTagToken }; + node = { + firstChild: { + firstChild: { + next: { + next: { + literal: attributeDefinition, + }, + }, + }, + }, + }; + + expect(renderWithAttributeDefinitions(node, context)).toEqual({ + ...openTagToken, + attributes, + }); + }); + }); + + it('does not attach attributes when token type is "closeTag"', () => { + const context = { origin: () => closeTagToken }; + + expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js new file mode 100644 index 00000000000..2f2d3beb53d --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js @@ -0,0 +1,11 @@ +import sanitizeHTML from '~/static_site_editor/rich_content_editor/services/sanitize_html'; + +describe('rich_content_editor/services/sanitize_html', () => { + it.each` + input | result + ${''} | ${''} + ${''} | ${''} + `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => { + expect(sanitizeHTML(input)).toBe(result); + }); +}); diff --git a/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js new file mode 100644 index 00000000000..c9dcf9cfe2e --- /dev/null +++ b/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js @@ -0,0 +1,57 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ToolbarItem from '~/static_site_editor/rich_content_editor/toolbar_item.vue'; + +describe('Toolbar Item', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + const findButton = () => wrapper.find('button'); + + const buildWrapper = (propsData) => { + wrapper = shallowMount(ToolbarItem, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + describe.each` + icon | tooltip + ${'heading'} | ${'Headings'} + ${'bold'} | ${'Add bold text'} + ${'italic'} | ${'Add italic text'} + ${'strikethrough'} | ${'Add strikethrough text'} + ${'quote'} | ${'Insert a quote'} + ${'link'} | ${'Add a link'} + ${'doc-code'} | ${'Insert a code block'} + ${'list-bulleted'} | ${'Add a bullet list'} + ${'list-numbered'} | ${'Add a numbered list'} + ${'list-task'} | ${'Add a task list'} + ${'list-indent'} | ${'Indent'} + ${'list-outdent'} | ${'Outdent'} + ${'dash'} | ${'Add a line'} + ${'table'} | ${'Add a table'} + ${'code'} | ${'Insert an image'} + ${'code'} | ${'Insert inline code'} + `('toolbar item component', ({ icon, tooltip }) => { + beforeEach(() => buildWrapper({ icon, tooltip })); + + it('renders a toolbar button', () => { + expect(findButton().exists()).toBe(true); + }); + + it('renders the correct tooltip', () => { + const buttonTooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(buttonTooltip).toBeDefined(); + expect(buttonTooltip.value.title).toBe(tooltip); + }); + + it(`renders the ${icon} icon`, () => { + expect(findIcon().exists()).toBe(true); + expect(findIcon().props().name).toBe(icon); + }); + }); +}); diff --git a/spec/frontend/tracking/get_standard_context_spec.js b/spec/frontend/tracking/get_standard_context_spec.js new file mode 100644 index 00000000000..b7bdc56b801 --- /dev/null +++ b/spec/frontend/tracking/get_standard_context_spec.js @@ -0,0 +1,53 @@ +import { SNOWPLOW_JS_SOURCE } from '~/tracking/constants'; +import getStandardContext from '~/tracking/get_standard_context'; + +describe('~/tracking/get_standard_context', () => { + beforeEach(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = {}; + }); + + it('returns default object if called without server context', () => { + expect(getStandardContext()).toStrictEqual({ + schema: undefined, + data: { + source: SNOWPLOW_JS_SOURCE, + extra: {}, + }, + }); + }); + + it('returns filled object if called with server context', () => { + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + }, + }; + + expect(getStandardContext()).toStrictEqual({ + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: SNOWPLOW_JS_SOURCE, + extra: {}, + }, + }); + }); + + it('always overrides `source` property', () => { + window.gl.snowplowStandardContext = { + data: { + source: 'custom_source', + }, + }; + + expect(getStandardContext().data.source).toBe(SNOWPLOW_JS_SOURCE); + }); + + it('accepts optional `extra` property', () => { + const extra = { foo: 'bar' }; + + expect(getStandardContext({ extra }).data.extra).toBe(extra); + }); +}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index dd4c8198b72..d8dae2b2dc0 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,14 +1,31 @@ import { setHTMLFixture } from 'helpers/fixtures'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData } from '~/experimentation/utils'; -import Tracking, { initUserTracking, initDefaultTrackers, STANDARD_CONTEXT } from '~/tracking'; +import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import getStandardContext from '~/tracking/get_standard_context'; jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); describe('Tracking', () => { + let standardContext; let snowplowSpy; let bindDocumentSpy; let trackLoadEventsSpy; + let enableFormTracking; + + beforeAll(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: 'unknown', + extra: {}, + }, + }; + + standardContext = getStandardContext(); + }); beforeEach(() => { getExperimentData.mockReturnValue(undefined); @@ -38,6 +55,10 @@ describe('Tracking', () => { formTracking: false, linkClickTracking: false, pageUnloadTimer: 10, + formTrackingConfig: { + fields: { allow: [] }, + forms: { allow: [] }, + }, }); }); }); @@ -46,12 +67,15 @@ describe('Tracking', () => { beforeEach(() => { bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); + enableFormTracking = jest + .spyOn(Tracking, 'enableFormTracking') + .mockImplementation(() => null); }); it('should activate features based on what has been enabled', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [STANDARD_CONTEXT]); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); @@ -59,10 +83,11 @@ describe('Tracking', () => { ...window.snowplowOptions, formTracking: true, linkClickTracking: true, + formTrackingConfig: { forms: { whitelist: ['foo'] }, fields: { whitelist: ['bar'] } }, }; initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking'); + expect(enableFormTracking).toHaveBeenCalledWith(window.snowplowOptions.formTrackingConfig); expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); }); @@ -84,34 +109,6 @@ describe('Tracking', () => { navigator.msDoNotTrack = undefined; }); - describe('builds the standard context', () => { - let standardContext; - - beforeAll(async () => { - window.gl = window.gl || {}; - window.gl.snowplowStandardContext = { - schema: 'iglu:com.gitlab/gitlab_standard', - data: { - environment: 'testing', - source: 'unknown', - }, - }; - - jest.resetModules(); - - ({ STANDARD_CONTEXT: standardContext } = await import('~/tracking')); - }); - - it('uses server data', () => { - expect(standardContext.schema).toBe('iglu:com.gitlab/gitlab_standard'); - expect(standardContext.data.environment).toBe('testing'); - }); - - it('overrides schema source', () => { - expect(standardContext.data.source).toBe('gitlab-javascript'); - }); - }); - it('tracks to snowplow (our current tracking system)', () => { Tracking.event('_category_', '_eventName_', { label: '_label_' }); @@ -122,7 +119,31 @@ describe('Tracking', () => { '_label_', undefined, undefined, - [STANDARD_CONTEXT], + [standardContext], + ); + }); + + it('allows adding extra data to the default context', () => { + const extra = { foo: 'bar' }; + + Tracking.event('_category_', '_eventName_', { extra }); + + expect(snowplowSpy).toHaveBeenCalledWith( + 'trackStructEvent', + '_category_', + '_eventName_', + undefined, + undefined, + undefined, + [ + { + ...standardContext, + data: { + ...standardContext.data, + extra, + }, + }, + ], ); }); @@ -156,26 +177,23 @@ describe('Tracking', () => { }); describe('.enableFormTracking', () => { - it('tells snowplow to enable form tracking', () => { - const config = { forms: { whitelist: [''] }, fields: { whitelist: [''] } }; - Tracking.enableFormTracking(config, ['_passed_context_']); + it('tells snowplow to enable form tracking, with only explicit contexts', () => { + const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } }; + Tracking.enableFormTracking(config, ['_passed_context_', standardContext]); - expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking', config, [ - { data: { source: 'gitlab-javascript' }, schema: undefined }, - '_passed_context_', - ]); + expect(snowplowSpy).toHaveBeenCalledWith( + 'enableFormTracking', + { forms: { whitelist: ['form-class1'] }, fields: { whitelist: ['input-class1'] } }, + ['_passed_context_'], + ); }); - it('throws an error if no whitelist rules are provided', () => { - const expectedError = new Error( - 'Unable to enable form event tracking without whitelist rules.', - ); + it('throws an error if no allow rules are provided', () => { + const expectedError = new Error('Unable to enable form event tracking without allow rules.'); expect(() => Tracking.enableFormTracking()).toThrow(expectedError); - expect(() => Tracking.enableFormTracking({ fields: { whitelist: [] } })).toThrow( - expectedError, - ); - expect(() => Tracking.enableFormTracking({ fields: { whitelist: [1] } })).not.toThrow( + expect(() => Tracking.enableFormTracking({ fields: { allow: true } })).toThrow(expectedError); + expect(() => Tracking.enableFormTracking({ fields: { allow: [] } })).not.toThrow( expectedError, ); }); @@ -197,7 +215,7 @@ describe('Tracking', () => { '_label_', undefined, undefined, - [STANDARD_CONTEXT], + [standardContext], ); }); }); @@ -213,13 +231,15 @@ describe('Tracking', () => { eventSpy = jest.spyOn(Tracking, 'event'); Tracking.bindDocument('_category_'); // only happens once setHTMLFixture(` - - - + + +
+ + `); }); @@ -228,7 +248,7 @@ describe('Tracking', () => { expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', { label: '_label_', - value: '_value_', + value: '0', }); }); @@ -242,7 +262,7 @@ describe('Tracking', () => { document.querySelector(`[data-track-${term}="click_input2"]`).click(); expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', { - value: '_value_override_', + value: '0', }); }); @@ -252,13 +272,13 @@ describe('Tracking', () => { checkbox.click(); // unchecking expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { - value: false, + value: 0, }); checkbox.click(); // checking expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { - value: '_value_', + value: '1', }); }); @@ -295,6 +315,20 @@ describe('Tracking', () => { context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData }, }); }); + + it('supports extra data as JSON', () => { + document.querySelector(`[data-track-${term}="event_with_extra"]`).click(); + + expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_extra', { + extra: { foo: 'bar' }, + }); + }); + + it('ignores extra if provided JSON is invalid', () => { + document.querySelector(`[data-track-${term}="event_with_invalid_extra"]`).click(); + + expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_invalid_extra', {}); + }); }); describe.each` @@ -307,8 +341,8 @@ describe('Tracking', () => { beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); setHTMLFixture(` - - + + Something @@ -323,7 +357,7 @@ describe('Tracking', () => { 'render', { label: 'label1', - value: '_value_', + value: '1', property: '_property_', }, ], @@ -332,7 +366,7 @@ describe('Tracking', () => { 'render', { label: 'label2', - value: '_value_', + value: '1', }, ], ]); diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js new file mode 100644 index 00000000000..7a33c6faac9 --- /dev/null +++ b/spec/frontend/user_lists/components/user_lists_spec.js @@ -0,0 +1,195 @@ +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { within } from '@testing-library/dom'; +import { mount, createWrapper } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import UserListsComponent from '~/user_lists/components/user_lists.vue'; +import UserListsTable from '~/user_lists/components/user_lists_table.vue'; +import createStore from '~/user_lists/store/index'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { userList } from '../../feature_flags/mock_data'; + +jest.mock('~/api'); + +Vue.use(Vuex); + +describe('~/user_lists/components/user_lists.vue', () => { + const mockProvide = { + newUserListPath: '/user-lists/new', + featureFlagsHelpPagePath: '/help/feature-flags', + errorStateSvgPath: '/assets/illustrations/feature_flag.svg', + }; + + const mockState = { + projectId: '1', + }; + + let wrapper; + let store; + + const factory = (provide = mockProvide, fn = mount) => { + store = createStore(mockState); + wrapper = fn(UserListsComponent, { + store, + provide, + }); + }; + + const newButton = () => within(wrapper.element).queryAllByText('New user list'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('without permissions', () => { + const provideData = { + ...mockProvide, + newUserListPath: null, + }; + + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [], headers: {} }); + factory(provideData); + }); + + it('does not render new user list button', () => { + expect(newButton()).toHaveLength(0); + }); + }); + + describe('loading state', () => { + it('renders a loading icon', () => { + Api.fetchFeatureFlagUserLists.mockReturnValue(new Promise(() => {})); + + factory(); + + const loadingElement = wrapper.findComponent(GlLoadingIcon); + + expect(loadingElement.exists()).toBe(true); + expect(loadingElement.props('label')).toEqual('Loading user lists'); + }); + }); + + describe('successful request', () => { + describe('without user lists', () => { + let emptyState; + + beforeEach(async () => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [], headers: {} }); + + factory(); + await waitForPromises(); + await Vue.nextTick(); + + emptyState = wrapper.findComponent(GlEmptyState); + }); + + it('should render the empty state', async () => { + expect(emptyState.exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton()).not.toHaveLength(0); + }); + + it('renders generic title', () => { + const title = createWrapper( + within(emptyState.element).getByText('Get started with user lists'), + ); + expect(title.exists()).toBe(true); + }); + + it('renders generic description', () => { + const description = createWrapper( + within(emptyState.element).getByText( + 'User lists allow you to define a set of users to use with Feature Flags.', + ), + ); + expect(description.exists()).toBe(true); + }); + }); + + describe('with paginated user lists', () => { + let table; + + beforeEach(async () => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ + data: [userList], + headers: { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }, + }); + + factory(); + jest.spyOn(store, 'dispatch'); + await Vue.nextTick(); + table = wrapper.findComponent(UserListsTable); + }); + + it('should render a table with feature flags', () => { + expect(table.exists()).toBe(true); + expect(table.props('userLists')).toEqual([userList]); + }); + + it('renders new feature flag button', () => { + expect(newButton()).not.toHaveLength(0); + }); + + describe('pagination', () => { + let pagination; + + beforeEach(() => { + pagination = wrapper.findComponent(TablePagination); + }); + + it('should render pagination', () => { + expect(pagination.exists()).toBe(true); + }); + + it('should make an API request when page is clicked', () => { + jest.spyOn(store, 'dispatch'); + pagination.vm.change('4'); + + expect(store.dispatch).toHaveBeenCalledWith('setUserListsOptions', { + page: '4', + }); + }); + }); + }); + }); + + describe('unsuccessful request', () => { + beforeEach(async () => { + Api.fetchFeatureFlagUserLists.mockRejectedValue(); + factory(); + + await Vue.nextTick(); + }); + + it('should render error state', () => { + const emptyState = wrapper.findComponent(GlEmptyState); + const title = createWrapper( + within(emptyState.element).getByText('There was an error fetching the user lists.'), + ); + expect(title.exists()).toBe(true); + const description = createWrapper( + within(emptyState.element).getByText( + 'Try again in a few moments or contact your support team.', + ), + ); + expect(description.exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton()).not.toHaveLength(0); + }); + }); +}); diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js new file mode 100644 index 00000000000..7f4d510a39c --- /dev/null +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -0,0 +1,98 @@ +import { GlModal } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import * as timeago from 'timeago.js'; +import UserListsTable from '~/user_lists/components/user_lists_table.vue'; +import { userList } from '../../feature_flags/mock_data'; + +jest.mock('timeago.js', () => ({ + format: jest.fn().mockReturnValue('2 weeks ago'), + register: jest.fn(), +})); + +describe('User Lists Table', () => { + let wrapper; + let userLists; + + beforeEach(() => { + userLists = new Array(5).fill(userList).map((x, i) => ({ ...x, id: i })); + wrapper = mount(UserListsTable, { + propsData: { userLists }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should display the details of a user list', () => { + expect(wrapper.find('[data-testid="ffUserListName"]').text()).toBe(userList.name); + expect(wrapper.find('[data-testid="ffUserListIds"]').text()).toBe( + userList.user_xids.replace(/,/g, ', '), + ); + expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago'); + expect(timeago.format).toHaveBeenCalledWith(userList.created_at); + }); + + it('should set the title for a tooltip on the created stamp', () => { + expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe( + 'Feb 4, 2020 8:13am UTC', + ); + }); + + it('should display a user list entry per user list', () => { + const lists = wrapper.findAll('[data-testid="ffUserList"]'); + expect(lists).toHaveLength(5); + lists.wrappers.forEach((list) => { + expect(list.find('[data-testid="ffUserListName"]').exists()).toBe(true); + expect(list.find('[data-testid="ffUserListIds"]').exists()).toBe(true); + expect(list.find('[data-testid="ffUserListTimestamp"]').exists()).toBe(true); + }); + }); + + describe('edit button', () => { + it('should link to the path for the user list', () => { + expect(wrapper.find('[data-testid="edit-user-list"]').attributes('href')).toBe(userList.path); + }); + }); + + describe('delete button', () => { + it('should display the confirmation modal', () => { + const modal = wrapper.find(GlModal); + + wrapper.find('[data-testid="delete-user-list"]').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(modal.text()).toContain(`Delete ${userList.name}?`); + expect(modal.text()).toContain(`User list ${userList.name} will be removed.`); + }); + }); + }); + + describe('confirmation modal', () => { + let modal; + + beforeEach(() => { + modal = wrapper.find(GlModal); + + wrapper.find('button').trigger('click'); + + return wrapper.vm.$nextTick(); + }); + + it('should emit delete with list on confirmation', () => { + modal.find('[data-testid="modal-confirm"]').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('delete')).toEqual([[userLists[0]]]); + }); + }); + + it('should not emit delete with list when not confirmed', () => { + modal.find('button').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('delete')).toBeUndefined(); + }); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/index/actions_spec.js b/spec/frontend/user_lists/store/index/actions_spec.js new file mode 100644 index 00000000000..c5d7d557de9 --- /dev/null +++ b/spec/frontend/user_lists/store/index/actions_spec.js @@ -0,0 +1,203 @@ +import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; +import { + setUserListsOptions, + requestUserLists, + receiveUserListsSuccess, + receiveUserListsError, + fetchUserLists, + deleteUserList, + receiveDeleteUserListError, + clearAlert, +} from '~/user_lists/store/index/actions'; +import * as types from '~/user_lists/store/index/mutation_types'; +import createState from '~/user_lists/store/index/state'; +import { userList } from '../../../feature_flags/mock_data'; + +jest.mock('~/api.js'); + +describe('~/user_lists/store/index/actions', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '1' }); + }); + + describe('setUserListsOptions', () => { + it('should commit SET_USER_LISTS_OPTIONS mutation', (done) => { + testAction( + setUserListsOptions, + { page: '1', scope: 'all' }, + state, + [{ type: types.SET_USER_LISTS_OPTIONS, payload: { page: '1', scope: 'all' } }], + [], + done, + ); + }); + }); + + describe('fetchUserLists', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} }); + }); + + describe('success', () => { + it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => { + testAction( + fetchUserLists, + null, + state, + [], + [ + { + type: 'requestUserLists', + }, + { + payload: { data: [userList], headers: {} }, + type: 'receiveUserListsSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestUserLists and receiveUserListsError ', (done) => { + Api.fetchFeatureFlagUserLists.mockRejectedValue(); + + testAction( + fetchUserLists, + null, + state, + [], + [ + { + type: 'requestUserLists', + }, + { + type: 'receiveUserListsError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestUserLists', () => { + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { + testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], [], done); + }); + }); + + describe('receiveUserListsSuccess', () => { + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { + testAction( + receiveUserListsSuccess, + { data: [userList], headers: {} }, + state, + [ + { + type: types.RECEIVE_USER_LISTS_SUCCESS, + payload: { data: [userList], headers: {} }, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveUserListsError', () => { + it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => { + testAction( + receiveUserListsError, + null, + state, + [{ type: types.RECEIVE_USER_LISTS_ERROR }], + [], + done, + ); + }); + }); + + describe('deleteUserList', () => { + beforeEach(() => { + state.userLists = [userList]; + }); + + describe('success', () => { + beforeEach(() => { + Api.deleteFeatureFlagUserList.mockResolvedValue(); + }); + + it('should refresh the user lists', (done) => { + testAction( + deleteUserList, + userList, + state, + [], + [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } }); + }); + + it('should dispatch receiveDeleteUserListError', (done) => { + testAction( + deleteUserList, + userList, + state, + [], + [ + { type: 'requestDeleteUserList', payload: userList }, + { + type: 'receiveDeleteUserListError', + payload: { list: userList, error: 'some error' }, + }, + ], + done, + ); + }); + }); + }); + + describe('receiveDeleteUserListError', () => { + it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => { + testAction( + receiveDeleteUserListError, + { list: userList, error: 'mock error' }, + state, + [ + { + type: 'RECEIVE_DELETE_USER_LIST_ERROR', + payload: { list: userList, error: 'mock error' }, + }, + ], + [], + done, + ); + }); + }); + + describe('clearAlert', () => { + it('should commit RECEIVE_CLEAR_ALERT', (done) => { + const alertIndex = 3; + + testAction( + clearAlert, + alertIndex, + state, + [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/index/mutations_spec.js b/spec/frontend/user_lists/store/index/mutations_spec.js new file mode 100644 index 00000000000..370838ae5fb --- /dev/null +++ b/spec/frontend/user_lists/store/index/mutations_spec.js @@ -0,0 +1,121 @@ +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import * as types from '~/user_lists/store/index/mutation_types'; +import mutations from '~/user_lists/store/index/mutations'; +import createState from '~/user_lists/store/index/state'; +import { userList } from '../../../feature_flags/mock_data'; + +describe('~/user_lists/store/index/mutations', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '1' }); + }); + + describe('SET_USER_LISTS_OPTIONS', () => { + it('should set provided options', () => { + mutations[types.SET_USER_LISTS_OPTIONS](state, { page: '1', scope: 'all' }); + + expect(state.options).toEqual({ page: '1', scope: 'all' }); + }); + }); + + describe('REQUEST_USER_LISTS', () => { + it('sets isLoading to true', () => { + mutations[types.REQUEST_USER_LISTS](state); + expect(state.isLoading).toBe(true); + }); + }); + + describe('RECEIVE_USER_LISTS_SUCCESS', () => { + const headers = { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }; + + beforeEach(() => { + mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, { data: [userList], headers }); + }); + + it('sets isLoading to false', () => { + expect(state.isLoading).toBe(false); + }); + + it('sets userLists to the received userLists', () => { + expect(state.userLists).toEqual([userList]); + }); + + it('sets pagination info for user lits', () => { + expect(state.pageInfo).toEqual(parseIntPagination(normalizeHeaders(headers))); + }); + + it('sets the count for user lists', () => { + expect(state.count).toBe(parseInt(headers['X-TOTAL'], 10)); + }); + }); + + describe('RECEIVE_USER_LISTS_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_USER_LISTS_ERROR](state); + }); + + it('should set isLoading to false', () => { + expect(state.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(state.hasError).toEqual(true); + }); + }); + + describe('REQUEST_DELETE_USER_LIST', () => { + beforeEach(() => { + state.userLists = [userList]; + mutations[types.REQUEST_DELETE_USER_LIST](state, userList); + }); + + it('should remove the deleted list', () => { + expect(state.userLists).not.toContain(userList); + }); + }); + + describe('RECEIVE_DELETE_USER_LIST_ERROR', () => { + beforeEach(() => { + state.userLists = []; + mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](state, { + list: userList, + error: 'some error', + }); + }); + + it('should set isLoading to false and hasError to false', () => { + expect(state.isLoading).toBe(false); + expect(state.hasError).toBe(false); + }); + + it('should add the user list back to the list of user lists', () => { + expect(state.userLists).toContain(userList); + }); + }); + + describe('RECEIVE_CLEAR_ALERT', () => { + it('clears the alert', () => { + state.alerts = ['a server error']; + + mutations[types.RECEIVE_CLEAR_ALERT](state, 0); + + expect(state.alerts).toEqual([]); + }); + + it('clears the alert at the specified index', () => { + state.alerts = ['a server error', 'another error', 'final error']; + + mutations[types.RECEIVE_CLEAR_ALERT](state, 1); + + expect(state.alerts).toEqual(['a server error', 'final error']); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index d6a1c2d3b07..af6624a6c43 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -1,6 +1,6 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; @@ -125,7 +125,7 @@ describe('MRWidget approvals', () => { }); it('flashes error', () => { - expect(createFlash).toHaveBeenCalledWith(FETCH_ERROR); + expect(createFlash).toHaveBeenCalledWith({ message: FETCH_ERROR }); }); }); @@ -264,7 +264,7 @@ describe('MRWidget approvals', () => { }); it('flashes error message', () => { - expect(createFlash).toHaveBeenCalledWith(APPROVE_ERROR); + expect(createFlash).toHaveBeenCalledWith({ message: APPROVE_ERROR }); }); }); }); @@ -315,7 +315,7 @@ describe('MRWidget approvals', () => { }); it('flashes error message', () => { - expect(createFlash).toHaveBeenCalledWith(UNAPPROVE_ERROR); + expect(createFlash).toHaveBeenCalledWith({ message: UNAPPROVE_ERROR }); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js index 07e869a070f..5d923d0383f 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js @@ -1,76 +1,45 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlLink, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import MrWidgetAlertMessage from '~/vue_merge_request_widget/components/mr_widget_alert_message.vue'; -describe('MrWidgetAlertMessage', () => { - let wrapper; - - beforeEach(() => { - const localVue = createLocalVue(); +let wrapper; - wrapper = shallowMount(localVue.extend(MrWidgetAlertMessage), { - propsData: {}, - localVue, - }); +function createComponent(propsData = {}) { + wrapper = shallowMount(MrWidgetAlertMessage, { + propsData, }); +} +describe('MrWidgetAlertMessage', () => { afterEach(() => { wrapper.destroy(); }); - describe('when type is not provided', () => { - it('should render a red message', (done) => { - wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain('danger_message'); - expect(wrapper.classes()).not.toContain('warning_message'); - done(); - }); - }); - }); - - describe('when type === "danger"', () => { - it('should render a red message', (done) => { - wrapper.setProps({ type: 'danger' }); - wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain('danger_message'); - expect(wrapper.classes()).not.toContain('warning_message'); - done(); - }); - }); - }); + it('should render a GlAert', () => { + createComponent({ type: 'danger' }); - describe('when type === "warning"', () => { - it('should render a red message', (done) => { - wrapper.setProps({ type: 'warning' }); - wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain('warning_message'); - expect(wrapper.classes()).not.toContain('danger_message'); - done(); - }); - }); + expect(wrapper.findComponent(GlAlert).exists()).toBe(true); + expect(wrapper.findComponent(GlAlert).props('variant')).toBe('danger'); }); describe('when helpPath is not provided', () => { - it('should not render a help icon/link', (done) => { - wrapper.vm.$nextTick(() => { - const link = wrapper.find(GlLink); + it('should not render a help link', () => { + createComponent({ type: 'info' }); + + const link = wrapper.findComponent(GlLink); - expect(link.exists()).toBe(false); - done(); - }); + expect(link.exists()).toBe(false); }); }); describe('when helpPath is provided', () => { - it('should render a help icon/link', (done) => { - wrapper.setProps({ helpPath: '/path/to/help/docs' }); - wrapper.vm.$nextTick(() => { - const link = wrapper.find(GlLink); + it('should render a help link', () => { + createComponent({ type: 'info', helpPath: 'https://gitlab.com' }); + + const link = wrapper.findComponent(GlLink); - expect(link.exists()).toBe(true); - expect(link.attributes().href).toBe('/path/to/help/docs'); - done(); - }); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('https://gitlab.com'); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index 924dc37aab9..ecaca16a2cd 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -94,7 +94,7 @@ describe('MRWidgetPipeline', () => { it('should render pipeline finished timestamp', () => { expect(findPipelineFinishedAt().attributes()).toMatchObject({ - title: 'Apr 7, 2017 2:00pm GMT+0000', + title: 'Apr 7, 2017 2:00pm UTC', datetime: mockData.pipeline.details.finished_at, }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js index 55d7e2391b2..6ae218ce6f8 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js @@ -18,8 +18,8 @@ describe('MRWidgetClosed', () => { avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', }, - mergedAt: 'Jan 24, 2018 1:02pm GMT+0000', - closedAt: 'Jan 24, 2018 1:02pm GMT+0000', + mergedAt: 'Jan 24, 2018 1:02pm UTC', + closedAt: 'Jan 24, 2018 1:02pm UTC', readableMergedAt: '', readableClosedAt: 'less than a minute ago', }, diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 6af8ac9e18e..6bb87893c31 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -37,10 +37,10 @@ describe('MRWidgetMerged', () => { avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', }, - mergedAt: 'Jan 24, 2018 1:02pm GMT+0000', + mergedAt: 'Jan 24, 2018 1:02pm UTC', readableMergedAt: '', closedBy: {}, - closedAt: 'Jan 24, 2018 1:02pm GMT+0000', + closedAt: 'Jan 24, 2018 1:02pm UTC', readableClosedAt: '', }, updatedAt: 'mergedUpdatedAt', @@ -236,6 +236,6 @@ describe('MRWidgetMerged', () => { }); it('should use mergedEvent mergedAt as tooltip title', () => { - expect(vm.$el.querySelector('time').getAttribute('title')).toBe('Jan 24, 2018 1:02pm GMT+0000'); + expect(vm.$el.querySelector('time').getAttribute('title')).toBe('Jan 24, 2018 1:02pm UTC'); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js index bd77a1d657e..9b10b078e89 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js @@ -22,7 +22,7 @@ describe('MRWidgetPipelineBlocked', () => { createWrapper(); expect(wrapper.text()).toBe( - 'Pipeline blocked. The pipeline for this merge request requires a manual action to proceed', + "Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.", ); }); }); 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 85a42946325..2d00cd8e8d4 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -59,12 +59,17 @@ const createTestService = () => ({ }); let wrapper; -const createComponent = (customConfig = {}) => { +const createComponent = (customConfig = {}, mergeRequestWidgetGraphql = false) => { wrapper = shallowMount(ReadyToMerge, { propsData: { mr: createTestMr(customConfig), service: createTestService(), }, + provide: { + glFeatures: { + mergeRequestWidgetGraphql, + }, + }, }); }; @@ -123,26 +128,26 @@ describe('ReadyToMerge', () => { }); describe('mergeButtonVariant', () => { - it('defaults to success class', () => { + it('defaults to confirm class', () => { createComponent({ mr: { availableAutoMergeStrategies: [] }, }); - expect(wrapper.vm.mergeButtonVariant).toEqual('success'); + expect(wrapper.vm.mergeButtonVariant).toEqual('confirm'); }); - it('returns success class for success status', () => { + it('returns confirm class for success status', () => { createComponent({ mr: { availableAutoMergeStrategies: [], pipeline: true }, }); - expect(wrapper.vm.mergeButtonVariant).toEqual('success'); + expect(wrapper.vm.mergeButtonVariant).toEqual('confirm'); }); - it('returns info class for pending status', () => { + it('returns confirm class for pending status', () => { createComponent(); - expect(wrapper.vm.mergeButtonVariant).toEqual('info'); + expect(wrapper.vm.mergeButtonVariant).toEqual('confirm'); }); it('returns danger class for failed status', () => { @@ -673,6 +678,34 @@ describe('ReadyToMerge', () => { expect(findCommitEditElements().length).toBe(2); }); + it('should have two edit components when squash is enabled and there is more than 1 commit and mergeRequestWidgetGraphql is enabled', async () => { + createComponent( + { + mr: { + commitsCount: 2, + squashIsSelected: true, + enableSquashBeforeMerge: true, + }, + }, + true, + ); + + wrapper.setData({ + loading: false, + state: { + ...createTestMr({}), + userPermissions: {}, + squash: true, + mergeable: true, + commitCount: 2, + commitsWithoutMergeCommits: {}, + }, + }); + await wrapper.vm.$nextTick(); + + expect(findCommitEditElements().length).toBe(2); + }); + it('should have one edit components when squash is enabled and there is 1 commit only', () => { createComponent({ mr: { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index e0077a008a2..0609086997b 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import WorkInProgress from '~/vue_merge_request_widget/components/states/work_in_progress.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; @@ -63,10 +63,10 @@ describe('Wip', () => { setImmediate(() => { expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); - expect(createFlash).toHaveBeenCalledWith( - 'The merge request can now be merged.', - 'notice', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'The merge request can now be merged.', + type: 'notice', + }); done(); }); }); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js index 22e58ac6abf..49783560bf2 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { CREATED, @@ -203,9 +203,9 @@ describe('DeploymentAction component', () => { it('should call createFlash with error message', () => { expect(createFlash).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith( - actionButtonMocks[configConst].errorMessage, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: actionButtonMocks[configConst].errorMessage, + }); }); }); }); 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 446cd2a1e2f..9da370747fc 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -26,7 +26,7 @@ describe('MrWidgetOptions', () => { let wrapper; let mock; - const COLLABORATION_MESSAGE = 'Allows commits from members who can merge to the target branch'; + const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; beforeEach(() => { gl.mrWidgetData = { ...mockData }; @@ -532,7 +532,7 @@ describe('MrWidgetOptions', () => { nextTick(() => { const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - expect(wrapper.text()).toContain('Deletes source branch'); + expect(wrapper.text()).toContain('The source branch will be deleted'); expect(tooltip.attributes('title')).toBe( 'A user with write access to the source branch selected this option', ); @@ -548,7 +548,7 @@ describe('MrWidgetOptions', () => { nextTick(() => { expect(wrapper.text()).toContain('The source branch has been deleted'); - expect(wrapper.text()).not.toContain('Deletes source branch'); + expect(wrapper.text()).not.toContain('The source branch will be deleted'); done(); }); diff --git a/spec/frontend/vue_shared/alert_details/alert_status_spec.js b/spec/frontend/vue_shared/alert_details/alert_status_spec.js index c532f688cbd..3fc13243bce 100644 --- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js @@ -1,5 +1,5 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import updateAlertStatusMutation from '~/graphql_shared//mutations/alert_status_update.mutation.graphql'; import Tracking from '~/tracking'; @@ -10,9 +10,10 @@ const mockAlert = mockAlerts[0]; describe('AlertManagementStatus', () => { let wrapper; - const findStatusDropdown = () => wrapper.find(GlDropdown); - const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + const findStatusDropdown = () => wrapper.findComponent(GlDropdown); + const findFirstStatusOption = () => findStatusDropdown().findComponent(GlDropdownItem); const findAllStatusOptions = () => findStatusDropdown().findAll(GlDropdownItem); + const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header'); const selectFirstStatusOption = () => { findFirstStatusOption().vm.$emit('click'); @@ -21,7 +22,7 @@ describe('AlertManagementStatus', () => { }; function mountComponent({ props = {}, provide = {}, loading = false, stubs = {} } = {}) { - wrapper = shallowMount(AlertManagementStatus, { + wrapper = shallowMountExtended(AlertManagementStatus, { propsData: { alert: { ...mockAlert }, projectPath: 'gitlab-org/gitlab', @@ -43,17 +44,29 @@ describe('AlertManagementStatus', () => { }); } - beforeEach(() => { - mountComponent(); - }); - afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); + describe('sidebar', () => { + it('displays the dropdown status header', () => { + mountComponent({ props: { isSidebar: true } }); + expect(findStatusDropdownHeader().exists()).toBe(true); + }); + + it('hides the dropdown by default', () => { + mountComponent({ props: { isSidebar: true } }); + expect(wrapper.classes()).toContain('gl-display-none'); + }); + + it('shows the dropdown', () => { + mountComponent({ props: { isSidebar: true, isDropdownShowing: true } }); + expect(wrapper.classes()).toContain('show'); + }); + }); + describe('updating the alert status', () => { const iid = '1527542'; const mockUpdatedMutationResult = { @@ -99,6 +112,13 @@ describe('AlertManagementStatus', () => { ]); }); + it('emits an update event at the start and ending of the updating', async () => { + await selectFirstStatusOption(); + expect(wrapper.emitted('handle-updating').length > 1).toBe(true); + expect(wrapper.emitted('handle-updating')[0]).toEqual([true]); + expect(wrapper.emitted('handle-updating')[1]).toEqual([false]); + }); + it('emits an error when triggered a second time', async () => { await selectFirstStatusOption(); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js index db9b0930c06..9ae45071f45 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js @@ -21,6 +21,7 @@ describe('Alert Details Sidebar Assignees', () => { id: 1, name: 'User 1', username: 'root', + webUrl: 'https://gitlab:3443/root', }, { avatar_url: @@ -28,6 +29,7 @@ describe('Alert Details Sidebar Assignees', () => { id: 2, name: 'User 2', username: 'not-root', + webUrl: 'https://gitlab:3443/non-root', }, ]; @@ -128,7 +130,7 @@ describe('Alert Details Sidebar Assignees', () => { variables: { iid: '1527542', assigneeUsernames: ['root'], - projectPath: 'projectPath', + fullPath: 'projectPath', }, }); }); @@ -137,7 +139,7 @@ describe('Alert Details Sidebar Assignees', () => { wrapper.setData({ isDropdownSearching: false }); const errorMutationResult = { data: { - alertSetAssignees: { + issuableSetAssignees: { errors: ['There was a problem for sure.'], alert: {}, }, diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js index d5be5b623b8..b00a20dab1a 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js @@ -1,6 +1,5 @@ -import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; +import { GlDropdown, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; import AlertSidebarStatus from '~/vue_shared/alert_details/components/sidebar/sidebar_status.vue'; import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; @@ -11,9 +10,7 @@ const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar Status', () => { let wrapper; const findStatusDropdown = () => wrapper.findComponent(GlDropdown); - const findStatusDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findStatusLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header'); const findAlertStatus = () => wrapper.findComponent(AlertStatus); const findStatus = () => wrapper.findByTestId('status'); const findSidebarIcon = () => wrapper.findByTestId('status-icon'); @@ -25,7 +22,7 @@ describe('Alert Details Sidebar Status', () => { stubs = {}, provide = {}, } = {}) { - wrapper = mountExtended(AlertSidebarStatus, { + wrapper = shallowMountExtended(AlertSidebarStatus, { propsData: { alert: { ...mockAlert }, ...data, @@ -63,11 +60,7 @@ describe('Alert Details Sidebar Status', () => { }); it('displays status dropdown', () => { - expect(findStatusDropdown().exists()).toBe(true); - }); - - it('displays the dropdown status header', () => { - expect(findStatusDropdownHeader().exists()).toBe(true); + expect(findAlertStatus().exists()).toBe(true); }); it('does not display the collapsed sidebar icon', () => { @@ -75,42 +68,24 @@ describe('Alert Details Sidebar Status', () => { }); describe('updating the alert status', () => { - const mockUpdatedMutationResult = { - data: { - updateAlertStatus: { - errors: [], - alert: { - status: 'acknowledged', - }, - }, - }, - }; - - beforeEach(() => { + it('ensures dropdown is hidden when loading', async () => { mountComponent({ data: { alert: mockAlert }, sidebarCollapsed: false, loading: false, }); - }); - - it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - findStatusDropdownItem().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateAlertStatusMutation, - variables: { - iid: '1527542', - status: 'TRIGGERED', - projectPath: 'projectPath', - }, - }); + findAlertStatus().vm.$emit('handle-updating', true); + await wrapper.vm.$nextTick(); + expect(findStatusLoadingIcon().exists()).toBe(true); }); it('stops updating when the request fails', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - findStatusDropdownItem().vm.$emit('click'); + mountComponent({ + data: { alert: mockAlert }, + sidebarCollapsed: false, + loading: false, + }); + findAlertStatus().vm.$emit('handle-updating', false); expect(findStatusLoadingIcon().exists()).toBe(false); expect(findStatus().text()).toBe('Triggered'); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 3be609f0dad..3f91591f5cd 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -5,7 +5,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` class="awards js-awards-block" >