From 8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 18 Jun 2020 11:18:50 +0000 Subject: Add latest changes from gitlab-org/gitlab@13-1-stable-ee --- spec/frontend/.eslintrc.yml | 2 +- spec/frontend/__mocks__/lodash/throttle.js | 4 + spec/frontend/__mocks__/monaco-editor/index.js | 3 + .../components/alert_management_detail_spec.js | 161 +-- .../components/alert_management_list_spec.js | 280 ++++-- .../alert_management_system_note_spec.js | 34 + .../alert_managment_sidebar_assignees_spec.js | 133 +++ .../components/alert_sidebar_spec.js | 55 + .../components/alert_sidebar_status_spec.js | 107 ++ spec/frontend/alert_management/mocks/alerts.json | 91 +- .../__snapshots__/alerts_service_form_spec.js.snap | 2 +- .../components/alerts_service_form_spec.js | 7 +- spec/frontend/api_spec.js | 56 ++ .../authentication/u2f/authenticate_spec.js | 109 ++ .../frontend/authentication/u2f/mock_u2f_device.js | 23 + spec/frontend/authentication/u2f/register_spec.js | 83 ++ spec/frontend/authentication/u2f/util_spec.js | 61 ++ spec/frontend/awards_handler_spec.js | 403 ++++++++ .../components/diff_file_drafts_spec.js | 61 ++ .../batch_comments/components/draft_note_spec.js | 125 +++ .../batch_comments/components/drafts_count_spec.js | 43 + .../batch_comments/components/preview_item_spec.js | 130 +++ .../components/publish_button_spec.js | 52 + .../components/publish_dropdown_spec.js | 96 ++ spec/frontend/batch_comments/mock_data.js | 27 + .../stores/modules/batch_comments/actions_spec.js | 403 ++++++++ .../stores/modules/batch_comments/getters_spec.js | 27 + .../modules/batch_comments/mutations_spec.js | 159 +++ spec/frontend/behaviors/autosize_spec.js | 20 + spec/frontend/behaviors/bind_in_out_spec.js | 10 +- spec/frontend/behaviors/copy_as_gfm_spec.js | 125 +++ .../behaviors/gl_emoji/unicode_support_map_spec.js | 52 + .../markdown/highlight_current_user_spec.js | 55 + spec/frontend/behaviors/requires_input_spec.js | 62 ++ .../behaviors/shortcuts/shortcuts_issuable_spec.js | 322 ++++++ .../blob_header_filepath_spec.js.snap | 2 +- .../components/blob_header_default_actions_spec.js | 8 + .../blob/components/blob_header_filepath_spec.js | 5 +- spec/frontend/blob/components/blob_header_spec.js | 11 + spec/frontend/boards/board_list_helper.js | 66 ++ spec/frontend/boards/board_list_spec.js | 2 +- .../boards/components/board_column_spec.js | 88 +- .../boards/components/board_list_header_spec.js | 166 +++ spec/frontend/boards/stores/actions_spec.js | 17 +- spec/frontend/boards/stores/mutations_spec.js | 147 +-- .../components/ci_variable_modal_spec.js | 40 + spec/frontend/clusters/clusters_bundle_spec.js | 9 +- .../__snapshots__/applications_spec.js.snap | 89 ++ .../clusters/components/application_row_spec.js | 439 ++++---- .../clusters/components/applications_spec.js | 418 ++++---- .../components/fluentd_output_settings_spec.js | 12 +- .../update_application_confirmation_modal_spec.js | 52 + .../clusters_list/components/clusters_spec.js | 158 ++- spec/frontend/clusters_list/mock_data.js | 75 +- spec/frontend/clusters_list/store/actions_spec.js | 144 ++- .../components/__snapshots__/popover_spec.js.snap | 22 +- .../code_navigation/components/popover_spec.js | 19 +- spec/frontend/collapsed_sidebar_todo_spec.js | 172 ++++ spec/frontend/comment_type_toggle_spec.js | 169 ++++ spec/frontend/confirm_modal_spec.js | 6 +- .../__snapshots__/contributors_spec.js.snap | 6 + .../eks_cluster/store/actions_spec.js | 8 +- .../__snapshots__/design_note_pin_spec.js.snap | 12 +- .../components/design_note_pin_spec.js | 4 +- .../__snapshots__/design_note_spec.js.snap | 8 +- .../design_notes/design_discussion_spec.js | 241 ++++- .../design_notes/design_reply_form_spec.js | 6 +- .../design_notes/toggle_replies_widget_spec.js | 98 ++ .../components/design_overlay_spec.js | 103 +- .../components/design_presentation_spec.js | 9 +- .../components/design_sidebar_spec.js | 236 +++++ .../upload/__snapshots__/button_spec.js.snap | 6 +- .../frontend/design_management/mock_data/design.js | 20 + spec/frontend/design_management/mock_data/notes.js | 14 + .../pages/design/__snapshots__/index_spec.js.snap | 116 ++- .../design_management/pages/design/index_spec.js | 137 +-- .../frontend/design_management/pages/index_spec.js | 34 +- spec/frontend/design_management/router_spec.js | 1 + .../utils/design_management_utils_spec.js | 8 +- spec/frontend/diffs/components/diff_file_spec.js | 2 +- .../diffs/components/diff_line_note_form_spec.js | 14 +- .../diffs/components/inline_diff_view_spec.js | 2 +- .../diffs/components/parallel_diff_view_spec.js | 2 +- spec/frontend/diffs/mock_data/diff_file.js | 1 + spec/frontend/diffs/mock_data/diff_metadata.js | 58 ++ spec/frontend/diffs/store/actions_spec.js | 228 +++-- spec/frontend/diffs/store/utils_spec.js | 243 +++-- spec/frontend/diffs/utils/uuids_spec.js | 92 ++ spec/frontend/droplab/drop_down_spec.js | 662 ++++++++++++ spec/frontend/droplab/hook_spec.js | 94 ++ spec/frontend/droplab/plugins/input_setter_spec.js | 259 +++++ spec/frontend/dropzone_input_spec.js | 97 ++ spec/frontend/environment.js | 2 +- .../frontend/environments/environments_app_spec.js | 4 +- .../components/error_details_spec.js | 72 +- .../components/error_tracking_list_spec.js | 36 + .../filtered_search_dropdown_manager_spec.js | 130 +++ .../filtered_search_visual_tokens_spec.js | 732 ++++++++++++++ spec/frontend/fixtures/abuse_reports.rb | 2 +- spec/frontend/fixtures/admin_users.rb | 2 +- spec/frontend/fixtures/application_settings.rb | 2 +- spec/frontend/fixtures/autocomplete_sources.rb | 2 +- spec/frontend/fixtures/blob.rb | 2 +- spec/frontend/fixtures/boards.rb | 2 +- spec/frontend/fixtures/branches.rb | 2 +- spec/frontend/fixtures/clusters.rb | 2 +- spec/frontend/fixtures/commit.rb | 2 +- spec/frontend/fixtures/deploy_keys.rb | 2 +- spec/frontend/fixtures/groups.rb | 2 +- spec/frontend/fixtures/issues.rb | 4 +- spec/frontend/fixtures/jobs.rb | 2 +- spec/frontend/fixtures/labels.rb | 2 +- spec/frontend/fixtures/merge_requests.rb | 2 +- spec/frontend/fixtures/merge_requests_diffs.rb | 2 +- spec/frontend/fixtures/metrics_dashboard.rb | 2 +- spec/frontend/fixtures/pipeline_schedules.rb | 2 +- spec/frontend/fixtures/pipelines.rb | 2 +- spec/frontend/fixtures/projects.rb | 2 +- spec/frontend/fixtures/prometheus_service.rb | 2 +- spec/frontend/fixtures/raw.rb | 2 +- spec/frontend/fixtures/search.rb | 2 +- spec/frontend/fixtures/services.rb | 2 +- spec/frontend/fixtures/sessions.rb | 2 +- spec/frontend/fixtures/snippet.rb | 2 +- .../fixtures/static/global_search_input.html | 15 + .../fixtures/static/oauth_remember_me.html | 22 +- .../fixtures/static/search_autocomplete.html | 15 - spec/frontend/fixtures/test_report.rb | 2 +- spec/frontend/fixtures/todos.rb | 2 +- spec/frontend/fixtures/u2f.rb | 2 +- spec/frontend/gl_dropdown_spec.js | 345 +++++++ spec/frontend/gl_form_spec.js | 115 +++ spec/frontend/global_search_input_spec.js | 215 ++++ spec/frontend/header_spec.js | 4 +- .../helpers/dom_shims/element_scroll_to.js | 6 + .../helpers/dom_shims/image_element_properties.js | 2 +- spec/frontend/helpers/dom_shims/index.js | 2 + .../helpers/dom_shims/mutation_observer.js | 7 + spec/frontend/helpers/local_storage_helper.js | 20 +- spec/frontend/helpers/local_storage_helper_spec.js | 21 + spec/frontend/helpers/mock_dom_observer.js | 94 ++ .../helpers/mock_window_location_helper.js | 43 + spec/frontend/helpers/scroll_into_view_promise.js | 28 - .../helpers/set_window_location_helper_spec.js | 2 +- spec/frontend/helpers/vue_mock_directive.js | 17 + spec/frontend/helpers/wait_for_attribute_change.js | 16 - spec/frontend/ide/commit_icon_spec.js | 45 + spec/frontend/ide/components/branches/item_spec.js | 11 +- .../ide/components/commit_sidebar/form_spec.js | 136 ++- .../components/commit_sidebar/list_item_spec.js | 13 +- .../commit_sidebar/message_field_spec.js | 170 ++++ .../ide/components/ide_sidebar_nav_spec.js | 118 +++ spec/frontend/ide/components/ide_spec.js | 9 +- .../ide/components/ide_status_list_spec.js | 16 +- .../jobs/__snapshots__/stage_spec.js.snap | 4 +- spec/frontend/ide/components/jobs/detail_spec.js | 187 ++++ .../ide/components/merge_requests/item_spec.js | 106 +- .../ide/components/new_dropdown/modal_spec.js | 40 + .../ide/components/new_dropdown/upload_spec.js | 2 - .../components/panes/collapsible_sidebar_spec.js | 113 +-- spec/frontend/ide/components/panes/right_spec.js | 57 +- .../frontend/ide/components/pipelines/list_spec.js | 2 +- .../ide/components/repo_commit_section_spec.js | 46 +- spec/frontend/ide/components/repo_editor_spec.js | 664 ++++++++++++ spec/frontend/ide/components/repo_tab_spec.js | 12 +- spec/frontend/ide/components/repo_tabs_spec.js | 2 - .../ide/components/resizable_panel_spec.js | 114 +++ .../ide/components/terminal/empty_state_spec.js | 107 ++ .../ide/components/terminal/session_spec.js | 96 ++ .../components/terminal/terminal_controls_spec.js | 65 ++ .../ide/components/terminal/terminal_spec.js | 225 +++++ spec/frontend/ide/components/terminal/view_spec.js | 91 ++ .../terminal_sync_status_safe_spec.js | 47 + .../terminal_sync/terminal_sync_status_spec.js | 99 ++ spec/frontend/ide/file_helpers.js | 35 + spec/frontend/ide/ide_router_spec.js | 37 +- spec/frontend/ide/lib/common/model_spec.js | 72 ++ spec/frontend/ide/lib/create_diff_spec.js | 182 ++++ spec/frontend/ide/lib/create_file_diff_spec.js | 163 +++ spec/frontend/ide/lib/diff/diff_spec.js | 8 + spec/frontend/ide/lib/editor_options_spec.js | 11 - spec/frontend/ide/lib/editor_spec.js | 46 +- spec/frontend/ide/lib/editorconfig/mock_data.js | 146 +++ spec/frontend/ide/lib/editorconfig/parser_spec.js | 18 + .../ide/lib/editorconfig/rules_mapper_spec.js | 43 + spec/frontend/ide/lib/files_spec.js | 4 - spec/frontend/ide/lib/mirror_spec.js | 184 ++++ spec/frontend/ide/stores/actions/file_spec.js | 40 +- .../ide/stores/actions/merge_request_spec.js | 504 ++++++++++ spec/frontend/ide/stores/actions/project_spec.js | 397 ++++++++ spec/frontend/ide/stores/actions/tree_spec.js | 218 ++++ spec/frontend/ide/stores/actions_spec.js | 1062 ++++++++++++++++++++ spec/frontend/ide/stores/extend_spec.js | 74 ++ spec/frontend/ide/stores/getters_spec.js | 65 ++ .../ide/stores/modules/commit/actions_spec.js | 598 +++++++++++ .../ide/stores/modules/pane/getters_spec.js | 32 +- .../ide/stores/modules/router/actions_spec.js | 19 + .../ide/stores/modules/router/mutations_spec.js | 23 + .../stores/modules/terminal/actions/checks_spec.js | 289 ++++++ .../terminal/actions/session_controls_spec.js | 300 ++++++ .../terminal/actions/session_status_spec.js | 169 ++++ .../stores/modules/terminal/actions/setup_spec.js | 40 + .../ide/stores/modules/terminal/getters_spec.js | 50 + .../ide/stores/modules/terminal/messages_spec.js | 38 + .../ide/stores/modules/terminal/mutations_spec.js | 142 +++ .../stores/modules/terminal_sync/actions_spec.js | 118 +++ .../stores/modules/terminal_sync/mutations_spec.js | 89 ++ spec/frontend/ide/stores/mutations/file_spec.js | 37 - spec/frontend/ide/stores/mutations_spec.js | 36 - spec/frontend/ide/stores/plugins/terminal_spec.js | 58 ++ .../ide/stores/plugins/terminal_sync_spec.js | 72 ++ spec/frontend/ide/stores/utils_spec.js | 93 +- spec/frontend/ide/sync_router_and_store_spec.js | 150 +++ spec/frontend/ide/utils_spec.js | 137 ++- .../components/bitbucket_status_table_spec.js | 59 ++ .../components/import_projects_table_spec.js | 286 +++--- .../components/provider_repo_table_row_spec.js | 11 +- .../frontend/import_projects/store/actions_spec.js | 189 +--- .../frontend/import_projects/store/getters_spec.js | 15 + spec/frontend/importer_status_spec.js | 141 +++ .../edit/components/dynamic_field_spec.js | 179 ++++ .../edit/components/integration_form_spec.js | 21 + spec/frontend/issue_show/components/app_spec.js | 335 +++--- .../issue_show/components/pinned_links_spec.js | 34 +- .../jira_import/components/jira_import_app_spec.js | 28 +- spec/frontend/jira_import/mock_data.js | 72 ++ .../jira_import/utils/cache_update_spec.js | 64 ++ .../jira_import/utils/jira_import_utils_spec.js | 91 ++ spec/frontend/jira_import/utils_spec.js | 62 -- .../jobs/components/artifacts_block_spec.js | 150 ++- spec/frontend/jobs/components/job_log_spec.js | 2 +- spec/frontend/jobs/components/log/mock_data.js | 2 +- spec/frontend/labels_issue_sidebar_spec.js | 99 ++ spec/frontend/lazy_loader_spec.js | 153 +++ spec/frontend/lib/utils/common_utils_spec.js | 81 ++ spec/frontend/lib/utils/text_markdown_spec.js | 16 +- spec/frontend/lib/utils/text_utility_spec.js | 16 + spec/frontend/lib/utils/url_utility_spec.js | 17 + spec/frontend/line_highlighter_spec.js | 268 +++++ .../logs/components/environment_logs_spec.js | 4 +- spec/frontend/logs/stores/actions_spec.js | 73 +- spec/frontend/matchers.js | 33 + spec/frontend/matchers_spec.js | 48 + spec/frontend/merge_request_spec.js | 191 ++++ spec/frontend/merge_request_tabs_spec.js | 293 ++++++ spec/frontend/mini_pipeline_graph_dropdown_spec.js | 106 ++ .../__snapshots__/alert_widget_spec.js.snap | 6 +- .../__snapshots__/dashboard_template_spec.js.snap | 10 +- .../monitoring/components/charts/anomaly_spec.js | 2 - .../monitoring/components/charts/column_spec.js | 52 +- .../monitoring/components/charts/heatmap_spec.js | 107 +- .../components/charts/stacked_column_spec.js | 193 +++- .../components/charts/time_series_spec.js | 368 ++++--- .../monitoring/components/dashboard_panel_spec.js | 160 ++- .../monitoring/components/dashboard_spec.js | 215 +++- .../components/dashboard_template_spec.js | 13 +- .../components/dashboard_url_time_spec.js | 5 +- .../components/duplicate_dashboard_form_spec.js | 2 + .../components/embeds/metric_embed_spec.js | 3 + .../monitoring/components/embeds/mock_data.js | 1 - .../monitoring/components/graph_group_spec.js | 20 + .../monitoring/components/links_section_spec.js | 64 ++ .../components/variables_section_spec.js | 17 +- spec/frontend/monitoring/mock_data.js | 137 ++- .../monitoring/pages/dashboard_page_spec.js | 36 + spec/frontend/monitoring/store/actions_spec.js | 116 +-- spec/frontend/monitoring/store/getters_spec.js | 40 +- spec/frontend/monitoring/store/index_spec.js | 23 + spec/frontend/monitoring/store/mutations_spec.js | 26 +- spec/frontend/monitoring/store/utils_spec.js | 188 ++++ .../monitoring/store/variable_mapping_spec.js | 27 +- spec/frontend/monitoring/store_utils.js | 23 +- .../frontend/namespace_storage_limit_alert_spec.js | 36 + .../notes/components/diff_with_note_spec.js | 9 +- .../discussion_reply_placeholder_spec.js | 2 +- .../components/multiline_comment_utils_spec.js | 49 + .../frontend/notes/components/note_actions_spec.js | 60 +- spec/frontend/notes/components/note_form_spec.js | 54 +- .../notes/components/noteable_note_spec.js | 53 +- .../notes/mixins/discussion_navigation_spec.js | 12 +- spec/frontend/notes/mock_data.js | 13 + spec/frontend/notes/stores/actions_spec.js | 214 +++- spec/frontend/notes/stores/mutation_spec.js | 117 +++ spec/frontend/oauth_remember_me_spec.js | 26 +- spec/frontend/onboarding_issues/index_spec.js | 137 +++ .../components/external_dashboard_spec.js | 168 ---- .../components/metrics_settings_spec.js | 214 ++++ .../operation_settings/store/mutations_spec.js | 12 +- spec/frontend/pager_spec.js | 167 +++ .../pages/dashboard/todos/index/todos_spec.js | 111 ++ .../bitbucket_server_status_table_spec.js | 47 + .../__snapshots__/code_coverage_spec.js.snap | 88 ++ .../pages/projects/graphs/code_coverage_spec.js | 164 +++ spec/frontend/pages/projects/graphs/mock_data.js | 60 ++ .../sessions/new/preserve_url_fragment_spec.js | 25 +- .../sessions/new/signin_tabs_memoizer_spec.js | 218 ++++ spec/frontend/pdf/index_spec.js | 62 ++ spec/frontend/pdf/page_spec.js | 39 + .../components/detailed_metric_spec.js | 98 +- spec/frontend/performance_bar/index_spec.js | 85 ++ spec/frontend/persistent_user_callout_spec.js | 158 +++ .../dag/__snapshots__/dag_graph_spec.js.snap | 230 +++++ .../pipelines/components/dag/dag_graph_spec.js | 218 ++++ spec/frontend/pipelines/components/dag/dag_spec.js | 137 +++ .../pipelines/components/dag/drawing_utils_spec.js | 57 ++ .../frontend/pipelines/components/dag/mock_data.js | 390 +++++++ .../pipelines/components/dag/parsing_utils_spec.js | 133 +++ .../components/pipelines_filtered_search_spec.js | 123 ++- .../pipelines/graph/graph_component_spec.js | 5 + spec/frontend/pipelines/mock_data.js | 98 ++ spec/frontend/pipelines/pipelines_spec.js | 9 +- .../tokens/pipeline_branch_name_token_spec.js | 17 +- .../pipelines/tokens/pipeline_status_token_spec.js | 62 ++ .../tokens/pipeline_tag_name_token_spec.js | 98 ++ .../tokens/pipeline_trigger_author_token_spec.js | 10 + .../components/app_spec.js | 70 ++ .../components/legacy_container_spec.js | 63 ++ .../components/welcome_spec.js | 31 + .../pipelines_area_chart_spec.js.snap | 3 + spec/frontend/read_more_spec.js | 23 + .../__snapshots__/group_empty_state_spec.js.snap | 21 - .../__snapshots__/project_empty_state_spec.js.snap | 119 --- .../__snapshots__/tags_loader_spec.js.snap | 63 ++ .../components/details_page/delete_alert_spec.js | 116 +++ .../components/details_page/delete_modal_spec.js | 79 ++ .../components/details_page/details_header_spec.js | 32 + .../components/details_page/empty_tags_state.js | 43 + .../components/details_page/tags_loader_spec.js | 49 + .../components/details_page/tags_table_spec.js | 286 ++++++ .../explorer/components/group_empty_state_spec.js | 40 - .../explorer/components/image_list_spec.js | 74 -- .../__snapshots__/group_empty_state_spec.js.snap | 21 + .../__snapshots__/project_empty_state_spec.js.snap | 119 +++ .../components/list_page/cli_commands_spec.js | 120 +++ .../components/list_page/group_empty_state_spec.js | 40 + .../components/list_page/image_list_row_spec.js | 140 +++ .../components/list_page/image_list_spec.js | 62 ++ .../list_page/project_empty_state_spec.js | 46 + .../components/list_page/registry_header_spec.js | 221 ++++ .../components/project_empty_state_spec.js | 46 - .../components/project_policy_alert_spec.js | 132 --- .../components/quickstart_dropdown_spec.js | 120 --- spec/frontend/registry/explorer/mock_data.js | 4 +- .../registry/explorer/pages/details_spec.js | 456 ++------- .../frontend/registry/explorer/pages/index_spec.js | 4 +- spec/frontend/registry/explorer/pages/list_spec.js | 57 +- .../registry/explorer/stores/getters_spec.js | 29 - .../registry/explorer/stores/mutations_spec.js | 9 +- spec/frontend/registry/explorer/stubs.js | 21 + .../frontend/releases/components/app_index_spec.js | 150 +++ .../releases/components/asset_links_form_spec.js | 34 +- .../components/release_block_assets_spec.js | 137 +++ spec/frontend/releases/mock_data.js | 91 ++ .../releases/stores/modules/detail/actions_spec.js | 49 + .../stores/modules/detail/mutations_spec.js | 41 +- .../releases/stores/modules/list/actions_spec.js | 131 +++ .../releases/stores/modules/list/helpers.js | 6 + .../releases/stores/modules/list/mutations_spec.js | 55 + .../components/grouped_test_reports_app_spec.js | 320 +++--- .../reports/mock_data/new_errors_report.json | 20 +- spec/frontend/right_sidebar_spec.js | 87 ++ spec/frontend/shortcuts_spec.js | 46 + .../sidebar/confidential_issue_sidebar_spec.js | 7 +- .../__snapshots__/snippet_blob_edit_spec.js.snap | 2 +- .../snippet_description_edit_spec.js.snap | 82 +- .../snippet_description_view_spec.js.snap | 2 +- spec/frontend/snippets/components/edit_spec.js | 136 ++- .../snippets/components/snippet_blob_view_spec.js | 20 +- .../components/snippet_description_edit_spec.js | 4 + .../components/edit_area_spec.js | 36 +- .../unsaved_changes_confirm_dialog_spec.js | 44 + spec/frontend/static_site_editor/mock_data.js | 11 +- .../frontend/static_site_editor/pages/home_spec.js | 16 + .../services/parse_source_file_spec.js | 64 ++ .../services/submit_content_changes_spec.js | 30 +- spec/frontend/test_setup.js | 15 - spec/frontend/toggle_buttons_spec.js | 115 +++ spec/frontend/tracking_spec.js | 25 +- spec/frontend/u2f/authenticate_spec.js | 109 -- spec/frontend/u2f/mock_u2f_device.js | 23 - spec/frontend/u2f/register_spec.js | 83 -- spec/frontend/u2f/util_spec.js | 61 -- spec/frontend/user_popovers_spec.js | 99 ++ .../components/mr_widget_alert_message_spec.js | 76 ++ .../components/mr_widget_author_spec.js | 39 + .../components/mr_widget_author_time_spec.js | 44 + .../components/mr_widget_header_spec.js | 313 ++++++ .../components/mr_widget_memory_usage_spec.js | 239 +++++ .../components/mr_widget_merge_help_spec.js | 70 ++ .../components/mr_widget_pipeline_spec.js | 326 ++++++ .../components/mr_widget_rebase_spec.js | 139 +++ .../components/mr_widget_related_links_spec.js | 85 ++ .../components/mr_widget_status_icon_spec.js | 48 + .../components/mr_widget_terraform_plan_spec.js | 10 +- .../components/review_app_link_spec.js | 52 + .../components/states/mr_widget_archived_spec.js | 31 + .../states/mr_widget_auto_merge_enabled_spec.js | 230 +++++ .../components/states/mr_widget_checking_spec.js | 31 + .../components/states/mr_widget_closed_spec.js | 69 ++ .../components/states/mr_widget_conflicts_spec.js | 226 +++++ .../states/mr_widget_failed_to_merge_spec.js | 156 +++ .../components/states/mr_widget_merged_spec.js | 219 ++++ .../components/states/mr_widget_merging_spec.js | 43 + .../states/mr_widget_missing_branch_spec.js | 40 + .../states/mr_widget_not_allowed_spec.js | 26 + .../states/mr_widget_nothing_to_merge_spec.js | 34 + .../states/mr_widget_pipeline_blocked_spec.js | 26 + .../states/mr_widget_pipeline_failed_spec.js | 19 + .../states/mr_widget_ready_to_merge_spec.js | 981 ++++++++++++++++++ .../states/mr_widget_sha_mismatch_spec.js | 25 + .../states/mr_widget_squash_before_merge_spec.js | 99 ++ .../mr_widget_unresolved_discussions_spec.js | 46 + .../components/states/mr_widget_wip_spec.js | 104 ++ .../date_time_picker/date_time_picker_lib_spec.js | 178 ++-- .../date_time_picker/date_time_picker_spec.js | 274 +++-- .../components/deprecated_modal_2_spec.js | 258 +++++ .../vue_shared/components/deprecated_modal_spec.js | 73 ++ .../components/diff_viewer/diff_viewer_spec.js | 30 +- .../components/diff_viewer/viewers/renamed_spec.js | 283 ++++++ .../components/file_finder/index_spec.js | 368 +++++++ .../filtered_search_bar_root_spec.js | 259 +++++ .../components/filtered_search_bar/mock_data.js | 64 ++ .../tokens/author_token_spec.js | 150 +++ spec/frontend/vue_shared/components/icon_spec.js | 78 ++ .../components/issue/related_issuable_item_spec.js | 14 +- .../__snapshots__/suggestion_diff_spec.js.snap | 3 + .../vue_shared/components/markdown/field_spec.js | 4 +- .../markdown/suggestion_diff_header_spec.js | 163 ++- .../components/markdown/suggestion_diff_spec.js | 34 +- .../vue_shared/components/panel_resizer_spec.js | 85 ++ .../frontend/vue_shared/components/pikaday_spec.js | 38 +- .../project_selector/project_selector_spec.js | 23 + .../rich_content_editor/editor_service_spec.js | 77 ++ .../modals/add_image_modal_spec.js | 41 + .../rich_content_editor_spec.js | 59 ++ .../rich_content_editor/toolbar_item_spec.js | 51 +- .../rich_content_editor/toolbar_service_spec.js | 29 - .../components/sidebar/date_picker_spec.js | 162 +-- .../dropdown_contents_labels_view_spec.js | 5 + .../sidebar/labels_select_vue/label_item_spec.js | 27 +- .../components/smart_virtual_list_spec.js | 83 ++ .../vue_shared/directives/autofocusonshow_spec.js | 46 + .../frontend/vue_shared/directives/tooltip_spec.js | 98 ++ spec/frontend/vue_shared/translate_spec.js | 214 ++++ .../vuex_shared/modules/modal/actions_spec.js | 31 + spec/frontend/wikis_spec.js | 2 +- spec/frontend/zen_mode_spec.js | 112 +++ 447 files changed, 35206 insertions(+), 5005 deletions(-) create mode 100644 spec/frontend/__mocks__/lodash/throttle.js create mode 100644 spec/frontend/alert_management/components/alert_management_system_note_spec.js create mode 100644 spec/frontend/alert_management/components/alert_managment_sidebar_assignees_spec.js create mode 100644 spec/frontend/alert_management/components/alert_sidebar_spec.js create mode 100644 spec/frontend/alert_management/components/alert_sidebar_status_spec.js create mode 100644 spec/frontend/authentication/u2f/authenticate_spec.js create mode 100644 spec/frontend/authentication/u2f/mock_u2f_device.js create mode 100644 spec/frontend/authentication/u2f/register_spec.js create mode 100644 spec/frontend/authentication/u2f/util_spec.js create mode 100644 spec/frontend/awards_handler_spec.js create mode 100644 spec/frontend/batch_comments/components/diff_file_drafts_spec.js create mode 100644 spec/frontend/batch_comments/components/draft_note_spec.js create mode 100644 spec/frontend/batch_comments/components/drafts_count_spec.js create mode 100644 spec/frontend/batch_comments/components/preview_item_spec.js create mode 100644 spec/frontend/batch_comments/components/publish_button_spec.js create mode 100644 spec/frontend/batch_comments/components/publish_dropdown_spec.js create mode 100644 spec/frontend/batch_comments/mock_data.js create mode 100644 spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js create mode 100644 spec/frontend/batch_comments/stores/modules/batch_comments/getters_spec.js create mode 100644 spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js create mode 100644 spec/frontend/behaviors/autosize_spec.js create mode 100644 spec/frontend/behaviors/copy_as_gfm_spec.js create mode 100644 spec/frontend/behaviors/gl_emoji/unicode_support_map_spec.js create mode 100644 spec/frontend/behaviors/markdown/highlight_current_user_spec.js create mode 100644 spec/frontend/behaviors/requires_input_spec.js create mode 100644 spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js create mode 100644 spec/frontend/boards/board_list_helper.js create mode 100644 spec/frontend/boards/components/board_list_header_spec.js create mode 100644 spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap create mode 100644 spec/frontend/clusters/components/update_application_confirmation_modal_spec.js create mode 100644 spec/frontend/collapsed_sidebar_todo_spec.js create mode 100644 spec/frontend/comment_type_toggle_spec.js create mode 100644 spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js create mode 100644 spec/frontend/design_management/components/design_sidebar_spec.js create mode 100644 spec/frontend/diffs/mock_data/diff_metadata.js create mode 100644 spec/frontend/diffs/utils/uuids_spec.js create mode 100644 spec/frontend/droplab/drop_down_spec.js create mode 100644 spec/frontend/droplab/hook_spec.js create mode 100644 spec/frontend/droplab/plugins/input_setter_spec.js create mode 100644 spec/frontend/dropzone_input_spec.js create mode 100644 spec/frontend/filtered_search/filtered_search_dropdown_manager_spec.js create mode 100644 spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js create mode 100644 spec/frontend/fixtures/static/global_search_input.html delete mode 100644 spec/frontend/fixtures/static/search_autocomplete.html create mode 100644 spec/frontend/gl_dropdown_spec.js create mode 100644 spec/frontend/gl_form_spec.js create mode 100644 spec/frontend/global_search_input_spec.js create mode 100644 spec/frontend/helpers/dom_shims/element_scroll_to.js create mode 100644 spec/frontend/helpers/dom_shims/mutation_observer.js create mode 100644 spec/frontend/helpers/local_storage_helper_spec.js create mode 100644 spec/frontend/helpers/mock_dom_observer.js create mode 100644 spec/frontend/helpers/mock_window_location_helper.js delete mode 100644 spec/frontend/helpers/scroll_into_view_promise.js create mode 100644 spec/frontend/helpers/vue_mock_directive.js delete mode 100644 spec/frontend/helpers/wait_for_attribute_change.js create mode 100644 spec/frontend/ide/commit_icon_spec.js create mode 100644 spec/frontend/ide/components/commit_sidebar/message_field_spec.js create mode 100644 spec/frontend/ide/components/ide_sidebar_nav_spec.js create mode 100644 spec/frontend/ide/components/jobs/detail_spec.js create mode 100644 spec/frontend/ide/components/repo_editor_spec.js create mode 100644 spec/frontend/ide/components/resizable_panel_spec.js create mode 100644 spec/frontend/ide/components/terminal/empty_state_spec.js create mode 100644 spec/frontend/ide/components/terminal/session_spec.js create mode 100644 spec/frontend/ide/components/terminal/terminal_controls_spec.js create mode 100644 spec/frontend/ide/components/terminal/terminal_spec.js create mode 100644 spec/frontend/ide/components/terminal/view_spec.js create mode 100644 spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js create mode 100644 spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js create mode 100644 spec/frontend/ide/file_helpers.js create mode 100644 spec/frontend/ide/lib/create_diff_spec.js create mode 100644 spec/frontend/ide/lib/create_file_diff_spec.js delete mode 100644 spec/frontend/ide/lib/editor_options_spec.js create mode 100644 spec/frontend/ide/lib/editorconfig/mock_data.js create mode 100644 spec/frontend/ide/lib/editorconfig/parser_spec.js create mode 100644 spec/frontend/ide/lib/editorconfig/rules_mapper_spec.js create mode 100644 spec/frontend/ide/lib/mirror_spec.js create mode 100644 spec/frontend/ide/stores/actions/merge_request_spec.js create mode 100644 spec/frontend/ide/stores/actions/project_spec.js create mode 100644 spec/frontend/ide/stores/actions/tree_spec.js create mode 100644 spec/frontend/ide/stores/actions_spec.js create mode 100644 spec/frontend/ide/stores/extend_spec.js create mode 100644 spec/frontend/ide/stores/modules/commit/actions_spec.js create mode 100644 spec/frontend/ide/stores/modules/router/actions_spec.js create mode 100644 spec/frontend/ide/stores/modules/router/mutations_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal/actions/setup_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal/getters_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal/messages_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal/mutations_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js create mode 100644 spec/frontend/ide/stores/modules/terminal_sync/mutations_spec.js create mode 100644 spec/frontend/ide/stores/plugins/terminal_spec.js create mode 100644 spec/frontend/ide/stores/plugins/terminal_sync_spec.js create mode 100644 spec/frontend/ide/sync_router_and_store_spec.js create mode 100644 spec/frontend/import_projects/components/bitbucket_status_table_spec.js create mode 100644 spec/frontend/importer_status_spec.js create mode 100644 spec/frontend/integrations/edit/components/dynamic_field_spec.js create mode 100644 spec/frontend/jira_import/mock_data.js create mode 100644 spec/frontend/jira_import/utils/cache_update_spec.js create mode 100644 spec/frontend/jira_import/utils/jira_import_utils_spec.js delete mode 100644 spec/frontend/jira_import/utils_spec.js create mode 100644 spec/frontend/labels_issue_sidebar_spec.js create mode 100644 spec/frontend/lazy_loader_spec.js create mode 100644 spec/frontend/line_highlighter_spec.js create mode 100644 spec/frontend/matchers_spec.js create mode 100644 spec/frontend/merge_request_spec.js create mode 100644 spec/frontend/merge_request_tabs_spec.js create mode 100644 spec/frontend/mini_pipeline_graph_dropdown_spec.js create mode 100644 spec/frontend/monitoring/components/links_section_spec.js create mode 100644 spec/frontend/monitoring/pages/dashboard_page_spec.js create mode 100644 spec/frontend/monitoring/store/index_spec.js create mode 100644 spec/frontend/namespace_storage_limit_alert_spec.js create mode 100644 spec/frontend/notes/components/multiline_comment_utils_spec.js create mode 100644 spec/frontend/onboarding_issues/index_spec.js delete mode 100644 spec/frontend/operation_settings/components/external_dashboard_spec.js create mode 100644 spec/frontend/operation_settings/components/metrics_settings_spec.js create mode 100644 spec/frontend/pager_spec.js create mode 100644 spec/frontend/pages/dashboard/todos/index/todos_spec.js create mode 100644 spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js create mode 100644 spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap create mode 100644 spec/frontend/pages/projects/graphs/code_coverage_spec.js create mode 100644 spec/frontend/pages/projects/graphs/mock_data.js create mode 100644 spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js create mode 100644 spec/frontend/pdf/index_spec.js create mode 100644 spec/frontend/pdf/page_spec.js create mode 100644 spec/frontend/performance_bar/index_spec.js create mode 100644 spec/frontend/persistent_user_callout_spec.js create mode 100644 spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap create mode 100644 spec/frontend/pipelines/components/dag/dag_graph_spec.js create mode 100644 spec/frontend/pipelines/components/dag/dag_spec.js create mode 100644 spec/frontend/pipelines/components/dag/drawing_utils_spec.js create mode 100644 spec/frontend/pipelines/components/dag/mock_data.js create mode 100644 spec/frontend/pipelines/components/dag/parsing_utils_spec.js create mode 100644 spec/frontend/pipelines/tokens/pipeline_status_token_spec.js create mode 100644 spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js create mode 100644 spec/frontend/projects/experiment_new_project_creation/components/app_spec.js create mode 100644 spec/frontend/projects/experiment_new_project_creation/components/legacy_container_spec.js create mode 100644 spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js create mode 100644 spec/frontend/read_more_spec.js delete mode 100644 spec/frontend/registry/explorer/components/__snapshots__/group_empty_state_spec.js.snap delete mode 100644 spec/frontend/registry/explorer/components/__snapshots__/project_empty_state_spec.js.snap create mode 100644 spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap create mode 100644 spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js create mode 100644 spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js create mode 100644 spec/frontend/registry/explorer/components/details_page/details_header_spec.js create mode 100644 spec/frontend/registry/explorer/components/details_page/empty_tags_state.js create mode 100644 spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js create mode 100644 spec/frontend/registry/explorer/components/details_page/tags_table_spec.js delete mode 100644 spec/frontend/registry/explorer/components/group_empty_state_spec.js delete mode 100644 spec/frontend/registry/explorer/components/image_list_spec.js create mode 100644 spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap create mode 100644 spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap create mode 100644 spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js create mode 100644 spec/frontend/registry/explorer/components/list_page/group_empty_state_spec.js create mode 100644 spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js create mode 100644 spec/frontend/registry/explorer/components/list_page/image_list_spec.js create mode 100644 spec/frontend/registry/explorer/components/list_page/project_empty_state_spec.js create mode 100644 spec/frontend/registry/explorer/components/list_page/registry_header_spec.js delete mode 100644 spec/frontend/registry/explorer/components/project_empty_state_spec.js delete mode 100644 spec/frontend/registry/explorer/components/project_policy_alert_spec.js delete mode 100644 spec/frontend/registry/explorer/components/quickstart_dropdown_spec.js create mode 100644 spec/frontend/releases/components/app_index_spec.js create mode 100644 spec/frontend/releases/components/release_block_assets_spec.js create mode 100644 spec/frontend/releases/stores/modules/list/actions_spec.js create mode 100644 spec/frontend/releases/stores/modules/list/helpers.js create mode 100644 spec/frontend/releases/stores/modules/list/mutations_spec.js create mode 100644 spec/frontend/right_sidebar_spec.js create mode 100644 spec/frontend/shortcuts_spec.js create mode 100644 spec/frontend/static_site_editor/components/unsaved_changes_confirm_dialog_spec.js create mode 100644 spec/frontend/static_site_editor/services/parse_source_file_spec.js create mode 100644 spec/frontend/toggle_buttons_spec.js delete mode 100644 spec/frontend/u2f/authenticate_spec.js delete mode 100644 spec/frontend/u2f/mock_u2f_device.js delete mode 100644 spec/frontend/u2f/register_spec.js delete mode 100644 spec/frontend/u2f/util_spec.js create mode 100644 spec/frontend/user_popovers_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_author_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_author_time_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_status_icon_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/review_app_link_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_not_allowed_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_failed_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js create mode 100644 spec/frontend/vue_shared/components/deprecated_modal_2_spec.js create mode 100644 spec/frontend/vue_shared/components/deprecated_modal_spec.js create mode 100644 spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js create mode 100644 spec/frontend/vue_shared/components/file_finder/index_spec.js create mode 100644 spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js create mode 100644 spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js create mode 100644 spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js create mode 100644 spec/frontend/vue_shared/components/icon_spec.js create mode 100644 spec/frontend/vue_shared/components/panel_resizer_spec.js create mode 100644 spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js create mode 100644 spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js delete mode 100644 spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js create mode 100644 spec/frontend/vue_shared/components/smart_virtual_list_spec.js create mode 100644 spec/frontend/vue_shared/directives/autofocusonshow_spec.js create mode 100644 spec/frontend/vue_shared/directives/tooltip_spec.js create mode 100644 spec/frontend/vue_shared/translate_spec.js create mode 100644 spec/frontend/vuex_shared/modules/modal/actions_spec.js create mode 100644 spec/frontend/zen_mode_spec.js (limited to 'spec/frontend') diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml index b9159191114..8e6faa90c58 100644 --- a/spec/frontend/.eslintrc.yml +++ b/spec/frontend/.eslintrc.yml @@ -10,7 +10,7 @@ settings: - path import/resolver: jest: - jestConfigFile: 'jest.config.unit.js' + jestConfigFile: 'jest.config.js' globals: getJSONFixture: false loadFixtures: false diff --git a/spec/frontend/__mocks__/lodash/throttle.js b/spec/frontend/__mocks__/lodash/throttle.js new file mode 100644 index 00000000000..aef391afd0c --- /dev/null +++ b/spec/frontend/__mocks__/lodash/throttle.js @@ -0,0 +1,4 @@ +// Similar to `lodash/debounce`, `lodash/throttle` also causes flaky specs. +// See `./debounce.js` for more details. + +export default fn => fn; diff --git a/spec/frontend/__mocks__/monaco-editor/index.js b/spec/frontend/__mocks__/monaco-editor/index.js index 18cc3a7c377..7c53cfb5174 100644 --- a/spec/frontend/__mocks__/monaco-editor/index.js +++ b/spec/frontend/__mocks__/monaco-editor/index.js @@ -9,5 +9,8 @@ import 'monaco-editor/esm/vs/language/json/monaco.contribution'; import 'monaco-editor/esm/vs/language/html/monaco.contribution'; import 'monaco-editor/esm/vs/basic-languages/monaco.contribution'; +// This language starts trying to spin up web workers which obviously breaks in Jest environment +jest.mock('monaco-editor/esm/vs/language/typescript/tsMode'); + export * from 'monaco-editor/esm/vs/editor/editor.api'; export default global.monaco; diff --git a/spec/frontend/alert_management/components/alert_management_detail_spec.js b/spec/frontend/alert_management/components/alert_management_detail_spec.js index 1e4c2e24ccb..14e45a4f563 100644 --- a/spec/frontend/alert_management/components/alert_management_detail_spec.js +++ b/spec/frontend/alert_management/components/alert_management_detail_spec.js @@ -1,39 +1,37 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GlAlert, GlLoadingIcon, GlDropdownItem, GlTable } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import AlertDetails from '~/alert_management/components/alert_details.vue'; -import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; -import createFlash from '~/flash'; - +import createIssueQuery from '~/alert_management/graphql/mutations/create_issue_from_alert.graphql'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { + trackAlertsDetailsViewsOptions, + ALERTS_SEVERITY_LABELS, +} from '~/alert_management/constants'; +import Tracking from '~/tracking'; import mockAlerts from '../mocks/alerts.json'; const mockAlert = mockAlerts[0]; -jest.mock('~/flash'); describe('AlertDetails', () => { let wrapper; - const newIssuePath = 'root/alerts/-/issues/new'; - const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); + let mock; + const projectPath = 'root/alerts'; + const projectIssuesPath = 'root/alerts/-/issues'; + const findDetailsTable = () => wrapper.find(GlTable); - function mountComponent({ - data, - createIssueFromAlertEnabled = false, - loading = false, - mountMethod = shallowMount, - stubs = {}, - } = {}) { + function mountComponent({ data, loading = false, mountMethod = shallowMount, stubs = {} } = {}) { wrapper = mountMethod(AlertDetails, { propsData: { alertId: 'alertId', - projectPath: 'projectPath', - newIssuePath, + projectPath, + projectIssuesPath, }, data() { return { alert: { ...mockAlert }, ...data }; }, - provide: { - glFeatures: { createIssueFromAlertEnabled }, - }, mocks: { $apollo: { mutate: jest.fn(), @@ -48,13 +46,22 @@ describe('AlertDetails', () => { }); } + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { if (wrapper) { - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + } } + mock.restore(); }); - const findCreatedIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]'); + const findCreateIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]'); + const findViewIssueBtn = () => wrapper.find('[data-testid="viewIssueBtn"]'); + const findIssueCreationAlert = () => wrapper.find('[data-testid="issueCreationError"]'); describe('Alert details', () => { describe('when alert is null', () => { @@ -80,6 +87,12 @@ describe('AlertDetails', () => { expect(wrapper.find('[data-testid="fullDetailsTab"]').exists()).toBe(true); }); + it('renders severity', () => { + expect(wrapper.find('[data-testid="severity"]').text()).toBe( + ALERTS_SEVERITY_LABELS[mockAlert.severity], + ); + }); + it('renders a title', () => { expect(wrapper.find('[data-testid="title"]').text()).toBe(mockAlert.title); }); @@ -117,18 +130,54 @@ describe('AlertDetails', () => { }); describe('Create issue from alert', () => { - describe('createIssueFromAlertEnabled feature flag enabled', () => { - it('should display a button that links to new issue page', () => { - mountComponent({ createIssueFromAlertEnabled: true }); - expect(findCreatedIssueBtn().exists()).toBe(true); - expect(findCreatedIssueBtn().attributes('href')).toBe(newIssuePath); + it('should display "View issue" button that links the issue page when issue exists', () => { + const issueIid = '3'; + mountComponent({ + data: { alert: { ...mockAlert, issueIid } }, }); + expect(findViewIssueBtn().exists()).toBe(true); + expect(findViewIssueBtn().attributes('href')).toBe(joinPaths(projectIssuesPath, issueIid)); + expect(findCreateIssueBtn().exists()).toBe(false); }); - describe('createIssueFromAlertEnabled feature flag disabled', () => { - it('should display a button that links to a new issue page', () => { - mountComponent({ createIssueFromAlertEnabled: false }); - expect(findCreatedIssueBtn().exists()).toBe(false); + it('should display "Create issue" button when issue doesn\'t exist yet', () => { + const issueIid = null; + mountComponent({ + mountMethod: mount, + data: { alert: { ...mockAlert, issueIid } }, + }); + expect(findViewIssueBtn().exists()).toBe(false); + expect(findCreateIssueBtn().exists()).toBe(true); + }); + + it('calls `$apollo.mutate` with `createIssueQuery`', () => { + const issueIid = '10'; + jest + .spyOn(wrapper.vm.$apollo, 'mutate') + .mockResolvedValue({ data: { createAlertIssue: { issue: { iid: issueIid } } } }); + + findCreateIssueBtn().trigger('click'); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: createIssueQuery, + variables: { + iid: mockAlert.iid, + projectPath, + }, + }); + }); + + it('shows error alert when issue creation fails ', () => { + const errorMsg = 'Something went wrong'; + mountComponent({ + mountMethod: mount, + data: { alert: { ...mockAlert, alertIid: 1 } }, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); + findCreateIssueBtn().trigger('click'); + + setImmediate(() => { + expect(findIssueCreationAlert().text()).toBe(errorMsg); }); }); }); @@ -171,15 +220,15 @@ describe('AlertDetails', () => { describe('individual header fields', () => { describe.each` - severity | createdAt | monitoringTool | result - ${'MEDIUM'} | ${'2020-04-17T23:18:14.996Z'} | ${null} | ${'Medium • Reported now'} - ${'INFO'} | ${'2020-04-17T23:18:14.996Z'} | ${'Datadog'} | ${'Info • Reported now by Datadog'} + createdAt | monitoringTool | result + ${'2020-04-17T23:18:14.996Z'} | ${null} | ${'Alert Reported now'} + ${'2020-04-17T23:18:14.996Z'} | ${'Datadog'} | ${'Alert Reported now by Datadog'} `( - `When severity=$severity, createdAt=$createdAt, monitoringTool=$monitoringTool`, - ({ severity, createdAt, monitoringTool, result }) => { + `When createdAt=$createdAt, monitoringTool=$monitoringTool`, + ({ createdAt, monitoringTool, result }) => { beforeEach(() => { mountComponent({ - data: { alert: { ...mockAlert, severity, createdAt, monitoringTool } }, + data: { alert: { ...mockAlert, createdAt, monitoringTool } }, mountMethod: mount, stubs, }); @@ -194,19 +243,9 @@ describe('AlertDetails', () => { }); }); - describe('updating the alert status', () => { - const mockUpdatedMutationResult = { - data: { - updateAlertStatus: { - errors: [], - alert: { - status: 'acknowledged', - }, - }, - }, - }; - + describe('Snowplow tracking', () => { beforeEach(() => { + jest.spyOn(Tracking, 'event'); mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alert: mockAlert }, @@ -214,29 +253,9 @@ describe('AlertDetails', () => { }); }); - 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: updateAlertStatus, - variables: { - iid: 'alertId', - status: 'TRIGGERED', - projectPath: 'projectPath', - }, - }); - }); - - it('calls `createFlash` when request fails', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - findStatusDropdownItem().vm.$emit('click'); - - setImmediate(() => { - expect(createFlash).toHaveBeenCalledWith( - 'There was an error while updating the status of the alert. Please try again.', - ); - }); + it('should track alert details page views', () => { + const { category, action } = trackAlertsDetailsViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); }); }); }); diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js index c4630ac57fe..0154e5fa112 100644 --- a/spec/frontend/alert_management/components/alert_management_list_spec.js +++ b/spec/frontend/alert_management/components/alert_management_list_spec.js @@ -7,15 +7,23 @@ import { GlDropdown, GlDropdownItem, GlIcon, + GlTabs, GlTab, + GlBadge, + GlPagination, } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import createFlash from '~/flash'; import AlertManagementList from '~/alert_management/components/alert_management_list.vue'; -import { ALERTS_STATUS_TABS } from '../../../../app/assets/javascripts/alert_management/constants'; +import { + ALERTS_STATUS_TABS, + trackAlertListViewsOptions, + trackAlertStatusUpdateOptions, +} from '~/alert_management/constants'; import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; import mockAlerts from '../mocks/alerts.json'; +import Tracking from '~/tracking'; jest.mock('~/flash'); @@ -33,9 +41,21 @@ describe('AlertManagementList', () => { const findLoader = () => wrapper.find(GlLoadingIcon); const findStatusDropdown = () => wrapper.find(GlDropdown); const findStatusFilterTabs = () => wrapper.findAll(GlTab); + const findStatusTabs = () => wrapper.find(GlTabs); + const findStatusFilterBadge = () => wrapper.findAll(GlBadge); const findDateFields = () => wrapper.findAll(TimeAgo); const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]'); const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); + const findSeverityColumnHeader = () => wrapper.findAll('th').at(0); + const findPagination = () => wrapper.find(GlPagination); + const alertsCount = { + open: 14, + triggered: 10, + acknowledged: 6, + resolved: 1, + all: 16, + }; function mountComponent({ props = { @@ -44,7 +64,6 @@ describe('AlertManagementList', () => { }, data = {}, loading = false, - alertListStatusFilteringEnabled = false, stubs = {}, } = {}) { wrapper = mount(AlertManagementList, { @@ -54,17 +73,13 @@ describe('AlertManagementList', () => { emptyAlertSvgPath: 'illustration/path', ...props, }, - provide: { - glFeatures: { - alertListStatusFilteringEnabled, - }, - }, data() { return data; }, mocks: { $apollo: { mutate: jest.fn(), + query: jest.fn(), queries: { alerts: { loading, @@ -86,49 +101,32 @@ describe('AlertManagementList', () => { } }); - describe('alert management feature renders empty state', () => { + describe('Empty state', () => { it('shows empty state', () => { expect(wrapper.find(GlEmptyState).exists()).toBe(true); }); }); describe('Status Filter Tabs', () => { - describe('alertListStatusFilteringEnabled feature flag enabled', () => { - beforeEach(() => { - mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts }, - loading: false, - alertListStatusFilteringEnabled: true, - stubs: { - GlTab: true, - }, - }); - }); - - it('should display filter tabs for all statuses', () => { - const tabs = findStatusFilterTabs().wrappers; - tabs.forEach((tab, i) => { - expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title); - }); + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: mockAlerts, alertsCount }, + loading: false, + stubs: { + GlTab: true, + }, }); }); - describe('alertListStatusFilteringEnabled feature flag disabled', () => { - beforeEach(() => { - mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts }, - loading: false, - alertListStatusFilteringEnabled: false, - stubs: { - GlTab: true, - }, - }); - }); + it('should display filter tabs with alerts count badge for each status', () => { + const tabs = findStatusFilterTabs().wrappers; + const badges = findStatusFilterBadge(); - it('should NOT display tabs', () => { - expect(findStatusFilterTabs()).not.toExist(); + tabs.forEach((tab, i) => { + const status = ALERTS_STATUS_TABS[i].status.toLowerCase(); + expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title); + expect(badges.at(i).text()).toContain(alertsCount[status]); }); }); }); @@ -137,52 +135,72 @@ describe('AlertManagementList', () => { it('loading state', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: null }, + data: { alerts: {}, alertsCount: null }, loading: true, }); expect(findAlertsTable().exists()).toBe(true); expect(findLoader().exists()).toBe(true); + expect( + findAlerts() + .at(0) + .classes(), + ).not.toContain('gl-hover-bg-blue-50'); }); it('error state', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: null, errored: true }, + data: { alerts: { errors: ['error'] }, alertsCount: null, errored: true }, loading: false, }); expect(findAlertsTable().exists()).toBe(true); expect(findAlertsTable().text()).toContain('No alerts to display'); expect(findLoader().exists()).toBe(false); expect(findAlert().props().variant).toBe('danger'); + expect( + findAlerts() + .at(0) + .classes(), + ).not.toContain('gl-hover-bg-blue-50'); }); it('empty state', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: [], errored: false }, + data: { alerts: { list: [], pageInfo: {} }, alertsCount: { all: 0 }, errored: false }, loading: false, }); expect(findAlertsTable().exists()).toBe(true); expect(findAlertsTable().text()).toContain('No alerts to display'); expect(findLoader().exists()).toBe(false); expect(findAlert().props().variant).toBe('info'); + expect( + findAlerts() + .at(0) + .classes(), + ).not.toContain('gl-hover-bg-blue-50'); }); it('has data state', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); expect(findLoader().exists()).toBe(false); expect(findAlertsTable().exists()).toBe(true); expect(findAlerts()).toHaveLength(mockAlerts.length); + expect( + findAlerts() + .at(0) + .classes(), + ).toContain('gl-hover-bg-blue-50'); }); it('displays status dropdown', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); expect(findStatusDropdown().exists()).toBe(true); @@ -191,7 +209,7 @@ describe('AlertManagementList', () => { it('shows correct severity icons', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); @@ -208,7 +226,7 @@ describe('AlertManagementList', () => { it('renders severity text', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); @@ -219,10 +237,38 @@ describe('AlertManagementList', () => { ).toBe('Critical'); }); + it('renders Unassigned when no assignee(s) present', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + loading: false, + }); + + expect( + findAssignees() + .at(0) + .text(), + ).toBe('Unassigned'); + }); + + it('renders username(s) when assignee(s) present', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + loading: false, + }); + + expect( + findAssignees() + .at(1) + .text(), + ).toBe(mockAlerts[1].assignees.nodes[0].username); + }); + it('navigates to the detail page when alert row is clicked', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); @@ -237,15 +283,19 @@ describe('AlertManagementList', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { - alerts: [ - { - iid: 1, - status: 'acknowledged', - startedAt: '2020-03-17T23:18:14.996Z', - endedAt: '2020-04-17T23:18:14.996Z', - severity: 'high', - }, - ], + alerts: { + list: [ + { + iid: 1, + status: 'acknowledged', + startedAt: '2020-03-17T23:18:14.996Z', + endedAt: '2020-04-17T23:18:14.996Z', + severity: 'high', + assignees: { nodes: [] }, + }, + ], + }, + alertsCount, errored: false, }, loading: false, @@ -266,6 +316,7 @@ describe('AlertManagementList', () => { severity: 'high', }, ], + alertsCount, errored: false, }, loading: false, @@ -275,6 +326,32 @@ describe('AlertManagementList', () => { }); }); + describe('sorting the alert list by column', () => { + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { + alerts: { list: mockAlerts }, + errored: false, + sort: 'STARTED_AT_DESC', + alertsCount, + }, + loading: false, + stubs: { GlTable }, + }); + }); + + it('updates sort with new direction and column key', () => { + findSeverityColumnHeader().trigger('click'); + + expect(wrapper.vm.$data.sort).toBe('SEVERITY_DESC'); + + findSeverityColumnHeader().trigger('click'); + + expect(wrapper.vm.$data.sort).toBe('SEVERITY_ASC'); + }); + }); + describe('updating the alert status', () => { const iid = '1527542'; const mockUpdatedMutationResult = { @@ -292,7 +369,7 @@ describe('AlertManagementList', () => { beforeEach(() => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); }); @@ -322,4 +399,91 @@ describe('AlertManagementList', () => { }); }); }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts }, alertsCount }, + loading: false, + }); + }); + + it('should track alert list page views', () => { + const { category, action } = trackAlertListViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + + it('should track alert status updates', () => { + Tracking.event.mockClear(); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); + findFirstStatusOption().vm.$emit('click'); + const status = findFirstStatusOption().text(); + setImmediate(() => { + const { category, action, label } = trackAlertStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status }); + }); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts, pageInfo: {} }, alertsCount, errored: false }, + loading: false, + }); + }); + + it('does NOT show pagination control when list is smaller than default page size', () => { + findStatusTabs().vm.$emit('input', 3); + wrapper.vm.$nextTick(() => { + expect(findPagination().exists()).toBe(false); + }); + }); + + it('shows pagination control when list is larger than default page size', () => { + findStatusTabs().vm.$emit('input', 0); + wrapper.vm.$nextTick(() => { + expect(findPagination().exists()).toBe(true); + }); + }); + + describe('prevPage', () => { + it('returns prevPage number', () => { + findPagination().vm.$emit('input', 3); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.prevPage).toBe(2); + }); + }); + + it('returns 0 when it is the first page', () => { + findPagination().vm.$emit('input', 1); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.prevPage).toBe(0); + }); + }); + }); + + describe('nextPage', () => { + it('returns nextPage number', () => { + findPagination().vm.$emit('input', 1); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.nextPage).toBe(2); + }); + }); + + it('returns `null` when currentPage is already last page', () => { + findStatusTabs().vm.$emit('input', 3); + findPagination().vm.$emit('input', 1); + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.nextPage).toBeNull(); + }); + }); + }); + }); }); diff --git a/spec/frontend/alert_management/components/alert_management_system_note_spec.js b/spec/frontend/alert_management/components/alert_management_system_note_spec.js new file mode 100644 index 00000000000..87dc36cc7cb --- /dev/null +++ b/spec/frontend/alert_management/components/alert_management_system_note_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import SystemNote from '~/alert_management/components/system_notes/system_note.vue'; +import mockAlerts from '../mocks/alerts.json'; + +const mockAlert = mockAlerts[1]; + +describe('Alert Details System Note', () => { + let wrapper; + + function mountComponent({ stubs = {} } = {}) { + wrapper = shallowMount(SystemNote, { + propsData: { + note: { ...mockAlert.notes.nodes[0] }, + }, + stubs, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('System notes', () => { + beforeEach(() => { + mountComponent({}); + }); + + it('renders the correct system note', () => { + expect(wrapper.find('.note-wrapper').attributes('id')).toBe('note_1628'); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/alert_managment_sidebar_assignees_spec.js new file mode 100644 index 00000000000..5dbd83dbdac --- /dev/null +++ b/spec/frontend/alert_management/components/alert_managment_sidebar_assignees_spec.js @@ -0,0 +1,133 @@ +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { GlDropdownItem } from '@gitlab/ui'; +import SidebarAssignee from '~/alert_management/components/sidebar/sidebar_assignee.vue'; +import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue'; +import AlertSetAssignees from '~/alert_management/graphql/mutations/alert_set_assignees.graphql'; +import mockAlerts from '../mocks/alerts.json'; + +const mockAlert = mockAlerts[0]; + +describe('Alert Details Sidebar Assignees', () => { + let wrapper; + let mock; + + function mountComponent({ + data, + users = [], + isDropdownSearching = false, + sidebarCollapsed = true, + loading = false, + stubs = {}, + } = {}) { + wrapper = shallowMount(SidebarAssignees, { + data() { + return { + users, + isDropdownSearching, + }; + }, + propsData: { + alert: { ...mockAlert }, + ...data, + sidebarCollapsed, + projectPath: 'projectPath', + }, + mocks: { + $apollo: { + mutate: jest.fn(), + queries: { + alert: { + loading, + }, + }, + }, + }, + stubs, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + mock.restore(); + }); + + describe('updating the alert status', () => { + const mockUpdatedMutationResult = { + data: { + updateAlertStatus: { + errors: [], + alert: { + assigneeUsernames: ['root'], + }, + }, + }, + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + const path = '/autocomplete/users.json'; + const users = [ + { + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 1, + name: 'User 1', + username: 'root', + }, + { + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 2, + name: 'User 2', + username: 'not-root', + }, + ]; + + mock.onGet(path).replyOnce(200, users); + mountComponent({ + data: { alert: mockAlert }, + sidebarCollapsed: false, + loading: false, + users, + stubs: { + SidebarAssignee, + }, + }); + }); + + it('renders a unassigned option', () => { + wrapper.setData({ isDropdownSearching: false }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlDropdownItem).text()).toBe('Unassigned'); + }); + }); + + it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + wrapper.setData({ isDropdownSearching: false }); + + return wrapper.vm.$nextTick().then(() => { + wrapper.find(SidebarAssignee).vm.$emit('update-alert-assignees', 'root'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: AlertSetAssignees, + variables: { + iid: '1527542', + assigneeUsernames: ['root'], + projectPath: 'projectPath', + }, + }); + }); + }); + + it('stops updating and cancels loading when the request fails', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); + wrapper.vm.updateAlertAssignees('root'); + expect(wrapper.find('[data-testid="unassigned-users"]').text()).toBe('assign yourself'); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_sidebar_spec.js b/spec/frontend/alert_management/components/alert_sidebar_spec.js new file mode 100644 index 00000000000..80c4d9e0650 --- /dev/null +++ b/spec/frontend/alert_management/components/alert_sidebar_spec.js @@ -0,0 +1,55 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import AlertSidebar from '~/alert_management/components/alert_sidebar.vue'; +import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue'; +import mockAlerts from '../mocks/alerts.json'; + +const mockAlert = mockAlerts[0]; + +describe('Alert Details Sidebar', () => { + let wrapper; + let mock; + + function mountComponent({ + sidebarCollapsed = true, + mountMethod = shallowMount, + stubs = {}, + alert = {}, + } = {}) { + wrapper = mountMethod(AlertSidebar, { + propsData: { + alert, + sidebarCollapsed, + projectPath: 'projectPath', + }, + stubs, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + mock.restore(); + }); + + describe('the sidebar renders', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mountComponent(); + }); + + it('open as default', () => { + expect(wrapper.props('sidebarCollapsed')).toBe(true); + }); + + it('should render side bar assignee dropdown', () => { + mountComponent({ + mountMethod: mount, + alert: mockAlert, + }); + expect(wrapper.find(SidebarAssignees).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/alert_sidebar_status_spec.js new file mode 100644 index 00000000000..94643966a43 --- /dev/null +++ b/spec/frontend/alert_management/components/alert_sidebar_status_spec.js @@ -0,0 +1,107 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { trackAlertStatusUpdateOptions } from '~/alert_management/constants'; +import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue'; +import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; +import Tracking from '~/tracking'; +import mockAlerts from '../mocks/alerts.json'; + +const mockAlert = mockAlerts[0]; + +describe('Alert Details Sidebar Status', () => { + let wrapper; + const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); + const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); + + function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { + wrapper = shallowMount(AlertSidebarStatus, { + propsData: { + alert: { ...mockAlert }, + ...data, + sidebarCollapsed, + projectPath: 'projectPath', + }, + mocks: { + $apollo: { + mutate: jest.fn(), + queries: { + alert: { + loading, + }, + }, + }, + }, + stubs, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('updating the alert status', () => { + const mockUpdatedMutationResult = { + data: { + updateAlertStatus: { + errors: [], + alert: { + status: 'acknowledged', + }, + }, + }, + }; + + beforeEach(() => { + 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: updateAlertStatus, + variables: { + iid: '1527542', + status: 'TRIGGERED', + projectPath: 'projectPath', + }, + }); + }); + + it('stops updating when the request fails', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); + findStatusDropdownItem().vm.$emit('click'); + expect(findStatusLoadingIcon().exists()).toBe(false); + expect(wrapper.find('[data-testid="status"]').text()).toBe('Triggered'); + }); + }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alert: mockAlert }, + loading: false, + }); + }); + + it('should track alert status updates', () => { + Tracking.event.mockClear(); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); + findStatusDropdownItem().vm.$emit('click'); + const status = findStatusDropdownItem().text(); + setImmediate(() => { + const { category, action, label } = trackAlertStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status }); + }); + }); + }); +}); diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json index b67e2cfc52e..312d1756790 100644 --- a/spec/frontend/alert_management/mocks/alerts.json +++ b/spec/frontend/alert_management/mocks/alerts.json @@ -1,29 +1,66 @@ [ - { - "iid": "1527542", - "title": "SyntaxError: Invalid or unexpected token", - "severity": "CRITICAL", - "eventCount": 7, - "startedAt": "2020-04-17T23:18:14.996Z", - "endedAt": "2020-04-17T23:18:14.996Z", - "status": "TRIGGERED" - }, - { - "iid": "1527543", - "title": "Some other alert Some other alert Some other alert Some other alert Some other alert Some other alert", - "severity": "MEDIUM", - "eventCount": 1, - "startedAt": "2020-04-17T23:18:14.996Z", - "endedAt": "2020-04-17T23:18:14.996Z", - "status": "ACKNOWLEDGED" - }, - { - "iid": "1527544", - "title": "SyntaxError: Invalid or unexpected token", - "severity": "LOW", - "eventCount": 4, - "startedAt": "2020-04-17T23:18:14.996Z", - "endedAt": "2020-04-17T23:18:14.996Z", - "status": "RESOLVED" + { + "iid": "1527542", + "title": "SyntaxError: Invalid or unexpected token", + "severity": "CRITICAL", + "eventCount": 7, + "createdAt": "2020-04-17T23:18:14.996Z", + "startedAt": "2020-04-17T23:18:14.996Z", + "endedAt": "2020-04-17T23:18:14.996Z", + "status": "TRIGGERED", + "assignees": { "nodes": [] }, + "notes": { "nodes": [] } + }, + { + "iid": "1527543", + "title": "Some other alert Some other alert Some other alert Some other alert Some other alert Some other alert", + "severity": "MEDIUM", + "eventCount": 1, + "startedAt": "2020-04-17T23:18:14.996Z", + "endedAt": "2020-04-17T23:18:14.996Z", + "status": "ACKNOWLEDGED", + "assignees": { "nodes": [{ "username": "root" }] }, + "notes": { + "nodes": [ + { + "id": "gid://gitlab/Note/1628", + "author": { + "id": "gid://gitlab/User/1", + "state": "active", + "__typename": "User", + "avatarUrl": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon", + "name": "Administrator", + "username": "root", + "webUrl": "http://192.168.1.4:3000/root" + } + } + ] } - ] + }, + { + "iid": "1527544", + "title": "SyntaxError: Invalid or unexpected token", + "severity": "LOW", + "eventCount": 4, + "startedAt": "2020-04-17T23:18:14.996Z", + "endedAt": "2020-04-17T23:18:14.996Z", + "status": "RESOLVED", + "assignees": { "nodes": [{ "username": "root" }] }, + "notes": { + "nodes": [ + { + "id": "gid://gitlab/Note/1629", + "author": { + "id": "gid://gitlab/User/2", + "state": "active", + "__typename": "User", + "avatarUrl": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon", + "name": "Administrator", + "username": "root", + "webUrl": "http://192.168.1.4:3000/root" + } + } + ] + } + } +] diff --git a/spec/frontend/alerts_service_settings/components/__snapshots__/alerts_service_form_spec.js.snap b/spec/frontend/alerts_service_settings/components/__snapshots__/alerts_service_form_spec.js.snap index 36ec0badade..0d4171a20b3 100644 --- a/spec/frontend/alerts_service_settings/components/__snapshots__/alerts_service_form_spec.js.snap +++ b/spec/frontend/alerts_service_settings/components/__snapshots__/alerts_service_form_spec.js.snap @@ -6,4 +6,4 @@ exports[`AlertsServiceForm with default values renders "url" input 1`] = `""`; -exports[`AlertsServiceForm with default values shows description and "Learn More" link 1`] = `"Each alert source must be authorized using the following URL and authorization key. Learn more about configuring this endpoint to receive alerts."`; +exports[`AlertsServiceForm with default values shows description and docs links 1`] = `"

"`; diff --git a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js index b7a008c78d0..c7c15c8fd44 100644 --- a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js +++ b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js @@ -12,7 +12,8 @@ const defaultProps = { initialAuthorizationKey: 'abcedfg123', formPath: 'http://invalid', url: 'https://gitlab.com/endpoint-url', - learnMoreUrl: 'https://docs.gitlab.com/ee/user/project/integrations/generic_alerts.md', + alertsSetupUrl: 'http://invalid', + alertsUsageUrl: 'http://invalid', initialActivated: false, }; @@ -32,7 +33,7 @@ describe('AlertsServiceForm', () => { const findUrl = () => wrapper.find('#url'); const findAuthorizationKey = () => wrapper.find('#authorization-key'); - const findDescription = () => wrapper.find('p'); + const findDescription = () => wrapper.find('[data-testid="description"'); const findActiveStatusIcon = val => document.querySelector(`.js-service-active-status[data-value=${val.toString()}]`); @@ -67,7 +68,7 @@ describe('AlertsServiceForm', () => { expect(wrapper.find(ToggleButton).html()).toMatchSnapshot(); }); - it('shows description and "Learn More" link', () => { + it('shows description and docs links', () => { expect(findDescription().element.innerHTML).toMatchSnapshot(); }); }); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index d365048ab0b..c1a23d441b3 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -691,4 +691,60 @@ describe('Api', () => { }); }); }); + + describe('updateIssue', () => { + it('update an issue with the given payload', done => { + const projectId = 8; + const issue = 1; + const expectedArray = [1, 2, 3]; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/issues/${issue}`; + mock.onPut(expectedUrl).reply(200, { assigneeIds: expectedArray }); + + Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }) + .then(({ data }) => { + expect(data.assigneeIds).toEqual(expectedArray); + done(); + }) + .catch(done.fail); + }); + }); + + describe('updateMergeRequest', () => { + it('update an issue with the given payload', done => { + const projectId = 8; + const mergeRequest = 1; + const expectedArray = [1, 2, 3]; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/merge_requests/${mergeRequest}`; + mock.onPut(expectedUrl).reply(200, { assigneeIds: expectedArray }); + + Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }) + .then(({ data }) => { + expect(data.assigneeIds).toEqual(expectedArray); + done(); + }) + .catch(done.fail); + }); + }); + + describe('tags', () => { + it('fetches all tags of a particular project', done => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const projectId = 8; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/repository/tags`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.tags(projectId, query, options) + .then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js new file mode 100644 index 00000000000..8abef2ae1b2 --- /dev/null +++ b/spec/frontend/authentication/u2f/authenticate_spec.js @@ -0,0 +1,109 @@ +import $ from 'jquery'; +import U2FAuthenticate from '~/authentication/u2f/authenticate'; +import 'vendor/u2f'; +import MockU2FDevice from './mock_u2f_device'; + +describe('U2FAuthenticate', () => { + let u2fDevice; + let container; + let component; + + preloadFixtures('u2f/authenticate.html'); + + beforeEach(() => { + loadFixtures('u2f/authenticate.html'); + u2fDevice = new MockU2FDevice(); + container = $('#js-authenticate-token-2fa'); + component = new U2FAuthenticate( + container, + '#js-login-token-2fa-form', + { + sign_requests: [], + }, + document.querySelector('#js-login-2fa-device'), + document.querySelector('.js-2fa-form'), + ); + }); + + describe('with u2f unavailable', () => { + let oldu2f; + + beforeEach(() => { + jest.spyOn(component, 'switchToFallbackUI').mockImplementation(() => {}); + oldu2f = window.u2f; + window.u2f = null; + }); + + afterEach(() => { + window.u2f = oldu2f; + }); + + it('falls back to normal 2fa', done => { + component + .start() + .then(() => { + expect(component.switchToFallbackUI).toHaveBeenCalled(); + done(); + }) + .catch(done.fail); + }); + }); + + describe('with u2f available', () => { + beforeEach(done => { + // bypass automatic form submission within renderAuthenticated + jest.spyOn(component, 'renderAuthenticated').mockReturnValue(true); + u2fDevice = new MockU2FDevice(); + + component + .start() + .then(done) + .catch(done.fail); + }); + + it('allows authenticating via a U2F device', () => { + const inProgressMessage = container.find('p'); + + expect(inProgressMessage.text()).toContain('Trying to communicate with your device'); + u2fDevice.respondToAuthenticateRequest({ + deviceData: 'this is data from the device', + }); + + expect(component.renderAuthenticated).toHaveBeenCalledWith( + '{"deviceData":"this is data from the device"}', + ); + }); + + describe('errors', () => { + it('displays an error message', () => { + const setupButton = container.find('#js-login-u2f-device'); + setupButton.trigger('click'); + u2fDevice.respondToAuthenticateRequest({ + errorCode: 'error!', + }); + const errorMessage = container.find('p'); + + expect(errorMessage.text()).toContain('There was a problem communicating with your device'); + }); + + it('allows retrying authentication after an error', () => { + let setupButton = container.find('#js-login-u2f-device'); + setupButton.trigger('click'); + u2fDevice.respondToAuthenticateRequest({ + errorCode: 'error!', + }); + const retryButton = container.find('#js-token-2fa-try-again'); + retryButton.trigger('click'); + setupButton = container.find('#js-login-u2f-device'); + setupButton.trigger('click'); + u2fDevice.respondToAuthenticateRequest({ + deviceData: 'this is data from the device', + }); + + expect(component.renderAuthenticated).toHaveBeenCalledWith( + '{"deviceData":"this is data from the device"}', + ); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/u2f/mock_u2f_device.js b/spec/frontend/authentication/u2f/mock_u2f_device.js new file mode 100644 index 00000000000..ec8425a4e3e --- /dev/null +++ b/spec/frontend/authentication/u2f/mock_u2f_device.js @@ -0,0 +1,23 @@ +/* eslint-disable no-unused-expressions */ + +export default class MockU2FDevice { + constructor() { + this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this); + this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this); + window.u2f || (window.u2f = {}); + window.u2f.register = (appId, registerRequests, signRequests, callback) => { + this.registerCallback = callback; + }; + window.u2f.sign = (appId, challenges, signRequests, callback) => { + this.authenticateCallback = callback; + }; + } + + respondToRegisterRequest(params) { + return this.registerCallback(params); + } + + respondToAuthenticateRequest(params) { + return this.authenticateCallback(params); + } +} diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js new file mode 100644 index 00000000000..3c2ecdbba66 --- /dev/null +++ b/spec/frontend/authentication/u2f/register_spec.js @@ -0,0 +1,83 @@ +import $ from 'jquery'; +import U2FRegister from '~/authentication/u2f/register'; +import 'vendor/u2f'; +import MockU2FDevice from './mock_u2f_device'; + +describe('U2FRegister', () => { + let u2fDevice; + let container; + let component; + + preloadFixtures('u2f/register.html'); + + beforeEach(done => { + loadFixtures('u2f/register.html'); + u2fDevice = new MockU2FDevice(); + container = $('#js-register-u2f'); + component = new U2FRegister(container, $('#js-register-u2f-templates'), {}, 'token'); + component + .start() + .then(done) + .catch(done.fail); + }); + + it('allows registering a U2F device', () => { + const setupButton = container.find('#js-setup-u2f-device'); + + expect(setupButton.text()).toBe('Set up new U2F device'); + setupButton.trigger('click'); + const inProgressMessage = container.children('p'); + + expect(inProgressMessage.text()).toContain('Trying to communicate with your device'); + u2fDevice.respondToRegisterRequest({ + deviceData: 'this is data from the device', + }); + const registeredMessage = container.find('p'); + const deviceResponse = container.find('#js-device-response'); + + expect(registeredMessage.text()).toContain('Your device was successfully set up!'); + expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}'); + }); + + describe('errors', () => { + it("doesn't allow the same device to be registered twice (for the same user", () => { + const setupButton = container.find('#js-setup-u2f-device'); + setupButton.trigger('click'); + u2fDevice.respondToRegisterRequest({ + errorCode: 4, + }); + const errorMessage = container.find('p'); + + expect(errorMessage.text()).toContain('already been registered with us'); + }); + + it('displays an error message for other errors', () => { + const setupButton = container.find('#js-setup-u2f-device'); + setupButton.trigger('click'); + u2fDevice.respondToRegisterRequest({ + errorCode: 'error!', + }); + const errorMessage = container.find('p'); + + expect(errorMessage.text()).toContain('There was a problem communicating with your device'); + }); + + it('allows retrying registration after an error', () => { + let setupButton = container.find('#js-setup-u2f-device'); + setupButton.trigger('click'); + u2fDevice.respondToRegisterRequest({ + errorCode: 'error!', + }); + const retryButton = container.find('#U2FTryAgain'); + retryButton.trigger('click'); + setupButton = container.find('#js-setup-u2f-device'); + setupButton.trigger('click'); + u2fDevice.respondToRegisterRequest({ + deviceData: 'this is data from the device', + }); + const registeredMessage = container.find('p'); + + expect(registeredMessage.text()).toContain('Your device was successfully set up!'); + }); + }); +}); diff --git a/spec/frontend/authentication/u2f/util_spec.js b/spec/frontend/authentication/u2f/util_spec.js new file mode 100644 index 00000000000..67fd4c73243 --- /dev/null +++ b/spec/frontend/authentication/u2f/util_spec.js @@ -0,0 +1,61 @@ +import { canInjectU2fApi } from '~/authentication/u2f/util'; + +describe('U2F Utils', () => { + describe('canInjectU2fApi', () => { + it('returns false for Chrome < 41', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.28 Safari/537.36'; + + expect(canInjectU2fApi(userAgent)).toBe(false); + }); + + it('returns true for Chrome >= 41', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36'; + + expect(canInjectU2fApi(userAgent)).toBe(true); + }); + + it('returns false for Opera < 40', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36 OPR/32.0.1948.25'; + + expect(canInjectU2fApi(userAgent)).toBe(false); + }); + + it('returns true for Opera >= 40', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 OPR/43.0.2442.991'; + + expect(canInjectU2fApi(userAgent)).toBe(true); + }); + + it('returns false for Safari', () => { + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/10.1.1 Safari/603.2.4'; + + expect(canInjectU2fApi(userAgent)).toBe(false); + }); + + it('returns false for Chrome on Android', () => { + const userAgent = + 'Mozilla/5.0 (Linux; Android 7.0; VS988 Build/NRD90U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3145.0 Mobile Safari/537.36'; + + expect(canInjectU2fApi(userAgent)).toBe(false); + }); + + it('returns false for Chrome on iOS', () => { + const userAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1'; + + expect(canInjectU2fApi(userAgent)).toBe(false); + }); + + it('returns false for Safari on iOS', () => { + const userAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A356 Safari/604.1'; + + expect(canInjectU2fApi(userAgent)).toBe(false); + }); + }); +}); diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js new file mode 100644 index 00000000000..754f0702b84 --- /dev/null +++ b/spec/frontend/awards_handler_spec.js @@ -0,0 +1,403 @@ +import $ from 'jquery'; +import Cookies from 'js-cookie'; +import loadAwardsHandler from '~/awards_handler'; +import '~/lib/utils/common_utils'; +import waitForPromises from './helpers/wait_for_promises'; + +window.gl = window.gl || {}; +window.gon = window.gon || {}; + +let openAndWaitForEmojiMenu; +let awardsHandler = null; +const urlRoot = gon.relative_url_root; + +const lazyAssert = (done, assertFn) => { + jest.runOnlyPendingTimers(); + waitForPromises() + .then(() => { + assertFn(); + done(); + }) + .catch(e => { + throw e; + }); +}; + +describe('AwardsHandler', () => { + preloadFixtures('snippets/show.html'); + beforeEach(done => { + loadFixtures('snippets/show.html'); + loadAwardsHandler(true) + .then(obj => { + awardsHandler = obj; + jest.spyOn(awardsHandler, 'postEmoji').mockImplementation((button, url, emoji, cb) => cb()); + done(); + }) + .catch(done.fail); + + let isEmojiMenuBuilt = false; + openAndWaitForEmojiMenu = () => { + return new Promise(resolve => { + if (isEmojiMenuBuilt) { + resolve(); + } else { + $('.js-add-award') + .eq(0) + .click(); + const $menu = $('.emoji-menu'); + $menu.one('build-emoji-menu-finish', () => { + isEmojiMenuBuilt = true; + resolve(); + }); + } + }); + }; + }); + + afterEach(() => { + // restore original url root value + gon.relative_url_root = urlRoot; + + // Undo what we did to the shared + $('body').removeAttr('data-page'); + + awardsHandler.destroy(); + }); + + describe('::showEmojiMenu', () => { + it('should show emoji menu when Add emoji button clicked', done => { + $('.js-add-award') + .eq(0) + .click(); + lazyAssert(done, () => { + const $emojiMenu = $('.emoji-menu'); + + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(true); + expect($emojiMenu.find('.js-emoji-menu-search').length).toBe(1); + expect($('.js-awards-block.current').length).toBe(1); + }); + }); + + it('should also show emoji menu for the smiley icon in notes', done => { + $('.js-add-award.note-action-button').click(); + lazyAssert(done, () => { + const $emojiMenu = $('.emoji-menu'); + + expect($emojiMenu.length).toBe(1); + }); + }); + + it('should remove emoji menu when body is clicked', done => { + $('.js-add-award') + .eq(0) + .click(); + lazyAssert(done, () => { + const $emojiMenu = $('.emoji-menu'); + $('body').click(); + + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(false); + expect($('.js-awards-block.current').length).toBe(0); + }); + }); + + it('should not remove emoji menu when search is clicked', done => { + $('.js-add-award') + .eq(0) + .click(); + lazyAssert(done, () => { + const $emojiMenu = $('.emoji-menu'); + $('.emoji-search').click(); + + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(true); + expect($('.js-awards-block.current').length).toBe(1); + }); + }); + }); + + describe('::addAwardToEmojiBar', () => { + it('should add emoji to votes block', () => { + const $votesBlock = $('.js-awards-block').eq(0); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + const $emojiButton = $votesBlock.find('[data-name=heart]'); + + expect($emojiButton.length).toBe(1); + expect($emojiButton.next('.js-counter').text()).toBe('1'); + expect($votesBlock.hasClass('hidden')).toBe(false); + }); + + it('should remove the emoji when we click again', () => { + const $votesBlock = $('.js-awards-block').eq(0); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + const $emojiButton = $votesBlock.find('[data-name=heart]'); + + expect($emojiButton.length).toBe(0); + }); + + it('should decrement the emoji counter', () => { + const $votesBlock = $('.js-awards-block').eq(0); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + const $emojiButton = $votesBlock.find('[data-name=heart]'); + $emojiButton.next('.js-counter').text(5); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + + expect($emojiButton.length).toBe(1); + expect($emojiButton.next('.js-counter').text()).toBe('4'); + }); + }); + + describe('::userAuthored', () => { + it('should update tooltip to user authored title', () => { + const $votesBlock = $('.js-awards-block').eq(0); + const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'sam'); + awardsHandler.userAuthored($thumbsUpEmoji); + + expect($thumbsUpEmoji.data('originalTitle')).toBe( + 'You cannot vote on your own issue, MR and note', + ); + }); + + it('should restore tooltip back to initial vote list', () => { + const $votesBlock = $('.js-awards-block').eq(0); + const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'sam'); + awardsHandler.userAuthored($thumbsUpEmoji); + jest.advanceTimersByTime(2801); + + expect($thumbsUpEmoji.data('originalTitle')).toBe('sam'); + }); + }); + + describe('::getAwardUrl', () => { + it('returns the url for request', () => { + expect(awardsHandler.getAwardUrl()).toBe('http://test.host/snippets/1/toggle_award_emoji'); + }); + }); + + describe('::addAward and ::checkMutuality', () => { + it('should handle :+1: and :-1: mutuality', () => { + const awardUrl = awardsHandler.getAwardUrl(); + const $votesBlock = $('.js-awards-block').eq(0); + const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); + const $thumbsDownEmoji = $votesBlock.find('[data-name=thumbsdown]').parent(); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + + expect($thumbsUpEmoji.hasClass('active')).toBe(true); + expect($thumbsDownEmoji.hasClass('active')).toBe(false); + $thumbsUpEmoji.tooltip(); + $thumbsDownEmoji.tooltip(); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsdown', true); + + expect($thumbsUpEmoji.hasClass('active')).toBe(false); + expect($thumbsDownEmoji.hasClass('active')).toBe(true); + }); + }); + + describe('::removeEmoji', () => { + it('should remove emoji', () => { + const awardUrl = awardsHandler.getAwardUrl(); + const $votesBlock = $('.js-awards-block').eq(0); + awardsHandler.addAward($votesBlock, awardUrl, 'fire', false); + + expect($votesBlock.find('[data-name=fire]').length).toBe(1); + awardsHandler.removeEmoji($votesBlock.find('[data-name=fire]').closest('button')); + + expect($votesBlock.find('[data-name=fire]').length).toBe(0); + }); + }); + + describe('::addYouToUserList', () => { + it('should prepend "You" to the award tooltip', () => { + const awardUrl = awardsHandler.getAwardUrl(); + const $votesBlock = $('.js-awards-block').eq(0); + const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + + expect($thumbsUpEmoji.data('originalTitle')).toBe('You, sam, jerry, max, and andy'); + }); + + it('handles the special case where "You" is not cleanly comma separated', () => { + const awardUrl = awardsHandler.getAwardUrl(); + const $votesBlock = $('.js-awards-block').eq(0); + const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'sam'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + + expect($thumbsUpEmoji.data('originalTitle')).toBe('You and sam'); + }); + }); + + describe('::removeYouToUserList', () => { + it('removes "You" from the front of the tooltip', () => { + const awardUrl = awardsHandler.getAwardUrl(); + const $votesBlock = $('.js-awards-block').eq(0); + const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy'); + $thumbsUpEmoji.addClass('active'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + + expect($thumbsUpEmoji.data('originalTitle')).toBe('sam, jerry, max, and andy'); + }); + + it('handles the special case where "You" is not cleanly comma separated', () => { + const awardUrl = awardsHandler.getAwardUrl(); + const $votesBlock = $('.js-awards-block').eq(0); + const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'You and sam'); + $thumbsUpEmoji.addClass('active'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + + expect($thumbsUpEmoji.data('originalTitle')).toBe('sam'); + }); + }); + + describe('::searchEmojis', () => { + it('should filter the emoji', done => { + openAndWaitForEmojiMenu() + .then(() => { + expect($('[data-name=angel]').is(':visible')).toBe(true); + expect($('[data-name=anger]').is(':visible')).toBe(true); + awardsHandler.searchEmojis('ali'); + + expect($('[data-name=angel]').is(':visible')).toBe(false); + expect($('[data-name=anger]').is(':visible')).toBe(false); + expect($('[data-name=alien]').is(':visible')).toBe(true); + expect($('.js-emoji-menu-search').val()).toBe('ali'); + }) + .then(done) + .catch(err => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + + it('should clear the search when searching for nothing', done => { + openAndWaitForEmojiMenu() + .then(() => { + awardsHandler.searchEmojis('ali'); + + expect($('[data-name=angel]').is(':visible')).toBe(false); + expect($('[data-name=anger]').is(':visible')).toBe(false); + expect($('[data-name=alien]').is(':visible')).toBe(true); + awardsHandler.searchEmojis(''); + + expect($('[data-name=angel]').is(':visible')).toBe(true); + expect($('[data-name=anger]').is(':visible')).toBe(true); + expect($('[data-name=alien]').is(':visible')).toBe(true); + expect($('.js-emoji-menu-search').val()).toBe(''); + }) + .then(done) + .catch(err => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + }); + + describe('emoji menu', () => { + const emojiSelector = '[data-name="sunglasses"]'; + const openEmojiMenuAndAddEmoji = () => { + return openAndWaitForEmojiMenu().then(() => { + const $menu = $('.emoji-menu'); + const $block = $('.js-awards-block'); + const $emoji = $menu.find(`.emoji-menu-list:not(.frequent-emojis) ${emojiSelector}`); + + expect($emoji.length).toBe(1); + expect($block.find(emojiSelector).length).toBe(0); + $emoji.click(); + + expect($menu.hasClass('.is-visible')).toBe(false); + expect($block.find(emojiSelector).length).toBe(1); + }); + }; + + it('should add selected emoji to awards block', done => { + openEmojiMenuAndAddEmoji() + .then(done) + .catch(err => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + + it('should remove already selected emoji', done => { + openEmojiMenuAndAddEmoji() + .then(() => { + $('.js-add-award') + .eq(0) + .click(); + const $block = $('.js-awards-block'); + const $emoji = $('.emoji-menu').find( + `.emoji-menu-list:not(.frequent-emojis) ${emojiSelector}`, + ); + $emoji.click(); + + expect($block.find(emojiSelector).length).toBe(0); + }) + .then(done) + .catch(err => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + }); + + describe('frequently used emojis', () => { + beforeEach(() => { + // Clear it out + Cookies.set('frequently_used_emojis', ''); + }); + + it('shouldn\'t have any "Frequently used" heading if no frequently used emojis', done => { + return openAndWaitForEmojiMenu() + .then(() => { + const emojiMenu = document.querySelector('.emoji-menu'); + Array.prototype.forEach.call(emojiMenu.querySelectorAll('.emoji-menu-title'), title => { + expect(title.textContent.trim().toLowerCase()).not.toBe('frequently used'); + }); + }) + .then(done) + .catch(err => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + + it('should have any frequently used section when there are frequently used emojis', done => { + awardsHandler.addEmojiToFrequentlyUsedList('8ball'); + + return openAndWaitForEmojiMenu() + .then(() => { + const emojiMenu = document.querySelector('.emoji-menu'); + const hasFrequentlyUsedHeading = Array.prototype.some.call( + emojiMenu.querySelectorAll('.emoji-menu-title'), + title => title.textContent.trim().toLowerCase() === 'frequently used', + ); + + expect(hasFrequentlyUsedHeading).toBe(true); + }) + .then(done) + .catch(err => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + + it('should disregard invalid frequently used emoji that are being attempted to be added', () => { + awardsHandler.addEmojiToFrequentlyUsedList('8ball'); + awardsHandler.addEmojiToFrequentlyUsedList('invalid_emoji'); + awardsHandler.addEmojiToFrequentlyUsedList('grinning'); + + expect(awardsHandler.getFrequentlyUsedEmojis()).toEqual(['8ball', 'grinning']); + }); + + it('should disregard invalid frequently used emoji already set in cookie', () => { + Cookies.set('frequently_used_emojis', '8ball,invalid_emoji,grinning'); + + expect(awardsHandler.getFrequentlyUsedEmojis()).toEqual(['8ball', 'grinning']); + }); + }); +}); diff --git a/spec/frontend/batch_comments/components/diff_file_drafts_spec.js b/spec/frontend/batch_comments/components/diff_file_drafts_spec.js new file mode 100644 index 00000000000..6e0b61db9fa --- /dev/null +++ b/spec/frontend/batch_comments/components/diff_file_drafts_spec.js @@ -0,0 +1,61 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('Batch comments diff file drafts component', () => { + let vm; + + function factory() { + const store = new Vuex.Store({ + modules: { + batchComments: { + namespaced: true, + getters: { + draftsForFile: () => () => [{ id: 1 }, { id: 2 }], + }, + }, + }, + }); + + vm = shallowMount(localVue.extend(DiffFileDrafts), { + store, + localVue, + propsData: { fileHash: 'filehash' }, + }); + } + + afterEach(() => { + vm.destroy(); + }); + + it('renders list of draft notes', () => { + factory(); + + expect(vm.findAll(DraftNote).length).toEqual(2); + }); + + it('renders index of draft note', () => { + factory(); + + expect(vm.findAll('.js-diff-notes-index').length).toEqual(2); + + expect( + vm + .findAll('.js-diff-notes-index') + .at(0) + .text(), + ).toEqual('1'); + + expect( + vm + .findAll('.js-diff-notes-index') + .at(1) + .text(), + ).toEqual('2'); + }); +}); diff --git a/spec/frontend/batch_comments/components/draft_note_spec.js b/spec/frontend/batch_comments/components/draft_note_spec.js new file mode 100644 index 00000000000..eea7f25dbc1 --- /dev/null +++ b/spec/frontend/batch_comments/components/draft_note_spec.js @@ -0,0 +1,125 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; +import { createStore } from '~/batch_comments/stores'; +import NoteableNote from '~/notes/components/noteable_note.vue'; +import '~/behaviors/markdown/render_gfm'; +import { createDraft } from '../mock_data'; + +const localVue = createLocalVue(); + +describe('Batch comments draft note component', () => { + let wrapper; + let draft; + + beforeEach(() => { + const store = createStore(); + + draft = createDraft(); + + wrapper = shallowMount(localVue.extend(DraftNote), { + store, + propsData: { draft }, + localVue, + }); + + jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders template', () => { + expect(wrapper.find('.draft-pending-label').exists()).toBe(true); + + const note = wrapper.find(NoteableNote); + + expect(note.exists()).toBe(true); + expect(note.props().note).toEqual(draft); + }); + + describe('add comment now', () => { + it('dispatches publishSingleDraft when clicking', () => { + const publishNowButton = wrapper.find({ ref: 'publishNowButton' }); + publishNowButton.vm.$emit('click'); + + expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith( + 'batchComments/publishSingleDraft', + 1, + ); + }); + + it('sets as loading when draft is publishing', done => { + wrapper.vm.$store.state.batchComments.currentlyPublishingDrafts.push(1); + + wrapper.vm.$nextTick(() => { + const publishNowButton = wrapper.find({ ref: 'publishNowButton' }); + + expect(publishNowButton.props().loading).toBe(true); + + done(); + }); + }); + }); + + describe('update', () => { + it('dispatches updateDraft', done => { + const note = wrapper.find(NoteableNote); + + note.vm.$emit('handleEdit'); + + wrapper.vm + .$nextTick() + .then(() => { + const formData = { + note: draft, + noteText: 'a', + resolveDiscussion: false, + }; + + note.vm.$emit('handleUpdateNote', formData); + + expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith( + 'batchComments/updateDraft', + formData, + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('deleteDraft', () => { + it('dispatches deleteDraft', () => { + jest.spyOn(window, 'confirm').mockImplementation(() => true); + + const note = wrapper.find(NoteableNote); + + note.vm.$emit('handleDeleteNote', draft); + + expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('batchComments/deleteDraft', draft); + }); + }); + + describe('quick actions', () => { + it('renders referenced commands', done => { + wrapper.setProps({ + draft: { + ...draft, + references: { + commands: 'test command', + }, + }, + }); + + wrapper.vm.$nextTick(() => { + const referencedCommands = wrapper.find('.referenced-commands'); + + expect(referencedCommands.exists()).toBe(true); + expect(referencedCommands.text()).toContain('test command'); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/batch_comments/components/drafts_count_spec.js b/spec/frontend/batch_comments/components/drafts_count_spec.js new file mode 100644 index 00000000000..9d9fffce7e7 --- /dev/null +++ b/spec/frontend/batch_comments/components/drafts_count_spec.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import DraftsCount from '~/batch_comments/components/drafts_count.vue'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/batch_comments/stores'; + +describe('Batch comments drafts count component', () => { + let vm; + let Component; + + beforeAll(() => { + Component = Vue.extend(DraftsCount); + }); + + beforeEach(() => { + const store = createStore(); + + store.state.batchComments.drafts.push('comment'); + + vm = mountComponentWithStore(Component, { store }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders count', () => { + expect(vm.$el.querySelector('.drafts-count-number').textContent).toBe('1'); + }); + + it('renders screen reader text', done => { + const el = vm.$el.querySelector('.sr-only'); + + expect(el.textContent).toContain('draft'); + + vm.$store.state.batchComments.drafts.push('comment 2'); + + vm.$nextTick(() => { + expect(el.textContent).toContain('drafts'); + + done(); + }); + }); +}); diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js new file mode 100644 index 00000000000..7d951fd7799 --- /dev/null +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -0,0 +1,130 @@ +import Vue from 'vue'; +import PreviewItem from '~/batch_comments/components/preview_item.vue'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/batch_comments/stores'; +import diffsModule from '~/diffs/store/modules'; +import notesModule from '~/notes/stores/modules'; +import '~/behaviors/markdown/render_gfm'; +import { createDraft } from '../mock_data'; + +describe('Batch comments draft preview item component', () => { + let vm; + let Component; + let draft; + + function createComponent(isLast = false, extra = {}, extendStore = () => {}) { + const store = createStore(); + store.registerModule('diffs', diffsModule()); + store.registerModule('notes', notesModule()); + + extendStore(store); + + draft = { + ...createDraft(), + ...extra, + }; + + vm = mountComponentWithStore(Component, { store, props: { draft, isLast } }); + } + + beforeAll(() => { + Component = Vue.extend(PreviewItem); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders text content', () => { + createComponent(false, { note_html: '

Hello world

' }); + + expect(vm.$el.querySelector('.review-preview-item-content').innerHTML).toEqual( + '

Hello world

', + ); + }); + + it('adds is last class', () => { + createComponent(true); + + expect(vm.$el.classList).toContain('is-last'); + }); + + it('scrolls to draft on click', () => { + createComponent(); + + jest.spyOn(vm.$store, 'dispatch').mockImplementation(); + + vm.$el.click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/scrollToDraft', vm.draft); + }); + + describe('for file', () => { + it('renders file path', () => { + createComponent(false, { file_path: 'index.js', file_hash: 'abc', position: {} }); + + expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain( + 'index.js', + ); + }); + + it('renders new line position', () => { + createComponent(false, { + file_path: 'index.js', + file_hash: 'abc', + position: { new_line: 1 }, + }); + + expect(vm.$el.querySelector('.bold').textContent).toContain(':1'); + }); + + it('renders old line position', () => { + createComponent(false, { + file_path: 'index.js', + file_hash: 'abc', + position: { old_line: 2 }, + }); + + expect(vm.$el.querySelector('.bold').textContent).toContain(':2'); + }); + + it('renders image position', () => { + createComponent(false, { + file_path: 'index.js', + file_hash: 'abc', + position: { position_type: 'image', x: 10, y: 20 }, + }); + + expect(vm.$el.querySelector('.bold').textContent).toContain('10x 20y'); + }); + }); + + describe('for thread', () => { + beforeEach(() => { + createComponent(false, { discussion_id: '1', resolve_discussion: true }, store => { + store.state.notes.discussions.push({ + id: '1', + notes: [ + { + author: { + name: 'Author Name', + }, + }, + ], + }); + }); + }); + + it('renders title', () => { + expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain( + "Author Name's thread", + ); + }); + + it('it renders thread resolved text', () => { + expect(vm.$el.querySelector('.draft-note-resolution').textContent).toContain( + 'Thread will be resolved', + ); + }); + }); +}); diff --git a/spec/frontend/batch_comments/components/publish_button_spec.js b/spec/frontend/batch_comments/components/publish_button_spec.js new file mode 100644 index 00000000000..97f3a1c8939 --- /dev/null +++ b/spec/frontend/batch_comments/components/publish_button_spec.js @@ -0,0 +1,52 @@ +import Vue from 'vue'; +import PublishButton from '~/batch_comments/components/publish_button.vue'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/batch_comments/stores'; + +describe('Batch comments publish button component', () => { + let vm; + let Component; + + beforeAll(() => { + Component = Vue.extend(PublishButton); + }); + + beforeEach(() => { + const store = createStore(); + + vm = mountComponentWithStore(Component, { store, props: { shouldPublish: true } }); + + jest.spyOn(vm.$store, 'dispatch').mockImplementation(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('dispatches publishReview on click', () => { + vm.$el.click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/publishReview', undefined); + }); + + it('dispatches toggleReviewDropdown when shouldPublish is false on click', () => { + vm.shouldPublish = false; + + vm.$el.click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith( + 'batchComments/toggleReviewDropdown', + undefined, + ); + }); + + it('sets loading when isPublishing is true', done => { + vm.$store.state.batchComments.isPublishing = true; + + vm.$nextTick(() => { + expect(vm.$el.getAttribute('disabled')).toBe('disabled'); + + done(); + }); + }); +}); diff --git a/spec/frontend/batch_comments/components/publish_dropdown_spec.js b/spec/frontend/batch_comments/components/publish_dropdown_spec.js new file mode 100644 index 00000000000..b50ae340691 --- /dev/null +++ b/spec/frontend/batch_comments/components/publish_dropdown_spec.js @@ -0,0 +1,96 @@ +import Vue from 'vue'; +import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/mr_notes/stores'; +import '~/behaviors/markdown/render_gfm'; +import { createDraft } from '../mock_data'; + +describe('Batch comments publish dropdown component', () => { + let vm; + let Component; + + function createComponent(extendStore = () => {}) { + const store = createStore(); + store.state.batchComments.drafts.push(createDraft(), { ...createDraft(), id: 2 }); + + extendStore(store); + + vm = mountComponentWithStore(Component, { store }); + } + + beforeAll(() => { + Component = Vue.extend(PreviewDropdown); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('toggles dropdown when clicking button', done => { + createComponent(); + + jest.spyOn(vm.$store, 'dispatch'); + + vm.$el.querySelector('.review-preview-dropdown-toggle').click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith( + 'batchComments/toggleReviewDropdown', + expect.anything(), + ); + + setImmediate(() => { + expect(vm.$el.classList).toContain('show'); + + done(); + }); + }); + + it('toggles dropdown when clicking body', () => { + createComponent(); + + vm.$store.state.batchComments.showPreviewDropdown = true; + + jest.spyOn(vm.$store, 'dispatch').mockImplementation(); + + document.body.click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith( + 'batchComments/toggleReviewDropdown', + undefined, + ); + }); + + it('renders list of drafts', () => { + createComponent(store => { + Object.assign(store.state.notes, { + isNotesFetched: true, + }); + }); + + expect(vm.$el.querySelectorAll('.dropdown-content li').length).toBe(2); + }); + + it('adds is-last class to last item', () => { + createComponent(store => { + Object.assign(store.state.notes, { + isNotesFetched: true, + }); + }); + + expect(vm.$el.querySelectorAll('.dropdown-content li')[1].querySelector('.is-last')).not.toBe( + null, + ); + }); + + it('renders draft count in dropdown title', () => { + createComponent(); + + expect(vm.$el.querySelector('.dropdown-title').textContent).toContain('2 pending comments'); + }); + + it('renders publish button in footer', () => { + createComponent(); + + expect(vm.$el.querySelector('.dropdown-footer .js-publish-draft-button')).not.toBe(null); + }); +}); diff --git a/spec/frontend/batch_comments/mock_data.js b/spec/frontend/batch_comments/mock_data.js new file mode 100644 index 00000000000..c50fea94fe3 --- /dev/null +++ b/spec/frontend/batch_comments/mock_data.js @@ -0,0 +1,27 @@ +import { TEST_HOST } from 'spec/test_constants'; + +export const createDraft = () => ({ + author: { + id: 1, + name: 'Test', + username: 'test', + state: 'active', + avatar_url: TEST_HOST, + }, + current_user: { can_edit: true, can_award_emoji: false, can_resolve: false }, + discussion_id: null, + file_hash: null, + file_path: null, + id: 1, + line_code: null, + merge_request_id: 1, + note: 'a', + note_html: '

Test

', + noteable_type: 'MergeRequest', + references: { users: [], commands: '' }, + resolve_discussion: false, + isDraft: true, + position: null, +}); + +export default () => {}; 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 new file mode 100644 index 00000000000..2ec114d026a --- /dev/null +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -0,0 +1,403 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import * as actions from '~/batch_comments/stores/modules/batch_comments/actions'; +import axios from '~/lib/utils/axios_utils'; + +describe('Batch comments store actions', () => { + let res = {}; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + res = {}; + mock.restore(); + }); + + describe('saveDraft', () => { + it('dispatches saveNote on root', () => { + const dispatch = jest.fn(); + + actions.saveDraft({ dispatch }, { id: 1 }); + + expect(dispatch).toHaveBeenCalledWith('saveNote', { id: 1, isDraft: true }, { root: true }); + }); + }); + + describe('addDraftToDiscussion', () => { + it('commits ADD_NEW_DRAFT if no errors returned', done => { + res = { id: 1 }; + mock.onAny().reply(200, res); + + testAction( + actions.addDraftToDiscussion, + { endpoint: gl.TEST_HOST, data: 'test' }, + null, + [{ type: 'ADD_NEW_DRAFT', payload: res }], + [], + done, + ); + }); + + it('does not commit ADD_NEW_DRAFT if errors returned', done => { + mock.onAny().reply(500); + + testAction( + actions.addDraftToDiscussion, + { endpoint: gl.TEST_HOST, data: 'test' }, + null, + [], + [], + done, + ); + }); + }); + + describe('createNewDraft', () => { + it('commits ADD_NEW_DRAFT if no errors returned', done => { + res = { id: 1 }; + mock.onAny().reply(200, res); + + testAction( + actions.createNewDraft, + { endpoint: gl.TEST_HOST, data: 'test' }, + null, + [{ type: 'ADD_NEW_DRAFT', payload: res }], + [], + done, + ); + }); + + it('does not commit ADD_NEW_DRAFT if errors returned', done => { + mock.onAny().reply(500); + + testAction( + actions.createNewDraft, + { endpoint: gl.TEST_HOST, data: 'test' }, + null, + [], + [], + done, + ); + }); + }); + + describe('deleteDraft', () => { + let getters; + + beforeEach(() => { + getters = { + getNotesData: { + draftsDiscardPath: gl.TEST_HOST, + }, + }; + }); + + it('commits DELETE_DRAFT if no errors returned', done => { + const commit = jest.fn(); + const context = { + getters, + commit, + }; + res = { id: 1 }; + mock.onAny().reply(200); + + actions + .deleteDraft(context, { id: 1 }) + .then(() => { + expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1); + }) + .then(done) + .catch(done.fail); + }); + + it('does not commit DELETE_DRAFT if errors returned', done => { + const commit = jest.fn(); + const context = { + getters, + commit, + }; + mock.onAny().reply(500); + + actions + .deleteDraft(context, { id: 1 }) + .then(() => { + expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('fetchDrafts', () => { + let getters; + + beforeEach(() => { + getters = { + getNotesData: { + draftsPath: gl.TEST_HOST, + }, + }; + }); + + it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', done => { + const commit = jest.fn(); + const context = { + getters, + commit, + }; + res = { id: 1 }; + mock.onAny().reply(200, res); + + actions + .fetchDrafts(context) + .then(() => { + expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('publishReview', () => { + let dispatch; + let commit; + let getters; + let rootGetters; + + beforeEach(() => { + dispatch = jest.fn(); + commit = jest.fn(); + getters = { + getNotesData: { draftsPublishPath: gl.TEST_HOST, discussionsPath: gl.TEST_HOST }, + }; + rootGetters = { discussionsStructuredByLineCode: 'discussions' }; + }); + + it('dispatches actions & commits', done => { + mock.onAny().reply(200); + + actions + .publishReview({ dispatch, commit, getters, rootGetters }) + .then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']); + + expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']); + }) + .then(done) + .catch(done.fail); + }); + + it('dispatches error commits', done => { + mock.onAny().reply(500); + + actions + .publishReview({ dispatch, commit, getters, rootGetters }) + .then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('discardReview', () => { + it('commits mutations', done => { + const getters = { + getNotesData: { draftsDiscardPath: gl.TEST_HOST }, + }; + const commit = jest.fn(); + mock.onAny().reply(200); + + actions + .discardReview({ getters, commit }) + .then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_DISCARD_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_DISCARD_REVIEW_SUCCESS']); + }) + .then(done) + .catch(done.fail); + }); + + it('commits error mutations', done => { + const getters = { + getNotesData: { draftsDiscardPath: gl.TEST_HOST }, + }; + const commit = jest.fn(); + mock.onAny().reply(500); + + actions + .discardReview({ getters, commit }) + .then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_DISCARD_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_DISCARD_REVIEW_ERROR']); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateDraft', () => { + let getters; + + beforeEach(() => { + getters = { + getNotesData: { + draftsPath: gl.TEST_HOST, + }, + }; + }); + + it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', done => { + const commit = jest.fn(); + const context = { + getters, + commit, + }; + res = { id: 1 }; + mock.onAny().reply(200, res); + + actions + .updateDraft(context, { note: { id: 1 }, noteText: 'test', callback() {} }) + .then(() => { + expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 }); + }) + .then(done) + .catch(done.fail); + }); + + it('calls passed callback', done => { + const commit = jest.fn(); + const context = { + getters, + commit, + }; + const callback = jest.fn(); + res = { id: 1 }; + mock.onAny().reply(200, res); + + actions + .updateDraft(context, { note: { id: 1 }, noteText: 'test', callback }) + .then(() => { + expect(callback).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('toggleReviewDropdown', () => { + it('dispatches openReviewDropdown', done => { + testAction( + actions.toggleReviewDropdown, + null, + { showPreviewDropdown: false }, + [], + [{ type: 'openReviewDropdown' }], + done, + ); + }); + + it('dispatches closeReviewDropdown when showPreviewDropdown is true', done => { + testAction( + actions.toggleReviewDropdown, + null, + { showPreviewDropdown: true }, + [], + [{ type: 'closeReviewDropdown' }], + done, + ); + }); + }); + + describe('openReviewDropdown', () => { + it('commits OPEN_REVIEW_DROPDOWN', done => { + testAction( + actions.openReviewDropdown, + null, + null, + [{ type: 'OPEN_REVIEW_DROPDOWN' }], + [], + done, + ); + }); + }); + + describe('closeReviewDropdown', () => { + it('commits CLOSE_REVIEW_DROPDOWN', done => { + testAction( + actions.closeReviewDropdown, + null, + null, + [{ type: 'CLOSE_REVIEW_DROPDOWN' }], + [], + done, + ); + }); + }); + + describe('expandAllDiscussions', () => { + it('dispatches expandDiscussion for all drafts', done => { + const state = { + drafts: [ + { + discussion_id: '1', + }, + ], + }; + + testAction( + actions.expandAllDiscussions, + null, + state, + [], + [ + { + type: 'expandDiscussion', + payload: { discussionId: '1' }, + }, + ], + done, + ); + }); + }); + + describe('scrollToDraft', () => { + beforeEach(() => { + window.mrTabs = { + currentAction: 'notes', + tabShown: jest.fn(), + }; + }); + + it('scrolls to draft item', () => { + const dispatch = jest.fn(); + const rootGetters = { + getDiscussion: () => ({ + id: '1', + diff_discussion: true, + }), + }; + const draft = { + discussion_id: '1', + id: '2', + }; + + actions.scrollToDraft({ dispatch, rootGetters }, draft); + + expect(dispatch.mock.calls[0]).toEqual(['closeReviewDropdown']); + + expect(dispatch.mock.calls[1]).toEqual([ + 'expandDiscussion', + { discussionId: '1' }, + { root: true }, + ]); + + expect(window.mrTabs.tabShown).toHaveBeenCalledWith('diffs'); + }); + }); +}); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/getters_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/getters_spec.js new file mode 100644 index 00000000000..2398bb4feb1 --- /dev/null +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/getters_spec.js @@ -0,0 +1,27 @@ +import * as getters from '~/batch_comments/stores/modules/batch_comments/getters'; + +describe('Batch comments store getters', () => { + describe('draftsForFile', () => { + it('returns drafts for a file hash', () => { + const state = { + drafts: [ + { + file_hash: 'filehash', + comment: 'testing 123', + }, + { + file_hash: 'filehash2', + comment: 'testing 1234', + }, + ], + }; + + expect(getters.draftsForFile(state)('filehash')).toEqual([ + { + file_hash: 'filehash', + comment: 'testing 123', + }, + ]); + }); + }); +}); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js new file mode 100644 index 00000000000..a86726269ef --- /dev/null +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js @@ -0,0 +1,159 @@ +import createState from '~/batch_comments/stores/modules/batch_comments/state'; +import mutations from '~/batch_comments/stores/modules/batch_comments/mutations'; +import * as types from '~/batch_comments/stores/modules/batch_comments/mutation_types'; + +describe('Batch comments mutations', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(types.ADD_NEW_DRAFT, () => { + it('adds processed object into drafts array', () => { + const draft = { id: 1, note: 'test' }; + + mutations[types.ADD_NEW_DRAFT](state, draft); + + expect(state.drafts).toEqual([ + { + ...draft, + isDraft: true, + }, + ]); + }); + }); + + describe(types.DELETE_DRAFT, () => { + it('removes draft from array by ID', () => { + state.drafts.push({ id: 1 }, { id: 2 }); + + mutations[types.DELETE_DRAFT](state, 1); + + expect(state.drafts).toEqual([{ id: 2 }]); + }); + }); + + describe(types.SET_BATCH_COMMENTS_DRAFTS, () => { + it('adds to processed drafts in state', () => { + const drafts = [{ id: 1 }, { id: 2 }]; + + mutations[types.SET_BATCH_COMMENTS_DRAFTS](state, drafts); + + expect(state.drafts).toEqual([ + { + id: 1, + isDraft: true, + }, + { + id: 2, + isDraft: true, + }, + ]); + }); + }); + + describe(types.REQUEST_PUBLISH_REVIEW, () => { + it('sets isPublishing to true', () => { + mutations[types.REQUEST_PUBLISH_REVIEW](state); + + expect(state.isPublishing).toBe(true); + }); + }); + + describe(types.RECEIVE_PUBLISH_REVIEW_SUCCESS, () => { + it('resets drafts', () => { + state.drafts.push('test'); + + mutations[types.RECEIVE_PUBLISH_REVIEW_SUCCESS](state); + + expect(state.drafts).toEqual([]); + }); + + it('sets isPublishing to false', () => { + state.isPublishing = true; + + mutations[types.RECEIVE_PUBLISH_REVIEW_SUCCESS](state); + + expect(state.isPublishing).toBe(false); + }); + }); + + describe(types.RECEIVE_PUBLISH_REVIEW_ERROR, () => { + it('updates isPublishing to false', () => { + state.isPublishing = true; + + mutations[types.RECEIVE_PUBLISH_REVIEW_ERROR](state); + + expect(state.isPublishing).toBe(false); + }); + }); + + describe(types.REQUEST_DISCARD_REVIEW, () => { + it('sets isDiscarding to true', () => { + mutations[types.REQUEST_DISCARD_REVIEW](state); + + expect(state.isDiscarding).toBe(true); + }); + }); + + describe(types.RECEIVE_DISCARD_REVIEW_SUCCESS, () => { + it('emptys drafts array', () => { + state.drafts.push('test'); + + mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state); + + expect(state.drafts).toEqual([]); + }); + + it('sets isDiscarding to false', () => { + state.isDiscarding = true; + + mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state); + + expect(state.isDiscarding).toBe(false); + }); + }); + + describe(types.RECEIVE_DISCARD_REVIEW_ERROR, () => { + it('updates isDiscarding to false', () => { + state.isDiscarding = true; + + mutations[types.RECEIVE_DISCARD_REVIEW_ERROR](state); + + expect(state.isDiscarding).toBe(false); + }); + }); + + describe(types.RECEIVE_DRAFT_UPDATE_SUCCESS, () => { + it('updates draft in store', () => { + state.drafts.push({ id: 1 }); + + mutations[types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, { id: 1, note: 'test' }); + + expect(state.drafts).toEqual([ + { + id: 1, + note: 'test', + isDraft: true, + }, + ]); + }); + }); + + describe(types.OPEN_REVIEW_DROPDOWN, () => { + it('sets showPreviewDropdown to true', () => { + mutations[types.OPEN_REVIEW_DROPDOWN](state); + + expect(state.showPreviewDropdown).toBe(true); + }); + }); + + describe(types.CLOSE_REVIEW_DROPDOWN, () => { + it('sets showPreviewDropdown to false', () => { + mutations[types.CLOSE_REVIEW_DROPDOWN](state); + + expect(state.showPreviewDropdown).toBe(false); + }); + }); +}); diff --git a/spec/frontend/behaviors/autosize_spec.js b/spec/frontend/behaviors/autosize_spec.js new file mode 100644 index 00000000000..59abae479d4 --- /dev/null +++ b/spec/frontend/behaviors/autosize_spec.js @@ -0,0 +1,20 @@ +import $ from 'jquery'; +import '~/behaviors/autosize'; + +function load() { + $(document).trigger('load'); +} + +describe('Autosize behavior', () => { + beforeEach(() => { + setFixtures(''); + }); + + it('does not overwrite the resize property', () => { + load(); + + expect($('textarea')).toHaveCss({ + resize: 'vertical', + }); + }); +}); diff --git a/spec/frontend/behaviors/bind_in_out_spec.js b/spec/frontend/behaviors/bind_in_out_spec.js index 923b6d372dd..92a68ddd387 100644 --- a/spec/frontend/behaviors/bind_in_out_spec.js +++ b/spec/frontend/behaviors/bind_in_out_spec.js @@ -163,14 +163,8 @@ describe('BindInOut', () => { describe('init', () => { beforeEach(() => { - // eslint-disable-next-line func-names - jest.spyOn(BindInOut.prototype, 'addEvents').mockImplementation(function() { - return this; - }); - // eslint-disable-next-line func-names - jest.spyOn(BindInOut.prototype, 'updateOut').mockImplementation(function() { - return this; - }); + jest.spyOn(BindInOut.prototype, 'addEvents').mockReturnThis(); + jest.spyOn(BindInOut.prototype, 'updateOut').mockReturnThis(); testContext.init = BindInOut.init({}, {}); }); diff --git a/spec/frontend/behaviors/copy_as_gfm_spec.js b/spec/frontend/behaviors/copy_as_gfm_spec.js new file mode 100644 index 00000000000..cf96ac488a8 --- /dev/null +++ b/spec/frontend/behaviors/copy_as_gfm_spec.js @@ -0,0 +1,125 @@ +import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; + +describe('CopyAsGFM', () => { + describe('CopyAsGFM.pasteGFM', () => { + function callPasteGFM() { + const e = { + originalEvent: { + clipboardData: { + getData(mimeType) { + // When GFM code is copied, we put the regular plain text + // on the clipboard as `text/plain`, and the GFM as `text/x-gfm`. + // This emulates the behavior of `getData` with that data. + if (mimeType === 'text/plain') { + return 'code'; + } + if (mimeType === 'text/x-gfm') { + return '`code`'; + } + return null; + }, + }, + }, + preventDefault() {}, + }; + + CopyAsGFM.pasteGFM(e); + } + + it('wraps pasted code when not already in code tags', () => { + jest.spyOn(window.gl.utils, 'insertText').mockImplementation((el, textFunc) => { + const insertedText = textFunc('This is code: ', ''); + + expect(insertedText).toEqual('`code`'); + }); + + callPasteGFM(); + }); + + it('does not wrap pasted code when already in code tags', () => { + jest.spyOn(window.gl.utils, 'insertText').mockImplementation((el, textFunc) => { + const insertedText = textFunc('This is code: `', '`'); + + expect(insertedText).toEqual('code'); + }); + + callPasteGFM(); + }); + }); + + describe('CopyAsGFM.copyGFM', () => { + // Stub getSelection to return a purpose-built object. + const stubSelection = (html, parentNode) => ({ + getRangeAt: () => ({ + commonAncestorContainer: { tagName: parentNode }, + cloneContents: () => { + const fragment = document.createDocumentFragment(); + const node = document.createElement('div'); + node.innerHTML = html; + Array.from(node.childNodes).forEach(item => fragment.appendChild(item)); + return fragment; + }, + }), + rangeCount: 1, + }); + + const clipboardData = { + setData() {}, + }; + + const simulateCopy = () => { + const e = { + originalEvent: { + clipboardData, + }, + preventDefault() {}, + stopPropagation() {}, + }; + CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); + return clipboardData; + }; + + beforeAll(done => { + initCopyAsGFM(); + + // Fake call to nodeToGfm so the import of lazy bundle happened + CopyAsGFM.nodeToGFM(document.createElement('div')) + .then(() => { + done(); + }) + .catch(done.fail); + }); + + beforeEach(() => jest.spyOn(clipboardData, 'setData')); + + describe('list handling', () => { + it('uses correct gfm for unordered lists', done => { + const selection = stubSelection('
  • List Item1
  • List Item2
  • \n', 'UL'); + + window.getSelection = jest.fn(() => selection); + simulateCopy(); + + setImmediate(() => { + const expectedGFM = '* List Item1\n* List Item2'; + + expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); + done(); + }); + }); + + it('uses correct gfm for ordered lists', done => { + const selection = stubSelection('
  • List Item1
  • List Item2
  • \n', 'OL'); + + window.getSelection = jest.fn(() => selection); + simulateCopy(); + + setImmediate(() => { + const expectedGFM = '1. List Item1\n1. List Item2'; + + expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/behaviors/gl_emoji/unicode_support_map_spec.js b/spec/frontend/behaviors/gl_emoji/unicode_support_map_spec.js new file mode 100644 index 00000000000..aaee9c30cac --- /dev/null +++ b/spec/frontend/behaviors/gl_emoji/unicode_support_map_spec.js @@ -0,0 +1,52 @@ +import getUnicodeSupportMap from '~/emoji/support/unicode_support_map'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +describe('Unicode Support Map', () => { + useLocalStorageSpy(); + describe('getUnicodeSupportMap', () => { + const stringSupportMap = 'stringSupportMap'; + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockImplementation(() => {}); + jest.spyOn(JSON, 'parse').mockImplementation(() => {}); + jest.spyOn(JSON, 'stringify').mockReturnValue(stringSupportMap); + }); + + describe('if isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + + getUnicodeSupportMap(); + }); + + it('should call .getItem and .setItem', () => { + const getArgs = window.localStorage.getItem.mock.calls; + const setArgs = window.localStorage.setItem.mock.calls; + + expect(getArgs[0][0]).toBe('gl-emoji-version'); + expect(getArgs[1][0]).toBe('gl-emoji-user-agent'); + + expect(setArgs[0][0]).toBe('gl-emoji-version'); + expect(setArgs[0][1]).toBe('0.2.0'); + expect(setArgs[1][0]).toBe('gl-emoji-user-agent'); + expect(setArgs[1][1]).toBe(navigator.userAgent); + expect(setArgs[2][0]).toBe('gl-emoji-unicode-support-map'); + expect(setArgs[2][1]).toBe(stringSupportMap); + }); + }); + + describe('if isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false); + + getUnicodeSupportMap(); + }); + + it('should not call .getItem or .setItem', () => { + expect(window.localStorage.getItem.mock.calls.length).toBe(1); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/behaviors/markdown/highlight_current_user_spec.js b/spec/frontend/behaviors/markdown/highlight_current_user_spec.js new file mode 100644 index 00000000000..3305ddc412d --- /dev/null +++ b/spec/frontend/behaviors/markdown/highlight_current_user_spec.js @@ -0,0 +1,55 @@ +import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; + +describe('highlightCurrentUser', () => { + let rootElement; + let elements; + + beforeEach(() => { + setFixtures(` +
    +
    @first
    +
    @second
    +
    + `); + rootElement = document.getElementById('dummy-root-element'); + elements = rootElement.querySelectorAll('[data-user]'); + }); + + describe('without current user', () => { + beforeEach(() => { + window.gon = window.gon || {}; + window.gon.current_user_id = null; + }); + + afterEach(() => { + delete window.gon.current_user_id; + }); + + it('does not highlight the user', () => { + const initialHtml = rootElement.outerHTML; + + highlightCurrentUser(elements); + + expect(rootElement.outerHTML).toBe(initialHtml); + }); + }); + + describe('with current user', () => { + beforeEach(() => { + window.gon = window.gon || {}; + window.gon.current_user_id = 2; + }); + + afterEach(() => { + delete window.gon.current_user_id; + }); + + it('highlights current user', () => { + highlightCurrentUser(elements); + + expect(elements.length).toBe(2); + expect(elements[0]).not.toHaveClass('current-user'); + expect(elements[1]).toHaveClass('current-user'); + }); + }); +}); diff --git a/spec/frontend/behaviors/requires_input_spec.js b/spec/frontend/behaviors/requires_input_spec.js new file mode 100644 index 00000000000..617fe49b059 --- /dev/null +++ b/spec/frontend/behaviors/requires_input_spec.js @@ -0,0 +1,62 @@ +import $ from 'jquery'; +import '~/behaviors/requires_input'; + +describe('requiresInput', () => { + let submitButton; + preloadFixtures('branches/new_branch.html'); + + beforeEach(() => { + loadFixtures('branches/new_branch.html'); + submitButton = $('button[type="submit"]'); + }); + + it('disables submit when any field is required', () => { + $('.js-requires-input').requiresInput(); + + expect(submitButton).toBeDisabled(); + }); + + it('enables submit when no field is required', () => { + $('*[required=required]').prop('required', false); + $('.js-requires-input').requiresInput(); + + expect(submitButton).not.toBeDisabled(); + }); + + it('enables submit when all required fields are pre-filled', () => { + $('*[required=required]').remove(); + $('.js-requires-input').requiresInput(); + + expect($('.submit')).not.toBeDisabled(); + }); + + it('enables submit when all required fields receive input', () => { + $('.js-requires-input').requiresInput(); + $('#required1') + .val('input1') + .change(); + + expect(submitButton).toBeDisabled(); + + $('#optional1') + .val('input1') + .change(); + + expect(submitButton).toBeDisabled(); + + $('#required2') + .val('input2') + .change(); + $('#required3') + .val('input3') + .change(); + $('#required4') + .val('input4') + .change(); + $('#required5') + .val('1') + .change(); + + expect($('.submit')).not.toBeDisabled(); + }); +}); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js new file mode 100644 index 00000000000..6391a544985 --- /dev/null +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -0,0 +1,322 @@ +import $ from 'jquery'; +import 'mousetrap'; +import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; +import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; +import { getSelectedFragment } from '~/lib/utils/common_utils'; + +const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form'; + +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + getSelectedFragment: jest.fn().mockName('getSelectedFragment'), +})); + +describe('ShortcutsIssuable', () => { + const fixtureName = 'snippets/show.html'; + + preloadFixtures(fixtureName); + + beforeAll(done => { + initCopyAsGFM(); + + // Fake call to nodeToGfm so the import of lazy bundle happened + CopyAsGFM.nodeToGFM(document.createElement('div')) + .then(() => { + done(); + }) + .catch(done.fail); + }); + + beforeEach(() => { + loadFixtures(fixtureName); + $('body').append( + `
    + +
    `, + ); + document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); + + window.shortcut = new ShortcutsIssuable(true); + }); + + afterEach(() => { + $(FORM_SELECTOR).remove(); + + delete window.shortcut; + }); + + describe('replyWithSelectedText', () => { + // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML. + const stubSelection = (html, invalidNode) => { + getSelectedFragment.mockImplementation(() => { + const documentFragment = document.createDocumentFragment(); + const node = document.createElement('div'); + + node.innerHTML = html; + if (!invalidNode) node.className = 'md'; + + documentFragment.appendChild(node); + return documentFragment; + }); + }; + + describe('with empty selection', () => { + it('does not return an error', () => { + ShortcutsIssuable.replyWithSelectedText(true); + + expect($(FORM_SELECTOR).val()).toBe(''); + }); + + it('triggers `focus`', () => { + const spy = jest.spyOn(document.querySelector(FORM_SELECTOR), 'focus'); + ShortcutsIssuable.replyWithSelectedText(true); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('with any selection', () => { + beforeEach(() => { + stubSelection('

    Selected text.

    '); + }); + + it('leaves existing input intact', done => { + $(FORM_SELECTOR).val('This text was already here.'); + + expect($(FORM_SELECTOR).val()).toBe('This text was already here.'); + + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect($(FORM_SELECTOR).val()).toBe( + 'This text was already here.\n\n> Selected text.\n\n', + ); + done(); + }); + }); + + it('triggers `input`', done => { + let triggered = false; + $(FORM_SELECTOR).on('input', () => { + triggered = true; + }); + + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect(triggered).toBe(true); + done(); + }); + }); + + it('triggers `focus`', done => { + const spy = jest.spyOn(document.querySelector(FORM_SELECTOR), 'focus'); + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('with a one-line selection', () => { + it('quotes the selection', done => { + stubSelection('

    This text has been selected.

    '); + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect($(FORM_SELECTOR).val()).toBe('> This text has been selected.\n\n'); + done(); + }); + }); + }); + + describe('with a multi-line selection', () => { + it('quotes the selected lines as a group', done => { + stubSelection( + '

    Selected line one.

    \n

    Selected line two.

    \n

    Selected line three.

    ', + ); + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect($(FORM_SELECTOR).val()).toBe( + '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n', + ); + done(); + }); + }); + }); + + describe('with an invalid selection', () => { + beforeEach(() => { + stubSelection('

    Selected text.

    ', true); + }); + + it('does not add anything to the input', done => { + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect($(FORM_SELECTOR).val()).toBe(''); + done(); + }); + }); + + it('triggers `focus`', done => { + const spy = jest.spyOn(document.querySelector(FORM_SELECTOR), 'focus'); + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('with a semi-valid selection', () => { + beforeEach(() => { + stubSelection('
    Selected text.

    Invalid selected text.

    ', true); + }); + + it('only adds the valid part to the input', done => { + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect($(FORM_SELECTOR).val()).toBe('> Selected text.\n\n'); + done(); + }); + }); + + it('triggers `focus`', done => { + const spy = jest.spyOn(document.querySelector(FORM_SELECTOR), 'focus'); + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); + }); + + it('triggers `input`', done => { + let triggered = false; + $(FORM_SELECTOR).on('input', () => { + triggered = true; + }); + + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect(triggered).toBe(true); + done(); + }); + }); + }); + + describe('with a selection in a valid block', () => { + beforeEach(() => { + getSelectedFragment.mockImplementation(() => { + const documentFragment = document.createDocumentFragment(); + const node = document.createElement('div'); + const originalNode = document.createElement('body'); + originalNode.innerHTML = `
    +
    Text...
    +

    Selected text.

    +
    `; + documentFragment.originalNodes = [originalNode.querySelector('em')]; + + node.innerHTML = 'Selected text.'; + + documentFragment.appendChild(node); + + return documentFragment; + }); + }); + + it('adds the quoted selection to the input', done => { + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n'); + done(); + }); + }); + + it('triggers `focus`', done => { + const spy = jest.spyOn(document.querySelector(FORM_SELECTOR), 'focus'); + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); + }); + + it('triggers `input`', done => { + let triggered = false; + $(FORM_SELECTOR).on('input', () => { + triggered = true; + }); + + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect(triggered).toBe(true); + done(); + }); + }); + }); + + describe('with a selection in an invalid block', () => { + beforeEach(() => { + getSelectedFragment.mockImplementation(() => { + const documentFragment = document.createDocumentFragment(); + const node = document.createElement('div'); + const originalNode = document.createElement('body'); + originalNode.innerHTML = `
    +
    Selected text.
    +

    Valid text

    +
    `; + documentFragment.originalNodes = [originalNode.querySelector('b')]; + + node.innerHTML = 'Selected text.'; + + documentFragment.appendChild(node); + + return documentFragment; + }); + }); + + it('does not add anything to the input', done => { + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect($(FORM_SELECTOR).val()).toBe(''); + done(); + }); + }); + + it('triggers `focus`', done => { + const spy = jest.spyOn(document.querySelector(FORM_SELECTOR), 'focus'); + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('with a valid selection with no text content', () => { + it('returns the proper markdown', done => { + stubSelection('logo'); + ShortcutsIssuable.replyWithSelectedText(true); + + setImmediate(() => { + expect($(FORM_SELECTOR).val()).toBe('> ![logo](https://gitlab.com/logo.png)\n\n'); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap index 2ac6e0d5d24..005b2c5da1c 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap @@ -14,7 +14,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` foo/bar/dummy.md diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js index 684840afe1c..0247a12d8d3 100644 --- a/spec/frontend/blob/components/blob_header_default_actions_spec.js +++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js @@ -66,5 +66,13 @@ describe('Blob Header Default Actions', () => { expect(buttons.at(0).attributes('disabled')).toBeTruthy(); }); + + it('does not render the copy button if a rendering error is set', () => { + createComponent({ + hasRenderError: true, + }); + + expect(wrapper.find('[data-testid="copyContentsButton"]').exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js index 3a53208f357..43057353051 100644 --- a/spec/frontend/blob/components/blob_header_filepath_spec.js +++ b/spec/frontend/blob/components/blob_header_filepath_spec.js @@ -4,9 +4,8 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { Blob as MockBlob } from './mock_data'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -const mockHumanReadableSize = 'a lot'; jest.mock('~/lib/utils/number_utils', () => ({ - numberToHumanSize: jest.fn(() => mockHumanReadableSize), + numberToHumanSize: jest.fn(() => 'a lot'), })); describe('Blob Header Filepath', () => { @@ -57,7 +56,7 @@ describe('Blob Header Filepath', () => { it('renders filesize in a human-friendly format', () => { createComponent(); expect(numberToHumanSize).toHaveBeenCalled(); - expect(wrapper.vm.blobSize).toBe(mockHumanReadableSize); + expect(wrapper.vm.blobSize).toBe('a lot'); }); it('renders a slot and prepends its contents to the existing one', () => { diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js index 0e7d2f6516a..01d4bf834d2 100644 --- a/spec/frontend/blob/components/blob_header_spec.js +++ b/spec/frontend/blob/components/blob_header_spec.js @@ -87,6 +87,17 @@ describe('Blob Header Default Actions', () => { expect(wrapper.text()).toContain(slotContent); }); }); + + it('passes information about render error down to default actions', () => { + createComponent( + {}, + {}, + { + hasRenderError: true, + }, + ); + expect(wrapper.find(DefaultActions).props('hasRenderError')).toBe(true); + }); }); describe('functionality', () => { diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js new file mode 100644 index 00000000000..b51a82f2a35 --- /dev/null +++ b/spec/frontend/boards/board_list_helper.js @@ -0,0 +1,66 @@ +/* global List */ +/* global ListIssue */ + +import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; +import Sortable from 'sortablejs'; +import axios from '~/lib/utils/axios_utils'; +import BoardList from '~/boards/components/board_list.vue'; + +import '~/boards/models/issue'; +import '~/boards/models/list'; +import { listObj, boardsMockInterceptor } from './mock_data'; +import store from '~/boards/stores'; +import boardsStore from '~/boards/stores/boards_store'; + +window.Sortable = Sortable; + +export default function createComponent({ + done, + listIssueProps = {}, + componentProps = {}, + listProps = {}, +}) { + const el = document.createElement('div'); + + document.body.appendChild(el); + const mock = new MockAdapter(axios); + mock.onAny().reply(boardsMockInterceptor); + boardsStore.create(); + + const BoardListComp = Vue.extend(BoardList); + const list = new List({ ...listObj, ...listProps }); + const issue = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [], + assignees: [], + ...listIssueProps, + }); + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { + list.issuesSize = 1; + } + list.issues.push(issue); + + const component = new BoardListComp({ + el, + store, + propsData: { + disabled: false, + list, + issues: list.issues, + loading: false, + issueLinkBase: '/issues', + rootPath: '/', + ...componentProps, + }, + }).$mount(); + + Vue.nextTick(() => { + done(); + }); + + return { component, mock }; +} diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index fa21053e2de..3a64b004847 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -118,7 +118,7 @@ describe('Board list component', () => { }); it('shows new issue form after eventhub event', () => { - eventHub.$emit(`hide-issue-form-${component.list.id}`); + eventHub.$emit(`toggle-issue-form-${component.list.id}`); return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index 7cf6ec913b4..6853fe2559d 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -70,37 +70,6 @@ describe('Board Column Component', () => { const isExpandable = () => wrapper.classes('is-expandable'); const isCollapsed = () => wrapper.classes('is-collapsed'); - const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); - - describe('Add issue button', () => { - const hasNoAddButton = [ListType.promotion, ListType.blank, ListType.closed]; - const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; - - it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { - createComponent({ listType }); - - expect(findAddIssueButton().exists()).toBe(false); - }); - - it.each(hasAddButton)('does render when List Type is `%s`', listType => { - createComponent({ listType }); - - expect(findAddIssueButton().exists()).toBe(true); - }); - - it('has a test for each list type', () => { - Object.values(ListType).forEach(value => { - expect([...hasAddButton, ...hasNoAddButton]).toContain(value); - }); - }); - - it('does render when logged out', () => { - createComponent(); - - expect(findAddIssueButton().exists()).toBe(true); - }); - }); - describe('Given different list types', () => { it('is expandable when List Type is `backlog`', () => { createComponent({ listType: ListType.backlog }); @@ -109,64 +78,17 @@ describe('Board Column Component', () => { }); }); - describe('expanding / collapsing the column', () => { - it('does not collapse when clicking the header', () => { - createComponent(); - expect(isCollapsed()).toBe(false); - wrapper.find('.board-header').trigger('click'); + describe('expanded / collaped column', () => { + it('has class is-collapsed when list is collapsed', () => { + createComponent({ collapsed: false }); - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); - - it('collapses expanded Column when clicking the collapse icon', () => { - createComponent(); expect(wrapper.vm.list.isExpanded).toBe(true); - wrapper.find('.board-title-caret').trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(true); - }); }); - it('expands collapsed Column when clicking the expand icon', () => { + it('does not have class is-collapsed when list is expanded', () => { createComponent({ collapsed: true }); - expect(isCollapsed()).toBe(true); - wrapper.find('.board-title-caret').trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); - - 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 }); - wrapper.find('.board-title-caret').trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); - }); - }); - - it("when logged out it doesn't call list update and sets localStorage", () => { - jest.spyOn(List.prototype, 'update'); - - createComponent(); - - wrapper.find('.board-title-caret').trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).toHaveBeenCalledTimes(0); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe( - String(wrapper.vm.list.isExpanded), - ); - }); + expect(isCollapsed()).toBe(true); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js new file mode 100644 index 00000000000..95673da1c56 --- /dev/null +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -0,0 +1,166 @@ +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +import BoardListHeader from '~/boards/components/board_list_header.vue'; +import List from '~/boards/models/list'; +import { ListType } from '~/boards/constants'; +import axios from '~/lib/utils/axios_utils'; + +import { TEST_HOST } from 'helpers/test_constants'; +import { listObj } from 'jest/boards/mock_data'; + +describe('Board List Header Component', () => { + let wrapper; + let axiosMock; + + beforeEach(() => { + window.gon = {}; + axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); + }); + + afterEach(() => { + axiosMock.restore(); + + wrapper.destroy(); + + localStorage.clear(); + }); + + const createComponent = ({ + listType = ListType.backlog, + collapsed = false, + withLocalStorage = true, + } = {}) => { + const boardId = '1'; + + const listMock = { + ...listObj, + list_type: listType, + collapsed, + }; + + if (listType === ListType.assignee) { + delete listMock.label; + listMock.user = {}; + } + + // Making List reactive + const list = Vue.observable(new List(listMock)); + + if (withLocalStorage) { + localStorage.setItem( + `boards.${boardId}.${list.type}.${list.id}.expanded`, + (!collapsed).toString(), + ); + } + + wrapper = shallowMount(BoardListHeader, { + propsData: { + boardId, + disabled: false, + issueLinkBase: '/', + rootPath: '/', + list, + }, + }); + }; + + const isCollapsed = () => !wrapper.props().list.isExpanded; + const isExpanded = () => wrapper.vm.list.isExpanded; + + const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); + const findCaret = () => wrapper.find('.board-title-caret'); + + describe('Add issue button', () => { + const hasNoAddButton = [ListType.promotion, ListType.blank, ListType.closed]; + const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; + + it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { + createComponent({ listType }); + + expect(findAddIssueButton().exists()).toBe(false); + }); + + it.each(hasAddButton)('does render when List Type is `%s`', listType => { + createComponent({ listType }); + + expect(findAddIssueButton().exists()).toBe(true); + }); + + it('has a test for each list type', () => { + Object.values(ListType).forEach(value => { + expect([...hasAddButton, ...hasNoAddButton]).toContain(value); + }); + }); + + it('does render when logged out', () => { + createComponent(); + + expect(findAddIssueButton().exists()).toBe(true); + }); + }); + + describe('expanding / collapsing the column', () => { + it('does not collapse when clicking the header', () => { + createComponent(); + + expect(isCollapsed()).toBe(false); + wrapper.find('[data-testid="board-list-header"]').vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); + }); + + it('collapses expanded Column when clicking the collapse icon', () => { + createComponent(); + + expect(isExpanded()).toBe(true); + findCaret().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(true); + }); + }); + + it('expands collapsed Column when clicking the expand icon', () => { + createComponent({ collapsed: true }); + + expect(isCollapsed()).toBe(true); + findCaret().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); + }); + + 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 }); + + findCaret().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); + }); + }); + + it("when logged out it doesn't call list update and sets localStorage", () => { + jest.spyOn(List.prototype, 'update'); + + createComponent(); + + findCaret().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).not.toHaveBeenCalled(); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); + }); + }); + }); +}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index d23393db60d..0debca1310a 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1,4 +1,6 @@ import actions from '~/boards/stores/actions'; +import * as types from '~/boards/stores/mutation_types'; +import testAction from 'helpers/vuex_action_helper'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -7,7 +9,20 @@ const expectNotImplemented = action => { }; describe('setEndpoints', () => { - expectNotImplemented(actions.setEndpoints); + it('sets endpoints object', () => { + const mockEndpoints = { + foo: 'bar', + bar: 'baz', + }; + + return testAction( + actions.setEndpoints, + mockEndpoints, + {}, + [{ type: types.SET_ENDPOINTS, payload: mockEndpoints }], + [], + ); + }); }); describe('fetchLists', () => { diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index aa477766978..bc57c30b354 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,4 +1,6 @@ import mutations from '~/boards/stores/mutations'; +import * as types from '~/boards/stores/mutation_types'; +import defaultState from '~/boards/stores/state'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -6,86 +8,107 @@ const expectNotImplemented = action => { }); }; -describe('SET_ENDPOINTS', () => { - expectNotImplemented(mutations.SET_ENDPOINTS); -}); +describe('Board Store Mutations', () => { + let state; -describe('REQUEST_ADD_LIST', () => { - expectNotImplemented(mutations.REQUEST_ADD_LIST); -}); + beforeEach(() => { + state = defaultState(); + }); -describe('RECEIVE_ADD_LIST_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_ADD_LIST_SUCCESS); -}); + describe('SET_ENDPOINTS', () => { + it('Should set initial Boards data to state', () => { + const endpoints = { + boardsEndpoint: '/boards/', + recentBoardsEndpoint: '/boards/', + listsEndpoint: '/boards/lists', + bulkUpdatePath: '/boards/bulkUpdate', + boardId: 1, + fullPath: 'gitlab-org', + }; + + mutations[types.SET_ENDPOINTS](state, endpoints); + + expect(state.endpoints).toEqual(endpoints); + }); + }); -describe('RECEIVE_ADD_LIST_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_ADD_LIST_ERROR); -}); + describe('REQUEST_ADD_LIST', () => { + expectNotImplemented(mutations.REQUEST_ADD_LIST); + }); -describe('REQUEST_UPDATE_LIST', () => { - expectNotImplemented(mutations.REQUEST_UPDATE_LIST); -}); + describe('RECEIVE_ADD_LIST_SUCCESS', () => { + expectNotImplemented(mutations.RECEIVE_ADD_LIST_SUCCESS); + }); -describe('RECEIVE_UPDATE_LIST_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_SUCCESS); -}); + describe('RECEIVE_ADD_LIST_ERROR', () => { + expectNotImplemented(mutations.RECEIVE_ADD_LIST_ERROR); + }); -describe('RECEIVE_UPDATE_LIST_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_ERROR); -}); + describe('REQUEST_UPDATE_LIST', () => { + expectNotImplemented(mutations.REQUEST_UPDATE_LIST); + }); -describe('REQUEST_REMOVE_LIST', () => { - expectNotImplemented(mutations.REQUEST_REMOVE_LIST); -}); + describe('RECEIVE_UPDATE_LIST_SUCCESS', () => { + expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_SUCCESS); + }); -describe('RECEIVE_REMOVE_LIST_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_SUCCESS); -}); + describe('RECEIVE_UPDATE_LIST_ERROR', () => { + expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_ERROR); + }); -describe('RECEIVE_REMOVE_LIST_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR); -}); + describe('REQUEST_REMOVE_LIST', () => { + expectNotImplemented(mutations.REQUEST_REMOVE_LIST); + }); -describe('REQUEST_ADD_ISSUE', () => { - expectNotImplemented(mutations.REQUEST_ADD_ISSUE); -}); + describe('RECEIVE_REMOVE_LIST_SUCCESS', () => { + expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_SUCCESS); + }); -describe('RECEIVE_ADD_ISSUE_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_SUCCESS); -}); + describe('RECEIVE_REMOVE_LIST_ERROR', () => { + expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR); + }); -describe('RECEIVE_ADD_ISSUE_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_ERROR); -}); + describe('REQUEST_ADD_ISSUE', () => { + expectNotImplemented(mutations.REQUEST_ADD_ISSUE); + }); -describe('REQUEST_MOVE_ISSUE', () => { - expectNotImplemented(mutations.REQUEST_MOVE_ISSUE); -}); + describe('RECEIVE_ADD_ISSUE_SUCCESS', () => { + expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_SUCCESS); + }); -describe('RECEIVE_MOVE_ISSUE_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_SUCCESS); -}); + describe('RECEIVE_ADD_ISSUE_ERROR', () => { + expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_ERROR); + }); -describe('RECEIVE_MOVE_ISSUE_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_ERROR); -}); + describe('REQUEST_MOVE_ISSUE', () => { + expectNotImplemented(mutations.REQUEST_MOVE_ISSUE); + }); -describe('REQUEST_UPDATE_ISSUE', () => { - expectNotImplemented(mutations.REQUEST_UPDATE_ISSUE); -}); + describe('RECEIVE_MOVE_ISSUE_SUCCESS', () => { + expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_SUCCESS); + }); -describe('RECEIVE_UPDATE_ISSUE_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_SUCCESS); -}); + describe('RECEIVE_MOVE_ISSUE_ERROR', () => { + expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_ERROR); + }); -describe('RECEIVE_UPDATE_ISSUE_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR); -}); + describe('REQUEST_UPDATE_ISSUE', () => { + expectNotImplemented(mutations.REQUEST_UPDATE_ISSUE); + }); -describe('SET_CURRENT_PAGE', () => { - expectNotImplemented(mutations.SET_CURRENT_PAGE); -}); + describe('RECEIVE_UPDATE_ISSUE_SUCCESS', () => { + expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_SUCCESS); + }); -describe('TOGGLE_EMPTY_STATE', () => { - expectNotImplemented(mutations.TOGGLE_EMPTY_STATE); + describe('RECEIVE_UPDATE_ISSUE_ERROR', () => { + expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR); + }); + + describe('SET_CURRENT_PAGE', () => { + expectNotImplemented(mutations.SET_CURRENT_PAGE); + }); + + describe('TOGGLE_EMPTY_STATE', () => { + expectNotImplemented(mutations.TOGGLE_EMPTY_STATE); + }); }); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 9179302f786..094fdcdc185 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -105,6 +105,46 @@ describe('Ci variable modal', () => { }); }); + describe('Adding a new non-AWS variable', () => { + beforeEach(() => { + const [variable] = mockData.mockVariables; + const invalidKeyVariable = { + ...variable, + key: 'key', + value: 'value', + secret_value: 'secret_value', + }; + createComponent(mount); + store.state.variable = invalidKeyVariable; + }); + + it('does not show AWS guidance tip', () => { + const tip = wrapper.find(`div[data-testid='aws-guidance-tip']`); + expect(tip.exists()).toBe(true); + expect(tip.isVisible()).toBe(false); + }); + }); + + describe('Adding a new AWS variable', () => { + beforeEach(() => { + const [variable] = mockData.mockVariables; + const invalidKeyVariable = { + ...variable, + key: AWS_ACCESS_KEY_ID, + value: 'AKIAIOSFODNN7EXAMPLEjdhy', + secret_value: 'AKIAIOSFODNN7EXAMPLEjdhy', + }; + createComponent(mount); + store.state.variable = invalidKeyVariable; + }); + + it('shows AWS guidance tip', () => { + const tip = wrapper.find(`[data-testid='aws-guidance-tip']`); + expect(tip.exists()).toBe(true); + expect(tip.isVisible()).toBe(true); + }); + }); + describe('Editing a variable', () => { beforeEach(() => { const [variable] = mockData.mockVariables; diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 9d0ed423759..a9870e4db57 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -268,13 +268,18 @@ describe('Clusters', () => { cluster.store.state.applications[applicationId].status = INSTALLABLE; + const params = {}; + if (applicationId === 'knative') { + params.hostname = 'test-example.com'; + } + // eslint-disable-next-line promise/valid-params cluster - .installApplication({ id: applicationId }) + .installApplication({ id: applicationId, params }) .then(() => { expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING); expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined); + expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, params); done(); }) .catch(); diff --git a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap new file mode 100644 index 00000000000..92237590550 --- /dev/null +++ b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Applications Cert-Manager application shows the correct description 1`] = ` +

    + Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates. Installing Cert-Manager on your cluster will issue a certificate by + + Let's Encrypt + + and ensure that certificates are valid and up-to-date. +

    +`; + +exports[`Applications Crossplane application shows the correct description 1`] = ` +

    + Crossplane enables declarative provisioning of managed services from your cloud of choice using + + kubectl + + or + + GitLab Integration + + . Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on. +

    +`; + +exports[`Applications Ingress application shows the correct warning message 1`] = ` + + Installing Ingress may incur additional costs. Learn more about + + pricing + + . + +`; + +exports[`Applications Knative application shows the correct description 1`] = ` + + installed via + + Cloud Run + + +`; + +exports[`Applications Prometheus application shows the correct description 1`] = ` + + Prometheus is an open-source monitoring system with + + GitLab Integration + + to monitor deployed applications. + +`; diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js index 33ff1424c61..94bdd7b7778 100644 --- a/spec/frontend/clusters/components/application_row_spec.js +++ b/spec/frontend/clusters/components/application_row_spec.js @@ -1,242 +1,194 @@ -import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { GlSprintf } from '@gitlab/ui'; import eventHub from '~/clusters/event_hub'; -import { APPLICATION_STATUS } from '~/clusters/constants'; -import applicationRow from '~/clusters/components/application_row.vue'; +import { APPLICATION_STATUS, ELASTIC_STACK } from '~/clusters/constants'; +import ApplicationRow from '~/clusters/components/application_row.vue'; import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue'; +import UpdateApplicationConfirmationModal from '~/clusters/components/update_application_confirmation_modal.vue'; import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; describe('Application Row', () => { - let vm; - let ApplicationRow; - - beforeEach(() => { - ApplicationRow = Vue.extend(applicationRow); - }); + let wrapper; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); + const mountComponent = data => { + wrapper = shallowMount(ApplicationRow, { + stubs: { GlSprintf }, + propsData: { + ...DEFAULT_APPLICATION_STATE, + ...data, + }, + }); + }; + describe('Title', () => { it('shows title', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - titleLink: null, - }); - const title = vm.$el.querySelector('.js-cluster-application-title'); + mountComponent({ titleLink: null }); + + const title = wrapper.find('.js-cluster-application-title'); - expect(title.tagName).toEqual('SPAN'); - expect(title.textContent.trim()).toEqual(DEFAULT_APPLICATION_STATE.title); + expect(title.element).toBeInstanceOf(HTMLSpanElement); + expect(title.text()).toEqual(DEFAULT_APPLICATION_STATE.title); }); it('shows title link', () => { expect(DEFAULT_APPLICATION_STATE.titleLink).toBeDefined(); + mountComponent(); + const title = wrapper.find('.js-cluster-application-title'); - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - }); - const title = vm.$el.querySelector('.js-cluster-application-title'); - - expect(title.tagName).toEqual('A'); - expect(title.textContent.trim()).toEqual(DEFAULT_APPLICATION_STATE.title); + expect(title.element).toBeInstanceOf(HTMLAnchorElement); + expect(title.text()).toEqual(DEFAULT_APPLICATION_STATE.title); }); }); describe('Install button', () => { + const button = () => wrapper.find('.js-cluster-application-install-button'); + const checkButtonState = (label, loading, disabled) => { + expect(button().props('label')).toEqual(label); + expect(button().props('loading')).toEqual(loading); + expect(button().props('disabled')).toEqual(disabled); + }; + it('has indeterminate state on page load', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: null, - }); + mountComponent({ status: null }); - expect(vm.installButtonLabel).toBeUndefined(); + expect(button().props('label')).toBeUndefined(); }); it('has install button', () => { - const installationBtn = vm.$el.querySelector('.js-cluster-application-install-button'); + mountComponent(); - expect(installationBtn).not.toBe(null); + expect(button().exists()).toBe(true); }); it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.NOT_INSTALLABLE, - }); + mountComponent({ status: APPLICATION_STATUS.NOT_INSTALLABLE }); - expect(vm.installButtonLabel).toEqual('Install'); - expect(vm.installButtonLoading).toEqual(false); - expect(vm.installButtonDisabled).toEqual(true); + checkButtonState('Install', false, true); }); it('has enabled "Install" when APPLICATION_STATUS.INSTALLABLE', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - }); + mountComponent({ status: APPLICATION_STATUS.INSTALLABLE }); - expect(vm.installButtonLabel).toEqual('Install'); - expect(vm.installButtonLoading).toEqual(false); - expect(vm.installButtonDisabled).toEqual(false); + checkButtonState('Install', false, false); }); it('has loading "Installing" when APPLICATION_STATUS.INSTALLING', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLING, - }); + mountComponent({ status: APPLICATION_STATUS.INSTALLING }); - expect(vm.installButtonLabel).toEqual('Installing'); - expect(vm.installButtonLoading).toEqual(true); - expect(vm.installButtonDisabled).toEqual(true); + checkButtonState('Installing', true, true); }); it('has disabled "Installed" when application is installed and not uninstallable', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.INSTALLED, installed: true, uninstallable: false, }); - expect(vm.installButtonLabel).toEqual('Installed'); - expect(vm.installButtonLoading).toEqual(false); - expect(vm.installButtonDisabled).toEqual(true); + checkButtonState('Installed', false, true); }); it('hides when application is installed and uninstallable', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.INSTALLED, installed: true, uninstallable: true, }); - const installBtn = vm.$el.querySelector('.js-cluster-application-install-button'); - expect(installBtn).toBe(null); + expect(button().exists()).toBe(false); }); it('has enabled "Install" when install fails', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.INSTALLABLE, installFailed: true, }); - expect(vm.installButtonLabel).toEqual('Install'); - expect(vm.installButtonLoading).toEqual(false); - expect(vm.installButtonDisabled).toEqual(false); + checkButtonState('Install', false, false); }); it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - }); + mountComponent({ status: APPLICATION_STATUS.INSTALLABLE }); - expect(vm.installButtonLabel).toEqual('Install'); - expect(vm.installButtonLoading).toEqual(false); - expect(vm.installButtonDisabled).toEqual(false); + checkButtonState('Install', false, false); }); it('clicking install button emits event', () => { - jest.spyOn(eventHub, '$emit'); - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - }); - const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); + const spy = jest.spyOn(eventHub, '$emit'); + mountComponent({ status: APPLICATION_STATUS.INSTALLABLE }); - installButton.click(); + button().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', { + expect(spy).toHaveBeenCalledWith('installApplication', { id: DEFAULT_APPLICATION_STATE.id, params: {}, }); }); it('clicking install button when installApplicationRequestParams are provided emits event', () => { - jest.spyOn(eventHub, '$emit'); - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + const spy = jest.spyOn(eventHub, '$emit'); + mountComponent({ status: APPLICATION_STATUS.INSTALLABLE, installApplicationRequestParams: { hostname: 'jupyter' }, }); - const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); - installButton.click(); + button().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', { + expect(spy).toHaveBeenCalledWith('installApplication', { id: DEFAULT_APPLICATION_STATE.id, params: { hostname: 'jupyter' }, }); }); it('clicking disabled install button emits nothing', () => { - jest.spyOn(eventHub, '$emit'); - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLING, - }); - const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); + const spy = jest.spyOn(eventHub, '$emit'); + mountComponent({ status: APPLICATION_STATUS.INSTALLING }); - expect(vm.installButtonDisabled).toEqual(true); + expect(button().props('disabled')).toEqual(true); - installButton.click(); + button().vm.$emit('click'); - expect(eventHub.$emit).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); }); describe('Uninstall button', () => { it('displays button when app is installed and uninstallable', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ installed: true, uninstallable: true, status: APPLICATION_STATUS.NOT_INSTALLABLE, }); - const uninstallButton = vm.$el.querySelector('.js-cluster-application-uninstall-button'); + const uninstallButton = wrapper.find('.js-cluster-application-uninstall-button'); - expect(uninstallButton).toBeTruthy(); + expect(uninstallButton.exists()).toBe(true); }); - it('displays a success toast message if application uninstall was successful', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + it('displays a success toast message if application uninstall was successful', async () => { + mountComponent({ title: 'GitLab Runner', uninstallSuccessful: false, }); - vm.$toast = { show: jest.fn() }; - vm.uninstallSuccessful = true; + wrapper.vm.$toast = { show: jest.fn() }; + wrapper.setProps({ uninstallSuccessful: true }); - return vm.$nextTick(() => { - expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner uninstalled successfully.'); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( + 'GitLab Runner uninstalled successfully.', + ); }); }); describe('when confirmation modal triggers confirm event', () => { - let wrapper; - - beforeEach(() => { - wrapper = shallowMount(ApplicationRow, { - propsData: { - ...DEFAULT_APPLICATION_STATE, - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - it('triggers uninstallApplication event', () => { jest.spyOn(eventHub, '$emit'); + mountComponent(); wrapper.find(UninstallApplicationConfirmationModal).vm.$emit('confirm'); expect(eventHub.$emit).toHaveBeenCalledWith('uninstallApplication', { @@ -246,172 +198,226 @@ describe('Application Row', () => { }); describe('Update button', () => { + const button = () => wrapper.find('.js-cluster-application-update-button'); + it('has indeterminate state on page load', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: null, - }); - const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button'); + mountComponent(); - expect(updateBtn).toBe(null); + expect(button().exists()).toBe(false); }); it('has enabled "Update" when "updateAvailable" is true', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - updateAvailable: true, - }); - const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button'); + mountComponent({ updateAvailable: true }); - expect(updateBtn).not.toBe(null); - expect(updateBtn.innerHTML).toContain('Update'); + expect(button().exists()).toBe(true); + expect(button().props('label')).toContain('Update'); }); it('has enabled "Retry update" when update process fails', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.INSTALLED, updateFailed: true, }); - const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button'); - expect(updateBtn).not.toBe(null); - expect(updateBtn.innerHTML).toContain('Retry update'); + expect(button().exists()).toBe(true); + expect(button().props('label')).toContain('Retry update'); }); it('has disabled "Updating" when APPLICATION_STATUS.UPDATING', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATING, - }); - const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button'); + mountComponent({ status: APPLICATION_STATUS.UPDATING }); - expect(updateBtn).not.toBe(null); - expect(vm.isUpdating).toBe(true); - expect(updateBtn.innerHTML).toContain('Updating'); + expect(button().exists()).toBe(true); + expect(button().props('label')).toContain('Updating'); }); it('clicking update button emits event', () => { - jest.spyOn(eventHub, '$emit'); - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + const spy = jest.spyOn(eventHub, '$emit'); + mountComponent({ status: APPLICATION_STATUS.INSTALLED, updateAvailable: true, }); - const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button'); - updateBtn.click(); + button().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', { + expect(spy).toHaveBeenCalledWith('updateApplication', { id: DEFAULT_APPLICATION_STATE.id, params: {}, }); }); it('clicking disabled update button emits nothing', () => { - jest.spyOn(eventHub, '$emit'); - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATING, - }); - const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button'); + const spy = jest.spyOn(eventHub, '$emit'); + mountComponent({ status: APPLICATION_STATUS.UPDATING }); - updateBtn.click(); + button().vm.$emit('click'); - expect(eventHub.$emit).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); it('displays an error message if application update failed', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ title: 'GitLab Runner', status: APPLICATION_STATUS.INSTALLED, updateFailed: true, }); - const failureMessage = vm.$el.querySelector('.js-cluster-application-update-details'); + const failureMessage = wrapper.find('.js-cluster-application-update-details'); - expect(failureMessage).not.toBe(null); - expect(failureMessage.innerHTML).toContain( + expect(failureMessage.exists()).toBe(true); + expect(failureMessage.text()).toContain( 'Update failed. Please check the logs and try again.', ); }); - it('displays a success toast message if application update was successful', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + it('displays a success toast message if application update was successful', async () => { + mountComponent({ title: 'GitLab Runner', updateSuccessful: false, }); - vm.$toast = { show: jest.fn() }; - vm.updateSuccessful = true; + wrapper.vm.$toast = { show: jest.fn() }; + wrapper.setProps({ updateSuccessful: true }); - return vm.$nextTick(() => { - expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner updated successfully.'); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('GitLab Runner updated successfully.'); + }); + + describe('when updating does not require confirmation', () => { + beforeEach(() => mountComponent({ updateAvailable: true })); + + it('the modal is not rendered', () => { + expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false); + }); + + it('the correct button is rendered', () => { + expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true); + }); + }); + + describe('when updating requires confirmation', () => { + beforeEach(() => { + mountComponent({ + updateAvailable: true, + id: ELASTIC_STACK, + version: '1.1.2', + }); + }); + + it('displays a modal', () => { + expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true); + }); + + it('the correct button is rendered', () => { + expect(wrapper.contains("[data-qa-selector='update_button_with_confirmation']")).toBe(true); + }); + + it('triggers updateApplication event', () => { + jest.spyOn(eventHub, '$emit'); + wrapper.find(UpdateApplicationConfirmationModal).vm.$emit('confirm'); + + expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', { + id: ELASTIC_STACK, + params: {}, + }); + }); + }); + + describe('updating Elastic Stack special case', () => { + it('needs confirmation if version is lower than 3.0.0', () => { + mountComponent({ + updateAvailable: true, + id: ELASTIC_STACK, + version: '1.1.2', + }); + + expect(wrapper.contains("[data-qa-selector='update_button_with_confirmation']")).toBe(true); + expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true); + }); + + it('does not need confirmation is version is 3.0.0', () => { + mountComponent({ + updateAvailable: true, + id: ELASTIC_STACK, + version: '3.0.0', + }); + + expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true); + expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false); + }); + + it('does not need confirmation if version is higher than 3.0.0', () => { + mountComponent({ + updateAvailable: true, + id: ELASTIC_STACK, + version: '5.2.1', + }); + + expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true); + expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false); }); }); }); describe('Version', () => { + const updateDetails = () => wrapper.find('.js-cluster-application-update-details'); + const versionEl = () => wrapper.find('.js-cluster-application-update-version'); + it('displays a version number if application has been updated', () => { const version = '0.1.45'; - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.INSTALLED, updateSuccessful: true, version, }); - const updateDetails = vm.$el.querySelector('.js-cluster-application-update-details'); - const versionEl = vm.$el.querySelector('.js-cluster-application-update-version'); - expect(updateDetails.innerHTML).toContain('Updated'); - expect(versionEl).not.toBe(null); - expect(versionEl.innerHTML).toContain(version); + expect(updateDetails().text()).toBe(`Updated to chart v${version}`); }); it('contains a link to the chart repo if application has been updated', () => { const version = '0.1.45'; const chartRepo = 'https://gitlab.com/gitlab-org/charts/gitlab-runner'; - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.INSTALLED, updateSuccessful: true, chartRepo, version, }); - const versionEl = vm.$el.querySelector('.js-cluster-application-update-version'); - expect(versionEl.href).toEqual(chartRepo); - expect(versionEl.target).toEqual('_blank'); + expect(versionEl().attributes('href')).toEqual(chartRepo); + expect(versionEl().props('target')).toEqual('_blank'); }); it('does not display a version number if application update failed', () => { const version = '0.1.45'; - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.INSTALLED, updateFailed: true, version, }); - const updateDetails = vm.$el.querySelector('.js-cluster-application-update-details'); - const versionEl = vm.$el.querySelector('.js-cluster-application-update-version'); - expect(updateDetails.innerHTML).toContain('failed'); - expect(versionEl).toBe(null); + expect(updateDetails().text()).toBe('Update failed'); + expect(versionEl().exists()).toBe(false); + }); + + it('displays updating when the application update is currently updating', () => { + mountComponent({ + status: APPLICATION_STATUS.UPDATING, + updateSuccessful: true, + version: '1.2.3', + }); + + expect(updateDetails().text()).toBe('Updating'); + expect(versionEl().exists()).toBe(false); }); }); describe('Error block', () => { + const generalErrorMessage = () => wrapper.find('.js-cluster-application-general-error-message'); + describe('when nothing fails', () => { it('does not show error block', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - }); - const generalErrorMessage = vm.$el.querySelector( - '.js-cluster-application-general-error-message', - ); + mountComponent(); - expect(generalErrorMessage).toBeNull(); + expect(generalErrorMessage().exists()).toBe(false); }); }); @@ -420,8 +426,7 @@ describe('Application Row', () => { const requestReason = 'We broke the request 0.0'; beforeEach(() => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.ERROR, statusReason, requestReason, @@ -430,37 +435,28 @@ describe('Application Row', () => { }); it('shows status reason if it is available', () => { - const statusErrorMessage = vm.$el.querySelector( - '.js-cluster-application-status-error-message', - ); + const statusErrorMessage = wrapper.find('.js-cluster-application-status-error-message'); - expect(statusErrorMessage.textContent.trim()).toEqual(statusReason); + expect(statusErrorMessage.text()).toEqual(statusReason); }); it('shows request reason if it is available', () => { - const requestErrorMessage = vm.$el.querySelector( - '.js-cluster-application-request-error-message', - ); + const requestErrorMessage = wrapper.find('.js-cluster-application-request-error-message'); - expect(requestErrorMessage.textContent.trim()).toEqual(requestReason); + expect(requestErrorMessage.text()).toEqual(requestReason); }); }); describe('when install fails', () => { beforeEach(() => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.ERROR, installFailed: true, }); }); it('shows a general message indicating the installation failed', () => { - const generalErrorMessage = vm.$el.querySelector( - '.js-cluster-application-general-error-message', - ); - - expect(generalErrorMessage.textContent.trim()).toEqual( + expect(generalErrorMessage().text()).toEqual( `Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`, ); }); @@ -468,19 +464,14 @@ describe('Application Row', () => { describe('when uninstall fails', () => { beforeEach(() => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, + mountComponent({ status: APPLICATION_STATUS.ERROR, uninstallFailed: true, }); }); it('shows a general message indicating the uninstalling failed', () => { - const generalErrorMessage = vm.$el.querySelector( - '.js-cluster-application-general-error-message', - ); - - expect(generalErrorMessage.textContent.trim()).toEqual( + expect(generalErrorMessage().text()).toEqual( `Something went wrong while uninstalling ${DEFAULT_APPLICATION_STATE.title}`, ); }); diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index 33b30891d5e..7fc771201c1 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -1,174 +1,175 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import { shallowMount } from '@vue/test-utils'; -import applications from '~/clusters/components/applications.vue'; -import { CLUSTER_TYPE } from '~/clusters/constants'; +import { shallowMount, mount } from '@vue/test-utils'; +import Applications from '~/clusters/components/applications.vue'; +import { CLUSTER_TYPE, PROVIDER_TYPE } from '~/clusters/constants'; import { APPLICATIONS_MOCK_STATE } from '../services/mock_data'; import eventHub from '~/clusters/event_hub'; +import ApplicationRow from '~/clusters/components/application_row.vue'; import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue'; import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue'; describe('Applications', () => { - let vm; - let Applications; + let wrapper; beforeEach(() => { - Applications = Vue.extend(applications); - gon.features = gon.features || {}; gon.features.managedAppsLocalTiller = false; }); + const createApp = ({ applications, type } = {}, isShallow) => { + const mountMethod = isShallow ? shallowMount : mount; + + wrapper = mountMethod(Applications, { + stubs: { ApplicationRow }, + propsData: { + type, + applications: { ...APPLICATIONS_MOCK_STATE, ...applications }, + }, + }); + }; + + const createShallowApp = options => createApp(options, true); + const findByTestId = id => wrapper.find(`[data-testid="${id}"]`); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('Project cluster applications', () => { beforeEach(() => { - vm = mountComponent(Applications, { - applications: APPLICATIONS_MOCK_STATE, - type: CLUSTER_TYPE.PROJECT, - }); + createApp({ type: CLUSTER_TYPE.PROJECT }); }); it('renders a row for Helm Tiller', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-helm')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(true); }); it('renders a row for Ingress', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); }); it('renders a row for Cert-Manager', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-cert_manager').exists()).toBe(true); }); it('renders a row for Crossplane', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-crossplane').exists()).toBe(true); }); it('renders a row for Prometheus', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-prometheus').exists()).toBe(true); }); it('renders a row for GitLab Runner', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-runner')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-runner').exists()).toBe(true); }); it('renders a row for Jupyter', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-jupyter').exists()).toBe(true); }); it('renders a row for Knative', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-knative').exists()).toBe(true); }); it('renders a row for Elastic Stack', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); }); it('renders a row for Fluentd', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); }); }); describe('Group cluster applications', () => { beforeEach(() => { - vm = mountComponent(Applications, { - type: CLUSTER_TYPE.GROUP, - applications: APPLICATIONS_MOCK_STATE, - }); + createApp({ type: CLUSTER_TYPE.GROUP }); }); it('renders a row for Helm Tiller', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-helm')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(true); }); it('renders a row for Ingress', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); }); it('renders a row for Cert-Manager', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-cert_manager').exists()).toBe(true); }); it('renders a row for Crossplane', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-crossplane').exists()).toBe(true); }); it('renders a row for Prometheus', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-prometheus').exists()).toBe(true); }); it('renders a row for GitLab Runner', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-runner')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-runner').exists()).toBe(true); }); it('renders a row for Jupyter', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-jupyter').exists()).toBe(true); }); it('renders a row for Knative', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-knative').exists()).toBe(true); }); it('renders a row for Elastic Stack', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); }); it('renders a row for Fluentd', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); }); }); describe('Instance cluster applications', () => { beforeEach(() => { - vm = mountComponent(Applications, { - type: CLUSTER_TYPE.INSTANCE, - applications: APPLICATIONS_MOCK_STATE, - }); + createApp({ type: CLUSTER_TYPE.INSTANCE }); }); it('renders a row for Helm Tiller', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-helm')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(true); }); it('renders a row for Ingress', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); }); it('renders a row for Cert-Manager', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-cert_manager').exists()).toBe(true); }); it('renders a row for Crossplane', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-crossplane').exists()).toBe(true); }); it('renders a row for Prometheus', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-prometheus').exists()).toBe(true); }); it('renders a row for GitLab Runner', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-runner')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-runner').exists()).toBe(true); }); it('renders a row for Jupyter', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-jupyter').exists()).toBe(true); }); it('renders a row for Knative', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-knative').exists()).toBe(true); }); it('renders a row for Elastic Stack', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); }); it('renders a row for Fluentd', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull(); + expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); }); }); @@ -179,20 +180,21 @@ describe('Applications', () => { }); it('does not render a row for Helm Tiller', () => { - vm = mountComponent(Applications, { - applications: APPLICATIONS_MOCK_STATE, - }); - - expect(vm.$el.querySelector('.js-cluster-application-row-helm')).toBeNull(); + createApp(); + expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(false); }); }); }); describe('Ingress application', () => { + it('shows the correct warning message', () => { + createApp(); + expect(findByTestId('ingressCostWarning').element).toMatchSnapshot(); + }); + describe('with nested component', () => { const propsData = { applications: { - ...APPLICATIONS_MOCK_STATE, ingress: { title: 'Ingress', status: 'installed', @@ -200,13 +202,8 @@ describe('Applications', () => { }, }; - let wrapper; - beforeEach(() => { - wrapper = shallowMount(Applications, { propsData }); - }); - afterEach(() => { - wrapper.destroy(); - }); + beforeEach(() => createShallowApp(propsData)); + it('renders IngressModsecuritySettings', () => { const modsecuritySettings = wrapper.find(IngressModsecuritySettings); expect(modsecuritySettings.exists()).toBe(true); @@ -216,9 +213,8 @@ describe('Applications', () => { describe('when installed', () => { describe('with ip address', () => { it('renders ip address with a clipboard button', () => { - vm = mountComponent(Applications, { + createApp({ applications: { - ...APPLICATIONS_MOCK_STATE, ingress: { title: 'Ingress', status: 'installed', @@ -227,17 +223,16 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-endpoint').value).toEqual('0.0.0.0'); - - expect( - vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text'), - ).toEqual('0.0.0.0'); + expect(wrapper.find('.js-endpoint').element.value).toEqual('0.0.0.0'); + expect(wrapper.find('.js-clipboard-btn').attributes('data-clipboard-text')).toEqual( + '0.0.0.0', + ); }); }); describe('with hostname', () => { it('renders hostname with a clipboard button', () => { - vm = mountComponent(Applications, { + createApp({ applications: { ingress: { title: 'Ingress', @@ -257,19 +252,18 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-endpoint').value).toEqual('localhost.localdomain'); + expect(wrapper.find('.js-endpoint').element.value).toEqual('localhost.localdomain'); - expect( - vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text'), - ).toEqual('localhost.localdomain'); + expect(wrapper.find('.js-clipboard-btn').attributes('data-clipboard-text')).toEqual( + 'localhost.localdomain', + ); }); }); describe('without ip address', () => { it('renders an input text with a loading icon and an alert text', () => { - vm = mountComponent(Applications, { + createApp({ applications: { - ...APPLICATIONS_MOCK_STATE, ingress: { title: 'Ingress', status: 'installed', @@ -277,142 +271,139 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-ingress-ip-loading-icon')).not.toBe(null); - expect(vm.$el.querySelector('.js-no-endpoint-message')).not.toBe(null); + expect(wrapper.find('.js-ingress-ip-loading-icon').exists()).toBe(true); + expect(wrapper.find('.js-no-endpoint-message').exists()).toBe(true); }); }); }); describe('before installing', () => { it('does not render the IP address', () => { - vm = mountComponent(Applications, { - applications: APPLICATIONS_MOCK_STATE, - }); + createApp(); - expect(vm.$el.textContent).not.toContain('Ingress IP Address'); - expect(vm.$el.querySelector('.js-endpoint')).toBe(null); + expect(wrapper.text()).not.toContain('Ingress IP Address'); + expect(wrapper.find('.js-endpoint').exists()).toBe(false); }); }); + }); - describe('Cert-Manager application', () => { - describe('when not installed', () => { - it('renders email & allows editing', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - cert_manager: { - title: 'Cert-Manager', - email: 'before@example.com', - status: 'installable', - }, - }, - }); + describe('Cert-Manager application', () => { + it('shows the correct description', () => { + createApp(); + expect(findByTestId('certManagerDescription').element).toMatchSnapshot(); + }); - expect(vm.$el.querySelector('.js-email').value).toEqual('before@example.com'); - expect(vm.$el.querySelector('.js-email').getAttribute('readonly')).toBe(null); + describe('when not installed', () => { + it('renders email & allows editing', () => { + createApp({ + applications: { + cert_manager: { + title: 'Cert-Manager', + email: 'before@example.com', + status: 'installable', + }, + }, }); + + expect(wrapper.find('.js-email').element.value).toEqual('before@example.com'); + expect(wrapper.find('.js-email').attributes('readonly')).toBe(undefined); }); + }); - describe('when installed', () => { - it('renders email in readonly', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - cert_manager: { - title: 'Cert-Manager', - email: 'after@example.com', - status: 'installed', - }, + describe('when installed', () => { + it('renders email in readonly', () => { + createApp({ + applications: { + cert_manager: { + title: 'Cert-Manager', + email: 'after@example.com', + status: 'installed', }, - }); - - expect(vm.$el.querySelector('.js-email').value).toEqual('after@example.com'); - expect(vm.$el.querySelector('.js-email').getAttribute('readonly')).toEqual('readonly'); + }, }); + + expect(wrapper.find('.js-email').element.value).toEqual('after@example.com'); + expect(wrapper.find('.js-email').attributes('readonly')).toEqual('readonly'); }); }); + }); - describe('Jupyter application', () => { - describe('with ingress installed with ip & jupyter installable', () => { - it('renders hostname active input', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - ingress: { - title: 'Ingress', - status: 'installed', - externalIp: '1.1.1.1', - }, + describe('Jupyter application', () => { + describe('with ingress installed with ip & jupyter installable', () => { + it('renders hostname active input', () => { + createApp({ + applications: { + ingress: { + title: 'Ingress', + status: 'installed', + externalIp: '1.1.1.1', }, - }); - - expect( - vm.$el - .querySelector('.js-cluster-application-row-jupyter .js-hostname') - .getAttribute('readonly'), - ).toEqual(null); + }, }); - }); - describe('with ingress installed without external ip', () => { - it('does not render hostname input', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - ingress: { title: 'Ingress', status: 'installed' }, - }, - }); + expect( + wrapper.find('.js-cluster-application-row-jupyter .js-hostname').attributes('readonly'), + ).toEqual(undefined); + }); + }); - expect(vm.$el.querySelector('.js-cluster-application-row-jupyter .js-hostname')).toBe( - null, - ); + describe('with ingress installed without external ip', () => { + it('does not render hostname input', () => { + createApp({ + applications: { + ingress: { title: 'Ingress', status: 'installed' }, + }, }); - }); - describe('with ingress & jupyter installed', () => { - it('renders readonly input', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, - jupyter: { title: 'JupyterHub', status: 'installed', hostname: '' }, - }, - }); + expect(wrapper.find('.js-cluster-application-row-jupyter .js-hostname').exists()).toBe( + false, + ); + }); + }); - expect( - vm.$el - .querySelector('.js-cluster-application-row-jupyter .js-hostname') - .getAttribute('readonly'), - ).toEqual('readonly'); + describe('with ingress & jupyter installed', () => { + it('renders readonly input', () => { + createApp({ + applications: { + ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, + jupyter: { title: 'JupyterHub', status: 'installed', hostname: '' }, + }, }); + + expect( + wrapper.find('.js-cluster-application-row-jupyter .js-hostname').attributes('readonly'), + ).toEqual('readonly'); }); + }); - describe('without ingress installed', () => { - beforeEach(() => { - vm = mountComponent(Applications, { - applications: APPLICATIONS_MOCK_STATE, - }); - }); + describe('without ingress installed', () => { + beforeEach(() => { + createApp(); + }); - it('does not render input', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-jupyter .js-hostname')).toBe( - null, - ); - }); + it('does not render input', () => { + expect(wrapper.find('.js-cluster-application-row-jupyter .js-hostname').exists()).toBe( + false, + ); + }); - it('renders disabled install button', () => { - expect( - vm.$el - .querySelector( - '.js-cluster-application-row-jupyter .js-cluster-application-install-button', - ) - .getAttribute('disabled'), - ).toEqual('disabled'); - }); + it('renders disabled install button', () => { + expect( + wrapper + .find('.js-cluster-application-row-jupyter .js-cluster-application-install-button') + .attributes('disabled'), + ).toEqual('disabled'); }); }); }); + describe('Prometheus application', () => { + it('shows the correct description', () => { + createApp(); + expect(findByTestId('prometheusDescription').element).toMatchSnapshot(); + }); + }); + describe('Knative application', () => { const availableDomain = { id: 4, @@ -420,7 +411,6 @@ describe('Applications', () => { }; const propsData = { applications: { - ...APPLICATIONS_MOCK_STATE, knative: { title: 'Knative', hostname: 'example.com', @@ -432,18 +422,25 @@ describe('Applications', () => { }, }, }; - let wrapper; let knativeDomainEditor; beforeEach(() => { - wrapper = shallowMount(Applications, { propsData }); + createShallowApp(propsData); jest.spyOn(eventHub, '$emit'); knativeDomainEditor = wrapper.find(KnativeDomainEditor); }); - afterEach(() => { - wrapper.destroy(); + it('shows the correct description', async () => { + createApp(); + wrapper.setProps({ + providerType: PROVIDER_TYPE.GCP, + preInstalledKnative: true, + }); + + await wrapper.vm.$nextTick(); + + expect(findByTestId('installedVia').element).toMatchSnapshot(); }); it('emits saveKnativeDomain event when knative domain editor emits save event', () => { @@ -492,7 +489,6 @@ describe('Applications', () => { describe('Crossplane application', () => { const propsData = { applications: { - ...APPLICATIONS_MOCK_STATE, crossplane: { title: 'Crossplane', stack: { @@ -502,74 +498,58 @@ describe('Applications', () => { }, }; - let wrapper; - beforeEach(() => { - wrapper = shallowMount(Applications, { propsData }); - }); - afterEach(() => { - wrapper.destroy(); - }); + beforeEach(() => createShallowApp(propsData)); + it('renders the correct Component', () => { const crossplane = wrapper.find(CrossplaneProviderStack); expect(crossplane.exists()).toBe(true); }); + + it('shows the correct description', () => { + createApp(); + expect(findByTestId('crossplaneDescription').element).toMatchSnapshot(); + }); }); describe('Elastic Stack application', () => { describe('with elastic stack installable', () => { it('renders hostname active input', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - }, - }); + createApp(); expect( - vm.$el - .querySelector( + wrapper + .find( '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button', ) - .getAttribute('disabled'), + .attributes('disabled'), ).toEqual('disabled'); }); }); describe('elastic stack installed', () => { it('renders uninstall button', () => { - vm = mountComponent(Applications, { + createApp({ applications: { - ...APPLICATIONS_MOCK_STATE, elastic_stack: { title: 'Elastic Stack', status: 'installed' }, }, }); expect( - vm.$el - .querySelector( + wrapper + .find( '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button', ) - .getAttribute('disabled'), + .attributes('disabled'), ).toEqual('disabled'); }); }); }); describe('Fluentd application', () => { - const propsData = { - applications: { - ...APPLICATIONS_MOCK_STATE, - }, - }; + beforeEach(() => createShallowApp()); - let wrapper; - beforeEach(() => { - wrapper = shallowMount(Applications, { propsData }); - }); - afterEach(() => { - wrapper.destroy(); - }); it('renders the correct Component', () => { - expect(wrapper.contains(FluentdOutputSettings)).toBe(true); + expect(wrapper.find(FluentdOutputSettings).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js index 5e27cc49049..f03f2535947 100644 --- a/spec/frontend/clusters/components/fluentd_output_settings_spec.js +++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js @@ -70,12 +70,12 @@ describe('FluentdOutputSettings', () => { }); describe.each` - desc | changeFn | key | value - ${'when protocol dropdown is triggered'} | ${() => changeProtocol(1)} | ${'protocol'} | ${'udp'} - ${'when host is changed'} | ${() => changeHost('test-host')} | ${'host'} | ${'test-host'} - ${'when port is changed'} | ${() => changePort(123)} | ${'port'} | ${123} - ${'when wafLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send ModSecurity Logs'))} | ${'wafLogEnabled'} | ${!defaultSettings.wafLogEnabled} - ${'when ciliumLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send Cilium Logs'))} | ${'ciliumLogEnabled'} | ${!defaultSettings.ciliumLogEnabled} + desc | changeFn | key | value + ${'when protocol dropdown is triggered'} | ${() => changeProtocol(1)} | ${'protocol'} | ${'udp'} + ${'when host is changed'} | ${() => changeHost('test-host')} | ${'host'} | ${'test-host'} + ${'when port is changed'} | ${() => changePort(123)} | ${'port'} | ${123} + ${'when wafLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send Web Application Firewall Logs'))} | ${'wafLogEnabled'} | ${!defaultSettings.wafLogEnabled} + ${'when ciliumLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send Container Network Policies Logs'))} | ${'ciliumLogEnabled'} | ${!defaultSettings.ciliumLogEnabled} `('$desc', ({ changeFn, key, value }) => { beforeEach(() => { changeFn(); diff --git a/spec/frontend/clusters/components/update_application_confirmation_modal_spec.js b/spec/frontend/clusters/components/update_application_confirmation_modal_spec.js new file mode 100644 index 00000000000..dd3aaf6f946 --- /dev/null +++ b/spec/frontend/clusters/components/update_application_confirmation_modal_spec.js @@ -0,0 +1,52 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import UpdateApplicationConfirmationModal from '~/clusters/components/update_application_confirmation_modal.vue'; +import { ELASTIC_STACK } from '~/clusters/constants'; + +describe('UpdateApplicationConfirmationModal', () => { + let wrapper; + const appTitle = 'Elastic stack'; + + const createComponent = (props = {}) => { + wrapper = shallowMount(UpdateApplicationConfirmationModal, { + propsData: { ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + createComponent({ application: ELASTIC_STACK, applicationTitle: appTitle }); + }); + + it(`renders a modal with a title "Update ${appTitle}"`, () => { + expect(wrapper.find(GlModal).attributes('title')).toEqual(`Update ${appTitle}`); + }); + + it(`renders a modal with an ok button labeled "Update ${appTitle}"`, () => { + expect(wrapper.find(GlModal).attributes('ok-title')).toEqual(`Update ${appTitle}`); + }); + + describe('when ok button is clicked', () => { + beforeEach(() => { + wrapper.find(GlModal).vm.$emit('ok'); + }); + + it('emits confirm event', () => + wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('confirm')).toBeTruthy(); + })); + + it('displays a warning text indicating the app will be updated', () => { + expect(wrapper.text()).toContain(`You are about to update ${appTitle} on your cluster.`); + }); + + it('displays a custom warning text depending on the application', () => { + expect(wrapper.text()).toContain( + `Your Elasticsearch cluster will be re-created during this upgrade. Your logs will be re-indexed, and you will lose historical logs from hosts terminated in the last 30 days.`, + ); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index e2d2e4b73b3..07faee7e50b 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -5,6 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import { apiData } from '../mock_data'; import { mount } from '@vue/test-utils'; import { GlLoadingIcon, GlTable, GlPagination } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; describe('Clusters', () => { let mock; @@ -13,6 +14,13 @@ describe('Clusters', () => { const endpoint = 'some/endpoint'; + const entryData = { + endpoint, + imgTagsAwsText: 'AWS Icon', + imgTagsDefaultText: 'Default Icon', + imgTagsGcpText: 'GCP Icon', + }; + const findLoader = () => wrapper.find(GlLoadingIcon); const findPaginatedButtons = () => wrapper.find(GlPagination); const findTable = () => wrapper.find(GlTable); @@ -23,18 +31,26 @@ describe('Clusters', () => { }; const mountWrapper = () => { - store = ClusterStore({ endpoint }); + store = ClusterStore(entryData); wrapper = mount(Clusters, { store }); return axios.waitForAll(); }; + const paginationHeader = (total = apiData.clusters.length, perPage = 20, currentPage = 1) => { + return { + 'x-total': total, + 'x-per-page': perPage, + 'x-page': currentPage, + }; + }; + + let captureException; + beforeEach(() => { + captureException = jest.spyOn(Sentry, 'captureException'); + mock = new MockAdapter(axios); - mockPollingApi(200, apiData, { - 'x-total': apiData.clusters.length, - 'x-per-page': 20, - 'x-page': 1, - }); + mockPollingApi(200, apiData, paginationHeader()); return mountWrapper(); }); @@ -42,6 +58,7 @@ describe('Clusters', () => { afterEach(() => { wrapper.destroy(); mock.restore(); + captureException.mockRestore(); }); describe('clusters table', () => { @@ -77,25 +94,108 @@ describe('Clusters', () => { }); }); + describe('cluster icon', () => { + it.each` + providerText | lineNumber + ${'GCP Icon'} | ${0} + ${'AWS Icon'} | ${1} + ${'Default Icon'} | ${2} + ${'Default Icon'} | ${3} + ${'Default Icon'} | ${4} + ${'Default Icon'} | ${5} + `('renders provider image and alt text for each cluster', ({ providerText, lineNumber }) => { + const images = findTable().findAll('.js-status img'); + const image = images.at(lineNumber); + + expect(image.attributes('alt')).toBe(providerText); + }); + }); + describe('cluster status', () => { it.each` - statusName | className | lineNumber - ${'disabled'} | ${'disabled'} | ${0} - ${'unreachable'} | ${'bg-danger'} | ${1} - ${'authentication_failure'} | ${'bg-warning'} | ${2} - ${'deleting'} | ${null} | ${3} - ${'created'} | ${'bg-success'} | ${4} - ${'default'} | ${'bg-white'} | ${5} - `('renders a status for each cluster', ({ statusName, className, lineNumber }) => { - const statuses = findStatuses(); - const status = statuses.at(lineNumber); - if (statusName !== 'deleting') { - const statusIndicator = status.find('.cluster-status-indicator'); - expect(statusIndicator.exists()).toBe(true); - expect(statusIndicator.classes()).toContain(className); - } else { - expect(status.find(GlLoadingIcon).exists()).toBe(true); - } + statusName | lineNumber | result + ${'creating'} | ${0} | ${true} + ${null} | ${1} | ${false} + ${null} | ${2} | ${false} + ${'deleting'} | ${3} | ${true} + ${null} | ${4} | ${false} + ${null} | ${5} | ${false} + `( + 'renders $result when status=$statusName and lineNumber=$lineNumber', + ({ lineNumber, result }) => { + const statuses = findStatuses(); + const status = statuses.at(lineNumber); + expect(status.find(GlLoadingIcon).exists()).toBe(result); + }, + ); + }); + + describe('nodes present', () => { + it.each` + nodeSize | lineNumber + ${'Unknown'} | ${0} + ${'1'} | ${1} + ${'2'} | ${2} + ${'1'} | ${3} + ${'1'} | ${4} + ${'Unknown'} | ${5} + `('renders node size for each cluster', ({ nodeSize, lineNumber }) => { + const sizes = findTable().findAll('td:nth-child(3)'); + const size = sizes.at(lineNumber); + + expect(size.text()).toBe(nodeSize); + }); + + describe('nodes with unknown quantity', () => { + it('notifies Sentry about all missing quantity types', () => { + expect(captureException).toHaveBeenCalledTimes(8); + }); + + it('notifies Sentry about CPU missing quantity types', () => { + const missingCpuTypeError = new Error('UnknownK8sCpuQuantity:1missingCpuUnit'); + + expect(captureException).toHaveBeenCalledWith(missingCpuTypeError); + }); + + it('notifies Sentry about Memory missing quantity types', () => { + const missingMemoryTypeError = new Error('UnknownK8sMemoryQuantity:1missingMemoryUnit'); + + expect(captureException).toHaveBeenCalledWith(missingMemoryTypeError); + }); + }); + }); + + describe('cluster CPU', () => { + it.each` + clusterCpu | lineNumber + ${''} | ${0} + ${'1.93 (87% free)'} | ${1} + ${'3.87 (86% free)'} | ${2} + ${'(% free)'} | ${3} + ${'(% free)'} | ${4} + ${''} | ${5} + `('renders total cpu for each cluster', ({ clusterCpu, lineNumber }) => { + const clusterCpus = findTable().findAll('td:nth-child(4)'); + const cpuData = clusterCpus.at(lineNumber); + + expect(cpuData.text()).toBe(clusterCpu); + }); + }); + + describe('cluster Memory', () => { + it.each` + clusterMemory | lineNumber + ${''} | ${0} + ${'5.92 (78% free)'} | ${1} + ${'12.86 (79% free)'} | ${2} + ${'(% free)'} | ${3} + ${'(% free)'} | ${4} + ${''} | ${5} + `('renders total memory for each cluster', ({ clusterMemory, lineNumber }) => { + const clusterMemories = findTable().findAll('td:nth-child(5)'); + const memoryData = clusterMemories.at(lineNumber); + + expect(memoryData.text()).toBe(clusterMemory); }); }); @@ -105,11 +205,7 @@ describe('Clusters', () => { const totalSecondPage = 500; beforeEach(() => { - mockPollingApi(200, apiData, { - 'x-total': totalFirstPage, - 'x-per-page': perPage, - 'x-page': 1, - }); + mockPollingApi(200, apiData, paginationHeader(totalFirstPage, perPage, 1)); return mountWrapper(); }); @@ -123,11 +219,7 @@ describe('Clusters', () => { describe('when updating currentPage', () => { beforeEach(() => { - mockPollingApi(200, apiData, { - 'x-total': totalSecondPage, - 'x-per-page': perPage, - 'x-page': 2, - }); + mockPollingApi(200, apiData, paginationHeader(totalSecondPage, perPage, 2)); wrapper.setData({ currentPage: 2 }); return axios.waitForAll(); }); diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js index 9a90a378f31..48af3b91c94 100644 --- a/spec/frontend/clusters_list/mock_data.js +++ b/spec/frontend/clusters_list/mock_data.js @@ -1,57 +1,70 @@ export const clusterList = [ { name: 'My Cluster 1', - environmentScope: '*', - size: '3', - clusterType: 'group_type', - status: 'disabled', - cpu: '6 (100% free)', - memory: '22.50 (30% free)', + environment_scope: '*', + cluster_type: 'group_type', + provider_type: 'gcp', + status: 'creating', + nodes: null, }, { name: 'My Cluster 2', - environmentScope: 'development', - size: '12', - clusterType: 'project_type', + environment_scope: 'development', + cluster_type: 'project_type', + provider_type: 'aws', status: 'unreachable', - cpu: '3 (50% free)', - memory: '11 (60% free)', + nodes: [ + { + status: { allocatable: { cpu: '1930m', memory: '5777156Ki' } }, + usage: { cpu: '246155922n', memory: '1255212Ki' }, + }, + ], }, { name: 'My Cluster 3', - environmentScope: 'development', - size: '12', - clusterType: 'project_type', + environment_scope: 'development', + cluster_type: 'project_type', + provider_type: 'none', status: 'authentication_failure', - cpu: '1 (0% free)', - memory: '22 (33% free)', + nodes: [ + { + status: { allocatable: { cpu: '1930m', memory: '5777156Ki' } }, + usage: { cpu: '246155922n', memory: '1255212Ki' }, + }, + { + status: { allocatable: { cpu: '1940m', memory: '6777156Ki' } }, + usage: { cpu: '307051934n', memory: '1379136Ki' }, + }, + ], }, { name: 'My Cluster 4', - environmentScope: 'production', - size: '12', - clusterType: 'project_type', + environment_scope: 'production', + cluster_type: 'project_type', status: 'deleting', - cpu: '6 (100% free)', - memory: '45 (15% free)', + nodes: [ + { + status: { allocatable: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' } }, + usage: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' }, + }, + ], }, { name: 'My Cluster 5', - environmentScope: 'development', - size: '12', - clusterType: 'project_type', + environment_scope: 'development', + cluster_type: 'project_type', status: 'created', - cpu: '6 (100% free)', - memory: '20.12 (35% free)', + nodes: [ + { + status: { allocatable: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' } }, + }, + ], }, { name: 'My Cluster 6', - environmentScope: '*', - size: '1', - clusterType: 'project_type', + environment_scope: '*', + cluster_type: 'project_type', status: 'cleanup_ongoing', - cpu: '6 (100% free)', - memory: '20.12 (35% free)', }, ]; diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 70766af3ec4..74e351a3704 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -1,10 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; +import Poll from '~/lib/utils/poll'; import flashError from '~/flash'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; import { apiData } from '../mock_data'; +import { MAX_REQUESTS } from '~/clusters_list/constants'; import * as types from '~/clusters_list/store/mutation_types'; import * as actions from '~/clusters_list/store/actions'; +import * as Sentry from '@sentry/browser'; jest.mock('~/flash.js'); @@ -12,6 +16,24 @@ describe('Clusters store actions', () => { describe('fetchClusters', () => { let mock; + const headers = { + 'x-next-page': 1, + 'x-total': apiData.clusters.length, + 'x-total-pages': 1, + 'x-per-page': 20, + 'x-page': 1, + 'x-prev-page': 1, + }; + + const paginationInformation = { + nextPage: 1, + page: 1, + perPage: 20, + previousPage: 1, + total: apiData.clusters.length, + totalPages: 1, + }; + beforeEach(() => { mock = new MockAdapter(axios); }); @@ -19,21 +41,6 @@ describe('Clusters store actions', () => { afterEach(() => mock.restore()); it('should commit SET_CLUSTERS_DATA with received response', done => { - const headers = { - 'x-total': apiData.clusters.length, - 'x-per-page': 20, - 'x-page': 1, - }; - - const paginationInformation = { - nextPage: NaN, - page: 1, - perPage: 20, - previousPage: NaN, - total: apiData.clusters.length, - totalPages: NaN, - }; - mock.onGet().reply(200, apiData, headers); testAction( @@ -52,9 +59,110 @@ describe('Clusters store actions', () => { it('should show flash on API error', done => { mock.onGet().reply(400, 'Not Found'); - testAction(actions.fetchClusters, { endpoint: apiData.endpoint }, {}, [], [], () => { - expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); - done(); + testAction( + actions.fetchClusters, + { endpoint: apiData.endpoint }, + {}, + [{ type: types.SET_LOADING_STATE, payload: false }], + [], + () => { + expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); + done(); + }, + ); + }); + + describe('multiple api requests', () => { + let captureException; + let pollRequest; + let pollStop; + + const pollInterval = 10; + const pollHeaders = { 'poll-interval': pollInterval, ...headers }; + + beforeEach(() => { + captureException = jest.spyOn(Sentry, 'captureException'); + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + + mock.onGet().reply(200, apiData, pollHeaders); + }); + + afterEach(() => { + captureException.mockRestore(); + pollRequest.mockRestore(); + pollStop.mockRestore(); + }); + + it('should stop polling after MAX Requests', done => { + testAction( + actions.fetchClusters, + { endpoint: apiData.endpoint }, + {}, + [ + { type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + + waitForPromises() + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(2); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + // Stops poll once it exceeds the MAX_REQUESTS limit + expect(pollStop).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + // Additional poll requests are not made once pollStop is called + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + expect(pollStop).toHaveBeenCalledTimes(1); + }) + .then(done) + .catch(done.fail); + }, + ); + }); + + it('should stop polling and report to Sentry when data is invalid', done => { + const badApiResponse = { clusters: {} }; + mock.onGet().reply(200, badApiResponse, pollHeaders); + + testAction( + actions.fetchClusters, + { endpoint: apiData.endpoint }, + {}, + [ + { + type: types.SET_CLUSTERS_DATA, + payload: { data: badApiResponse, paginationInformation }, + }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledTimes(1); + done(); + }, + ); }); }); }); diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap index c9fdd388585..7079ddfc2ab 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -16,7 +16,27 @@ exports[`Code navigation popover component renders popover 1`] = `
    -      console.log
    +      
    +        
    +          function
    +        
    +        
    +           main() {
    +        
    +      
    +      
    +        
    +          }
    +        
    +      
         
    diff --git a/spec/frontend/code_navigation/components/popover_spec.js b/spec/frontend/code_navigation/components/popover_spec.js index 858e94cf155..b3f814f1be4 100644 --- a/spec/frontend/code_navigation/components/popover_spec.js +++ b/spec/frontend/code_navigation/components/popover_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import Popover from '~/code_navigation/components/popover.vue'; +import DocLine from '~/code_navigation/components/doc_line.vue'; const DEFINITION_PATH_PREFIX = 'http://gitlab.com'; @@ -7,7 +8,22 @@ const MOCK_CODE_DATA = Object.freeze({ hover: [ { language: 'javascript', - value: 'console.log', + tokens: [ + [ + { + class: 'k', + value: 'function', + }, + { + value: ' main() {', + }, + ], + [ + { + value: '}', + }, + ], + ], }, ], definition_path: 'test.js#L20', @@ -28,6 +44,7 @@ let wrapper; function factory({ position, data, definitionPathPrefix, blobPath = 'index.js' }) { wrapper = shallowMount(Popover, { propsData: { position, data, definitionPathPrefix, blobPath }, + stubs: { DocLine }, }); } diff --git a/spec/frontend/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js new file mode 100644 index 00000000000..0ea797ce4b3 --- /dev/null +++ b/spec/frontend/collapsed_sidebar_todo_spec.js @@ -0,0 +1,172 @@ +/* eslint-disable no-new */ +import { clone } from 'lodash'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import Sidebar from '~/right_sidebar'; +import waitForPromises from './helpers/wait_for_promises'; +import { TEST_HOST } from 'spec/test_constants'; + +describe('Issuable right sidebar collapsed todo toggle', () => { + const fixtureName = 'issues/open-issue.html'; + const jsonFixtureName = 'todos/todos.json'; + let mock; + + preloadFixtures(fixtureName); + preloadFixtures(jsonFixtureName); + + beforeEach(() => { + const todoData = getJSONFixture(jsonFixtureName); + new Sidebar(); + loadFixtures(fixtureName); + + document.querySelector('.js-right-sidebar').classList.toggle('right-sidebar-expanded'); + document.querySelector('.js-right-sidebar').classList.toggle('right-sidebar-collapsed'); + + mock = new MockAdapter(axios); + + mock.onPost(`${TEST_HOST}/frontend-fixtures/issues-project/todos`).reply(() => { + const response = clone(todoData); + + return [200, response]; + }); + + mock.onDelete(/(.*)\/dashboard\/todos\/\d+$/).reply(() => { + const response = clone(todoData); + delete response.delete_path; + + return [200, response]; + }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('shows add todo button', () => { + expect(document.querySelector('.js-issuable-todo.sidebar-collapsed-icon')).not.toBeNull(); + + expect( + document + .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg use') + .getAttribute('xlink:href'), + ).toContain('todo-add'); + + expect( + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), + ).toBeNull(); + }); + + it('sets default tooltip title', () => { + expect( + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('title'), + ).toBe('Add a To Do'); + }); + + it('toggle todo state', done => { + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); + + setImmediate(() => { + expect( + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), + ).not.toBeNull(); + + expect( + document + .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg.todo-undone use') + .getAttribute('xlink:href'), + ).toContain('todo-done'); + + done(); + }); + }); + + it('toggle todo state of expanded todo toggle', done => { + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); + + setImmediate(() => { + expect( + document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(), + ).toBe('Mark as done'); + + done(); + }); + }); + + it('toggles todo button tooltip', done => { + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); + + setImmediate(() => { + expect( + document + .querySelector('.js-issuable-todo.sidebar-collapsed-icon') + .getAttribute('data-original-title'), + ).toBe('Mark as done'); + + done(); + }); + }); + + it('marks todo as done', done => { + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); + + waitForPromises() + .then(() => { + expect( + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), + ).not.toBeNull(); + + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); + }) + .then(waitForPromises) + .then(() => { + expect( + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), + ).toBeNull(); + + expect( + document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(), + ).toBe('Add a To Do'); + }) + .then(done) + .catch(done.fail); + }); + + it('updates aria-label to Mark as done', done => { + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); + + setImmediate(() => { + expect( + document + .querySelector('.js-issuable-todo.sidebar-collapsed-icon') + .getAttribute('aria-label'), + ).toBe('Mark as done'); + + done(); + }); + }); + + it('updates aria-label to add todo', done => { + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); + + waitForPromises() + .then(() => { + expect( + document + .querySelector('.js-issuable-todo.sidebar-collapsed-icon') + .getAttribute('aria-label'), + ).toBe('Mark as done'); + + document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); + }) + .then(waitForPromises) + .then(() => { + expect( + document + .querySelector('.js-issuable-todo.sidebar-collapsed-icon') + .getAttribute('aria-label'), + ).toBe('Add a To Do'); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/comment_type_toggle_spec.js b/spec/frontend/comment_type_toggle_spec.js new file mode 100644 index 00000000000..06dbfac1803 --- /dev/null +++ b/spec/frontend/comment_type_toggle_spec.js @@ -0,0 +1,169 @@ +import CommentTypeToggle from '~/comment_type_toggle'; +import DropLab from '~/droplab/drop_lab'; +import InputSetter from '~/droplab/plugins/input_setter'; + +describe('CommentTypeToggle', () => { + const testContext = {}; + + describe('class constructor', () => { + beforeEach(() => { + testContext.dropdownTrigger = {}; + testContext.dropdownList = {}; + testContext.noteTypeInput = {}; + testContext.submitButton = {}; + testContext.closeButton = {}; + + testContext.commentTypeToggle = new CommentTypeToggle({ + dropdownTrigger: testContext.dropdownTrigger, + dropdownList: testContext.dropdownList, + noteTypeInput: testContext.noteTypeInput, + submitButton: testContext.submitButton, + closeButton: testContext.closeButton, + }); + }); + + it('should set .dropdownTrigger', () => { + expect(testContext.commentTypeToggle.dropdownTrigger).toBe(testContext.dropdownTrigger); + }); + + it('should set .dropdownList', () => { + expect(testContext.commentTypeToggle.dropdownList).toBe(testContext.dropdownList); + }); + + it('should set .noteTypeInput', () => { + expect(testContext.commentTypeToggle.noteTypeInput).toBe(testContext.noteTypeInput); + }); + + it('should set .submitButton', () => { + expect(testContext.commentTypeToggle.submitButton).toBe(testContext.submitButton); + }); + + it('should set .closeButton', () => { + expect(testContext.commentTypeToggle.closeButton).toBe(testContext.closeButton); + }); + + it('should set .reopenButton', () => { + expect(testContext.commentTypeToggle.reopenButton).toBe(testContext.reopenButton); + }); + }); + + describe('initDroplab', () => { + beforeEach(() => { + testContext.commentTypeToggle = { + dropdownTrigger: {}, + dropdownList: {}, + noteTypeInput: {}, + submitButton: {}, + closeButton: {}, + setConfig: () => {}, + }; + testContext.config = {}; + + jest.spyOn(DropLab.prototype, 'init').mockImplementation(); + jest.spyOn(DropLab.prototype, 'constructor').mockImplementation(); + + jest.spyOn(testContext.commentTypeToggle, 'setConfig').mockReturnValue(testContext.config); + + CommentTypeToggle.prototype.initDroplab.call(testContext.commentTypeToggle); + }); + + it('should instantiate a DropLab instance and set .droplab', () => { + expect(testContext.commentTypeToggle.droplab instanceof DropLab).toBe(true); + }); + + it('should call .setConfig', () => { + expect(testContext.commentTypeToggle.setConfig).toHaveBeenCalled(); + }); + + it('should call DropLab.prototype.init', () => { + expect(DropLab.prototype.init).toHaveBeenCalledWith( + testContext.commentTypeToggle.dropdownTrigger, + testContext.commentTypeToggle.dropdownList, + [InputSetter], + testContext.config, + ); + }); + }); + + describe('setConfig', () => { + describe('if no .closeButton is provided', () => { + beforeEach(() => { + testContext.commentTypeToggle = { + dropdownTrigger: {}, + dropdownList: {}, + noteTypeInput: {}, + submitButton: {}, + reopenButton: {}, + }; + + testContext.setConfig = CommentTypeToggle.prototype.setConfig.call( + testContext.commentTypeToggle, + ); + }); + + it('should not add .closeButton related InputSetter config', () => { + expect(testContext.setConfig).toEqual({ + InputSetter: [ + { + input: testContext.commentTypeToggle.noteTypeInput, + valueAttribute: 'data-value', + }, + { + input: testContext.commentTypeToggle.submitButton, + valueAttribute: 'data-submit-text', + }, + { + input: testContext.commentTypeToggle.reopenButton, + valueAttribute: 'data-reopen-text', + }, + { + input: testContext.commentTypeToggle.reopenButton, + valueAttribute: 'data-reopen-text', + inputAttribute: 'data-alternative-text', + }, + ], + }); + }); + }); + + describe('if no .reopenButton is provided', () => { + beforeEach(() => { + testContext.commentTypeToggle = { + dropdownTrigger: {}, + dropdownList: {}, + noteTypeInput: {}, + submitButton: {}, + closeButton: {}, + }; + + testContext.setConfig = CommentTypeToggle.prototype.setConfig.call( + testContext.commentTypeToggle, + ); + }); + + it('should not add .reopenButton related InputSetter config', () => { + expect(testContext.setConfig).toEqual({ + InputSetter: [ + { + input: testContext.commentTypeToggle.noteTypeInput, + valueAttribute: 'data-value', + }, + { + input: testContext.commentTypeToggle.submitButton, + valueAttribute: 'data-submit-text', + }, + { + input: testContext.commentTypeToggle.closeButton, + valueAttribute: 'data-close-text', + }, + { + input: testContext.commentTypeToggle.closeButton, + valueAttribute: 'data-close-text', + inputAttribute: 'data-alternative-text', + }, + ], + }); + }); + }); + }); +}); diff --git a/spec/frontend/confirm_modal_spec.js b/spec/frontend/confirm_modal_spec.js index 89cfc3ef3a3..b14d1c3e01d 100644 --- a/spec/frontend/confirm_modal_spec.js +++ b/spec/frontend/confirm_modal_spec.js @@ -51,7 +51,7 @@ describe('ConfirmModal', () => { const findModalOkButton = (modal, variant) => modal.querySelector(`.modal-footer .btn-${variant}`); const findModalCancelButton = modal => modal.querySelector('.modal-footer .btn-secondary'); - const modalIsHidden = () => findModal().getAttribute('aria-hidden') === 'true'; + const modalIsHidden = () => findModal() === null; const serializeModal = (modal, buttonIndex) => { const { modalAttributes } = buttons[buttonIndex]; @@ -101,7 +101,9 @@ describe('ConfirmModal', () => { }); it('closes the modal', () => { - expect(modalIsHidden()).toBe(true); + setImmediate(() => { + expect(modalIsHidden()).toBe(true); + }); }); }); }); diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap index fafffcb6e0c..a5eb42e0f08 100644 --- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -20,7 +20,10 @@ exports[`Contributors charts should render charts when loading completed and the height="264" includelegendavgmax="true" legendaveragetext="Avg" + legendcurrenttext="Current" + legendlayout="inline" legendmaxtext="Max" + legendmintext="Min" option="[object Object]" thresholds="" width="0" @@ -48,7 +51,10 @@ exports[`Contributors charts should render charts when loading completed and the height="216" includelegendavgmax="true" legendaveragetext="Avg" + legendcurrenttext="Current" + legendlayout="inline" legendmaxtext="Max" + legendmintext="Min" option="[object Object]" thresholds="" width="0" diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index 1139f094705..01f7ada9cd6 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -1,4 +1,5 @@ import testAction from 'helpers/vuex_action_helper'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import MockAdapter from 'axios-mock-adapter'; import createState from '~/create_cluster/eks_cluster/store/state'; import * as actions from '~/create_cluster/eks_cluster/store/actions'; @@ -251,12 +252,7 @@ describe('EKS Cluster Store Actions', () => { }); describe('createClusterSuccess', () => { - beforeEach(() => { - jest.spyOn(window.location, 'assign').mockImplementation(() => {}); - }); - afterEach(() => { - window.location.assign.mockRestore(); - }); + useMockLocationHelper(); it('redirects to the new cluster URL', () => { actions.createClusterSuccess(null, newClusterUrl); diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap index 4828e8cb3c2..4c848256e5b 100644 --- a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap +++ b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Design discussions component should match the snapshot of note when repositioning 1`] = ` +exports[`Design note pin component should match the snapshot of note when repositioning 1`] = ` `; -exports[`Design discussions component should match the snapshot of note with index 1`] = ` +exports[`Design note pin component should match the snapshot of note with index 1`] = ` `; -exports[`Design discussions component should match the snapshot of note without index 1`] = ` +exports[`Design note pin component should match the snapshot of note without index 1`] = ` + + +
    + +
    + +
    + +
    + diff --git a/spec/frontend/fixtures/static/search_autocomplete.html b/spec/frontend/fixtures/static/search_autocomplete.html deleted file mode 100644 index 29db9020424..00000000000 --- a/spec/frontend/fixtures/static/search_autocomplete.html +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/spec/frontend/fixtures/test_report.rb b/spec/frontend/fixtures/test_report.rb index d0ecaf11994..16496aa901b 100644 --- a/spec/frontend/fixtures/test_report.rb +++ b/spec/frontend/fixtures/test_report.rb @@ -2,7 +2,7 @@ require "spec_helper" -describe Projects::PipelinesController, "(JavaScript fixtures)", type: :controller do +RSpec.describe Projects::PipelinesController, "(JavaScript fixtures)", type: :controller do include JavaScriptFixturesHelpers let(:namespace) { create(:namespace, name: "frontend-fixtures") } diff --git a/spec/frontend/fixtures/todos.rb b/spec/frontend/fixtures/todos.rb index e5bdb4998ed..399be272e9b 100644 --- a/spec/frontend/fixtures/todos.rb +++ b/spec/frontend/fixtures/todos.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'Todos (JavaScript fixtures)' do +RSpec.describe 'Todos (JavaScript fixtures)' do include JavaScriptFixturesHelpers let(:admin) { create(:admin) } diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb index 9710fbbc181..be3874d7c42 100644 --- a/spec/frontend/fixtures/u2f.rb +++ b/spec/frontend/fixtures/u2f.rb @@ -2,7 +2,7 @@ require 'spec_helper' -context 'U2F' do +RSpec.context 'U2F' do include JavaScriptFixturesHelpers let(:user) { create(:user, :two_factor_via_u2f, otp_secret: 'otpsecret:coolkids') } diff --git a/spec/frontend/gl_dropdown_spec.js b/spec/frontend/gl_dropdown_spec.js new file mode 100644 index 00000000000..8bfe7f56e37 --- /dev/null +++ b/spec/frontend/gl_dropdown_spec.js @@ -0,0 +1,345 @@ +/* eslint-disable no-param-reassign */ + +import $ from 'jquery'; +import '~/gl_dropdown'; +import '~/lib/utils/common_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn().mockName('visitUrl'), +})); + +describe('glDropdown', () => { + preloadFixtures('static/gl_dropdown.html'); + + const NON_SELECTABLE_CLASSES = + '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; + const SEARCH_INPUT_SELECTOR = '.dropdown-input-field'; + const ITEM_SELECTOR = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; + const FOCUSED_ITEM_SELECTOR = `${ITEM_SELECTOR} a.is-focused`; + const ARROW_KEYS = { + DOWN: 40, + UP: 38, + ENTER: 13, + ESC: 27, + }; + + let remoteCallback; + + const test = {}; + + const navigateWithKeys = (direction, steps, cb, i) => { + i = i || 0; + if (!i) direction = direction.toUpperCase(); + $('body').trigger({ + type: 'keydown', + which: ARROW_KEYS[direction], + keyCode: ARROW_KEYS[direction], + }); + i += 1; + if (i <= steps) { + navigateWithKeys(direction, steps, cb, i); + } else { + cb(); + } + }; + + const remoteMock = (data, term, callback) => { + remoteCallback = callback.bind({}, data); + }; + + function initDropDown(hasRemote, isFilterable, extraOpts = {}) { + const options = { + selectable: true, + filterable: isFilterable, + data: hasRemote ? remoteMock.bind({}, test.projectsData) : test.projectsData, + search: { + fields: ['name'], + }, + text: project => project.name_with_namespace || project.name, + id: project => project.id, + ...extraOpts, + }; + test.dropdownButtonElement = $( + '#js-project-dropdown', + test.dropdownContainerElement, + ).glDropdown(options); + } + + beforeEach(() => { + loadFixtures('static/gl_dropdown.html'); + test.dropdownContainerElement = $('.dropdown.inline'); + test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement); + test.projectsData = getJSONFixture('static/projects.json'); + }); + + afterEach(() => { + $('body').off('keydown'); + test.dropdownContainerElement.off('keyup'); + }); + + it('should open on click', () => { + initDropDown.call(this, false); + + expect(test.dropdownContainerElement).not.toHaveClass('show'); + test.dropdownButtonElement.click(); + + expect(test.dropdownContainerElement).toHaveClass('show'); + }); + + it('escapes HTML as text', () => { + test.projectsData[0].name_with_namespace = ''; + + initDropDown.call(this, false); + + test.dropdownButtonElement.click(); + + expect($('.dropdown-content li:first-child').text()).toBe(''); + }); + + it('should output HTML when highlighting', () => { + test.projectsData[0].name_with_namespace = 'testing'; + $('.dropdown-input .dropdown-input-field').val('test'); + + initDropDown.call(this, false, true, { + highlight: true, + }); + + test.dropdownButtonElement.click(); + + expect($('.dropdown-content li:first-child').text()).toBe('testing'); + + expect($('.dropdown-content li:first-child a').html()).toBe( + 'testing', + ); + }); + + describe('that is open', () => { + beforeEach(() => { + initDropDown.call(this, false, false); + test.dropdownButtonElement.click(); + }); + + it('should select a following item on DOWN keypress', () => { + expect($(FOCUSED_ITEM_SELECTOR, test.$dropdownMenuElement).length).toBe(0); + const randomIndex = Math.floor(Math.random() * (test.projectsData.length - 1)) + 0; + navigateWithKeys('down', randomIndex, () => { + expect($(FOCUSED_ITEM_SELECTOR, test.$dropdownMenuElement).length).toBe(1); + expect($(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, test.$dropdownMenuElement)).toHaveClass( + 'is-focused', + ); + }); + }); + + it('should select a previous item on UP keypress', () => { + expect($(FOCUSED_ITEM_SELECTOR, test.$dropdownMenuElement).length).toBe(0); + navigateWithKeys('down', test.projectsData.length - 1, () => { + expect($(FOCUSED_ITEM_SELECTOR, test.$dropdownMenuElement).length).toBe(1); + const randomIndex = Math.floor(Math.random() * (test.projectsData.length - 2)) + 0; + navigateWithKeys('up', randomIndex, () => { + expect($(FOCUSED_ITEM_SELECTOR, test.$dropdownMenuElement).length).toBe(1); + expect( + $( + `${ITEM_SELECTOR}:eq(${test.projectsData.length - 2 - randomIndex}) a`, + test.$dropdownMenuElement, + ), + ).toHaveClass('is-focused'); + }); + }); + }); + + it('should click the selected item on ENTER keypress', () => { + expect(test.dropdownContainerElement).toHaveClass('show'); + const randomIndex = Math.floor(Math.random() * (test.projectsData.length - 1)) + 0; + navigateWithKeys('down', randomIndex, () => { + navigateWithKeys('enter', null, () => { + expect(test.dropdownContainerElement).not.toHaveClass('show'); + const link = $(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, test.$dropdownMenuElement); + + expect(link).toHaveClass('is-active'); + const linkedLocation = link.attr('href'); + if (linkedLocation && linkedLocation !== '#') { + expect(visitUrl).toHaveBeenCalledWith(linkedLocation); + } + }); + }); + }); + + it('should close on ESC keypress', () => { + expect(test.dropdownContainerElement).toHaveClass('show'); + test.dropdownContainerElement.trigger({ + type: 'keyup', + which: ARROW_KEYS.ESC, + keyCode: ARROW_KEYS.ESC, + }); + + expect(test.dropdownContainerElement).not.toHaveClass('show'); + }); + }); + + describe('opened and waiting for a remote callback', () => { + beforeEach(() => { + initDropDown.call(this, true, true); + test.dropdownButtonElement.click(); + }); + + it('should show loading indicator while search results are being fetched by backend', () => { + const dropdownMenu = document.querySelector('.dropdown-menu'); + + expect(dropdownMenu.className.indexOf('is-loading')).not.toBe(-1); + remoteCallback(); + + expect(dropdownMenu.className.indexOf('is-loading')).toBe(-1); + }); + + it('should not focus search input while remote task is not complete', () => { + expect($(document.activeElement)).not.toEqual($(SEARCH_INPUT_SELECTOR)); + remoteCallback(); + + expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR)); + }); + + it('should focus search input after remote task is complete', () => { + remoteCallback(); + + expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR)); + }); + + it('should focus on input when opening for the second time after transition', () => { + remoteCallback(); + test.dropdownContainerElement.trigger({ + type: 'keyup', + which: ARROW_KEYS.ESC, + keyCode: ARROW_KEYS.ESC, + }); + test.dropdownButtonElement.click(); + test.dropdownContainerElement.trigger('transitionend'); + + expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR)); + }); + }); + + describe('input focus with array data', () => { + it('should focus input when passing array data to drop down', () => { + initDropDown.call(this, false, true); + test.dropdownButtonElement.click(); + test.dropdownContainerElement.trigger('transitionend'); + + expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR)); + }); + }); + + it('should still have input value on close and restore', () => { + const $searchInput = $(SEARCH_INPUT_SELECTOR); + initDropDown.call(this, false, true); + $searchInput + .trigger('focus') + .val('g') + .trigger('input'); + + expect($searchInput.val()).toEqual('g'); + test.dropdownButtonElement.trigger('hidden.bs.dropdown'); + $searchInput.trigger('blur').trigger('focus'); + + expect($searchInput.val()).toEqual('g'); + }); + + describe('renderItem', () => { + function dropdownWithOptions(options) { + const $dropdownDiv = $('
    '); + + $dropdownDiv.glDropdown(options); + + return $dropdownDiv.data('glDropdown'); + } + + function basicDropdown() { + return dropdownWithOptions({}); + } + + describe('without selected value', () => { + let dropdown; + + beforeEach(() => { + dropdown = basicDropdown(); + }); + + it('marks items without ID as active', () => { + const dummyData = {}; + + const html = dropdown.renderItem(dummyData, null, null); + + const link = html.querySelector('a'); + + expect(link).toHaveClass('is-active'); + }); + + it('does not mark items with ID as active', () => { + const dummyData = { + id: 'ea', + }; + + const html = dropdown.renderItem(dummyData, null, null); + + const link = html.querySelector('a'); + + expect(link).not.toHaveClass('is-active'); + }); + }); + + it('should return an empty .separator li when when appropriate', () => { + const dropdown = basicDropdown(); + const sep = { type: 'separator' }; + const li = dropdown.renderItem(sep); + + expect(li).toHaveClass('separator'); + expect(li.childNodes.length).toEqual(0); + }); + + it('should return an empty .divider li when when appropriate', () => { + const dropdown = basicDropdown(); + const div = { type: 'divider' }; + const li = dropdown.renderItem(div); + + expect(li).toHaveClass('divider'); + expect(li.childNodes.length).toEqual(0); + }); + + it('should return a .dropdown-header li with the correct content when when appropriate', () => { + const dropdown = basicDropdown(); + const text = 'My Header'; + const header = { type: 'header', content: text }; + const li = dropdown.renderItem(header); + + expect(li).toHaveClass('dropdown-header'); + expect(li.childNodes.length).toEqual(1); + expect(li.textContent).toEqual(text); + }); + }); + + it('should keep selected item after selecting a second time', () => { + const options = { + isSelectable(item, $el) { + return !$el.hasClass('is-active'); + }, + toggleLabel(item) { + return item && item.id; + }, + }; + initDropDown.call(this, false, false, options); + const $item = $(`${ITEM_SELECTOR}:first() a`, test.$dropdownMenuElement); + + // select item the first time + test.dropdownButtonElement.click(); + $item.click(); + + expect($item).toHaveClass('is-active'); + // select item the second time + test.dropdownButtonElement.click(); + $item.click(); + + expect($item).toHaveClass('is-active'); + + expect($('.dropdown-toggle-text')).toHaveText(test.projectsData[0].id.toString()); + }); +}); diff --git a/spec/frontend/gl_form_spec.js b/spec/frontend/gl_form_spec.js new file mode 100644 index 00000000000..150d8a053d5 --- /dev/null +++ b/spec/frontend/gl_form_spec.js @@ -0,0 +1,115 @@ +import $ from 'jquery'; +import autosize from 'autosize'; +import GLForm from '~/gl_form'; +import '~/lib/utils/text_utility'; +import '~/lib/utils/common_utils'; + +describe('GLForm', () => { + const testContext = {}; + + describe('when instantiated', () => { + beforeEach(done => { + testContext.form = $('