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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 17:34:42 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 17:34:42 +0300
commit9f46488805e86b1bc341ea1620b866016c2ce5ed (patch)
treef9748c7e287041e37d6da49e0a29c9511dc34768 /spec/frontend
parentdfc92d081ea0332d69c8aca2f0e745cb48ae5e6d (diff)
Add latest changes from gitlab-org/gitlab@13-0-stable-ee
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/.eslintrc.yml17
-rw-r--r--spec/frontend/__mocks__/@toast-ui/vue-editor/index.js29
-rw-r--r--spec/frontend/ajax_loading_spinner_spec.js57
-rw-r--r--spec/frontend/alert_management/components/alert_management_detail_spec.js242
-rw-r--r--spec/frontend/alert_management/components/alert_management_list_spec.js325
-rw-r--r--spec/frontend/alert_management/mocks/alerts.json29
-rw-r--r--spec/frontend/api_spec.js2
-rw-r--r--spec/frontend/autosave_spec.js90
-rw-r--r--spec/frontend/avatar_helper_spec.js110
-rw-r--r--spec/frontend/behaviors/markdown/paste_markdown_table_spec.js12
-rw-r--r--spec/frontend/behaviors/markdown/render_metrics_spec.js36
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap2
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap11
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap2
-rw-r--r--spec/frontend/blob/components/blob_content_error_spec.js51
-rw-r--r--spec/frontend/blob/components/blob_content_spec.js36
-rw-r--r--spec/frontend/blob/components/blob_header_filepath_spec.js10
-rw-r--r--spec/frontend/blob/components/blob_header_spec.js2
-rw-r--r--spec/frontend/blob/components/mock_data.js2
-rw-r--r--spec/frontend/blob/pipeline_tour_success_modal_spec.js2
-rw-r--r--spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js9
-rw-r--r--spec/frontend/blob/utils_spec.js42
-rw-r--r--spec/frontend/boards/board_list_spec.js2
-rw-r--r--spec/frontend/boards/boards_store_spec.js137
-rw-r--r--spec/frontend/boards/issue_spec.js22
-rw-r--r--spec/frontend/bootstrap_linked_tabs_spec.js67
-rw-r--r--spec/frontend/broadcast_notification_spec.js35
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js203
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js282
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js37
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js7
-rw-r--r--spec/frontend/ci_variable_list/services/mock_data.js12
-rw-r--r--spec/frontend/ci_variable_list/store/actions_spec.js10
-rw-r--r--spec/frontend/ci_variable_list/store/mutations_spec.js10
-rw-r--r--spec/frontend/close_reopen_report_toggle_spec.js288
-rw-r--r--spec/frontend/clusters/components/applications_spec.js33
-rw-r--r--spec/frontend/clusters/components/fluentd_output_settings_spec.js186
-rw-r--r--spec/frontend/clusters/components/knative_domain_editor_spec.js2
-rw-r--r--spec/frontend/clusters/services/mock_data.js1
-rw-r--r--spec/frontend/clusters/stores/clusters_store_spec.js18
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js110
-rw-r--r--spec/frontend/clusters_list/mock_data.js18
-rw-r--r--spec/frontend/clusters_list/store/actions_spec.js29
-rw-r--r--spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap11
-rw-r--r--spec/frontend/code_navigation/components/app_spec.js1
-rw-r--r--spec/frontend/code_navigation/components/popover_spec.js41
-rw-r--r--spec/frontend/code_navigation/store/actions_spec.js28
-rw-r--r--spec/frontend/commit/pipelines/pipelines_spec.js44
-rw-r--r--spec/frontend/commit_merge_requests_spec.js69
-rw-r--r--spec/frontend/commits_spec.js98
-rw-r--r--spec/frontend/contributors/store/actions_spec.js3
-rw-r--r--spec/frontend/contributors/store/getters_spec.js3
-rw-r--r--spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js14
-rw-r--r--spec/frontend/create_item_dropdown_spec.js195
-rw-r--r--spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js1
-rw-r--r--spec/frontend/deploy_keys/components/action_btn_spec.js54
-rw-r--r--spec/frontend/deploy_keys/components/app_spec.js142
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js161
-rw-r--r--spec/frontend/deploy_keys/components/keys_panel_spec.js63
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap42
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap104
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap115
-rw-r--r--spec/frontend/design_management/components/__snapshots__/image_spec.js.snap68
-rw-r--r--spec/frontend/design_management/components/delete_button_spec.js51
-rw-r--r--spec/frontend/design_management/components/design_note_pin_spec.js49
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap61
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap15
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js133
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js170
-rw-r--r--spec/frontend/design_management/components/design_notes/design_reply_form_spec.js182
-rw-r--r--spec/frontend/design_management/components/design_overlay_spec.js393
-rw-r--r--spec/frontend/design_management/components/design_presentation_spec.js546
-rw-r--r--spec/frontend/design_management/components/design_scaler_spec.js67
-rw-r--r--spec/frontend/design_management/components/image_spec.js133
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap472
-rw-r--r--spec/frontend/design_management/components/list/item_spec.js168
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap61
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap28
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap29
-rw-r--r--spec/frontend/design_management/components/toolbar/index_spec.js123
-rw-r--r--spec/frontend/design_management/components/toolbar/pagination_button_spec.js61
-rw-r--r--spec/frontend/design_management/components/toolbar/pagination_spec.js79
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap79
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap455
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap111
-rw-r--r--spec/frontend/design_management/components/upload/button_spec.js59
-rw-r--r--spec/frontend/design_management/components/upload/design_dropzone_spec.js132
-rw-r--r--spec/frontend/design_management/components/upload/design_version_dropdown_spec.js114
-rw-r--r--spec/frontend/design_management/components/upload/mock_data/all_versions.js14
-rw-r--r--spec/frontend/design_management/mock_data/all_versions.js8
-rw-r--r--spec/frontend/design_management/mock_data/design.js54
-rw-r--r--spec/frontend/design_management/mock_data/designs.js17
-rw-r--r--spec/frontend/design_management/mock_data/no_designs.js11
-rw-r--r--spec/frontend/design_management/mock_data/notes.js32
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap263
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap184
-rw-r--r--spec/frontend/design_management/pages/design/index_spec.js301
-rw-r--r--spec/frontend/design_management/pages/index_spec.js533
-rw-r--r--spec/frontend/design_management/router_spec.js81
-rw-r--r--spec/frontend/design_management/utils/cache_update_spec.js44
-rw-r--r--spec/frontend/design_management/utils/design_management_utils_spec.js176
-rw-r--r--spec/frontend/design_management/utils/error_messages_spec.js62
-rw-r--r--spec/frontend/design_management/utils/tracking_spec.js53
-rw-r--r--spec/frontend/diff_comments_store_spec.js136
-rw-r--r--spec/frontend/diffs/components/app_spec.js212
-rw-r--r--spec/frontend/diffs/components/commit_item_spec.js144
-rw-r--r--spec/frontend/diffs/components/diff_content_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_discussions_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_expansion_cell_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_gutter_avatars_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_line_note_form_spec.js2
-rw-r--r--spec/frontend/diffs/components/edit_button_spec.js19
-rw-r--r--spec/frontend/diffs/components/inline_diff_expansion_row_spec.js2
-rw-r--r--spec/frontend/diffs/components/inline_diff_view_spec.js4
-rw-r--r--spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js2
-rw-r--r--spec/frontend/diffs/components/parallel_diff_view_spec.js2
-rw-r--r--spec/frontend/diffs/store/actions_spec.js184
-rw-r--r--spec/frontend/diffs/store/getters_spec.js4
-rw-r--r--spec/frontend/diffs/store/getters_versions_dropdowns_spec.js9
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js30
-rw-r--r--spec/frontend/diffs/store/utils_spec.js84
-rw-r--r--spec/frontend/dirty_submit/dirty_submit_collection_spec.js22
-rw-r--r--spec/frontend/dirty_submit/dirty_submit_factory_spec.js18
-rw-r--r--spec/frontend/dirty_submit/dirty_submit_form_spec.js97
-rw-r--r--spec/frontend/dirty_submit/helper.js43
-rw-r--r--spec/frontend/editor/editor_lite_spec.js177
-rw-r--r--spec/frontend/emoji_spec.js485
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_helper_spec.js62
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_options_spec.js44
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_spec.js120
-rw-r--r--spec/frontend/filtered_search/dropdown_utils_spec.js374
-rw-r--r--spec/frontend/filtered_search/filtered_search_manager_spec.js587
-rw-r--r--spec/frontend/filtered_search/filtered_search_tokenizer_spec.js152
-rw-r--r--spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js148
-rw-r--r--spec/frontend/filtered_search/recent_searches_root_spec.js32
-rw-r--r--spec/frontend/filtered_search/services/recent_searches_service_spec.js161
-rw-r--r--spec/frontend/filtered_search/visual_token_value_spec.js389
-rw-r--r--spec/frontend/fixtures/test_report.rb2
-rw-r--r--spec/frontend/flash_spec.js233
-rw-r--r--spec/frontend/frequent_items/components/app_spec.js251
-rw-r--r--spec/frontend/frequent_items/mock_data.js127
-rw-r--r--spec/frontend/frequent_items/store/actions_spec.js228
-rw-r--r--spec/frontend/frequent_items/store/mutations_spec.js117
-rw-r--r--spec/frontend/frequent_items/utils_spec.js130
-rw-r--r--spec/frontend/groups/components/app_spec.js507
-rw-r--r--spec/frontend/groups/components/group_folder_spec.js65
-rw-r--r--spec/frontend/groups/components/group_item_spec.js215
-rw-r--r--spec/frontend/groups/components/groups_spec.js72
-rw-r--r--spec/frontend/groups/components/item_actions_spec.js84
-rw-r--r--spec/frontend/groups/components/item_caret_spec.js38
-rw-r--r--spec/frontend/groups/components/item_stats_spec.js119
-rw-r--r--spec/frontend/groups/components/item_stats_value_spec.js82
-rw-r--r--spec/frontend/groups/components/item_type_icon_spec.js53
-rw-r--r--spec/frontend/groups/mock_data.js398
-rw-r--r--spec/frontend/groups/service/groups_service_spec.js42
-rw-r--r--spec/frontend/groups/store/groups_store_spec.js123
-rw-r--r--spec/frontend/header_spec.js16
-rw-r--r--spec/frontend/helpers/class_spec_helper.js1
-rw-r--r--spec/frontend/helpers/event_hub_factory_spec.js94
-rw-r--r--spec/frontend/helpers/filtered_search_spec_helper.js69
-rw-r--r--spec/frontend/helpers/fixtures.js5
-rw-r--r--spec/frontend/helpers/set_window_location_helper.js40
-rw-r--r--spec/frontend/helpers/set_window_location_helper_spec.js40
-rw-r--r--spec/frontend/helpers/vue_mount_component_helper.js25
-rw-r--r--spec/frontend/helpers/web_worker_mock.js10
-rw-r--r--spec/frontend/ide/components/activity_bar_spec.js72
-rw-r--r--spec/frontend/ide/components/commit_sidebar/editor_header_spec.js50
-rw-r--r--spec/frontend/ide/components/commit_sidebar/form_spec.js111
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_spec.js2
-rw-r--r--spec/frontend/ide/components/commit_sidebar/radio_group_spec.js134
-rw-r--r--spec/frontend/ide/components/file_row_extra_spec.js170
-rw-r--r--spec/frontend/ide/components/file_templates/bar_spec.js117
-rw-r--r--spec/frontend/ide/components/ide_review_spec.js73
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js57
-rw-r--r--spec/frontend/ide/components/ide_spec.js125
-rw-r--r--spec/frontend/ide/components/ide_status_bar_spec.js127
-rw-r--r--spec/frontend/ide/components/ide_tree_list_spec.js77
-rw-r--r--spec/frontend/ide/components/ide_tree_spec.js34
-rw-r--r--spec/frontend/ide/components/jobs/detail/description_spec.js28
-rw-r--r--spec/frontend/ide/components/jobs/item_spec.js39
-rw-r--r--spec/frontend/ide/components/merge_requests/item_spec.js63
-rw-r--r--spec/frontend/ide/components/nav_dropdown_button_spec.js93
-rw-r--r--spec/frontend/ide/components/nav_dropdown_spec.js102
-rw-r--r--spec/frontend/ide/components/new_dropdown/button_spec.js65
-rw-r--r--spec/frontend/ide/components/new_dropdown/index_spec.js84
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js175
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js112
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js17
-rw-r--r--spec/frontend/ide/components/preview/clientside_spec.js8
-rw-r--r--spec/frontend/ide/components/repo_commit_section_spec.js1
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js185
-rw-r--r--spec/frontend/ide/components/repo_tabs_spec.js35
-rw-r--r--spec/frontend/ide/components/shared/tokened_input_spec.js133
-rw-r--r--spec/frontend/ide/lib/common/model_manager_spec.js126
-rw-r--r--spec/frontend/ide/lib/common/model_spec.js137
-rw-r--r--spec/frontend/ide/lib/decorations/controller_spec.js143
-rw-r--r--spec/frontend/ide/lib/diff/controller_spec.js215
-rw-r--r--spec/frontend/ide/lib/editor_spec.js302
-rw-r--r--spec/frontend/ide/lib/languages/vue_spec.js92
-rw-r--r--spec/frontend/ide/services/index_spec.js63
-rw-r--r--spec/frontend/ide/stores/mutations_spec.js41
-rw-r--r--spec/frontend/ide/stores/utils_spec.js71
-rw-r--r--spec/frontend/ide/utils_spec.js92
-rw-r--r--spec/frontend/image_diff/helpers/badge_helper_spec.js130
-rw-r--r--spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js144
-rw-r--r--spec/frontend/image_diff/helpers/dom_helper_spec.js120
-rw-r--r--spec/frontend/image_diff/helpers/utils_helper_spec.js152
-rw-r--r--spec/frontend/image_diff/image_badge_spec.js84
-rw-r--r--spec/frontend/image_diff/image_diff_spec.js361
-rw-r--r--spec/frontend/image_diff/mock_data.js28
-rw-r--r--spec/frontend/image_diff/replaced_image_diff_spec.js356
-rw-r--r--spec/frontend/import_projects/components/import_projects_table_spec.js5
-rw-r--r--spec/frontend/import_projects/components/provider_repo_table_row_spec.js4
-rw-r--r--spec/frontend/integrations/edit/components/active_toggle_spec.js8
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js99
-rw-r--r--spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js97
-rw-r--r--spec/frontend/integrations/edit/components/trigger_fields_spec.js136
-rw-r--r--spec/frontend/integrations/integration_settings_form_spec.js268
-rw-r--r--spec/frontend/issuable_spec.js64
-rw-r--r--spec/frontend/issuables_list/components/issuable_list_root_app_spec.js121
-rw-r--r--spec/frontend/issue_show/components/app_spec.js497
-rw-r--r--spec/frontend/issue_show/components/description_spec.js188
-rw-r--r--spec/frontend/issue_show/components/edited_spec.js49
-rw-r--r--spec/frontend/issue_show/components/fields/description_template_spec.js41
-rw-r--r--spec/frontend/issue_show/components/form_spec.js99
-rw-r--r--spec/frontend/issue_show/components/title_spec.js95
-rw-r--r--spec/frontend/jira_import/components/jira_import_app_spec.js102
-rw-r--r--spec/frontend/jira_import/components/jira_import_form_spec.js31
-rw-r--r--spec/frontend/jira_import/components/jira_import_progress_spec.js21
-rw-r--r--spec/frontend/jira_import/components/jira_import_setup_spec.js18
-rw-r--r--spec/frontend/jira_import/utils_spec.js65
-rw-r--r--spec/frontend/jobs/components/artifacts_block_spec.js119
-rw-r--r--spec/frontend/jobs/components/commit_block_spec.js89
-rw-r--r--spec/frontend/jobs/components/empty_state_spec.js141
-rw-r--r--spec/frontend/jobs/components/environments_block_spec.js261
-rw-r--r--spec/frontend/jobs/components/job_container_item_spec.js101
-rw-r--r--spec/frontend/jobs/components/job_log_spec.js65
-rw-r--r--spec/frontend/jobs/components/jobs_container_spec.js131
-rw-r--r--spec/frontend/jobs/components/log/line_header_spec.js2
-rw-r--r--spec/frontend/jobs/components/manual_variables_form_spec.js103
-rw-r--r--spec/frontend/jobs/components/sidebar_spec.js166
-rw-r--r--spec/frontend/jobs/components/stages_dropdown_spec.js163
-rw-r--r--spec/frontend/jobs/components/trigger_block_spec.js100
-rw-r--r--spec/frontend/jobs/components/unmet_prerequisites_block_spec.js37
-rw-r--r--spec/frontend/jobs/mixins/delayed_job_mixin_spec.js79
-rw-r--r--spec/frontend/jobs/store/actions_spec.js512
-rw-r--r--spec/frontend/jobs/store/helpers.js6
-rw-r--r--spec/frontend/jobs/store/mutations_spec.js2
-rw-r--r--spec/frontend/labels_select_spec.js15
-rw-r--r--spec/frontend/landing_spec.js184
-rw-r--r--spec/frontend/lib/utils/axios_utils_spec.js1
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js2
-rw-r--r--spec/frontend/lib/utils/csrf_token_spec.js57
-rw-r--r--spec/frontend/lib/utils/downloader_spec.js40
-rw-r--r--spec/frontend/lib/utils/navigation_utility_spec.js23
-rw-r--r--spec/frontend/lib/utils/poll_spec.js225
-rw-r--r--spec/frontend/lib/utils/sticky_spec.js77
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js8
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js147
-rw-r--r--spec/frontend/milestones/mock_data.js82
-rw-r--r--spec/frontend/milestones/project_milestone_combobox_spec.js150
-rw-r--r--spec/frontend/mocks/ce/diffs/workers/tree_worker.js9
-rw-r--r--spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js1
-rw-r--r--spec/frontend/mocks_spec.js13
-rw-r--r--spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap43
-rw-r--r--spec/frontend/monitoring/alert_widget_spec.js422
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap25
-rw-r--r--spec/frontend/monitoring/components/alert_widget_form_spec.js220
-rw-r--r--spec/frontend/monitoring/components/charts/single_stat_spec.js14
-rw-r--r--spec/frontend/monitoring/components/charts/time_series_spec.js43
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js (renamed from spec/frontend/monitoring/components/panel_type_spec.js)266
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js549
-rw-r--r--spec/frontend/monitoring/components/dashboard_template_spec.js15
-rw-r--r--spec/frontend/monitoring/components/dashboard_url_time_spec.js2
-rw-r--r--spec/frontend/monitoring/components/dashboards_dropdown_spec.js127
-rw-r--r--spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js26
-rw-r--r--spec/frontend/monitoring/components/embeds/metric_embed_spec.js10
-rw-r--r--spec/frontend/monitoring/components/variables/custom_variable_spec.js52
-rw-r--r--spec/frontend/monitoring/components/variables/text_variable_spec.js59
-rw-r--r--spec/frontend/monitoring/components/variables_section_spec.js126
-rw-r--r--spec/frontend/monitoring/mock_data.js231
-rw-r--r--spec/frontend/monitoring/store/actions_spec.js180
-rw-r--r--spec/frontend/monitoring/store/getters_spec.js84
-rw-r--r--spec/frontend/monitoring/store/mutations_spec.js92
-rw-r--r--spec/frontend/monitoring/store/utils_spec.js6
-rw-r--r--spec/frontend/monitoring/store/variable_mapping_spec.js22
-rw-r--r--spec/frontend/monitoring/store_utils.js43
-rw-r--r--spec/frontend/monitoring/stubs/modal_stub.js11
-rw-r--r--spec/frontend/monitoring/utils_spec.js302
-rw-r--r--spec/frontend/monitoring/validators_spec.js80
-rw-r--r--spec/frontend/notebook/cells/code_spec.js90
-rw-r--r--spec/frontend/notebook/cells/markdown_spec.js167
-rw-r--r--spec/frontend/notebook/cells/output/html_sanitize_tests.js68
-rw-r--r--spec/frontend/notebook/cells/output/html_spec.js31
-rw-r--r--spec/frontend/notebook/cells/output/index_spec.js115
-rw-r--r--spec/frontend/notebook/cells/prompt_spec.js56
-rw-r--r--spec/frontend/notebook/index_spec.js100
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js7
-rw-r--r--spec/frontend/notes/components/discussion_actions_spec.js2
-rw-r--r--spec/frontend/notes/components/discussion_counter_spec.js9
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js4
-rw-r--r--spec/frontend/notes/components/discussion_notes_spec.js2
-rw-r--r--spec/frontend/notes/components/note_form_spec.js8
-rw-r--r--spec/frontend/notes/components/note_header_spec.js94
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js2
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js2
-rw-r--r--spec/frontend/notes/mixins/discussion_navigation_spec.js4
-rw-r--r--spec/frontend/notes/mock_data.js1
-rw-r--r--spec/frontend/notes/old_notes_spec.js52
-rw-r--r--spec/frontend/notes/stores/actions_spec.js31
-rw-r--r--spec/frontend/notes/stores/collapse_utils_spec.js4
-rw-r--r--spec/frontend/notes/stores/mutation_spec.js56
-rw-r--r--spec/frontend/oauth_remember_me_spec.js39
-rw-r--r--spec/frontend/pages/admin/application_settings/account_and_limits_spec.js36
-rw-r--r--spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js64
-rw-r--r--spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap1
-rw-r--r--spec/frontend/pages/admin/users/new/index_spec.js43
-rw-r--r--spec/frontend/pages/labels/components/promote_label_modal_spec.js103
-rw-r--r--spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js109
-rw-r--r--spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js98
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js154
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js114
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js29
-rw-r--r--spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js61
-rw-r--r--spec/frontend/pipelines/components/pipelines_filtered_search_spec.js97
-rw-r--r--spec/frontend/pipelines/graph/stage_column_component_spec.js2
-rw-r--r--spec/frontend/pipelines/header_component_spec.js116
-rw-r--r--spec/frontend/pipelines/linked_pipelines_mock.json3536
-rw-r--r--spec/frontend/pipelines/mock_data.js568
-rw-r--r--spec/frontend/pipelines/pipeline_details_mediator_spec.js36
-rw-r--r--spec/frontend/pipelines/pipelines_actions_spec.js142
-rw-r--r--spec/frontend/pipelines/pipelines_artifacts_spec.js46
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js710
-rw-r--r--spec/frontend/pipelines/pipelines_table_row_spec.js2
-rw-r--r--spec/frontend/pipelines/pipelines_table_spec.js66
-rw-r--r--spec/frontend/pipelines/stage_spec.js156
-rw-r--r--spec/frontend/pipelines/stores/pipeline_store_spec.js135
-rw-r--r--spec/frontend/pipelines/test_reports/stores/mutations_spec.js6
-rw-r--r--spec/frontend/pipelines/test_reports/test_summary_spec.js18
-rw-r--r--spec/frontend/pipelines/test_reports/test_summary_table_spec.js36
-rw-r--r--spec/frontend/pipelines/time_ago_spec.js67
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js89
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js98
-rw-r--r--spec/frontend/pipelines_spec.js19
-rw-r--r--spec/frontend/prometheus_metrics/custom_metrics_spec.js2
-rw-r--r--spec/frontend/prometheus_metrics/mock_data.js44
-rw-r--r--spec/frontend/prometheus_metrics/prometheus_metrics_spec.js178
-rw-r--r--spec/frontend/registry/explorer/components/image_list_spec.js74
-rw-r--r--spec/frontend/registry/explorer/mock_data.js8
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js198
-rw-r--r--spec/frontend/registry/explorer/pages/index_spec.js36
-rw-r--r--spec/frontend/registry/explorer/pages/list_spec.js269
-rw-r--r--spec/frontend/registry/explorer/stores/actions_spec.js20
-rw-r--r--spec/frontend/registry/explorer/stubs.js5
-rw-r--r--spec/frontend/registry/settings/store/getters_spec.js14
-rw-r--r--spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap18
-rw-r--r--spec/frontend/registry/shared/components/expiration_policy_fields_spec.js37
-rw-r--r--spec/frontend/related_merge_requests/components/related_merge_requests_spec.js94
-rw-r--r--spec/frontend/related_merge_requests/store/actions_spec.js111
-rw-r--r--spec/frontend/related_merge_requests/store/mutations_spec.js49
-rw-r--r--spec/frontend/releases/components/app_edit_spec.js9
-rw-r--r--spec/frontend/releases/components/release_block_footer_spec.js81
-rw-r--r--spec/frontend/releases/components/release_block_metadata_spec.js67
-rw-r--r--spec/frontend/releases/components/release_block_milestone_info_spec.js4
-rw-r--r--spec/frontend/releases/components/release_block_spec.js13
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js12
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js31
-rw-r--r--spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js126
-rw-r--r--spec/frontend/reports/accessibility_report/mock_data.js55
-rw-r--r--spec/frontend/reports/accessibility_report/store/actions_spec.js121
-rw-r--r--spec/frontend/reports/accessibility_report/store/getters_spec.js149
-rw-r--r--spec/frontend/reports/accessibility_report/store/mutations_spec.js64
-rw-r--r--spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap25
-rw-r--r--spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap37
-rw-r--r--spec/frontend/reports/components/grouped_issues_list_spec.js86
-rw-r--r--spec/frontend/reports/components/grouped_test_reports_app_spec.js260
-rw-r--r--spec/frontend/reports/components/issue_status_icon_spec.js29
-rw-r--r--spec/frontend/reports/components/modal_open_name_spec.js47
-rw-r--r--spec/frontend/reports/components/modal_spec.js54
-rw-r--r--spec/frontend/reports/components/summary_row_spec.js37
-rw-r--r--spec/frontend/reports/components/test_issue_body_spec.js72
-rw-r--r--spec/frontend/reports/mock_data/mock_data.js24
-rw-r--r--spec/frontend/reports/mock_data/new_and_fixed_failures_report.json55
-rw-r--r--spec/frontend/reports/mock_data/new_errors_report.json38
-rw-r--r--spec/frontend/reports/mock_data/new_failures_report.json38
-rw-r--r--spec/frontend/reports/mock_data/no_failures_report.json28
-rw-r--r--spec/frontend/reports/mock_data/resolved_failures.json58
-rw-r--r--spec/frontend/reports/store/actions_spec.js171
-rw-r--r--spec/frontend/reports/store/mutations_spec.js126
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap8
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js1
-rw-r--r--spec/frontend/repository/utils/commit_spec.js2
-rw-r--r--spec/frontend/settings_panels_spec.js45
-rw-r--r--spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap12
-rw-r--r--spec/frontend/sidebar/assignees_realtime_spec.js102
-rw-r--r--spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js279
-rw-r--r--spec/frontend/sidebar/confidential/edit_form_buttons_spec.js41
-rw-r--r--spec/frontend/sidebar/confidential/edit_form_spec.js45
-rw-r--r--spec/frontend/sidebar/confidential_edit_buttons_spec.js35
-rw-r--r--spec/frontend/sidebar/confidential_edit_form_buttons_spec.js35
-rw-r--r--spec/frontend/sidebar/confidential_issue_sidebar_spec.js25
-rw-r--r--spec/frontend/sidebar/lock/edit_form_buttons_spec.js31
-rw-r--r--spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js99
-rw-r--r--spec/frontend/sidebar/participants_spec.js206
-rw-r--r--spec/frontend/sidebar/sidebar_assignees_spec.js46
-rw-r--r--spec/frontend/sidebar/sidebar_mediator_spec.js135
-rw-r--r--spec/frontend/sidebar/sidebar_move_issue_spec.js167
-rw-r--r--spec/frontend/sidebar/sidebar_subscriptions_spec.js36
-rw-r--r--spec/frontend/sidebar/subscriptions_spec.js106
-rw-r--r--spec/frontend/smart_interval_spec.js2
-rw-r--r--spec/frontend/snippet/snippet_bundle_spec.js141
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap1
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap4
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap16
-rw-r--r--spec/frontend/snippets/components/edit_spec.js16
-rw-r--r--spec/frontend/snippets/components/snippet_blob_view_spec.js38
-rw-r--r--spec/frontend/snippets/components/snippet_description_view_spec.js27
-rw-r--r--spec/frontend/snippets/components/snippet_header_spec.js86
-rw-r--r--spec/frontend/snippets/components/snippet_title_spec.js6
-rw-r--r--spec/frontend/static_site_editor/components/edit_area_spec.js76
-rw-r--r--spec/frontend/static_site_editor/components/publish_toolbar_spec.js17
-rw-r--r--spec/frontend/static_site_editor/components/saved_changes_message_spec.js7
-rw-r--r--spec/frontend/static_site_editor/components/static_site_editor_spec.js247
-rw-r--r--spec/frontend/static_site_editor/graphql/resolvers/file_spec.js25
-rw-r--r--spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js37
-rw-r--r--spec/frontend/static_site_editor/mock_data.js5
-rw-r--r--spec/frontend/static_site_editor/pages/home_spec.js211
-rw-r--r--spec/frontend/static_site_editor/pages/success_spec.js78
-rw-r--r--spec/frontend/static_site_editor/services/submit_content_changes_spec.js32
-rw-r--r--spec/frontend/static_site_editor/store/actions_spec.js152
-rw-r--r--spec/frontend/static_site_editor/store/getters_spec.js19
-rw-r--r--spec/frontend/static_site_editor/store/mutations_spec.js54
-rw-r--r--spec/frontend/tracking_spec.js57
-rw-r--r--spec/frontend/users_select/utils_spec.js33
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js100
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js18
-rw-r--r--spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js165
-rw-r--r--spec/frontend/vue_mr_widget/stores/get_state_key_spec.js24
-rw-r--r--spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js112
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap38
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap16
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap12
-rw-r--r--spec/frontend/vue_shared/components/awards_list_spec.js42
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap3
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/ci_badge_link_spec.js100
-rw-r--r--spec/frontend/vue_shared/components/ci_icon_spec.js122
-rw-r--r--spec/frontend/vue_shared/components/code_block_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js21
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js28
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js114
-rw-r--r--spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js98
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js81
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js36
-rw-r--r--spec/frontend/vue_shared/components/dropdown/mock_data.js11
-rw-r--r--spec/frontend/vue_shared/components/file_finder/item_spec.js140
-rw-r--r--spec/frontend/vue_shared/components/file_row_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js190
-rw-r--r--spec/frontend/vue_shared/components/gl_countdown_spec.js83
-rw-r--r--spec/frontend/vue_shared/components/header_ci_component_spec.js93
-rw-r--r--spec/frontend/vue_shared/components/identicon_spec.js37
-rw-r--r--spec/frontend/vue_shared/components/issue/issue_milestone_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_view_spec.js26
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestions_spec.js102
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_spec.js35
-rw-r--r--spec/frontend/vue_shared/components/navigation_tabs_spec.js64
-rw-r--r--spec/frontend/vue_shared/components/pikaday_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/project_avatar/default_spec.js58
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js109
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_selector_spec.js112
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js57
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js25
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js111
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js24
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js3
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js24
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/stacked_progress_bar_spec.js104
-rw-r--r--spec/frontend/vue_shared/components/tabs/tab_spec.js32
-rw-r--r--spec/frontend/vue_shared/components/tabs/tabs_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/toggle_button_spec.js101
-rw-r--r--spec/frontend/wikis_spec.js26
500 files changed, 44257 insertions, 2188 deletions
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml
index c8aacca5ef2..b9159191114 100644
--- a/spec/frontend/.eslintrc.yml
+++ b/spec/frontend/.eslintrc.yml
@@ -1,10 +1,6 @@
---
-env:
- jest/globals: true
-plugins:
- - jest
extends:
- - 'plugin:jest/recommended'
+ - 'plugin:@gitlab/jest'
settings:
# We have to teach eslint-plugin-import what node modules we use
# otherwise there is an error when it tries to resolve them
@@ -14,9 +10,18 @@ settings:
- path
import/resolver:
jest:
- jestConfigFile: 'jest.config.js'
+ jestConfigFile: 'jest.config.unit.js'
globals:
getJSONFixture: false
loadFixtures: false
preloadFixtures: false
setFixtures: false
+rules:
+ jest/expect-expect:
+ - off
+ - assertFunctionNames:
+ - 'expect*'
+ - 'assert*'
+ - 'testAction'
+ jest/no-test-callback:
+ - off
diff --git a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js
new file mode 100644
index 00000000000..726ed0fa030
--- /dev/null
+++ b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js
@@ -0,0 +1,29 @@
+export const Editor = {
+ props: {
+ initialValue: {
+ type: String,
+ required: true,
+ },
+ options: {
+ type: Object,
+ },
+ initialEditType: {
+ type: String,
+ },
+ height: {
+ type: String,
+ },
+ previewStyle: {
+ type: String,
+ },
+ },
+ render(h) {
+ return h('div');
+ },
+};
+
+export const Viewer = {
+ render(h) {
+ return h('div');
+ },
+};
diff --git a/spec/frontend/ajax_loading_spinner_spec.js b/spec/frontend/ajax_loading_spinner_spec.js
new file mode 100644
index 00000000000..8ed2ee49ff8
--- /dev/null
+++ b/spec/frontend/ajax_loading_spinner_spec.js
@@ -0,0 +1,57 @@
+import $ from 'jquery';
+import AjaxLoadingSpinner from '~/ajax_loading_spinner';
+
+describe('Ajax Loading Spinner', () => {
+ const fixtureTemplate = 'static/ajax_loading_spinner.html';
+ preloadFixtures(fixtureTemplate);
+
+ beforeEach(() => {
+ loadFixtures(fixtureTemplate);
+ AjaxLoadingSpinner.init();
+ });
+
+ it('change current icon with spinner icon and disable link while waiting ajax response', done => {
+ jest.spyOn($, 'ajax').mockImplementation(req => {
+ const xhr = new XMLHttpRequest();
+ const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner');
+ const icon = ajaxLoadingSpinner.querySelector('i');
+
+ req.beforeSend(xhr, { dataType: 'text/html' });
+
+ expect(icon).not.toHaveClass('fa-trash-o');
+ expect(icon).toHaveClass('fa-spinner');
+ expect(icon).toHaveClass('fa-spin');
+ expect(icon.dataset.icon).toEqual('fa-trash-o');
+ expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual('');
+
+ req.complete({});
+
+ done();
+ const deferred = $.Deferred();
+ return deferred.promise();
+ });
+ document.querySelector('.js-ajax-loading-spinner').click();
+ });
+
+ it('use original icon again and enabled the link after complete the ajax request', done => {
+ jest.spyOn($, 'ajax').mockImplementation(req => {
+ const xhr = new XMLHttpRequest();
+ const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner');
+
+ req.beforeSend(xhr, { dataType: 'text/html' });
+ req.complete({});
+
+ const icon = ajaxLoadingSpinner.querySelector('i');
+
+ expect(icon).toHaveClass('fa-trash-o');
+ expect(icon).not.toHaveClass('fa-spinner');
+ expect(icon).not.toHaveClass('fa-spin');
+ expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual(null);
+
+ done();
+ const deferred = $.Deferred();
+ return deferred.promise();
+ });
+ document.querySelector('.js-ajax-loading-spinner').click();
+ });
+});
diff --git a/spec/frontend/alert_management/components/alert_management_detail_spec.js b/spec/frontend/alert_management/components/alert_management_detail_spec.js
new file mode 100644
index 00000000000..1e4c2e24ccb
--- /dev/null
+++ b/spec/frontend/alert_management/components/alert_management_detail_spec.js
@@ -0,0 +1,242 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import { GlAlert, GlLoadingIcon, GlDropdownItem, GlTable } from '@gitlab/ui';
+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 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);
+ const findDetailsTable = () => wrapper.find(GlTable);
+
+ function mountComponent({
+ data,
+ createIssueFromAlertEnabled = false,
+ loading = false,
+ mountMethod = shallowMount,
+ stubs = {},
+ } = {}) {
+ wrapper = mountMethod(AlertDetails, {
+ propsData: {
+ alertId: 'alertId',
+ projectPath: 'projectPath',
+ newIssuePath,
+ },
+ data() {
+ return { alert: { ...mockAlert }, ...data };
+ },
+ provide: {
+ glFeatures: { createIssueFromAlertEnabled },
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ queries: {
+ alert: {
+ loading,
+ },
+ },
+ },
+ },
+ stubs,
+ });
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ const findCreatedIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]');
+
+ describe('Alert details', () => {
+ describe('when alert is null', () => {
+ beforeEach(() => {
+ mountComponent({ data: { alert: null } });
+ });
+
+ it('shows an empty state', () => {
+ expect(wrapper.find('[data-testid="alertDetailsTabs"]').exists()).toBe(false);
+ });
+ });
+
+ describe('when alert is present', () => {
+ beforeEach(() => {
+ mountComponent({ data: { alert: mockAlert } });
+ });
+
+ it('renders a tab with overview information', () => {
+ expect(wrapper.find('[data-testid="overviewTab"]').exists()).toBe(true);
+ });
+
+ it('renders a tab with full alert information', () => {
+ expect(wrapper.find('[data-testid="fullDetailsTab"]').exists()).toBe(true);
+ });
+
+ it('renders a title', () => {
+ expect(wrapper.find('[data-testid="title"]').text()).toBe(mockAlert.title);
+ });
+
+ it('renders a start time', () => {
+ expect(wrapper.find('[data-testid="startTimeItem"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="startTimeItem"]').props().time).toBe(
+ mockAlert.startedAt,
+ );
+ });
+ });
+
+ describe('individual alert fields', () => {
+ describe.each`
+ field | data | isShown
+ ${'eventCount'} | ${1} | ${true}
+ ${'eventCount'} | ${undefined} | ${false}
+ ${'monitoringTool'} | ${'New Relic'} | ${true}
+ ${'monitoringTool'} | ${undefined} | ${false}
+ ${'service'} | ${'Prometheus'} | ${true}
+ ${'service'} | ${undefined} | ${false}
+ `(`$desc`, ({ field, data, isShown }) => {
+ beforeEach(() => {
+ mountComponent({ data: { alert: { ...mockAlert, [field]: data } } });
+ });
+
+ it(`${field} is ${isShown ? 'displayed' : 'hidden'} correctly`, () => {
+ if (isShown) {
+ expect(wrapper.find(`[data-testid="${field}"]`).text()).toBe(data.toString());
+ } else {
+ expect(wrapper.find(`[data-testid="${field}"]`).exists()).toBe(false);
+ }
+ });
+ });
+ });
+
+ 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);
+ });
+ });
+
+ 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);
+ });
+ });
+ });
+
+ describe('View full alert details', () => {
+ beforeEach(() => {
+ mountComponent({ data: { alert: mockAlert } });
+ });
+ it('should display a table of raw alert details data', () => {
+ wrapper.find('[data-testid="fullDetailsTab"]').trigger('click');
+ expect(findDetailsTable().exists()).toBe(true);
+ });
+ });
+
+ describe('loading state', () => {
+ beforeEach(() => {
+ mountComponent({ loading: true });
+ });
+
+ it('displays a loading state when loading', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('error state', () => {
+ it('displays a error state correctly', () => {
+ mountComponent({ data: { errored: true } });
+ expect(wrapper.find(GlAlert).exists()).toBe(true);
+ });
+
+ it('does not display an error when dismissed', () => {
+ mountComponent({ data: { errored: true, isErrorDismissed: true } });
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
+ });
+ });
+
+ describe('header', () => {
+ const findHeader = () => wrapper.find('[data-testid="alert-header"]');
+ const stubs = { TimeAgoTooltip: '<span>now</span>' };
+
+ 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'}
+ `(
+ `When severity=$severity, createdAt=$createdAt, monitoringTool=$monitoringTool`,
+ ({ severity, createdAt, monitoringTool, result }) => {
+ beforeEach(() => {
+ mountComponent({
+ data: { alert: { ...mockAlert, severity, createdAt, monitoringTool } },
+ mountMethod: mount,
+ stubs,
+ });
+ });
+
+ it('header text is shown correctly', () => {
+ expect(findHeader().text()).toBe(result);
+ });
+ },
+ );
+ });
+ });
+ });
+
+ describe('updating the alert status', () => {
+ const mockUpdatedMutationResult = {
+ data: {
+ updateAlertStatus: {
+ errors: [],
+ alert: {
+ status: 'acknowledged',
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alert: mockAlert },
+ 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: '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.',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js
new file mode 100644
index 00000000000..c4630ac57fe
--- /dev/null
+++ b/spec/frontend/alert_management/components/alert_management_list_spec.js
@@ -0,0 +1,325 @@
+import { mount } from '@vue/test-utils';
+import {
+ GlEmptyState,
+ GlTable,
+ GlAlert,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlTab,
+} 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 updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql';
+import mockAlerts from '../mocks/alerts.json';
+
+jest.mock('~/flash');
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn().mockName('visitUrlMock'),
+ joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
+}));
+
+describe('AlertManagementList', () => {
+ let wrapper;
+
+ const findAlertsTable = () => wrapper.find(GlTable);
+ const findAlerts = () => wrapper.findAll('table tbody tr');
+ const findAlert = () => wrapper.find(GlAlert);
+ const findLoader = () => wrapper.find(GlLoadingIcon);
+ const findStatusDropdown = () => wrapper.find(GlDropdown);
+ const findStatusFilterTabs = () => wrapper.findAll(GlTab);
+ const findDateFields = () => wrapper.findAll(TimeAgo);
+ const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
+ const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]');
+
+ function mountComponent({
+ props = {
+ alertManagementEnabled: false,
+ userCanEnableAlertManagement: false,
+ },
+ data = {},
+ loading = false,
+ alertListStatusFilteringEnabled = false,
+ stubs = {},
+ } = {}) {
+ wrapper = mount(AlertManagementList, {
+ propsData: {
+ projectPath: 'gitlab-org/gitlab',
+ enableAlertManagementPath: '/link',
+ emptyAlertSvgPath: 'illustration/path',
+ ...props,
+ },
+ provide: {
+ glFeatures: {
+ alertListStatusFilteringEnabled,
+ },
+ },
+ data() {
+ return data;
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ queries: {
+ alerts: {
+ loading,
+ },
+ },
+ },
+ },
+ stubs,
+ });
+ }
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('alert management feature renders 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);
+ });
+ });
+ });
+
+ describe('alertListStatusFilteringEnabled feature flag disabled', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts },
+ loading: false,
+ alertListStatusFilteringEnabled: false,
+ stubs: {
+ GlTab: true,
+ },
+ });
+ });
+
+ it('should NOT display tabs', () => {
+ expect(findStatusFilterTabs()).not.toExist();
+ });
+ });
+ });
+
+ describe('Alerts table', () => {
+ it('loading state', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: null },
+ loading: true,
+ });
+ expect(findAlertsTable().exists()).toBe(true);
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('error state', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: 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');
+ });
+
+ it('empty state', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: [], 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');
+ });
+
+ it('has data state', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+ expect(findLoader().exists()).toBe(false);
+ expect(findAlertsTable().exists()).toBe(true);
+ expect(findAlerts()).toHaveLength(mockAlerts.length);
+ });
+
+ it('displays status dropdown', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+ expect(findStatusDropdown().exists()).toBe(true);
+ });
+
+ it('shows correct severity icons', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlTable).exists()).toBe(true);
+ expect(
+ findAlertsTable()
+ .find(GlIcon)
+ .classes('icon-critical'),
+ ).toBe(true);
+ });
+ });
+
+ it('renders severity text', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+
+ expect(
+ findSeverityFields()
+ .at(0)
+ .text(),
+ ).toBe('Critical');
+ });
+
+ it('navigates to the detail page when alert row is clicked', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+
+ findAlerts()
+ .at(0)
+ .trigger('click');
+ expect(visitUrl).toHaveBeenCalledWith('/1527542/details');
+ });
+
+ describe('handle date fields', () => {
+ it('should display time ago dates when values provided', () => {
+ 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',
+ },
+ ],
+ errored: false,
+ },
+ loading: false,
+ });
+ expect(findDateFields().length).toBe(2);
+ });
+
+ it('should not display time ago dates when values not provided', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: {
+ alerts: [
+ {
+ iid: 1,
+ status: 'acknowledged',
+ startedAt: null,
+ endedAt: null,
+ severity: 'high',
+ },
+ ],
+ errored: false,
+ },
+ loading: false,
+ });
+ expect(findDateFields().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('updating the alert status', () => {
+ const iid = '1527542';
+ const mockUpdatedMutationResult = {
+ data: {
+ updateAlertStatus: {
+ errors: [],
+ alert: {
+ iid,
+ status: 'acknowledged',
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+ });
+
+ it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
+ findFirstStatusOption().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateAlertStatus,
+ variables: {
+ iid,
+ status: 'TRIGGERED',
+ projectPath: 'gitlab-org/gitlab',
+ },
+ });
+ });
+
+ it('calls `createFlash` when request fails', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
+ findFirstStatusOption().vm.$emit('click');
+
+ setImmediate(() => {
+ expect(createFlash).toHaveBeenCalledWith(
+ 'There was an error while updating the status of the alert. Please try again.',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json
new file mode 100644
index 00000000000..b67e2cfc52e
--- /dev/null
+++ b/spec/frontend/alert_management/mocks/alerts.json
@@ -0,0 +1,29 @@
+[
+ {
+ "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"
+ }
+ ]
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index f34c2fb69eb..d365048ab0b 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -15,7 +15,7 @@ describe('Api', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
originalGon = window.gon;
- window.gon = Object.assign({}, dummyGon);
+ window.gon = { ...dummyGon };
});
afterEach(() => {
diff --git a/spec/frontend/autosave_spec.js b/spec/frontend/autosave_spec.js
index 3119477f385..bbdf3c6f91d 100644
--- a/spec/frontend/autosave_spec.js
+++ b/spec/frontend/autosave_spec.js
@@ -10,6 +10,8 @@ describe('Autosave', () => {
const field = $('<textarea></textarea>');
const key = 'key';
const fallbackKey = 'fallbackKey';
+ const lockVersionKey = 'lockVersionKey';
+ const lockVersion = 1;
describe('class constructor', () => {
beforeEach(() => {
@@ -30,6 +32,13 @@ describe('Autosave', () => {
expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true);
});
+
+ it('should set .isLocalStorageAvailable if lockVersion is passed', () => {
+ autosave = new Autosave(field, key, null, lockVersion);
+
+ expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ expect(autosave.isLocalStorageAvailable).toBe(true);
+ });
});
describe('restore', () => {
@@ -96,6 +105,40 @@ describe('Autosave', () => {
});
});
+ describe('getSavedLockVersion', () => {
+ beforeEach(() => {
+ autosave = {
+ field,
+ key,
+ lockVersionKey,
+ };
+ });
+
+ describe('if .isLocalStorageAvailable is `false`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = false;
+
+ Autosave.prototype.getSavedLockVersion.call(autosave);
+ });
+
+ it('should not call .getItem', () => {
+ expect(window.localStorage.getItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if .isLocalStorageAvailable is `true`', () => {
+ beforeEach(() => {
+ autosave.isLocalStorageAvailable = true;
+ });
+
+ it('should call .getItem', () => {
+ Autosave.prototype.getSavedLockVersion.call(autosave);
+
+ expect(window.localStorage.getItem).toHaveBeenCalledWith(lockVersionKey);
+ });
+ });
+ });
+
describe('save', () => {
beforeEach(() => {
autosave = { reset: jest.fn() };
@@ -128,10 +171,51 @@ describe('Autosave', () => {
});
});
+ describe('save with lockVersion', () => {
+ beforeEach(() => {
+ autosave = {
+ field,
+ key,
+ lockVersionKey,
+ lockVersion,
+ isLocalStorageAvailable: true,
+ };
+ });
+
+ describe('lockVersion is valid', () => {
+ it('should call .setItem', () => {
+ Autosave.prototype.save.call(autosave);
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(lockVersionKey, lockVersion);
+ });
+
+ it('should call .setItem when version is 0', () => {
+ autosave.lockVersion = 0;
+ Autosave.prototype.save.call(autosave);
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(
+ lockVersionKey,
+ autosave.lockVersion,
+ );
+ });
+ });
+
+ describe('lockVersion is invalid', () => {
+ it('should not call .setItem with lockVersion', () => {
+ delete autosave.lockVersion;
+ Autosave.prototype.save.call(autosave);
+
+ expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
+ lockVersionKey,
+ autosave.lockVersion,
+ );
+ });
+ });
+ });
+
describe('reset', () => {
beforeEach(() => {
autosave = {
key,
+ lockVersionKey,
};
});
@@ -156,6 +240,7 @@ describe('Autosave', () => {
it('should call .removeItem', () => {
expect(window.localStorage.removeItem).toHaveBeenCalledWith(key);
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith(lockVersionKey);
});
});
});
@@ -166,8 +251,8 @@ describe('Autosave', () => {
field,
key,
fallbackKey,
+ isLocalStorageAvailable: true,
};
- autosave.isLocalStorageAvailable = true;
});
it('should call .getItem', () => {
@@ -185,7 +270,8 @@ describe('Autosave', () => {
it('should call .removeItem for key and fallbackKey', () => {
Autosave.prototype.reset.call(autosave);
- expect(window.localStorage.removeItem).toHaveBeenCalledTimes(2);
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith(fallbackKey);
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith(key);
});
});
});
diff --git a/spec/frontend/avatar_helper_spec.js b/spec/frontend/avatar_helper_spec.js
new file mode 100644
index 00000000000..c4da7189751
--- /dev/null
+++ b/spec/frontend/avatar_helper_spec.js
@@ -0,0 +1,110 @@
+import { TEST_HOST } from 'spec/test_constants';
+import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
+import {
+ DEFAULT_SIZE_CLASS,
+ IDENTICON_BG_COUNT,
+ renderAvatar,
+ renderIdenticon,
+ getIdenticonBackgroundClass,
+ getIdenticonTitle,
+} from '~/helpers/avatar_helper';
+
+function matchAll(str) {
+ return new RegExp(`^${str}$`);
+}
+
+describe('avatar_helper', () => {
+ describe('getIdenticonBackgroundClass', () => {
+ it('returns identicon bg class from id that is a number', () => {
+ expect(getIdenticonBackgroundClass(1)).toEqual('bg2');
+ });
+
+ it('returns identicon bg class from id that is a string', () => {
+ expect(getIdenticonBackgroundClass('1')).toEqual('bg2');
+ });
+
+ it('returns identicon bg class from id that is a GraphQL string id', () => {
+ expect(getIdenticonBackgroundClass('gid://gitlab/Project/1')).toEqual('bg2');
+ });
+
+ it('returns identicon bg class from unparsable string', () => {
+ expect(getIdenticonBackgroundClass('gid://gitlab/')).toEqual('bg1');
+ });
+
+ it(`wraps around if id is bigger than ${IDENTICON_BG_COUNT}`, () => {
+ expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT + 4)).toEqual('bg5');
+ expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT * 5 + 6)).toEqual('bg7');
+ });
+ });
+
+ describe('getIdenticonTitle', () => {
+ it('returns identicon title from name', () => {
+ expect(getIdenticonTitle('Lorem')).toEqual('L');
+ expect(getIdenticonTitle('dolar-sit-amit')).toEqual('D');
+ expect(getIdenticonTitle('%-with-special-chars')).toEqual('%');
+ });
+
+ it('returns space if name is falsey', () => {
+ expect(getIdenticonTitle('')).toEqual(' ');
+ expect(getIdenticonTitle(null)).toEqual(' ');
+ });
+ });
+
+ describe('renderIdenticon', () => {
+ it('renders with the first letter as title and bg based on id', () => {
+ const entity = {
+ id: IDENTICON_BG_COUNT + 3,
+ name: 'Xavior',
+ };
+ const options = {
+ sizeClass: 's32',
+ };
+
+ const result = renderIdenticon(entity, options);
+
+ expect(result).toHaveClass(`identicon ${options.sizeClass} bg4`);
+ expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
+ });
+
+ it('renders with defaults, if no options are given', () => {
+ const entity = {
+ id: 1,
+ name: 'tanuki',
+ };
+
+ const result = renderIdenticon(entity);
+
+ expect(result).toHaveClass(`identicon ${DEFAULT_SIZE_CLASS} bg2`);
+ expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
+ });
+ });
+
+ describe('renderAvatar', () => {
+ it('renders an image with the avatarUrl', () => {
+ const avatarUrl = `${TEST_HOST}/not-real-assets/test.png`;
+
+ const result = renderAvatar({
+ avatar_url: avatarUrl,
+ });
+
+ expect(result).toBeMatchedBy('img');
+ expect(result).toHaveAttr('src', avatarUrl);
+ expect(result).toHaveClass(DEFAULT_SIZE_CLASS);
+ });
+
+ it('renders an identicon if no avatarUrl', () => {
+ const entity = {
+ id: 1,
+ name: 'walrus',
+ };
+ const options = {
+ sizeClass: 's16',
+ };
+
+ const result = renderAvatar(entity, options);
+
+ expect(result).toHaveClass(`identicon ${options.sizeClass} bg2`);
+ expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name)));
+ });
+ });
+});
diff --git a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js
index a98919e2113..eab805382bd 100644
--- a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js
+++ b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js
@@ -57,6 +57,18 @@ describe('PasteMarkdownTable', () => {
expect(new PasteMarkdownTable(data).isTable()).toBe(false);
});
+
+ it('returns false when the table copy comes from a diff', () => {
+ data.types = ['text/html', 'text/plain'];
+ data.getData = jest.fn().mockImplementation(mimeType => {
+ if (mimeType === 'text/html') {
+ return '<table class="diff-wrap-lines"><tr><td>First</td><td>Second</td></tr></table>';
+ }
+ return 'First\tSecond';
+ });
+
+ expect(new PasteMarkdownTable(data).isTable()).toBe(false);
+ });
});
describe('convertToTableMarkdown', () => {
diff --git a/spec/frontend/behaviors/markdown/render_metrics_spec.js b/spec/frontend/behaviors/markdown/render_metrics_spec.js
index 3f7beeb817b..ab81ed6b8f0 100644
--- a/spec/frontend/behaviors/markdown/render_metrics_spec.js
+++ b/spec/frontend/behaviors/markdown/render_metrics_spec.js
@@ -11,20 +11,20 @@ const getElements = () => Array.from(document.getElementsByClassName('js-render-
describe('Render metrics for Gitlab Flavoured Markdown', () => {
it('does nothing when no elements are found', () => {
- renderMetrics([]);
-
- expect(mockEmbedGroup).not.toHaveBeenCalled();
+ return renderMetrics([]).then(() => {
+ expect(mockEmbedGroup).not.toHaveBeenCalled();
+ });
});
it('renders a vue component when elements are found', () => {
document.body.innerHTML = `<div class="js-render-metrics" data-dashboard-url="${TEST_HOST}"></div>`;
- renderMetrics(getElements());
-
- expect(mockEmbedGroup).toHaveBeenCalledTimes(1);
- expect(mockEmbedGroup).toHaveBeenCalledWith(
- expect.objectContaining({ propsData: { urls: [`${TEST_HOST}`] } }),
- );
+ return renderMetrics(getElements()).then(() => {
+ expect(mockEmbedGroup).toHaveBeenCalledTimes(1);
+ expect(mockEmbedGroup).toHaveBeenCalledWith(
+ expect.objectContaining({ propsData: { urls: [`${TEST_HOST}`] } }),
+ );
+ });
});
it('takes sibling metrics and groups them under a shared parent', () => {
@@ -36,14 +36,14 @@ describe('Render metrics for Gitlab Flavoured Markdown', () => {
<div class="js-render-metrics" data-dashboard-url="${TEST_HOST}/3"></div>
`;
- renderMetrics(getElements());
-
- expect(mockEmbedGroup).toHaveBeenCalledTimes(2);
- expect(mockEmbedGroup).toHaveBeenCalledWith(
- expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/1`, `${TEST_HOST}/2`] } }),
- );
- expect(mockEmbedGroup).toHaveBeenCalledWith(
- expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/3`] } }),
- );
+ return renderMetrics(getElements()).then(() => {
+ expect(mockEmbedGroup).toHaveBeenCalledTimes(2);
+ expect(mockEmbedGroup).toHaveBeenCalledWith(
+ expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/1`, `${TEST_HOST}/2`] } }),
+ );
+ expect(mockEmbedGroup).toHaveBeenCalledWith(
+ expect.objectContaining({ propsData: { urls: [`${TEST_HOST}/3`] } }),
+ );
+ });
});
});
diff --git a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
index e47a7dcfa2a..1e639f91797 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap
@@ -5,7 +5,7 @@ exports[`Blob Header Editing rendering matches the snapshot 1`] = `
class="js-file-title file-title-flex-parent"
>
<gl-form-input-stub
- class="form-control js-snippet-file-name qa-snippet-file-name"
+ class="form-control js-snippet-file-name"
id="snippet_file_name"
name="snippet_file_name"
placeholder="Give your file a name to add code highlighting, e.g. example.rb for Ruby"
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 7382a3a4cf7..2ac6e0d5d24 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
@@ -8,14 +8,15 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
<file-icon-stub
aria-hidden="true"
cssclasses="mr-2"
- filename="dummy.md"
+ filename="foo/bar/dummy.md"
size="18"
/>
<strong
- class="file-title-name qa-file-title-name mr-1 js-blob-header-filepath"
+ class="file-title-name mr-1 js-blob-header-filepath"
+ data-qa-selector="file_title_name"
>
- dummy.md
+ foo/bar/dummy.md
</strong>
<small
@@ -26,8 +27,8 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
<clipboard-button-stub
cssclass="btn-clipboard btn-transparent lh-100 position-static"
- gfm="\`dummy.md\`"
- text="dummy.md"
+ gfm="\`foo/bar/dummy.md\`"
+ text="foo/bar/dummy.md"
title="Copy file path"
tooltipplacement="top"
/>
diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
index 2878ad492a4..7d868625956 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap
@@ -9,7 +9,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = `
/>
<div
- class="file-actions d-none d-sm-block"
+ class="file-actions d-none d-sm-flex"
>
<viewer-switcher-stub
value="simple"
diff --git a/spec/frontend/blob/components/blob_content_error_spec.js b/spec/frontend/blob/components/blob_content_error_spec.js
index 58a9ee761df..6eb5cfb71aa 100644
--- a/spec/frontend/blob/components/blob_content_error_spec.js
+++ b/spec/frontend/blob/components/blob_content_error_spec.js
@@ -1,27 +1,60 @@
import { shallowMount } from '@vue/test-utils';
import BlobContentError from '~/blob/components/blob_content_error.vue';
+import { GlSprintf } from '@gitlab/ui';
+
+import { BLOB_RENDER_ERRORS } from '~/blob/components/constants';
describe('Blob Content Error component', () => {
let wrapper;
- const viewerError = '<h1 id="error">Foo Error</h1>';
- function createComponent() {
+ function createComponent(props = {}) {
wrapper = shallowMount(BlobContentError, {
propsData: {
- viewerError,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
},
});
}
- beforeEach(() => {
- createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
});
- it('renders the passed error without transformations', () => {
- expect(wrapper.html()).toContain(viewerError);
+ describe('collapsed and too large blobs', () => {
+ it.each`
+ error | reason | options
+ ${BLOB_RENDER_ERRORS.REASONS.COLLAPSED} | ${'it is larger than 1.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.LOAD.text, BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ ${BLOB_RENDER_ERRORS.REASONS.TOO_LARGE} | ${'it is larger than 100.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ `('renders correct reason for $error.id', ({ error, reason, options }) => {
+ createComponent({
+ viewerError: error.id,
+ });
+ expect(wrapper.text()).toContain(reason);
+ options.forEach(option => {
+ expect(wrapper.text()).toContain(option);
+ });
+ });
+ });
+
+ describe('external blob', () => {
+ it.each`
+ storageType | reason | options
+ ${'lfs'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.lfs} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ ${'build_artifact'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.build_artifact} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ ${'default'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.default} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
+ `('renders correct reason for $storageType blob', ({ storageType, reason, options }) => {
+ createComponent({
+ viewerError: BLOB_RENDER_ERRORS.REASONS.EXTERNAL.id,
+ blob: {
+ externalStorage: storageType,
+ },
+ });
+ expect(wrapper.text()).toContain(reason);
+ options.forEach(option => {
+ expect(wrapper.text()).toContain(option);
+ });
+ });
});
});
diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js
index 6a130c9c43d..244ed41869d 100644
--- a/spec/frontend/blob/components/blob_content_spec.js
+++ b/spec/frontend/blob/components/blob_content_spec.js
@@ -2,6 +2,12 @@ import { shallowMount } from '@vue/test-utils';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobContentError from '~/blob/components/blob_content_error.vue';
import {
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
+ BLOB_RENDER_ERRORS,
+} from '~/blob/components/constants';
+import {
+ Blob,
RichViewerMock,
SimpleViewerMock,
RichBlobContentMock,
@@ -38,7 +44,7 @@ describe('Blob Content component', () => {
it('renders error if there is any in the viewer', () => {
const renderError = 'Oops';
- const viewer = Object.assign({}, SimpleViewerMock, { renderError });
+ const viewer = { ...SimpleViewerMock, renderError };
createComponent({}, viewer);
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(wrapper.contains(BlobContentError)).toBe(true);
@@ -67,4 +73,32 @@ describe('Blob Content component', () => {
expect(wrapper.find(viewer).html()).toContain(content);
});
});
+
+ describe('functionality', () => {
+ describe('render error', () => {
+ const findErrorEl = () => wrapper.find(BlobContentError);
+ const renderError = BLOB_RENDER_ERRORS.REASONS.COLLAPSED.id;
+ const viewer = { ...SimpleViewerMock, renderError };
+
+ beforeEach(() => {
+ createComponent({ blob: Blob }, viewer);
+ });
+
+ it('correctly sets blob on the blob-content-error component', () => {
+ expect(findErrorEl().props('blob')).toEqual(Blob);
+ });
+
+ it(`properly proxies ${BLOB_RENDER_EVENT_LOAD} event`, () => {
+ expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeUndefined();
+ findErrorEl().vm.$emit(BLOB_RENDER_EVENT_LOAD);
+ expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeTruthy();
+ });
+
+ it(`properly proxies ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => {
+ expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeUndefined();
+ findErrorEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE);
+ expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeTruthy();
+ });
+ });
+ });
});
diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js
index d029ba2a7a4..3a53208f357 100644
--- a/spec/frontend/blob/components/blob_header_filepath_spec.js
+++ b/spec/frontend/blob/components/blob_header_filepath_spec.js
@@ -15,7 +15,7 @@ describe('Blob Header Filepath', () => {
function createComponent(blobProps = {}, options = {}) {
wrapper = shallowMount(BlobHeaderFilepath, {
propsData: {
- blob: Object.assign({}, MockBlob, blobProps),
+ blob: { ...MockBlob, ...blobProps },
},
...options,
});
@@ -38,12 +38,12 @@ describe('Blob Header Filepath', () => {
.find('.js-blob-header-filepath')
.text()
.trim(),
- ).toBe(MockBlob.name);
+ ).toBe(MockBlob.path);
});
it('does not fail if the name is empty', () => {
- const emptyName = '';
- createComponent({ name: emptyName });
+ const emptyPath = '';
+ createComponent({ path: emptyPath });
expect(wrapper.find('.js-blob-header-filepath').exists()).toBe(false);
});
@@ -84,7 +84,7 @@ describe('Blob Header Filepath', () => {
describe('functionality', () => {
it('sets gfm value correctly on the clipboard-button', () => {
createComponent();
- expect(wrapper.vm.gfmCopyText).toBe('`dummy.md`');
+ expect(wrapper.vm.gfmCopyText).toBe(`\`${MockBlob.path}\``);
});
});
});
diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js
index d410ef10fc9..0e7d2f6516a 100644
--- a/spec/frontend/blob/components/blob_header_spec.js
+++ b/spec/frontend/blob/components/blob_header_spec.js
@@ -13,7 +13,7 @@ describe('Blob Header Default Actions', () => {
const method = shouldMount ? mount : shallowMount;
wrapper = method.call(this, BlobHeader, {
propsData: {
- blob: Object.assign({}, Blob, blobProps),
+ blob: { ...Blob, ...blobProps },
...propsData,
},
...options,
diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js
index bfcca14324f..0f7193846ff 100644
--- a/spec/frontend/blob/components/mock_data.js
+++ b/spec/frontend/blob/components/mock_data.js
@@ -21,7 +21,7 @@ export const RichViewerMock = {
export const Blob = {
binary: false,
name: 'dummy.md',
- path: 'dummy.md',
+ path: 'foo/bar/dummy.md',
rawPath: '/flightjs/flight/snippets/51/raw',
size: 75,
simpleViewer: {
diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
index 99940225652..6d4e5e46cb8 100644
--- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js
+++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
@@ -12,8 +12,8 @@ describe('PipelineTourSuccessModal', () => {
beforeEach(() => {
document.body.dataset.page = 'projects:blob:show';
-
trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
+
wrapper = shallowMount(pipelineTourSuccess, {
propsData: modalProps,
stubs: {
diff --git a/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js b/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js
index fb0964a3f32..3c03e6f04ab 100644
--- a/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js
+++ b/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js
@@ -69,8 +69,10 @@ describe('Suggest gitlab-ci.yml Popover', () => {
let trackingSpy;
beforeEach(() => {
+ document.body.dataset.page = 'projects:blob:new';
+ trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
+
createWrapper(commitTrackLabel);
- trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
});
afterEach(() => {
@@ -83,10 +85,6 @@ describe('Suggest gitlab-ci.yml Popover', () => {
const expectedLabel = 'suggest_commit_first_project_gitlab_ci_yml';
const expectedProperty = 'owner';
- document.body.dataset.page = 'projects:blob:new';
-
- wrapper.vm.trackOnShow();
-
expect(trackingSpy).toHaveBeenCalledWith(expectedCategory, expectedAction, {
label: expectedLabel,
property: expectedProperty,
@@ -99,6 +97,7 @@ describe('Suggest gitlab-ci.yml Popover', () => {
const expectedProperty = 'owner';
const expectedValue = '10';
const dismissButton = wrapper.find(GlDeprecatedButton);
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
triggerEvent(dismissButton.element);
diff --git a/spec/frontend/blob/utils_spec.js b/spec/frontend/blob/utils_spec.js
index 39a73aae444..119ed2dfe7a 100644
--- a/spec/frontend/blob/utils_spec.js
+++ b/spec/frontend/blob/utils_spec.js
@@ -8,11 +8,6 @@ jest.mock('~/editor/editor_lite', () => {
});
});
-const mockCreateAceInstance = jest.fn();
-global.ace = {
- edit: mockCreateAceInstance,
-};
-
describe('Blob utilities', () => {
beforeEach(() => {
Editor.mockClear();
@@ -29,21 +24,6 @@ describe('Blob utilities', () => {
});
describe('Monaco editor', () => {
- let origProp;
-
- beforeEach(() => {
- origProp = window.gon;
- window.gon = {
- features: {
- monacoSnippets: true,
- },
- };
- });
-
- afterEach(() => {
- window.gon = origProp;
- });
-
it('initializes the Editor Lite', () => {
utils.initEditorLite({ el: editorEl });
expect(Editor).toHaveBeenCalled();
@@ -69,27 +49,5 @@ describe('Blob utilities', () => {
]);
});
});
- describe('ACE editor', () => {
- let origProp;
-
- beforeEach(() => {
- origProp = window.gon;
- window.gon = {
- features: {
- monacoSnippets: false,
- },
- };
- });
-
- afterEach(() => {
- window.gon = origProp;
- });
-
- it('does not initialize the Editor Lite', () => {
- utils.initEditorLite({ el: editorEl });
- expect(Editor).not.toHaveBeenCalled();
- expect(mockCreateAceInstance).toHaveBeenCalledWith(editorEl);
- });
- });
});
});
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 882310030f8..fa21053e2de 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -64,7 +64,7 @@ describe('Board list component', () => {
let getIssues;
function generateIssues(compWrapper) {
for (let i = 1; i < 20; i += 1) {
- const issue = Object.assign({}, compWrapper.list.issues[0]);
+ const issue = { ...compWrapper.list.issues[0] };
issue.id += i;
compWrapper.list.issues.push(issue);
}
diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js
index 5c5315fd465..29cc8f981bd 100644
--- a/spec/frontend/boards/boards_store_spec.js
+++ b/spec/frontend/boards/boards_store_spec.js
@@ -214,6 +214,22 @@ describe('boardsStore', () => {
});
});
+ describe('getListIssues', () => {
+ let list;
+
+ beforeEach(() => {
+ list = new List(listObj);
+ setupDefaultResponses();
+ });
+
+ it('makes a request to get issues', () => {
+ const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] });
+ expect(list.issues).toEqual([]);
+
+ return expect(boardsStore.getListIssues(list, true)).resolves.toEqual(expectedResponse);
+ });
+ });
+
describe('getIssuesForList', () => {
const id = 'TOO-MUCH';
const url = `${endpoints.listsEndpoint}/${id}/issues?id=${id}`;
@@ -1040,5 +1056,126 @@ describe('boardsStore', () => {
});
});
});
+
+ describe('addListIssue', () => {
+ let list;
+ const issue1 = new ListIssue({
+ title: 'Testing',
+ id: 2,
+ iid: 2,
+ confidential: false,
+ labels: [
+ {
+ color: '#ff0000',
+ description: 'testing;',
+ id: 5000,
+ priority: undefined,
+ textColor: 'white',
+ title: 'Test',
+ },
+ ],
+ assignees: [],
+ });
+ const issue2 = new ListIssue({
+ title: 'Testing',
+ id: 1,
+ iid: 1,
+ confidential: false,
+ labels: [
+ {
+ id: 1,
+ title: 'test',
+ color: 'red',
+ description: 'testing',
+ },
+ ],
+ assignees: [
+ {
+ id: 1,
+ name: 'name',
+ username: 'username',
+ avatar_url: 'http://avatar_url',
+ },
+ ],
+ real_path: 'path/to/issue',
+ });
+
+ beforeEach(() => {
+ list = new List(listObj);
+ list.addIssue(issue1);
+ setupDefaultResponses();
+ });
+
+ it('adds issues that are not already on the list', () => {
+ expect(list.findIssue(issue2.id)).toBe(undefined);
+ expect(list.issues).toEqual([issue1]);
+
+ boardsStore.addListIssue(list, issue2);
+ expect(list.findIssue(issue2.id)).toBe(issue2);
+ expect(list.issues.length).toBe(2);
+ expect(list.issues).toEqual([issue1, issue2]);
+ });
+ });
+
+ describe('updateIssue', () => {
+ let issue;
+ let patchSpy;
+
+ beforeEach(() => {
+ issue = new ListIssue({
+ title: 'Testing',
+ id: 1,
+ iid: 1,
+ confidential: false,
+ labels: [
+ {
+ id: 1,
+ title: 'test',
+ color: 'red',
+ description: 'testing',
+ },
+ ],
+ assignees: [
+ {
+ id: 1,
+ name: 'name',
+ username: 'username',
+ avatar_url: 'http://avatar_url',
+ },
+ ],
+ real_path: 'path/to/issue',
+ });
+
+ patchSpy = jest.fn().mockReturnValue([200, { labels: [] }]);
+ axiosMock.onPatch(`path/to/issue.json`).reply(({ data }) => patchSpy(JSON.parse(data)));
+ });
+
+ it('passes assignee ids when there are assignees', () => {
+ boardsStore.updateIssue(issue);
+ return boardsStore.updateIssue(issue).then(() => {
+ expect(patchSpy).toHaveBeenCalledWith({
+ issue: {
+ milestone_id: null,
+ assignee_ids: [1],
+ label_ids: [1],
+ },
+ });
+ });
+ });
+
+ it('passes assignee ids of [0] when there are no assignees', () => {
+ issue.removeAllAssignees();
+
+ return boardsStore.updateIssue(issue).then(() => {
+ expect(patchSpy).toHaveBeenCalledWith({
+ issue: {
+ milestone_id: null,
+ assignee_ids: [0],
+ label_ids: [1],
+ },
+ });
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/boards/issue_spec.js b/spec/frontend/boards/issue_spec.js
index ff72edaa695..412f20684f5 100644
--- a/spec/frontend/boards/issue_spec.js
+++ b/spec/frontend/boards/issue_spec.js
@@ -1,6 +1,5 @@
/* global ListIssue */
-import axios from '~/lib/utils/axios_utils';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/issue';
@@ -173,25 +172,12 @@ describe('Issue model', () => {
});
describe('update', () => {
- it('passes assignee ids when there are assignees', done => {
- jest.spyOn(axios, 'patch').mockImplementation((url, data) => {
- expect(data.issue.assignee_ids).toEqual([1]);
- done();
- return Promise.resolve();
- });
-
- issue.update('url');
- });
+ it('passes update to boardsStore', () => {
+ jest.spyOn(boardsStore, 'updateIssue').mockImplementation();
- it('passes assignee ids of [0] when there are no assignees', done => {
- jest.spyOn(axios, 'patch').mockImplementation((url, data) => {
- expect(data.issue.assignee_ids).toEqual([0]);
- done();
- return Promise.resolve();
- });
+ issue.update();
- issue.removeAllAssignees();
- issue.update('url');
+ expect(boardsStore.updateIssue).toHaveBeenCalledWith(issue);
});
});
});
diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js
new file mode 100644
index 00000000000..2d8939e6480
--- /dev/null
+++ b/spec/frontend/bootstrap_linked_tabs_spec.js
@@ -0,0 +1,67 @@
+import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
+
+describe('Linked Tabs', () => {
+ preloadFixtures('static/linked_tabs.html');
+
+ beforeEach(() => {
+ loadFixtures('static/linked_tabs.html');
+ });
+
+ describe('when is initialized', () => {
+ beforeEach(() => {
+ jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+ });
+
+ it('should activate the tab correspondent to the given action', () => {
+ // eslint-disable-next-line no-new
+ new LinkedTabs({
+ action: 'tab1',
+ defaultAction: 'tab1',
+ parentEl: '.linked-tabs',
+ });
+
+ expect(document.querySelector('#tab1').classList).toContain('active');
+ });
+
+ it('should active the default tab action when the action is show', () => {
+ // eslint-disable-next-line no-new
+ new LinkedTabs({
+ action: 'show',
+ defaultAction: 'tab1',
+ parentEl: '.linked-tabs',
+ });
+
+ expect(document.querySelector('#tab1').classList).toContain('active');
+ });
+ });
+
+ describe('on click', () => {
+ it('should change the url according to the clicked tab', () => {
+ const historySpy = jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+
+ const linkedTabs = new LinkedTabs({
+ action: 'show',
+ defaultAction: 'tab1',
+ parentEl: '.linked-tabs',
+ });
+
+ const secondTab = document.querySelector('.linked-tabs li:nth-child(2) a');
+ const newState =
+ secondTab.getAttribute('href') +
+ linkedTabs.currentLocation.search +
+ linkedTabs.currentLocation.hash;
+
+ secondTab.click();
+
+ if (historySpy) {
+ expect(historySpy).toHaveBeenCalledWith(
+ {
+ url: newState,
+ },
+ document.title,
+ newState,
+ );
+ }
+ });
+ });
+});
diff --git a/spec/frontend/broadcast_notification_spec.js b/spec/frontend/broadcast_notification_spec.js
new file mode 100644
index 00000000000..8d433946632
--- /dev/null
+++ b/spec/frontend/broadcast_notification_spec.js
@@ -0,0 +1,35 @@
+import Cookies from 'js-cookie';
+import initBroadcastNotifications from '~/broadcast_notification';
+
+describe('broadcast message on dismiss', () => {
+ const dismiss = () => {
+ const button = document.querySelector('.js-dismiss-current-broadcast-notification');
+ button.click();
+ };
+ const endsAt = '2020-01-01T00:00:00Z';
+
+ beforeEach(() => {
+ setFixtures(`
+ <div class="js-broadcast-notification-1">
+ <button class="js-dismiss-current-broadcast-notification" data-id="1" data-expire-date="${endsAt}"></button>
+ </div>
+ `);
+
+ initBroadcastNotifications();
+ });
+
+ it('removes broadcast message', () => {
+ dismiss();
+
+ expect(document.querySelector('.js-broadcast-notification-1')).toBeNull();
+ });
+
+ it('calls Cookies.set', () => {
+ jest.spyOn(Cookies, 'set');
+ dismiss();
+
+ expect(Cookies.set).toHaveBeenCalledWith('hide_broadcast_message_1', true, {
+ expires: new Date(endsAt),
+ });
+ });
+});
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js
new file mode 100644
index 00000000000..93b185bd242
--- /dev/null
+++ b/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js
@@ -0,0 +1,203 @@
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list';
+
+const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/-/variables';
+const HIDE_CLASS = 'hide';
+
+describe('AjaxFormVariableList', () => {
+ preloadFixtures('projects/ci_cd_settings.html');
+ preloadFixtures('projects/ci_cd_settings_with_variables.html');
+
+ let container;
+ let saveButton;
+ let errorBox;
+
+ let mock;
+ let ajaxVariableList;
+
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html');
+ container = document.querySelector('.js-ci-variable-list-section');
+
+ mock = new MockAdapter(axios);
+
+ const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
+ saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button');
+ errorBox = container.querySelector('.js-ci-variable-error-box');
+ ajaxVariableList = new AjaxFormVariableList({
+ container,
+ formField: 'variables',
+ saveButton,
+ errorBox,
+ saveEndpoint: container.dataset.saveEndpoint,
+ maskableRegex: container.dataset.maskableRegex,
+ });
+
+ jest.spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables');
+ jest.spyOn(ajaxVariableList.variableList, 'toggleEnableRow');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('onSaveClicked', () => {
+ it('shows loading spinner while waiting for the request', () => {
+ const loadingIcon = saveButton.querySelector('.js-ci-variables-save-loading-icon');
+
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false);
+
+ return [200, {}];
+ });
+
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true);
+ });
+ });
+
+ it('calls `updateRowsWithPersistedVariables` with the persisted variables', () => {
+ const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }];
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {
+ variables: variablesResponse,
+ });
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(ajaxVariableList.updateRowsWithPersistedVariables).toHaveBeenCalledWith(
+ variablesResponse,
+ );
+ });
+ });
+
+ it('hides any previous error box', () => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+ });
+ });
+
+ it('disables remove buttons while waiting for the request', () => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => {
+ expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false);
+
+ return [200, {}];
+ });
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true);
+ });
+ });
+
+ it('hides secret values', () => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {});
+
+ const row = container.querySelector('.js-row');
+ const valueInput = row.querySelector('.js-ci-variable-input-value');
+ const valuePlaceholder = row.querySelector('.js-secret-value-placeholder');
+
+ valueInput.value = 'bar';
+ $(valueInput).trigger('input');
+
+ expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true);
+ expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false);
+ expect(valueInput.classList.contains(HIDE_CLASS)).toBe(true);
+ });
+ });
+
+ it('shows error box with validation errors', () => {
+ const validationError = 'some validation error';
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [validationError]);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false);
+ expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual(
+ `Validation failed ${validationError}`,
+ );
+ });
+ });
+
+ it('shows flash message when request fails', () => {
+ mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500);
+
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+
+ return ajaxVariableList.onSaveClicked().then(() => {
+ expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true);
+ });
+ });
+ });
+
+ describe('updateRowsWithPersistedVariables', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings_with_variables.html');
+ container = document.querySelector('.js-ci-variable-list-section');
+
+ const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section');
+ saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button');
+ errorBox = container.querySelector('.js-ci-variable-error-box');
+ ajaxVariableList = new AjaxFormVariableList({
+ container,
+ formField: 'variables',
+ saveButton,
+ errorBox,
+ saveEndpoint: container.dataset.saveEndpoint,
+ });
+ });
+
+ it('removes variable that was removed', () => {
+ expect(container.querySelectorAll('.js-row').length).toBe(3);
+
+ container.querySelector('.js-row-remove-button').click();
+
+ expect(container.querySelectorAll('.js-row').length).toBe(3);
+
+ ajaxVariableList.updateRowsWithPersistedVariables([]);
+
+ expect(container.querySelectorAll('.js-row').length).toBe(2);
+ });
+
+ it('updates new variable row with persisted ID', () => {
+ const row = container.querySelector('.js-row:last-child');
+ const idInput = row.querySelector('.js-ci-variable-input-id');
+ const keyInput = row.querySelector('.js-ci-variable-input-key');
+ const valueInput = row.querySelector('.js-ci-variable-input-value');
+
+ keyInput.value = 'foo';
+ $(keyInput).trigger('input');
+ valueInput.value = 'bar';
+ $(valueInput).trigger('input');
+
+ expect(idInput.value).toEqual('');
+
+ ajaxVariableList.updateRowsWithPersistedVariables([
+ {
+ id: 3,
+ key: 'foo',
+ value: 'bar',
+ },
+ ]);
+
+ expect(idInput.value).toEqual('3');
+ expect(row.dataset.isPersisted).toEqual('true');
+ });
+ });
+
+ describe('maskableRegex', () => {
+ it('takes in the regex provided by the data attribute', () => {
+ expect(container.dataset.maskableRegex).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$');
+ expect(ajaxVariableList.maskableRegex).toBe(container.dataset.maskableRegex);
+ });
+ });
+});
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
new file mode 100644
index 00000000000..9508203e5c2
--- /dev/null
+++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
@@ -0,0 +1,282 @@
+import $ from 'jquery';
+import waitForPromises from 'helpers/wait_for_promises';
+import VariableList from '~/ci_variable_list/ci_variable_list';
+
+const HIDE_CLASS = 'hide';
+
+describe('VariableList', () => {
+ preloadFixtures('pipeline_schedules/edit.html');
+ preloadFixtures('pipeline_schedules/edit_with_variables.html');
+ preloadFixtures('projects/ci_cd_settings.html');
+
+ let $wrapper;
+ let variableList;
+
+ describe('with only key/value inputs', () => {
+ describe('with no variables', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'schedule',
+ });
+ variableList.init();
+ });
+
+ it('should remove the row when clicking the remove button', () => {
+ $wrapper.find('.js-row-remove-button').trigger('click');
+
+ expect($wrapper.find('.js-row').length).toBe(0);
+ });
+
+ it('should add another row when editing the last rows key input', () => {
+ const $row = $wrapper.find('.js-row');
+ $row
+ .find('.js-ci-variable-input-key')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ // Check for the correct default in the new row
+ const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key');
+
+ expect($keyInput.val()).toBe('');
+ });
+
+ it('should add another row when editing the last rows value textarea', () => {
+ const $row = $wrapper.find('.js-row');
+ $row
+ .find('.js-ci-variable-input-value')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ // Check for the correct default in the new row
+ const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key');
+
+ expect($valueInput.val()).toBe('');
+ });
+
+ it('should remove empty row after blurring', () => {
+ const $row = $wrapper.find('.js-row');
+ $row
+ .find('.js-ci-variable-input-key')
+ .val('foo')
+ .trigger('input');
+
+ expect($wrapper.find('.js-row').length).toBe(2);
+
+ $row
+ .find('.js-ci-variable-input-key')
+ .val('')
+ .trigger('input')
+ .trigger('blur');
+
+ expect($wrapper.find('.js-row').length).toBe(1);
+ });
+ });
+
+ describe('with persisted variables', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit_with_variables.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'schedule',
+ });
+ variableList.init();
+ });
+
+ it('should have "Reveal values" button initially when there are already variables', () => {
+ expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values');
+ });
+
+ it('should reveal hidden values', () => {
+ const $row = $wrapper.find('.js-row:first-child');
+ const $inputValue = $row.find('.js-ci-variable-input-value');
+ const $placeholder = $row.find('.js-secret-value-placeholder');
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
+
+ // Reveal values
+ $wrapper.find('.js-secret-value-reveal-button').click();
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
+ });
+ });
+ });
+
+ describe('with all inputs(key, value, protected)', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ $wrapper.find('.js-ci-variable-input-protected').attr('data-default', 'false');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should not add another row when editing the last rows protected checkbox', () => {
+ const $row = $wrapper.find('.js-row:last-child');
+ $row.find('.ci-variable-protected-item .js-project-feature-toggle').click();
+
+ return waitForPromises().then(() => {
+ expect($wrapper.find('.js-row').length).toBe(1);
+ });
+ });
+
+ it('should not add another row when editing the last rows masked checkbox', () => {
+ jest.spyOn(variableList, 'checkIfRowTouched');
+ const $row = $wrapper.find('.js-row:last-child');
+ $row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
+
+ return waitForPromises().then(() => {
+ // This validates that we are checking after the event listener has run
+ expect(variableList.checkIfRowTouched).toHaveBeenCalled();
+ expect($wrapper.find('.js-row').length).toBe(1);
+ });
+ });
+
+ describe('validateMaskability', () => {
+ let $row;
+
+ const maskingErrorElement = '.js-row:last-child .masking-validation-error';
+ const clickToggle = () =>
+ $row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
+
+ beforeEach(() => {
+ $row = $wrapper.find('.js-row:last-child');
+ });
+
+ it('has a regex provided via a data attribute', () => {
+ clickToggle();
+
+ expect($wrapper.attr('data-maskable-regex')).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$');
+ });
+
+ it('allows values that are 8 characters long', () => {
+ $row.find('.js-ci-variable-input-value').val('looooong');
+
+ clickToggle();
+
+ expect($wrapper.find(maskingErrorElement)).toHaveClass('hide');
+ });
+
+ it('rejects values that are shorter than 8 characters', () => {
+ $row.find('.js-ci-variable-input-value').val('short');
+
+ clickToggle();
+
+ expect($wrapper.find(maskingErrorElement)).toBeVisible();
+ });
+
+ it('allows values with base 64 characters', () => {
+ $row.find('.js-ci-variable-input-value').val('abcABC123_+=/-');
+
+ clickToggle();
+
+ expect($wrapper.find(maskingErrorElement)).toHaveClass('hide');
+ });
+
+ it('rejects values with other special characters', () => {
+ $row.find('.js-ci-variable-input-value').val('1234567$');
+
+ clickToggle();
+
+ expect($wrapper.find(maskingErrorElement)).toBeVisible();
+ });
+ });
+ });
+
+ describe('toggleEnableRow method', () => {
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit_with_variables.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should disable all key inputs', () => {
+ expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
+
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3);
+ });
+
+ it('should disable all remove buttons', () => {
+ expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3);
+
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3);
+ });
+
+ it('should enable all remove buttons', () => {
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3);
+
+ variableList.toggleEnableRow(true);
+
+ expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3);
+ });
+
+ it('should enable all key inputs', () => {
+ variableList.toggleEnableRow(false);
+
+ expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3);
+
+ variableList.toggleEnableRow(true);
+
+ expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3);
+ });
+ });
+
+ describe('hideValues', () => {
+ beforeEach(() => {
+ loadFixtures('projects/ci_cd_settings.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ variableList = new VariableList({
+ container: $wrapper,
+ formField: 'variables',
+ });
+ variableList.init();
+ });
+
+ it('should hide value input and show placeholder stars', () => {
+ const $row = $wrapper.find('.js-row');
+ const $inputValue = $row.find('.js-ci-variable-input-value');
+ const $placeholder = $row.find('.js-secret-value-placeholder');
+
+ $row
+ .find('.js-ci-variable-input-value')
+ .val('foo')
+ .trigger('input');
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(true);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(false);
+
+ variableList.hideValues();
+
+ expect($placeholder.hasClass(HIDE_CLASS)).toBe(false);
+ expect($inputValue.hasClass(HIDE_CLASS)).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
new file mode 100644
index 00000000000..4982b68fa81
--- /dev/null
+++ b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
@@ -0,0 +1,37 @@
+import $ from 'jquery';
+import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
+
+describe('NativeFormVariableList', () => {
+ preloadFixtures('pipeline_schedules/edit.html');
+
+ let $wrapper;
+
+ beforeEach(() => {
+ loadFixtures('pipeline_schedules/edit.html');
+ $wrapper = $('.js-ci-variable-list-section');
+
+ setupNativeFormVariableList({
+ container: $wrapper,
+ formField: 'schedule',
+ });
+ });
+
+ describe('onFormSubmit', () => {
+ it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => {
+ const $row = $wrapper.find('.js-row');
+
+ expect($row.find('.js-ci-variable-input-key').attr('name')).toBe(
+ 'schedule[variables_attributes][][key]',
+ );
+
+ expect($row.find('.js-ci-variable-input-value').attr('name')).toBe(
+ 'schedule[variables_attributes][][secret_value]',
+ );
+
+ $wrapper.closest('form').trigger('trigger-submit');
+
+ expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('');
+ expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('');
+ });
+ });
+});
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 7b8d69df35e..9179302f786 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
@@ -96,6 +96,13 @@ describe('Ci variable modal', () => {
findModal().vm.$emit('hidden');
expect(store.dispatch).toHaveBeenCalledWith('clearModal');
});
+
+ it('should dispatch setVariableProtected when admin settings are configured to protect variables', () => {
+ store.state.isProtectedByDefault = true;
+ findModal().vm.$emit('shown');
+
+ expect(store.dispatch).toHaveBeenCalledWith('setVariableProtected');
+ });
});
describe('Editing a variable', () => {
diff --git a/spec/frontend/ci_variable_list/services/mock_data.js b/spec/frontend/ci_variable_list/services/mock_data.js
index 09c6cd9de21..7dab33050d9 100644
--- a/spec/frontend/ci_variable_list/services/mock_data.js
+++ b/spec/frontend/ci_variable_list/services/mock_data.js
@@ -8,7 +8,7 @@ export default {
protected: false,
secret_value: 'test_val',
value: 'test_val',
- variable_type: 'Var',
+ variable_type: 'Variable',
},
],
@@ -44,7 +44,7 @@ export default {
protected: false,
secret_value: 'test_val',
value: 'test_val',
- variable_type: 'Var',
+ variable_type: 'Variable',
},
{
environment_scope: 'All (default)',
@@ -104,7 +104,7 @@ export default {
id: 28,
key: 'goku_var',
value: 'goku_val',
- variable_type: 'Var',
+ variable_type: 'Variable',
protected: true,
masked: true,
environment_scope: 'staging',
@@ -114,7 +114,7 @@ export default {
id: 25,
key: 'test_var_4',
value: 'test_val_4',
- variable_type: 'Var',
+ variable_type: 'Variable',
protected: false,
masked: false,
environment_scope: 'production',
@@ -134,7 +134,7 @@ export default {
id: 24,
key: 'test_var_3',
value: 'test_val_3',
- variable_type: 'Var',
+ variable_type: 'Variable',
protected: false,
masked: false,
environment_scope: 'All (default)',
@@ -144,7 +144,7 @@ export default {
id: 26,
key: 'test_var_5',
value: 'test_val_5',
- variable_type: 'Var',
+ variable_type: 'Variable',
protected: false,
masked: false,
environment_scope: 'production',
diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js
index 84455612f0c..12b4311d0f5 100644
--- a/spec/frontend/ci_variable_list/store/actions_spec.js
+++ b/spec/frontend/ci_variable_list/store/actions_spec.js
@@ -75,6 +75,16 @@ describe('CI variable list store actions', () => {
});
});
+ describe('setVariableProtected', () => {
+ it('commits SET_VARIABLE_PROTECTED mutation', () => {
+ testAction(actions.setVariableProtected, {}, {}, [
+ {
+ type: types.SET_VARIABLE_PROTECTED,
+ },
+ ]);
+ });
+ });
+
describe('deleteVariable', () => {
it('dispatch correct actions on successful deleted variable', done => {
mock.onPatch(state.endpoint).reply(200);
diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js
index 8652359f3df..1934d108957 100644
--- a/spec/frontend/ci_variable_list/store/mutations_spec.js
+++ b/spec/frontend/ci_variable_list/store/mutations_spec.js
@@ -47,7 +47,7 @@ describe('CI variable list mutations', () => {
describe('CLEAR_MODAL', () => {
it('should clear modal state ', () => {
const modalState = {
- variable_type: 'Var',
+ variable_type: 'Variable',
key: '',
secret_value: '',
protected: false,
@@ -97,4 +97,12 @@ describe('CI variable list mutations', () => {
expect(stateCopy.environments).toEqual(['dev', 'production', 'staging']);
});
});
+
+ describe('SET_VARIABLE_PROTECTED', () => {
+ it('should set protected value to true', () => {
+ mutations[types.SET_VARIABLE_PROTECTED](stateCopy);
+
+ expect(stateCopy.variable.protected).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/close_reopen_report_toggle_spec.js b/spec/frontend/close_reopen_report_toggle_spec.js
new file mode 100644
index 00000000000..f6b5e4bed87
--- /dev/null
+++ b/spec/frontend/close_reopen_report_toggle_spec.js
@@ -0,0 +1,288 @@
+import CloseReopenReportToggle from '~/close_reopen_report_toggle';
+import DropLab from '~/droplab/drop_lab';
+
+describe('CloseReopenReportToggle', () => {
+ describe('class constructor', () => {
+ const dropdownTrigger = {};
+ const dropdownList = {};
+ const button = {};
+ let commentTypeToggle;
+
+ beforeEach(() => {
+ commentTypeToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+ });
+
+ it('sets .dropdownTrigger', () => {
+ expect(commentTypeToggle.dropdownTrigger).toBe(dropdownTrigger);
+ });
+
+ it('sets .dropdownList', () => {
+ expect(commentTypeToggle.dropdownList).toBe(dropdownList);
+ });
+
+ it('sets .button', () => {
+ expect(commentTypeToggle.button).toBe(button);
+ });
+ });
+
+ describe('initDroplab', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {
+ querySelector: jest.fn(),
+ };
+ const dropdownTrigger = {};
+ const button = {};
+ const reopenItem = {};
+ const closeItem = {};
+ const config = {};
+
+ beforeEach(() => {
+ jest.spyOn(DropLab.prototype, 'init').mockImplementation(() => {});
+ dropdownList.querySelector.mockReturnValueOnce(reopenItem).mockReturnValueOnce(closeItem);
+
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ jest.spyOn(closeReopenReportToggle, 'setConfig').mockReturnValue(config);
+
+ closeReopenReportToggle.initDroplab();
+ });
+
+ it('sets .reopenItem and .closeItem', () => {
+ expect(dropdownList.querySelector).toHaveBeenCalledWith('.reopen-item');
+ expect(dropdownList.querySelector).toHaveBeenCalledWith('.close-item');
+ expect(closeReopenReportToggle.reopenItem).toBe(reopenItem);
+ expect(closeReopenReportToggle.closeItem).toBe(closeItem);
+ });
+
+ it('sets .droplab', () => {
+ expect(closeReopenReportToggle.droplab).toEqual(expect.any(Object));
+ });
+
+ it('calls .setConfig', () => {
+ expect(closeReopenReportToggle.setConfig).toHaveBeenCalled();
+ });
+
+ it('calls droplab.init', () => {
+ expect(DropLab.prototype.init).toHaveBeenCalledWith(
+ dropdownTrigger,
+ dropdownList,
+ expect.any(Array),
+ config,
+ );
+ });
+ });
+
+ describe('updateButton', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {
+ blur: jest.fn(),
+ };
+ const isClosed = true;
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ jest.spyOn(closeReopenReportToggle, 'toggleButtonType').mockImplementation(() => {});
+
+ closeReopenReportToggle.updateButton(isClosed);
+ });
+
+ it('calls .toggleButtonType', () => {
+ expect(closeReopenReportToggle.toggleButtonType).toHaveBeenCalledWith(isClosed);
+ });
+
+ it('calls .button.blur', () => {
+ expect(closeReopenReportToggle.button.blur).toHaveBeenCalled();
+ });
+ });
+
+ describe('toggleButtonType', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ const isClosed = true;
+ const showItem = {
+ click: jest.fn(),
+ };
+ const hideItem = {};
+ showItem.classList = {
+ add: jest.fn(),
+ remove: jest.fn(),
+ };
+ hideItem.classList = {
+ add: jest.fn(),
+ remove: jest.fn(),
+ };
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ jest.spyOn(closeReopenReportToggle, 'getButtonTypes').mockReturnValue([showItem, hideItem]);
+
+ closeReopenReportToggle.toggleButtonType(isClosed);
+ });
+
+ it('calls .getButtonTypes', () => {
+ expect(closeReopenReportToggle.getButtonTypes).toHaveBeenCalledWith(isClosed);
+ });
+
+ it('removes hide class and add selected class to showItem, opposite for hideItem', () => {
+ expect(showItem.classList.remove).toHaveBeenCalledWith('hidden');
+ expect(showItem.classList.add).toHaveBeenCalledWith('droplab-item-selected');
+ expect(hideItem.classList.add).toHaveBeenCalledWith('hidden');
+ expect(hideItem.classList.remove).toHaveBeenCalledWith('droplab-item-selected');
+ });
+
+ it('clicks the showItem', () => {
+ expect(showItem.click).toHaveBeenCalled();
+ });
+ });
+
+ describe('getButtonTypes', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ const reopenItem = {};
+ const closeItem = {};
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ closeReopenReportToggle.reopenItem = reopenItem;
+ closeReopenReportToggle.closeItem = closeItem;
+ });
+
+ it('returns reopenItem, closeItem if isClosed is true', () => {
+ const buttonTypes = closeReopenReportToggle.getButtonTypes(true);
+
+ expect(buttonTypes).toEqual([reopenItem, closeItem]);
+ });
+
+ it('returns closeItem, reopenItem if isClosed is false', () => {
+ const buttonTypes = closeReopenReportToggle.getButtonTypes(false);
+
+ expect(buttonTypes).toEqual([closeItem, reopenItem]);
+ });
+ });
+
+ describe('setDisable', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {
+ setAttribute: jest.fn(),
+ removeAttribute: jest.fn(),
+ };
+ const button = {
+ setAttribute: jest.fn(),
+ removeAttribute: jest.fn(),
+ };
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+ });
+
+ it('disable .button and .dropdownTrigger if shouldDisable is true', () => {
+ closeReopenReportToggle.setDisable(true);
+
+ expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ });
+
+ it('disable .button and .dropdownTrigger if shouldDisable is undefined', () => {
+ closeReopenReportToggle.setDisable();
+
+ expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
+ });
+
+ it('enable .button and .dropdownTrigger if shouldDisable is false', () => {
+ closeReopenReportToggle.setDisable(false);
+
+ expect(button.removeAttribute).toHaveBeenCalledWith('disabled');
+ expect(dropdownTrigger.removeAttribute).toHaveBeenCalledWith('disabled');
+ });
+ });
+
+ describe('setConfig', () => {
+ let closeReopenReportToggle;
+ const dropdownList = {};
+ const dropdownTrigger = {};
+ const button = {};
+ let config;
+
+ beforeEach(() => {
+ closeReopenReportToggle = new CloseReopenReportToggle({
+ dropdownTrigger,
+ dropdownList,
+ button,
+ });
+
+ config = closeReopenReportToggle.setConfig();
+ });
+
+ it('returns a config object', () => {
+ expect(config).toEqual({
+ InputSetter: [
+ {
+ input: button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'data-value',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-text',
+ inputAttribute: 'title',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-button-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: dropdownTrigger,
+ valueAttribute: 'data-toggle-class',
+ inputAttribute: 'class',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-url',
+ inputAttribute: 'href',
+ },
+ {
+ input: button,
+ valueAttribute: 'data-method',
+ inputAttribute: 'data-method',
+ },
+ ],
+ });
+ });
+ });
+});
diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js
index 782e5215ad8..33b30891d5e 100644
--- a/spec/frontend/clusters/components/applications_spec.js
+++ b/spec/frontend/clusters/components/applications_spec.js
@@ -8,6 +8,7 @@ import eventHub from '~/clusters/event_hub';
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;
@@ -67,6 +68,10 @@ describe('Applications', () => {
it('renders a row for Elastic Stack', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
});
+
+ it('renders a row for Fluentd', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull();
+ });
});
describe('Group cluster applications', () => {
@@ -112,6 +117,10 @@ describe('Applications', () => {
it('renders a row for Elastic Stack', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
});
+
+ it('renders a row for Fluentd', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull();
+ });
});
describe('Instance cluster applications', () => {
@@ -157,6 +166,10 @@ describe('Applications', () => {
it('renders a row for Elastic Stack', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull();
});
+
+ it('renders a row for Fluentd', () => {
+ expect(vm.$el.querySelector('.js-cluster-application-row-fluentd')).not.toBeNull();
+ });
});
describe('Helm application', () => {
@@ -240,6 +253,7 @@ describe('Applications', () => {
jupyter: { title: 'JupyterHub', hostname: '' },
knative: { title: 'Knative', hostname: '' },
elastic_stack: { title: 'Elastic Stack' },
+ fluentd: { title: 'Fluentd' },
},
});
@@ -539,4 +553,23 @@ describe('Applications', () => {
});
});
});
+
+ describe('Fluentd application', () => {
+ const propsData = {
+ applications: {
+ ...APPLICATIONS_MOCK_STATE,
+ },
+ };
+
+ let wrapper;
+ beforeEach(() => {
+ wrapper = shallowMount(Applications, { propsData });
+ });
+ afterEach(() => {
+ wrapper.destroy();
+ });
+ it('renders the correct Component', () => {
+ expect(wrapper.contains(FluentdOutputSettings)).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
new file mode 100644
index 00000000000..5e27cc49049
--- /dev/null
+++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
@@ -0,0 +1,186 @@
+import { shallowMount } from '@vue/test-utils';
+import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue';
+import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
+import { GlAlert, GlDropdown, GlFormCheckbox } from '@gitlab/ui';
+import eventHub from '~/clusters/event_hub';
+
+const { UPDATING } = APPLICATION_STATUS;
+
+describe('FluentdOutputSettings', () => {
+ let wrapper;
+
+ const defaultSettings = {
+ protocol: 'tcp',
+ host: '127.0.0.1',
+ port: 514,
+ wafLogEnabled: true,
+ ciliumLogEnabled: false,
+ };
+ const defaultProps = {
+ status: 'installable',
+ updateFailed: false,
+ ...defaultSettings,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(FluentdOutputSettings, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+ const updateComponentPropsFromEvent = () => {
+ const { isEditingSettings, ...props } = eventHub.$emit.mock.calls[0][1];
+ wrapper.setProps(props);
+ };
+ const findSaveButton = () => wrapper.find({ ref: 'saveBtn' });
+ const findCancelButton = () => wrapper.find({ ref: 'cancelBtn' });
+ const findProtocolDropdown = () => wrapper.find(GlDropdown);
+ const findCheckbox = name =>
+ wrapper.findAll(GlFormCheckbox).wrappers.find(x => x.text() === name);
+ const findHost = () => wrapper.find('#fluentd-host');
+ const findPort = () => wrapper.find('#fluentd-port');
+ const changeCheckbox = checkbox => {
+ const currentValue = checkbox.attributes('checked')?.toString() === 'true';
+ checkbox.vm.$emit('input', !currentValue);
+ };
+ const changeInput = ({ element }, val) => {
+ element.value = val;
+ element.dispatchEvent(new Event('input'));
+ };
+ const changePort = val => changeInput(findPort(), val);
+ const changeHost = val => changeInput(findHost(), val);
+ const changeProtocol = idx => findProtocolDropdown().vm.$children[idx].$emit('click');
+ const toApplicationSettings = ({ wafLogEnabled, ciliumLogEnabled, ...settings }) => ({
+ ...settings,
+ waf_log_enabled: wafLogEnabled,
+ cilium_log_enabled: ciliumLogEnabled,
+ });
+
+ describe('when fluentd is installed', () => {
+ beforeEach(() => {
+ createComponent({ status: 'installed' });
+ jest.spyOn(eventHub, '$emit');
+ });
+
+ it('does not render save and cancel buttons', () => {
+ expect(findSaveButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(false);
+ });
+
+ 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 }) => {
+ beforeEach(() => {
+ changeFn();
+ });
+
+ it('triggers set event to be propagated with the current value', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('setFluentdSettings', {
+ [key]: value,
+ isEditingSettings: true,
+ });
+ });
+
+ describe('when value is updated from store', () => {
+ beforeEach(() => {
+ updateComponentPropsFromEvent();
+ });
+
+ it('enables save and cancel buttons', () => {
+ expect(findSaveButton().exists()).toBe(true);
+ expect(findSaveButton().attributes().disabled).toBeUndefined();
+ expect(findCancelButton().exists()).toBe(true);
+ expect(findCancelButton().attributes().disabled).toBeUndefined();
+ });
+
+ describe('and the save changes button is clicked', () => {
+ beforeEach(() => {
+ eventHub.$emit.mockClear();
+ findSaveButton().vm.$emit('click');
+ });
+
+ it('triggers save event and pass current values', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', {
+ id: FLUENTD,
+ params: toApplicationSettings({
+ ...defaultSettings,
+ [key]: value,
+ }),
+ });
+ });
+ });
+
+ describe('and the cancel button is clicked', () => {
+ beforeEach(() => {
+ eventHub.$emit.mockClear();
+ findCancelButton().vm.$emit('click');
+ });
+
+ it('triggers reset event', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('setFluentdSettings', {
+ ...defaultSettings,
+ isEditingSettings: false,
+ });
+ });
+
+ describe('when value is updated from store', () => {
+ beforeEach(() => {
+ updateComponentPropsFromEvent();
+ });
+
+ it('does not render save and cancel buttons', () => {
+ expect(findSaveButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
+ });
+ });
+ });
+
+ describe(`when fluentd status is ${UPDATING}`, () => {
+ beforeEach(() => {
+ createComponent({ installed: true, status: UPDATING });
+ });
+
+ it('renders loading spinner in save button', () => {
+ expect(findSaveButton().props('loading')).toBe(true);
+ });
+
+ it('renders disabled save button', () => {
+ expect(findSaveButton().props('disabled')).toBe(true);
+ });
+
+ it('renders save button with "Saving" label', () => {
+ expect(findSaveButton().text()).toBe('Saving');
+ });
+ });
+
+ describe('when fluentd fails to update', () => {
+ beforeEach(() => {
+ createComponent({ updateFailed: true });
+ });
+
+ it('displays a error message', () => {
+ expect(wrapper.contains(GlAlert)).toBe(true);
+ });
+ });
+ });
+
+ describe('when fluentd is not installed', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('does not render the save button', () => {
+ expect(findSaveButton().exists()).toBe(false);
+ expect(findCancelButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js
index 2de04f7da1f..73d08661199 100644
--- a/spec/frontend/clusters/components/knative_domain_editor_spec.js
+++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js
@@ -93,7 +93,7 @@ describe('KnativeDomainEditor', () => {
it('displays toast indicating a successful update', () => {
wrapper.vm.$toast = { show: jest.fn() };
- wrapper.setProps({ knative: Object.assign({ updateSuccessful: true }, knative) });
+ wrapper.setProps({ knative: { updateSuccessful: true, ...knative } });
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js
index 52d78ea1176..c5ec3f6e6a8 100644
--- a/spec/frontend/clusters/services/mock_data.js
+++ b/spec/frontend/clusters/services/mock_data.js
@@ -159,6 +159,7 @@ const APPLICATIONS_MOCK_STATE = {
jupyter: { title: 'JupyterHub', status: 'installable', hostname: '' },
knative: { title: 'Knative ', status: 'installable', hostname: '' },
elastic_stack: { title: 'Elastic Stack', status: 'installable' },
+ fluentd: { title: 'Fluentd', status: 'installable' },
};
export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE, APPLICATIONS_MOCK_STATE };
diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js
index 9fafc688af9..36e99c37be5 100644
--- a/spec/frontend/clusters/stores/clusters_store_spec.js
+++ b/spec/frontend/clusters/stores/clusters_store_spec.js
@@ -121,6 +121,24 @@ describe('Clusters Store', () => {
uninstallFailed: false,
validationError: null,
},
+ fluentd: {
+ title: 'Fluentd',
+ status: null,
+ statusReason: null,
+ requestReason: null,
+ port: null,
+ ciliumLogEnabled: null,
+ host: null,
+ protocol: null,
+ installed: false,
+ isEditingSettings: false,
+ installFailed: false,
+ uninstallable: false,
+ uninstallSuccessful: false,
+ uninstallFailed: false,
+ validationError: null,
+ wafLogEnabled: null,
+ },
jupyter: {
title: 'JupyterHub',
status: mockResponseData.applications[4].status,
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index 85c86b2c0a9..e2d2e4b73b3 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -1,46 +1,68 @@
-import Vuex from 'vuex';
-import { createLocalVue, mount } from '@vue/test-utils';
-import { GlTable, GlLoadingIcon } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
import Clusters from '~/clusters_list/components/clusters.vue';
-import mockData from '../mock_data';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
+import ClusterStore from '~/clusters_list/store';
+import MockAdapter from 'axios-mock-adapter';
+import { apiData } from '../mock_data';
+import { mount } from '@vue/test-utils';
+import { GlLoadingIcon, GlTable, GlPagination } from '@gitlab/ui';
describe('Clusters', () => {
+ let mock;
+ let store;
let wrapper;
- const findTable = () => wrapper.find(GlTable);
+ const endpoint = 'some/endpoint';
+
const findLoader = () => wrapper.find(GlLoadingIcon);
+ const findPaginatedButtons = () => wrapper.find(GlPagination);
+ const findTable = () => wrapper.find(GlTable);
const findStatuses = () => findTable().findAll('.js-status');
- const mountComponent = _state => {
- const state = { clusters: mockData, endpoint: 'some/endpoint', ..._state };
- const store = new Vuex.Store({
- state,
- });
+ const mockPollingApi = (response, body, header) => {
+ mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header);
+ };
- wrapper = mount(Clusters, { localVue, store });
+ const mountWrapper = () => {
+ store = ClusterStore({ endpoint });
+ wrapper = mount(Clusters, { store });
+ return axios.waitForAll();
};
beforeEach(() => {
- mountComponent({ loading: false });
+ mock = new MockAdapter(axios);
+ mockPollingApi(200, apiData, {
+ 'x-total': apiData.clusters.length,
+ 'x-per-page': 20,
+ 'x-page': 1,
+ });
+
+ return mountWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
});
describe('clusters table', () => {
- it('displays a loader instead of the table while loading', () => {
- mountComponent({ loading: true });
- expect(findLoader().exists()).toBe(true);
- expect(findTable().exists()).toBe(false);
+ describe('when data is loading', () => {
+ beforeEach(() => {
+ wrapper.vm.$store.state.loading = true;
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays a loader instead of the table while loading', () => {
+ expect(findLoader().exists()).toBe(true);
+ expect(findTable().exists()).toBe(false);
+ });
});
it('displays a table component', () => {
expect(findTable().exists()).toBe(true);
- expect(findTable().exists()).toBe(true);
});
it('renders the correct table headers', () => {
- const tableHeaders = wrapper.vm.$options.fields;
+ const tableHeaders = wrapper.vm.fields;
const headers = findTable().findAll('th');
expect(headers.length).toBe(tableHeaders.length);
@@ -62,7 +84,8 @@ describe('Clusters', () => {
${'unreachable'} | ${'bg-danger'} | ${1}
${'authentication_failure'} | ${'bg-warning'} | ${2}
${'deleting'} | ${null} | ${3}
- ${'connected'} | ${'bg-success'} | ${4}
+ ${'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);
@@ -75,4 +98,47 @@ describe('Clusters', () => {
}
});
});
+
+ describe('pagination', () => {
+ const perPage = apiData.clusters.length;
+ const totalFirstPage = 100;
+ const totalSecondPage = 500;
+
+ beforeEach(() => {
+ mockPollingApi(200, apiData, {
+ 'x-total': totalFirstPage,
+ 'x-per-page': perPage,
+ 'x-page': 1,
+ });
+ return mountWrapper();
+ });
+
+ it('should load to page 1 with header values', () => {
+ const buttons = findPaginatedButtons();
+
+ expect(buttons.props('perPage')).toBe(perPage);
+ expect(buttons.props('totalItems')).toBe(totalFirstPage);
+ expect(buttons.props('value')).toBe(1);
+ });
+
+ describe('when updating currentPage', () => {
+ beforeEach(() => {
+ mockPollingApi(200, apiData, {
+ 'x-total': totalSecondPage,
+ 'x-per-page': perPage,
+ 'x-page': 2,
+ });
+ wrapper.setData({ currentPage: 2 });
+ return axios.waitForAll();
+ });
+
+ it('should change pagination when currentPage changes', () => {
+ const buttons = findPaginatedButtons();
+
+ expect(buttons.props('perPage')).toBe(perPage);
+ expect(buttons.props('totalItems')).toBe(totalSecondPage);
+ expect(buttons.props('value')).toBe(2);
+ });
+ });
+ });
});
diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js
index 5398975d81c..9a90a378f31 100644
--- a/spec/frontend/clusters_list/mock_data.js
+++ b/spec/frontend/clusters_list/mock_data.js
@@ -1,4 +1,4 @@
-export default [
+export const clusterList = [
{
name: 'My Cluster 1',
environmentScope: '*',
@@ -40,8 +40,22 @@ export default [
environmentScope: 'development',
size: '12',
clusterType: 'project_type',
- status: 'connected',
+ status: 'created',
+ cpu: '6 (100% free)',
+ memory: '20.12 (35% free)',
+ },
+ {
+ name: 'My Cluster 6',
+ environmentScope: '*',
+ size: '1',
+ clusterType: 'project_type',
+ status: 'cleanup_ongoing',
cpu: '6 (100% free)',
memory: '20.12 (35% free)',
},
];
+
+export const apiData = {
+ clusters: clusterList,
+ has_ancestor_clusters: false,
+};
diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js
index e903200bf1d..70766af3ec4 100644
--- a/spec/frontend/clusters_list/store/actions_spec.js
+++ b/spec/frontend/clusters_list/store/actions_spec.js
@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import flashError from '~/flash';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
+import { apiData } from '../mock_data';
import * as types from '~/clusters_list/store/mutation_types';
import * as actions from '~/clusters_list/store/actions';
@@ -10,8 +11,6 @@ jest.mock('~/flash.js');
describe('Clusters store actions', () => {
describe('fetchClusters', () => {
let mock;
- const endpoint = '/clusters';
- const clusters = [{ name: 'test' }];
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -20,14 +19,29 @@ describe('Clusters store actions', () => {
afterEach(() => mock.restore());
it('should commit SET_CLUSTERS_DATA with received response', done => {
- mock.onGet().reply(200, clusters);
+ 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(
actions.fetchClusters,
- { endpoint },
+ { endpoint: apiData.endpoint },
{},
[
- { type: types.SET_CLUSTERS_DATA, payload: clusters },
+ { type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
@@ -38,13 +52,10 @@ describe('Clusters store actions', () => {
it('should show flash on API error', done => {
mock.onGet().reply(400, 'Not Found');
- testAction(actions.fetchClusters, { endpoint }, {}, [], [], () => {
+ testAction(actions.fetchClusters, { endpoint: apiData.endpoint }, {}, [], [], () => {
expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error'));
done();
});
});
});
});
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
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 c1534022242..c9fdd388585 100644
--- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
+++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
@@ -23,17 +23,20 @@ exports[`Code navigation popover component renders popover 1`] = `
<div
class="popover-body"
>
- <gl-deprecated-button-stub
+ <gl-button-stub
+ category="tertiary"
class="w-100"
- href="http://test.com"
- size="md"
+ data-testid="go-to-definition-btn"
+ href="http://gitlab.com/test.js#L20"
+ icon=""
+ size="medium"
target="_blank"
variant="default"
>
Go to definition
- </gl-deprecated-button-stub>
+ </gl-button-stub>
</div>
</div>
`;
diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js
index d5693cc4173..6dfc81dcc40 100644
--- a/spec/frontend/code_navigation/components/app_spec.js
+++ b/spec/frontend/code_navigation/components/app_spec.js
@@ -48,6 +48,7 @@ describe('Code navigation app component', () => {
factory({
currentDefinition: { hover: 'console' },
currentDefinitionPosition: { x: 0 },
+ currentBlobPath: 'index.js',
});
expect(wrapper.find(Popover).exists()).toBe(true);
diff --git a/spec/frontend/code_navigation/components/popover_spec.js b/spec/frontend/code_navigation/components/popover_spec.js
index df3bbc7c1c6..858e94cf155 100644
--- a/spec/frontend/code_navigation/components/popover_spec.js
+++ b/spec/frontend/code_navigation/components/popover_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import Popover from '~/code_navigation/components/popover.vue';
-const DEFINITION_PATH_PREFIX = 'http:/';
+const DEFINITION_PATH_PREFIX = 'http://gitlab.com';
const MOCK_CODE_DATA = Object.freeze({
hover: [
@@ -10,7 +10,7 @@ const MOCK_CODE_DATA = Object.freeze({
value: 'console.log',
},
],
- definition_path: 'test.com',
+ definition_path: 'test.js#L20',
});
const MOCK_DOCS_DATA = Object.freeze({
@@ -20,13 +20,15 @@ const MOCK_DOCS_DATA = Object.freeze({
value: 'console.log',
},
],
- definition_path: 'test.com',
+ definition_path: 'test.js#L20',
});
let wrapper;
-function factory(position, data, definitionPathPrefix) {
- wrapper = shallowMount(Popover, { propsData: { position, data, definitionPathPrefix } });
+function factory({ position, data, definitionPathPrefix, blobPath = 'index.js' }) {
+ wrapper = shallowMount(Popover, {
+ propsData: { position, data, definitionPathPrefix, blobPath },
+ });
}
describe('Code navigation popover component', () => {
@@ -35,14 +37,33 @@ describe('Code navigation popover component', () => {
});
it('renders popover', () => {
- factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA, DEFINITION_PATH_PREFIX);
+ factory({
+ position: { x: 0, y: 0, height: 0 },
+ data: MOCK_CODE_DATA,
+ definitionPathPrefix: DEFINITION_PATH_PREFIX,
+ });
expect(wrapper.element).toMatchSnapshot();
});
+ it('renders link with hash to current file', () => {
+ factory({
+ position: { x: 0, y: 0, height: 0 },
+ data: MOCK_CODE_DATA,
+ definitionPathPrefix: DEFINITION_PATH_PREFIX,
+ blobPath: 'test.js',
+ });
+
+ expect(wrapper.find('[data-testid="go-to-definition-btn"]').attributes('href')).toBe('#L20');
+ });
+
describe('code output', () => {
it('renders code output', () => {
- factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA, DEFINITION_PATH_PREFIX);
+ factory({
+ position: { x: 0, y: 0, height: 0 },
+ data: MOCK_CODE_DATA,
+ definitionPathPrefix: DEFINITION_PATH_PREFIX,
+ });
expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(true);
expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(false);
@@ -51,7 +72,11 @@ describe('Code navigation popover component', () => {
describe('documentation output', () => {
it('renders code output', () => {
- factory({ x: 0, y: 0, height: 0 }, MOCK_DOCS_DATA, DEFINITION_PATH_PREFIX);
+ factory({
+ position: { x: 0, y: 0, height: 0 },
+ data: MOCK_DOCS_DATA,
+ definitionPathPrefix: DEFINITION_PATH_PREFIX,
+ });
expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(false);
expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(true);
diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js
index 6d2ede6dda7..4cf77ed1be5 100644
--- a/spec/frontend/code_navigation/store/actions_spec.js
+++ b/spec/frontend/code_navigation/store/actions_spec.js
@@ -143,6 +143,16 @@ describe('Code navigation actions', () => {
expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']);
expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']);
});
+
+ it('does not call addInteractionClass when no data exists', () => {
+ const state = {
+ data: null,
+ };
+
+ actions.showBlobInteractionZones({ state }, 'index.js');
+
+ expect(addInteractionClass).not.toHaveBeenCalled();
+ });
});
describe('showDefinition', () => {
@@ -173,7 +183,11 @@ describe('Code navigation actions', () => {
[
{
type: 'SET_CURRENT_DEFINITION',
- payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
+ payload: {
+ blobPath: 'index.js',
+ definition: { hover: 'test' },
+ position: { height: 0, x: 0, y: 0 },
+ },
},
],
[],
@@ -193,7 +207,11 @@ describe('Code navigation actions', () => {
[
{
type: 'SET_CURRENT_DEFINITION',
- payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
+ payload: {
+ blobPath: 'index.js',
+ definition: { hover: 'test' },
+ position: { height: 0, x: 0, y: 0 },
+ },
},
],
[],
@@ -214,7 +232,11 @@ describe('Code navigation actions', () => {
[
{
type: 'SET_CURRENT_DEFINITION',
- payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
+ payload: {
+ blobPath: 'index.js',
+ definition: { hover: 'test' },
+ position: { height: 0, x: 0, y: 0 },
+ },
},
],
[],
diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js
index b88cba90b87..86ae207e7b7 100644
--- a/spec/frontend/commit/pipelines/pipelines_spec.js
+++ b/spec/frontend/commit/pipelines/pipelines_spec.js
@@ -118,7 +118,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
let pipelineCopy;
beforeEach(() => {
- pipelineCopy = Object.assign({}, pipeline);
+ pipelineCopy = { ...pipeline };
});
describe('when latest pipeline has detached flag and canRunPipeline is true', () => {
@@ -128,12 +128,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: true,
- }),
- );
+ vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: true });
setImmediate(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).not.toBeNull();
@@ -149,12 +144,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: false,
- }),
- );
+ vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: false });
setImmediate(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
@@ -170,12 +160,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: true,
- }),
- );
+ vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: true });
setImmediate(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
@@ -191,12 +176,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: false,
- }),
- );
+ vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: false });
setImmediate(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
@@ -211,14 +191,12 @@ describe('Pipelines table in Commits and Merge requests', () => {
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
- vm = mountComponent(
- PipelinesTable,
- Object.assign({}, props, {
- canRunPipeline: true,
- projectId: '5',
- mergeRequestId: 3,
- }),
- );
+ vm = mountComponent(PipelinesTable, {
+ ...props,
+ canRunPipeline: true,
+ projectId: '5',
+ mergeRequestId: 3,
+ });
});
it('updates the loading state', done => {
diff --git a/spec/frontend/commit_merge_requests_spec.js b/spec/frontend/commit_merge_requests_spec.js
new file mode 100644
index 00000000000..82968e028d1
--- /dev/null
+++ b/spec/frontend/commit_merge_requests_spec.js
@@ -0,0 +1,69 @@
+import * as CommitMergeRequests from '~/commit_merge_requests';
+
+describe('CommitMergeRequests', () => {
+ describe('createContent', () => {
+ it('should return created content', () => {
+ const content1 = CommitMergeRequests.createContent([
+ { iid: 1, path: '/path1', title: 'foo' },
+ { iid: 2, path: '/path2', title: 'baz' },
+ ])[0];
+
+ expect(content1.tagName).toEqual('SPAN');
+ expect(content1.childElementCount).toEqual(4);
+
+ const content2 = CommitMergeRequests.createContent([])[0];
+
+ expect(content2.tagName).toEqual('SPAN');
+ expect(content2.childElementCount).toEqual(0);
+ expect(content2.innerText).toEqual('No related merge requests found');
+ });
+ });
+
+ describe('getHeaderText', () => {
+ it('should return header text', () => {
+ expect(CommitMergeRequests.getHeaderText(0, 1)).toEqual('1 merge request');
+ expect(CommitMergeRequests.getHeaderText(0, 2)).toEqual('2 merge requests');
+ expect(CommitMergeRequests.getHeaderText(1, 1)).toEqual(',');
+ expect(CommitMergeRequests.getHeaderText(1, 2)).toEqual(',');
+ });
+ });
+
+ describe('createHeader', () => {
+ it('should return created header', () => {
+ const header = CommitMergeRequests.createHeader(0, 1)[0];
+
+ expect(header.tagName).toEqual('SPAN');
+ expect(header.innerText).toEqual('1 merge request');
+ });
+ });
+
+ describe('createItem', () => {
+ it('should return created item', () => {
+ const item = CommitMergeRequests.createItem({ iid: 1, path: '/path', title: 'foo' })[0];
+
+ expect(item.tagName).toEqual('SPAN');
+ expect(item.childElementCount).toEqual(2);
+ expect(item.children[0].tagName).toEqual('A');
+ expect(item.children[1].tagName).toEqual('SPAN');
+ });
+ });
+
+ describe('createLink', () => {
+ it('should return created link', () => {
+ const link = CommitMergeRequests.createLink({ iid: 1, path: '/path', title: 'foo' })[0];
+
+ expect(link.tagName).toEqual('A');
+ expect(link.href).toMatch(/\/path$/);
+ expect(link.innerText).toEqual('!1');
+ });
+ });
+
+ describe('createTitle', () => {
+ it('should return created title', () => {
+ const title = CommitMergeRequests.createTitle({ iid: 1, path: '/path', title: 'foo' })[0];
+
+ expect(title.tagName).toEqual('SPAN');
+ expect(title.innerText).toEqual('foo');
+ });
+ });
+});
diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js
new file mode 100644
index 00000000000..42bd37570b1
--- /dev/null
+++ b/spec/frontend/commits_spec.js
@@ -0,0 +1,98 @@
+import $ from 'jquery';
+import 'vendor/jquery.endless-scroll';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import CommitsList from '~/commits';
+import Pager from '~/pager';
+
+describe('Commits List', () => {
+ let commitsList;
+
+ beforeEach(() => {
+ setFixtures(`
+ <form class="commits-search-form" action="/h5bp/html5-boilerplate/commits/master">
+ <input id="commits-search">
+ </form>
+ <ol id="commits-list"></ol>
+ `);
+ jest.spyOn(Pager, 'init').mockImplementation(() => {});
+ commitsList = new CommitsList(25);
+ });
+
+ it('should be defined', () => {
+ expect(CommitsList).toBeDefined();
+ });
+
+ describe('processCommits', () => {
+ it('should join commit headers', () => {
+ commitsList.$contentList = $(`
+ <div>
+ <li class="commit-header" data-day="2016-09-20">
+ <span class="day">20 Sep, 2016</span>
+ <span class="commits-count">1 commit</span>
+ </li>
+ <li class="commit"></li>
+ </div>
+ `);
+
+ const data = `
+ <li class="commit-header" data-day="2016-09-20">
+ <span class="day">20 Sep, 2016</span>
+ <span class="commits-count">1 commit</span>
+ </li>
+ <li class="commit"></li>
+ `;
+
+ // The last commit header should be removed
+ // since the previous one has the same data-day value.
+ expect(commitsList.processCommits(data).find('li.commit-header').length).toBe(0);
+ });
+ });
+
+ describe('on entering input', () => {
+ let ajaxSpy;
+ let mock;
+
+ beforeEach(() => {
+ commitsList.searchField.val('');
+
+ jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+ mock = new MockAdapter(axios);
+
+ mock.onGet('/h5bp/html5-boilerplate/commits/master').reply(200, {
+ html: '<li>Result</li>',
+ });
+
+ ajaxSpy = jest.spyOn(axios, 'get');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should save the last search string', done => {
+ commitsList.searchField.val('GitLab');
+ commitsList
+ .filterResults()
+ .then(() => {
+ expect(ajaxSpy).toHaveBeenCalled();
+ expect(commitsList.lastSearch).toEqual('GitLab');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('should not make ajax call if the input does not change', done => {
+ commitsList
+ .filterResults()
+ .then(() => {
+ expect(ajaxSpy).not.toHaveBeenCalled();
+ expect(commitsList.lastSearch).toEqual('');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js
index fe3e2132d9d..55437da837c 100644
--- a/spec/frontend/contributors/store/actions_spec.js
+++ b/spec/frontend/contributors/store/actions_spec.js
@@ -55,6 +55,3 @@ describe('Contributors store actions', () => {
});
});
});
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/spec/frontend/contributors/store/getters_spec.js b/spec/frontend/contributors/store/getters_spec.js
index e6342a669b7..a4202e0ef4b 100644
--- a/spec/frontend/contributors/store/getters_spec.js
+++ b/spec/frontend/contributors/store/getters_spec.js
@@ -74,6 +74,3 @@ describe('Contributors Store Getters', () => {
});
});
});
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
index 490a2775b67..0ef09b4b87e 100644
--- a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js
@@ -75,7 +75,7 @@ describe('awsServicesFacade', () => {
});
it('return list of regions where each item has a name and value', () => {
- expect(fetchRoles()).resolves.toEqual(rolesOutput);
+ return expect(fetchRoles()).resolves.toEqual(rolesOutput);
});
});
@@ -91,7 +91,7 @@ describe('awsServicesFacade', () => {
});
it('return list of roles where each item has a name and value', () => {
- expect(fetchRegions()).resolves.toEqual(regionsOutput);
+ return expect(fetchRegions()).resolves.toEqual(regionsOutput);
});
});
@@ -112,7 +112,7 @@ describe('awsServicesFacade', () => {
});
it('return list of key pairs where each item has a name and value', () => {
- expect(fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput);
+ return expect(fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput);
});
});
@@ -133,7 +133,7 @@ describe('awsServicesFacade', () => {
});
it('return list of vpcs where each item has a name and value', () => {
- expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
+ return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
});
});
@@ -151,7 +151,7 @@ describe('awsServicesFacade', () => {
});
it('uses name tag value as the vpc name', () => {
- expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
+ return expect(fetchVpcs({ region })).resolves.toEqual(vpcsOutput);
});
});
@@ -167,7 +167,7 @@ describe('awsServicesFacade', () => {
});
it('return list of subnets where each item has a name and value', () => {
- expect(fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput);
+ return expect(fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput);
});
});
@@ -189,7 +189,7 @@ describe('awsServicesFacade', () => {
});
it('return list of security groups where each item has a name and value', () => {
- expect(fetchSecurityGroups({ region, vpc })).resolves.toEqual(securityGroupsOutput);
+ return expect(fetchSecurityGroups({ region, vpc })).resolves.toEqual(securityGroupsOutput);
});
});
});
diff --git a/spec/frontend/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js
new file mode 100644
index 00000000000..a814952faab
--- /dev/null
+++ b/spec/frontend/create_item_dropdown_spec.js
@@ -0,0 +1,195 @@
+import $ from 'jquery';
+import CreateItemDropdown from '~/create_item_dropdown';
+
+const DROPDOWN_ITEM_DATA = [
+ {
+ title: 'one',
+ id: 'one',
+ text: 'one',
+ },
+ {
+ title: 'two',
+ id: 'two',
+ text: 'two',
+ },
+ {
+ title: 'three',
+ id: 'three',
+ text: 'three',
+ },
+];
+
+describe('CreateItemDropdown', () => {
+ preloadFixtures('static/create_item_dropdown.html');
+
+ let $wrapperEl;
+ let createItemDropdown;
+
+ function createItemAndClearInput(text) {
+ // Filter for the new item
+ $wrapperEl
+ .find('.dropdown-input-field')
+ .val(text)
+ .trigger('input');
+
+ // Create the new item
+ const $createButton = $wrapperEl.find('.js-dropdown-create-new-item');
+ $createButton.click();
+
+ // Clear out the filter
+ $wrapperEl
+ .find('.dropdown-input-field')
+ .val('')
+ .trigger('input');
+ }
+
+ beforeEach(() => {
+ loadFixtures('static/create_item_dropdown.html');
+ $wrapperEl = $('.js-create-item-dropdown-fixture-root');
+ });
+
+ afterEach(() => {
+ $wrapperEl.remove();
+ });
+
+ describe('items', () => {
+ beforeEach(() => {
+ createItemDropdown = new CreateItemDropdown({
+ $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
+ defaultToggleLabel: 'All variables',
+ fieldName: 'variable[environment]',
+ getData: (term, callback) => {
+ callback(DROPDOWN_ITEM_DATA);
+ },
+ });
+ });
+
+ it('should have a dropdown item for each piece of data', () => {
+ // Get the data in the dropdown
+ $('.js-dropdown-menu-toggle').click();
+
+ const $itemEls = $wrapperEl.find('.js-dropdown-content a');
+
+ expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length);
+ });
+ });
+
+ describe('created items', () => {
+ const NEW_ITEM_TEXT = 'foobarbaz';
+
+ beforeEach(() => {
+ createItemDropdown = new CreateItemDropdown({
+ $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
+ defaultToggleLabel: 'All variables',
+ fieldName: 'variable[environment]',
+ getData: (term, callback) => {
+ callback(DROPDOWN_ITEM_DATA);
+ },
+ });
+
+ // Open the dropdown
+ $('.js-dropdown-menu-toggle').click();
+
+ // Filter for the new item
+ $wrapperEl
+ .find('.dropdown-input-field')
+ .val(NEW_ITEM_TEXT)
+ .trigger('input');
+ });
+
+ it('create new item button should include the filter text', () => {
+ expect($wrapperEl.find('.js-dropdown-create-new-item code').text()).toEqual(NEW_ITEM_TEXT);
+ });
+
+ it('should update the dropdown with the newly created item', () => {
+ // Create the new item
+ const $createButton = $wrapperEl.find('.js-dropdown-create-new-item');
+ $createButton.click();
+
+ expect($wrapperEl.find('.dropdown-toggle-text').text()).toEqual(NEW_ITEM_TEXT);
+ expect($wrapperEl.find('input[name="variable[environment]"]').val()).toEqual(NEW_ITEM_TEXT);
+ });
+
+ it('should include newly created item in dropdown list', () => {
+ createItemAndClearInput(NEW_ITEM_TEXT);
+
+ const $itemEls = $wrapperEl.find('.js-dropdown-content a');
+
+ expect($itemEls.length).toEqual(1 + DROPDOWN_ITEM_DATA.length);
+ expect($($itemEls.get(DROPDOWN_ITEM_DATA.length)).text()).toEqual(NEW_ITEM_TEXT);
+ });
+
+ it('should not duplicate an item when trying to create an existing item', () => {
+ createItemAndClearInput(DROPDOWN_ITEM_DATA[0].text);
+
+ const $itemEls = $wrapperEl.find('.js-dropdown-content a');
+
+ expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length);
+ });
+ });
+
+ describe('clearDropdown()', () => {
+ beforeEach(() => {
+ createItemDropdown = new CreateItemDropdown({
+ $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
+ defaultToggleLabel: 'All variables',
+ fieldName: 'variable[environment]',
+ getData: (term, callback) => {
+ callback(DROPDOWN_ITEM_DATA);
+ },
+ });
+ });
+
+ it('should clear all data and filter input', () => {
+ const filterInput = $wrapperEl.find('.dropdown-input-field');
+
+ // Get the data in the dropdown
+ $('.js-dropdown-menu-toggle').click();
+
+ // Filter for an item
+ filterInput.val('one').trigger('input');
+
+ const $itemElsAfterFilter = $wrapperEl.find('.js-dropdown-content a');
+
+ expect($itemElsAfterFilter.length).toEqual(1);
+
+ createItemDropdown.clearDropdown();
+
+ const $itemElsAfterClear = $wrapperEl.find('.js-dropdown-content a');
+
+ expect($itemElsAfterClear.length).toEqual(0);
+ expect(filterInput.val()).toEqual('');
+ });
+ });
+
+ describe('createNewItemFromValue option', () => {
+ beforeEach(() => {
+ createItemDropdown = new CreateItemDropdown({
+ $dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
+ defaultToggleLabel: 'All variables',
+ fieldName: 'variable[environment]',
+ getData: (term, callback) => {
+ callback(DROPDOWN_ITEM_DATA);
+ },
+ createNewItemFromValue: newValue => ({
+ title: `${newValue}-title`,
+ id: `${newValue}-id`,
+ text: `${newValue}-text`,
+ }),
+ });
+ });
+
+ it('all items go through createNewItemFromValue', () => {
+ // Get the data in the dropdown
+ $('.js-dropdown-menu-toggle').click();
+
+ createItemAndClearInput('new-item');
+
+ const $itemEls = $wrapperEl.find('.js-dropdown-content a');
+
+ expect($itemEls.length).toEqual(1 + DROPDOWN_ITEM_DATA.length);
+ expect($($itemEls[3]).text()).toEqual('new-item-text');
+ expect($wrapperEl.find('.dropdown-toggle-text').text()).toEqual('new-item-title');
+ });
+ });
+});
diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
index 61cbef0c557..79c37293fe5 100644
--- a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
+++ b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
@@ -152,7 +152,6 @@ describe('custom metrics form fields component', () => {
describe('when query validation is in flight', () => {
beforeEach(() => {
- jest.useFakeTimers();
mountComponent(
{ metricPersisted: true, ...makeFormData({ query: 'validQuery' }) },
{
diff --git a/spec/frontend/deploy_keys/components/action_btn_spec.js b/spec/frontend/deploy_keys/components/action_btn_spec.js
new file mode 100644
index 00000000000..b8211b02464
--- /dev/null
+++ b/spec/frontend/deploy_keys/components/action_btn_spec.js
@@ -0,0 +1,54 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import eventHub from '~/deploy_keys/eventhub';
+import actionBtn from '~/deploy_keys/components/action_btn.vue';
+
+describe('Deploy keys action btn', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ const deployKey = data.enabled_keys[0];
+ let wrapper;
+
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ beforeEach(() => {
+ wrapper = shallowMount(actionBtn, {
+ propsData: {
+ deployKey,
+ type: 'enable',
+ },
+ slots: {
+ default: 'Enable',
+ },
+ });
+ });
+
+ it('renders the default slot', () => {
+ expect(wrapper.text()).toBe('Enable');
+ });
+
+ it('sends eventHub event with btn type', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ wrapper.trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, expect.anything());
+ });
+ });
+
+ it('shows loading spinner after click', () => {
+ wrapper.trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ it('disables button after click', () => {
+ wrapper.trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.attributes('disabled')).toBe('disabled');
+ });
+ });
+});
diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js
new file mode 100644
index 00000000000..291502c9ed7
--- /dev/null
+++ b/spec/frontend/deploy_keys/components/app_spec.js
@@ -0,0 +1,142 @@
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'spec/test_constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import eventHub from '~/deploy_keys/eventhub';
+import deployKeysApp from '~/deploy_keys/components/app.vue';
+
+const TEST_ENDPOINT = `${TEST_HOST}/dummy/`;
+
+describe('Deploy keys app component', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ let wrapper;
+ let mock;
+
+ const mountComponent = () => {
+ wrapper = mount(deployKeysApp, {
+ propsData: {
+ endpoint: TEST_ENDPOINT,
+ projectId: '8',
+ },
+ });
+
+ return waitForPromises();
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(TEST_ENDPOINT).reply(200, data);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ const findLoadingIcon = () => wrapper.find('.gl-spinner');
+ const findKeyPanels = () => wrapper.findAll('.deploy-keys .nav-links li');
+
+ it('renders loading icon while waiting for request', () => {
+ mock.onGet(TEST_ENDPOINT).reply(() => new Promise());
+
+ mountComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ it('renders keys panels', () => {
+ return mountComponent().then(() => {
+ expect(findKeyPanels().length).toBe(3);
+ });
+ });
+
+ it.each`
+ selector | label | count
+ ${'.js-deployKeys-tab-enabled_keys'} | ${'Enabled deploy keys'} | ${1}
+ ${'.js-deployKeys-tab-available_project_keys'} | ${'Privately accessible deploy keys'} | ${0}
+ ${'.js-deployKeys-tab-public_keys'} | ${'Publicly accessible deploy keys'} | ${1}
+ `('$selector title is $label with keys count equal to $count', ({ selector, label, count }) => {
+ return mountComponent().then(() => {
+ const element = wrapper.find(selector);
+ expect(element.exists()).toBe(true);
+ expect(element.text().trim()).toContain(label);
+
+ expect(
+ element
+ .find('.badge')
+ .text()
+ .trim(),
+ ).toBe(count.toString());
+ });
+ });
+
+ it('does not render key panels when keys object is empty', () => {
+ mock.onGet(TEST_ENDPOINT).reply(200, []);
+
+ return mountComponent().then(() => {
+ expect(findKeyPanels().length).toBe(0);
+ });
+ });
+
+ it('re-fetches deploy keys when enabling a key', () => {
+ const key = data.public_keys[0];
+ return mountComponent()
+ .then(() => {
+ jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm.service, 'enableKey').mockImplementation(() => Promise.resolve());
+
+ eventHub.$emit('enable.key', key);
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.vm.service.enableKey).toHaveBeenCalledWith(key.id);
+ expect(wrapper.vm.service.getKeys).toHaveBeenCalled();
+ });
+ });
+
+ it('re-fetches deploy keys when disabling a key', () => {
+ const key = data.public_keys[0];
+ return mountComponent()
+ .then(() => {
+ jest.spyOn(window, 'confirm').mockReturnValue(true);
+ jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve());
+
+ eventHub.$emit('disable.key', key);
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.vm.service.disableKey).toHaveBeenCalledWith(key.id);
+ expect(wrapper.vm.service.getKeys).toHaveBeenCalled();
+ });
+ });
+
+ it('calls disableKey when removing a key', () => {
+ const key = data.public_keys[0];
+ return mountComponent()
+ .then(() => {
+ jest.spyOn(window, 'confirm').mockReturnValue(true);
+ jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve());
+
+ eventHub.$emit('remove.key', key);
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.vm.service.disableKey).toHaveBeenCalledWith(key.id);
+ expect(wrapper.vm.service.getKeys).toHaveBeenCalled();
+ });
+ });
+
+ it('hasKeys returns true when there are keys', () => {
+ return mountComponent().then(() => {
+ expect(wrapper.vm.hasKeys).toEqual(3);
+ });
+ });
+});
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
new file mode 100644
index 00000000000..7d942d969bb
--- /dev/null
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -0,0 +1,161 @@
+import { mount } from '@vue/test-utils';
+import DeployKeysStore from '~/deploy_keys/store';
+import key from '~/deploy_keys/components/key.vue';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+
+describe('Deploy keys key', () => {
+ let wrapper;
+ let store;
+
+ const data = getJSONFixture('deploy_keys/keys.json');
+
+ const findTextAndTrim = selector =>
+ wrapper
+ .find(selector)
+ .text()
+ .trim();
+
+ const createComponent = propsData => {
+ wrapper = mount(key, {
+ propsData: {
+ store,
+ endpoint: 'https://test.host/dummy/endpoint',
+ ...propsData,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ store = new DeployKeysStore();
+ store.keys = data;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('enabled key', () => {
+ const deployKey = data.enabled_keys[0];
+
+ it('renders the keys title', () => {
+ createComponent({ deployKey });
+
+ expect(findTextAndTrim('.title')).toContain('My title');
+ });
+
+ it('renders human friendly formatted created date', () => {
+ createComponent({ deployKey });
+
+ expect(findTextAndTrim('.key-created-at')).toBe(
+ `${getTimeago().format(deployKey.created_at)}`,
+ );
+ });
+
+ it('shows pencil button for editing', () => {
+ createComponent({ deployKey });
+
+ expect(wrapper.find('.btn .ic-pencil')).toExist();
+ });
+
+ it('shows disable button when the project is not deletable', () => {
+ createComponent({ deployKey });
+
+ expect(wrapper.find('.btn .ic-cancel')).toExist();
+ });
+
+ it('shows remove button when the project is deletable', () => {
+ createComponent({
+ deployKey: { ...deployKey, destroyed_when_orphaned: true, almost_orphaned: true },
+ });
+ expect(wrapper.find('.btn .ic-remove')).toExist();
+ });
+ });
+
+ describe('deploy key labels', () => {
+ const deployKey = data.enabled_keys[0];
+ const deployKeysProjects = [...deployKey.deploy_keys_projects];
+ it('shows write access title when key has write access', () => {
+ deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: true };
+ createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } });
+
+ expect(wrapper.find('.deploy-project-label').attributes('data-original-title')).toBe(
+ 'Write access allowed',
+ );
+ });
+
+ it('does not show write access title when key has write access', () => {
+ deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: false };
+ createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } });
+
+ expect(wrapper.find('.deploy-project-label').attributes('data-original-title')).toBe(
+ 'Read access only',
+ );
+ });
+
+ it('shows expandable button if more than two projects', () => {
+ createComponent({ deployKey });
+ const labels = wrapper.findAll('.deploy-project-label');
+
+ expect(labels.length).toBe(2);
+ expect(labels.at(1).text()).toContain('others');
+ expect(labels.at(1).attributes('data-original-title')).toContain('Expand');
+ });
+
+ it('expands all project labels after click', () => {
+ createComponent({ deployKey });
+ const { length } = deployKey.deploy_keys_projects;
+ wrapper
+ .findAll('.deploy-project-label')
+ .at(1)
+ .trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ const labels = wrapper.findAll('.deploy-project-label');
+
+ expect(labels.length).toBe(length);
+ expect(labels.at(1).text()).not.toContain(`+${length} others`);
+ expect(labels.at(1).attributes('data-original-title')).not.toContain('Expand');
+ });
+ });
+
+ it('shows two projects', () => {
+ createComponent({
+ deployKey: { ...deployKey, deploy_keys_projects: [...deployKeysProjects].slice(0, 2) },
+ });
+
+ const labels = wrapper.findAll('.deploy-project-label');
+
+ expect(labels.length).toBe(2);
+ expect(labels.at(1).text()).toContain(deployKey.deploy_keys_projects[1].project.full_name);
+ });
+ });
+
+ describe('public keys', () => {
+ const deployKey = data.public_keys[0];
+
+ it('renders deploy keys without any enabled projects', () => {
+ createComponent({ deployKey: { ...deployKey, deploy_keys_projects: [] } });
+
+ expect(findTextAndTrim('.deploy-project-list')).toBe('None');
+ });
+
+ it('shows enable button', () => {
+ createComponent({ deployKey });
+ expect(findTextAndTrim('.btn')).toBe('Enable');
+ });
+
+ it('shows pencil button for editing', () => {
+ createComponent({ deployKey });
+ expect(wrapper.find('.btn .ic-pencil')).toExist();
+ });
+
+ it('shows disable button when key is enabled', () => {
+ store.keys.enabled_keys.push(deployKey);
+
+ createComponent({ deployKey });
+
+ expect(wrapper.find('.btn .ic-cancel')).toExist();
+ });
+ });
+});
diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js
new file mode 100644
index 00000000000..53c8ba073bc
--- /dev/null
+++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js
@@ -0,0 +1,63 @@
+import { mount } from '@vue/test-utils';
+import DeployKeysStore from '~/deploy_keys/store';
+import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue';
+
+describe('Deploy keys panel', () => {
+ const data = getJSONFixture('deploy_keys/keys.json');
+ let wrapper;
+
+ const findTableRowHeader = () => wrapper.find('.table-row-header');
+
+ const mountComponent = props => {
+ const store = new DeployKeysStore();
+ store.keys = data;
+ wrapper = mount(deployKeysPanel, {
+ propsData: {
+ title: 'test',
+ keys: data.enabled_keys,
+ showHelpBox: true,
+ store,
+ endpoint: 'https://test.host/dummy/endpoint',
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders list of keys', () => {
+ mountComponent();
+ expect(wrapper.findAll('.deploy-key').length).toBe(wrapper.vm.keys.length);
+ });
+
+ it('renders table header', () => {
+ mountComponent();
+ const tableHeader = findTableRowHeader();
+
+ expect(tableHeader).toExist();
+ expect(tableHeader.text()).toContain('Deploy key');
+ expect(tableHeader.text()).toContain('Project usage');
+ expect(tableHeader.text()).toContain('Created');
+ });
+
+ it('renders help box if keys are empty', () => {
+ mountComponent({ keys: [] });
+
+ expect(wrapper.find('.settings-message').exists()).toBe(true);
+
+ expect(
+ wrapper
+ .find('.settings-message')
+ .text()
+ .trim(),
+ ).toBe('No deploy keys found. Create one with the form above.');
+ });
+
+ it('renders no table header if keys are empty', () => {
+ mountComponent({ keys: [] });
+ expect(findTableRowHeader().exists()).toBe(false);
+ });
+});
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
new file mode 100644
index 00000000000..4828e8cb3c2
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap
@@ -0,0 +1,42 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design discussions component should match the snapshot of note when repositioning 1`] = `
+<button
+ aria-label="Comment form position"
+ class="position-absolute btn-transparent comment-indicator"
+ style="left: 10px; top: 10px; cursor: move;"
+ type="button"
+>
+ <icon-stub
+ name="image-comment-dark"
+ size="16"
+ />
+</button>
+`;
+
+exports[`Design discussions component should match the snapshot of note with index 1`] = `
+<button
+ aria-label="Comment '1' position"
+ class="position-absolute js-image-badge badge badge-pill"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+
+ 1
+
+</button>
+`;
+
+exports[`Design discussions component should match the snapshot of note without index 1`] = `
+<button
+ aria-label="Comment form position"
+ class="position-absolute btn-transparent comment-indicator"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+ <icon-stub
+ name="image-comment-dark"
+ size="16"
+ />
+</button>
+`;
diff --git a/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap
new file mode 100644
index 00000000000..189962c5b2e
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap
@@ -0,0 +1,104 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <design-image-stub
+ image="test.jpg"
+ name="test"
+ scale="1"
+ />
+
+ <design-overlay-stub
+ currentcommentform="[object Object]"
+ dimensions="[object Object]"
+ notes=""
+ position="[object Object]"
+ />
+ </div>
+</div>
+`;
+
+exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <design-image-stub
+ image="test.jpg"
+ name="test"
+ scale="1"
+ />
+
+ <design-overlay-stub
+ dimensions="[object Object]"
+ notes=""
+ position="[object Object]"
+ />
+ </div>
+</div>
+`;
+
+exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <design-image-stub
+ image="test.jpg"
+ name="test"
+ scale="1"
+ />
+
+ <design-overlay-stub
+ dimensions="[object Object]"
+ notes=""
+ position="[object Object]"
+ />
+ </div>
+</div>
+`;
+
+exports[`Design management design presentation component renders empty state when no image provided 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <!---->
+
+ <!---->
+ </div>
+</div>
+`;
+
+exports[`Design management design presentation component renders image and overlay when image provided 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <design-image-stub
+ image="test.jpg"
+ name="test"
+ scale="1"
+ />
+
+ <design-overlay-stub
+ dimensions="[object Object]"
+ notes=""
+ position="[object Object]"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap
new file mode 100644
index 00000000000..cb4575cbd11
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap
@@ -0,0 +1,115 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = `
+<div
+ class="design-scaler btn-group"
+ role="group"
+>
+ <button
+ class="btn"
+ disabled="disabled"
+ >
+ <span
+ class="d-flex-center gl-icon s16"
+ >
+
+ –
+
+ </span>
+ </button>
+
+ <button
+ class="btn"
+ disabled="disabled"
+ >
+ <gl-icon-stub
+ name="redo"
+ size="16"
+ />
+ </button>
+
+ <button
+ class="btn"
+ >
+ <gl-icon-stub
+ name="plus"
+ size="16"
+ />
+ </button>
+</div>
+`;
+
+exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = `
+<div
+ class="design-scaler btn-group"
+ role="group"
+>
+ <button
+ class="btn"
+ >
+ <span
+ class="d-flex-center gl-icon s16"
+ >
+
+ –
+
+ </span>
+ </button>
+
+ <button
+ class="btn"
+ >
+ <gl-icon-stub
+ name="redo"
+ size="16"
+ />
+ </button>
+
+ <button
+ class="btn"
+ >
+ <gl-icon-stub
+ name="plus"
+ size="16"
+ />
+ </button>
+</div>
+`;
+
+exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = `
+<div
+ class="design-scaler btn-group"
+ role="group"
+>
+ <button
+ class="btn"
+ >
+ <span
+ class="d-flex-center gl-icon s16"
+ >
+
+ –
+
+ </span>
+ </button>
+
+ <button
+ class="btn"
+ >
+ <gl-icon-stub
+ name="redo"
+ size="16"
+ />
+ </button>
+
+ <button
+ class="btn"
+ disabled="disabled"
+ >
+ <gl-icon-stub
+ name="plus"
+ size="16"
+ />
+ </button>
+</div>
+`;
diff --git a/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap
new file mode 100644
index 00000000000..acaa62b11eb
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap
@@ -0,0 +1,68 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management large image component renders image 1`] = `
+<div
+ class="m-auto js-design-image"
+>
+ <!---->
+
+ <img
+ alt="test"
+ class="mh-100 img-fluid"
+ src="test.jpg"
+ />
+</div>
+`;
+
+exports[`Design management large image component renders loading state 1`] = `
+<div
+ class="m-auto js-design-image"
+ isloading="true"
+>
+ <!---->
+
+ <img
+ alt=""
+ class="mh-100 img-fluid"
+ src=""
+ />
+</div>
+`;
+
+exports[`Design management large image component renders media broken icon on error 1`] = `
+<gl-icon-stub
+ class="text-secondary-100"
+ name="media-broken"
+ size="48"
+/>
+`;
+
+exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = `
+<div
+ class="m-auto js-design-image"
+>
+ <!---->
+
+ <img
+ alt="test"
+ class="mh-100"
+ src="test.jpg"
+ style="width: 100px; height: 100px;"
+ />
+</div>
+`;
+
+exports[`Design management large image component zoom sets image style when zoomed 1`] = `
+<div
+ class="m-auto js-design-image"
+>
+ <!---->
+
+ <img
+ alt="test"
+ class="mh-100"
+ src="test.jpg"
+ style="width: 200px; height: 200px;"
+ />
+</div>
+`;
diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js
new file mode 100644
index 00000000000..9d3bcd98e44
--- /dev/null
+++ b/spec/frontend/design_management/components/delete_button_spec.js
@@ -0,0 +1,51 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import BatchDeleteButton from '~/design_management/components/delete_button.vue';
+
+describe('Batch delete button component', () => {
+ let wrapper;
+
+ const findButton = () => wrapper.find(GlDeprecatedButton);
+ const findModal = () => wrapper.find(GlModal);
+
+ function createComponent(isDeleting = false) {
+ wrapper = shallowMount(BatchDeleteButton, {
+ propsData: {
+ isDeleting,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders non-disabled button by default', () => {
+ createComponent();
+
+ expect(findButton().exists()).toBe(true);
+ expect(findButton().attributes('disabled')).toBeFalsy();
+ });
+
+ it('renders disabled button when design is deleting', () => {
+ createComponent(true);
+ expect(findButton().attributes('disabled')).toBeTruthy();
+ });
+
+ it('emits `deleteSelectedDesigns` event on modal ok click', () => {
+ createComponent();
+ findButton().vm.$emit('click');
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ findModal().vm.$emit('ok');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_note_pin_spec.js b/spec/frontend/design_management/components/design_note_pin_spec.js
new file mode 100644
index 00000000000..4f7260b1363
--- /dev/null
+++ b/spec/frontend/design_management/components/design_note_pin_spec.js
@@ -0,0 +1,49 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignNotePin from '~/design_management/components/design_note_pin.vue';
+
+describe('Design discussions component', () => {
+ let wrapper;
+
+ function createComponent(propsData = {}) {
+ wrapper = shallowMount(DesignNotePin, {
+ propsData: {
+ position: {
+ left: '10px',
+ top: '10px',
+ },
+ ...propsData,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should match the snapshot of note without index', () => {
+ createComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should match the snapshot of note with index', () => {
+ createComponent({ label: '1' });
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should match the snapshot of note when repositioning', () => {
+ createComponent({ repositioning: true });
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('pinStyle', () => {
+ it('sets cursor to `move` when repositioning = true', () => {
+ createComponent({ repositioning: true });
+ expect(wrapper.vm.pinStyle.cursor).toBe('move');
+ });
+
+ it('does not set cursor when repositioning = false', () => {
+ createComponent();
+ expect(wrapper.vm.pinStyle.cursor).toBe(undefined);
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
new file mode 100644
index 00000000000..e071274cc81
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design note component should match the snapshot 1`] = `
+<timeline-entry-item-stub
+ class="design-note note-form"
+ id="note_123"
+>
+ <user-avatar-link-stub
+ imgalt=""
+ imgcssclasses=""
+ imgsize="40"
+ imgsrc=""
+ linkhref=""
+ tooltipplacement="top"
+ tooltiptext=""
+ username=""
+ />
+
+ <div
+ class="d-flex justify-content-between"
+ >
+ <div>
+ <a
+ class="js-user-link"
+ data-user-id="author-id"
+ >
+ <span
+ class="note-header-author-name bold"
+ >
+
+ </span>
+
+ <!---->
+
+ <span
+ class="note-headline-light"
+ >
+ @
+ </span>
+ </a>
+
+ <span
+ class="note-headline-light note-headline-meta"
+ >
+ <span
+ class="system-note-message"
+ />
+
+ <!---->
+ </span>
+ </div>
+
+ <!---->
+ </div>
+
+ <div
+ class="note-text js-note-text md"
+ data-qa-selector="note_content"
+ />
+</timeline-entry-item-stub>
+`;
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
new file mode 100644
index 00000000000..e01c79e3520
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = `
+"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
+ <!---->
+ Comment
+</button>"
+`;
+
+exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
+"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
+ <!---->
+ Save comment
+</button>"
+`;
diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
new file mode 100644
index 00000000000..b16b26ff82f
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
@@ -0,0 +1,133 @@
+import { shallowMount } from '@vue/test-utils';
+import { ApolloMutation } from 'vue-apollo';
+import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
+import DesignNote from '~/design_management/components/design_notes/design_note.vue';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+import createNoteMutation from '~/design_management/graphql/mutations/createNote.mutation.graphql';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+
+describe('Design discussions component', () => {
+ let wrapper;
+
+ const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder);
+ const findReplyForm = () => wrapper.find(DesignReplyForm);
+
+ const mutationVariables = {
+ mutation: createNoteMutation,
+ update: expect.anything(),
+ variables: {
+ input: {
+ noteableId: 'noteable-id',
+ body: 'test',
+ discussionId: '0',
+ },
+ },
+ };
+ const mutate = jest.fn(() => Promise.resolve());
+ const $apollo = {
+ mutate,
+ };
+
+ function createComponent(props = {}, data = {}) {
+ wrapper = shallowMount(DesignDiscussion, {
+ propsData: {
+ discussion: {
+ id: '0',
+ notes: [
+ {
+ id: '1',
+ },
+ {
+ id: '2',
+ },
+ ],
+ },
+ noteableId: 'noteable-id',
+ designId: 'design-id',
+ discussionIndex: 1,
+ ...props,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ stubs: {
+ ReplyPlaceholder,
+ ApolloMutation,
+ },
+ mocks: { $apollo },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders correct amount of discussion notes', () => {
+ createComponent();
+ expect(wrapper.findAll(DesignNote)).toHaveLength(2);
+ });
+
+ it('renders reply placeholder by default', () => {
+ createComponent();
+ expect(findReplyPlaceholder().exists()).toBe(true);
+ });
+
+ it('hides reply placeholder and opens form on placeholder click', () => {
+ createComponent();
+ findReplyPlaceholder().trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findReplyPlaceholder().exists()).toBe(false);
+ expect(findReplyForm().exists()).toBe(true);
+ });
+ });
+
+ it('calls mutation on submitting form and closes the form', () => {
+ createComponent({}, { discussionComment: 'test', isFormRendered: true });
+
+ findReplyForm().vm.$emit('submitForm');
+ expect(mutate).toHaveBeenCalledWith(mutationVariables);
+
+ return mutate()
+ .then(() => {
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ });
+ });
+
+ it('clears the discussion comment on closing comment form', () => {
+ createComponent({}, { discussionComment: 'test', isFormRendered: true });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ findReplyForm().vm.$emit('cancelForm');
+
+ expect(wrapper.vm.discussionComment).toBe('');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ });
+ });
+
+ it('applies correct class to design notes when discussion is highlighted', () => {
+ createComponent(
+ {},
+ {
+ activeDiscussion: {
+ id: '1',
+ source: 'pin',
+ },
+ },
+ );
+
+ expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe(
+ true,
+ );
+ });
+});
diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js
new file mode 100644
index 00000000000..8b32d3022ee
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js
@@ -0,0 +1,170 @@
+import { shallowMount } from '@vue/test-utils';
+import { ApolloMutation } from 'vue-apollo';
+import DesignNote from '~/design_management/components/design_notes/design_note.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+
+const scrollIntoViewMock = jest.fn();
+const note = {
+ id: 'gid://gitlab/DiffNote/123',
+ author: {
+ id: 'author-id',
+ },
+ body: 'test',
+ userPermissions: {
+ adminNote: false,
+ },
+};
+HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
+
+const $route = {
+ hash: '#note_123',
+};
+
+const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } });
+
+describe('Design note component', () => {
+ let wrapper;
+
+ const findUserAvatar = () => wrapper.find(UserAvatarLink);
+ const findUserLink = () => wrapper.find('.js-user-link');
+ const findReplyForm = () => wrapper.find(DesignReplyForm);
+ const findEditButton = () => wrapper.find('.js-note-edit');
+ const findNoteContent = () => wrapper.find('.js-note-text');
+
+ function createComponent(props = {}, data = { isEditing: false }) {
+ wrapper = shallowMount(DesignNote, {
+ propsData: {
+ note: {},
+ ...props,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ mocks: {
+ $route,
+ $apollo: {
+ mutate,
+ },
+ },
+ stubs: {
+ ApolloMutation,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should match the snapshot', () => {
+ createComponent({
+ note,
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should render an author', () => {
+ createComponent({
+ note,
+ });
+
+ expect(findUserAvatar().exists()).toBe(true);
+ expect(findUserLink().exists()).toBe(true);
+ });
+
+ it('should render a time ago tooltip if note has createdAt property', () => {
+ createComponent({
+ note: {
+ ...note,
+ createdAt: '2019-07-26T15:02:20Z',
+ },
+ });
+
+ expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
+ });
+
+ it('should trigger a scrollIntoView method', () => {
+ createComponent({
+ note,
+ });
+
+ expect(scrollIntoViewMock).toHaveBeenCalled();
+ });
+
+ it('should not render edit icon when user does not have a permission', () => {
+ createComponent({
+ note,
+ });
+
+ expect(findEditButton().exists()).toBe(false);
+ });
+
+ describe('when user has a permission to edit note', () => {
+ it('should open an edit form on edit button click', () => {
+ createComponent({
+ note: {
+ ...note,
+ userPermissions: {
+ adminNote: true,
+ },
+ },
+ });
+
+ findEditButton().trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findReplyForm().exists()).toBe(true);
+ expect(findNoteContent().exists()).toBe(false);
+ });
+ });
+
+ describe('when edit form is rendered', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ note: {
+ ...note,
+ userPermissions: {
+ adminNote: true,
+ },
+ },
+ },
+ { isEditing: true },
+ );
+ });
+
+ it('should not render note content and should render reply form', () => {
+ expect(findNoteContent().exists()).toBe(false);
+ expect(findReplyForm().exists()).toBe(true);
+ });
+
+ it('hides the form on hideForm event', () => {
+ findReplyForm().vm.$emit('cancelForm');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ expect(findNoteContent().exists()).toBe(true);
+ });
+ });
+
+ it('calls a mutation on submitForm event and hides a form', () => {
+ findReplyForm().vm.$emit('submitForm');
+ expect(mutate).toHaveBeenCalled();
+
+ return mutate()
+ .then(() => {
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ expect(findNoteContent().exists()).toBe(true);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
new file mode 100644
index 00000000000..34b8f1f9fa8
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
@@ -0,0 +1,182 @@
+import { mount } from '@vue/test-utils';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+
+const showModal = jest.fn();
+
+const GlModal = {
+ template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
+ methods: {
+ show: showModal,
+ },
+};
+
+describe('Design reply form component', () => {
+ let wrapper;
+
+ const findTextarea = () => wrapper.find('textarea');
+ const findSubmitButton = () => wrapper.find({ ref: 'submitButton' });
+ const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
+ const findModal = () => wrapper.find({ ref: 'cancelCommentModal' });
+
+ function createComponent(props = {}) {
+ wrapper = mount(DesignReplyForm, {
+ propsData: {
+ value: '',
+ isSaving: false,
+ ...props,
+ },
+ stubs: { GlModal },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('textarea has focus after component mount', () => {
+ createComponent();
+
+ expect(findTextarea().element).toEqual(document.activeElement);
+ });
+
+ it('renders button text as "Comment" when creating a comment', () => {
+ createComponent();
+
+ expect(findSubmitButton().html()).toMatchSnapshot();
+ });
+
+ it('renders button text as "Save comment" when creating a comment', () => {
+ createComponent({ isNewComment: false });
+
+ expect(findSubmitButton().html()).toMatchSnapshot();
+ });
+
+ describe('when form has no text', () => {
+ beforeEach(() => {
+ createComponent({
+ value: '',
+ });
+ });
+
+ it('submit button is disabled', () => {
+ expect(findSubmitButton().attributes().disabled).toBeTruthy();
+ });
+
+ it('does not emit submitForm event on textarea ctrl+enter keydown', () => {
+ findTextarea().trigger('keydown.enter', {
+ ctrlKey: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeFalsy();
+ });
+ });
+
+ it('does not emit submitForm event on textarea meta+enter keydown', () => {
+ findTextarea().trigger('keydown.enter', {
+ metaKey: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeFalsy();
+ });
+ });
+
+ it('emits cancelForm event on pressing escape button on textarea', () => {
+ findTextarea().trigger('keyup.esc');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+
+ it('emits cancelForm event on clicking Cancel button', () => {
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted('cancelForm')).toHaveLength(1);
+ });
+ });
+
+ describe('when form has text', () => {
+ beforeEach(() => {
+ createComponent({
+ value: 'test',
+ });
+ });
+
+ it('submit button is enabled', () => {
+ expect(findSubmitButton().attributes().disabled).toBeFalsy();
+ });
+
+ it('emits submitForm event on Comment button click', () => {
+ findSubmitButton().vm.$emit('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeTruthy();
+ });
+ });
+
+ it('emits submitForm event on textarea ctrl+enter keydown', () => {
+ findTextarea().trigger('keydown.enter', {
+ ctrlKey: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeTruthy();
+ });
+ });
+
+ it('emits submitForm event on textarea meta+enter keydown', () => {
+ findTextarea().trigger('keydown.enter', {
+ metaKey: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeTruthy();
+ });
+ });
+
+ it('emits input event on changing textarea content', () => {
+ findTextarea().setValue('test2');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('input')).toBeTruthy();
+ });
+ });
+
+ it('emits cancelForm event on Escape key if text was not changed', () => {
+ findTextarea().trigger('keyup.esc');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+
+ it('opens confirmation modal on Escape key when text has changed', () => {
+ wrapper.setProps({ value: 'test2' });
+
+ return wrapper.vm.$nextTick().then(() => {
+ findTextarea().trigger('keyup.esc');
+ expect(showModal).toHaveBeenCalled();
+ });
+ });
+
+ it('emits cancelForm event on Cancel button click if text was not changed', () => {
+ findCancelButton().trigger('click');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+
+ it('opens confirmation modal on Cancel button click when text has changed', () => {
+ wrapper.setProps({ value: 'test2' });
+
+ return wrapper.vm.$nextTick().then(() => {
+ findCancelButton().trigger('click');
+ expect(showModal).toHaveBeenCalled();
+ });
+ });
+
+ it('emits cancelForm event on modal Ok button click', () => {
+ findTextarea().trigger('keyup.esc');
+ findModal().vm.$emit('ok');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js
new file mode 100644
index 00000000000..1c9b130aca6
--- /dev/null
+++ b/spec/frontend/design_management/components/design_overlay_spec.js
@@ -0,0 +1,393 @@
+import { mount } from '@vue/test-utils';
+import DesignOverlay from '~/design_management/components/design_overlay.vue';
+import updateActiveDiscussion from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
+import notes from '../mock_data/notes';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management/constants';
+
+const mutate = jest.fn(() => Promise.resolve());
+
+describe('Design overlay component', () => {
+ let wrapper;
+
+ const mockDimensions = { width: 100, height: 100 };
+ const mockNoteNotAuthorised = {
+ id: 'note-not-authorised',
+ discussion: { id: 'discussion-not-authorised' },
+ position: {
+ x: 1,
+ y: 80,
+ ...mockDimensions,
+ },
+ userPermissions: {},
+ };
+
+ const findOverlay = () => wrapper.find('.image-diff-overlay');
+ const findAllNotes = () => wrapper.findAll('.js-image-badge');
+ const findCommentBadge = () => wrapper.find('.comment-indicator');
+ const findFirstBadge = () => findAllNotes().at(0);
+ const findSecondBadge = () => findAllNotes().at(1);
+
+ const clickAndDragBadge = (elem, fromPoint, toPoint) => {
+ elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y });
+ return wrapper.vm.$nextTick().then(() => {
+ elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y });
+ return wrapper.vm.$nextTick();
+ });
+ };
+
+ function createComponent(props = {}, data = {}) {
+ wrapper = mount(DesignOverlay, {
+ propsData: {
+ dimensions: mockDimensions,
+ position: {
+ top: '0',
+ left: '0',
+ },
+ ...props,
+ },
+ data() {
+ return {
+ activeDiscussion: {
+ id: null,
+ source: null,
+ },
+ ...data,
+ };
+ },
+ mocks: {
+ $apollo: {
+ mutate,
+ },
+ },
+ });
+ }
+
+ it('should have correct inline style', () => {
+ createComponent();
+
+ expect(wrapper.find('.image-diff-overlay').attributes().style).toBe(
+ 'width: 100px; height: 100px; top: 0px; left: 0px;',
+ );
+ });
+
+ it('should emit `openCommentForm` when clicking on overlay', () => {
+ createComponent();
+ const newCoordinates = {
+ x: 10,
+ y: 10,
+ };
+
+ wrapper
+ .find('.image-diff-overlay-add-comment')
+ .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('openCommentForm')).toEqual([
+ [{ x: newCoordinates.x, y: newCoordinates.y }],
+ ]);
+ });
+ });
+
+ describe('with notes', () => {
+ beforeEach(() => {
+ createComponent({
+ notes,
+ });
+ });
+
+ it('should render a correct amount of notes', () => {
+ expect(findAllNotes()).toHaveLength(notes.length);
+ });
+
+ it('should have a correct style for each note badge', () => {
+ expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;');
+ expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
+ });
+
+ it('should recalculate badges positions on window resize', () => {
+ createComponent({
+ notes,
+ dimensions: {
+ width: 400,
+ height: 400,
+ },
+ });
+
+ expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;');
+
+ wrapper.setProps({
+ dimensions: {
+ width: 200,
+ height: 200,
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;');
+ });
+ });
+
+ it('should call an update active discussion mutation when clicking a note without moving it', () => {
+ const note = notes[0];
+ const { position } = note;
+ const mutationVariables = {
+ mutation: updateActiveDiscussion,
+ variables: {
+ id: note.id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
+ },
+ };
+
+ findFirstBadge().trigger('mousedown', { clientX: position.x, clientY: position.y });
+
+ return wrapper.vm.$nextTick().then(() => {
+ findFirstBadge().trigger('mouseup', { clientX: position.x, clientY: position.y });
+ expect(mutate).toHaveBeenCalledWith(mutationVariables);
+ });
+ });
+
+ it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => {
+ wrapper.setData({
+ activeDiscussion: {
+ id: notes[0].id,
+ source: 'discussion',
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findSecondBadge().classes()).toContain('inactive');
+ });
+ });
+ });
+
+ describe('when moving notes', () => {
+ it('should update badge style when note is being moved', () => {
+ createComponent({
+ notes,
+ });
+
+ const { position } = notes[0];
+
+ return clickAndDragBadge(
+ findFirstBadge(),
+ { x: position.x, y: position.y },
+ { x: 20, y: 20 },
+ ).then(() => {
+ expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;');
+ });
+ });
+
+ it('should emit `moveNote` event when note-moving action ends', () => {
+ createComponent({ notes });
+ const note = notes[0];
+ const { position } = note;
+ const newCoordinates = { x: 20, y: 20 };
+
+ wrapper.setData({
+ movingNoteNewPosition: {
+ ...position,
+ ...newCoordinates,
+ },
+ movingNoteStartPosition: {
+ noteId: notes[0].id,
+ discussionId: notes[0].discussion.id,
+ ...position,
+ },
+ });
+
+ const badge = findFirstBadge();
+ return clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates)
+ .then(() => {
+ badge.trigger('mouseup');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.emitted('moveNote')).toEqual([
+ [
+ {
+ noteId: notes[0].id,
+ discussionId: notes[0].discussion.id,
+ coordinates: newCoordinates,
+ },
+ ],
+ ]);
+ });
+ });
+
+ it('should do nothing if [adminNote] permission is not present', () => {
+ createComponent({
+ dimensions: mockDimensions,
+ notes: [mockNoteNotAuthorised],
+ });
+
+ const badge = findAllNotes().at(0);
+ return clickAndDragBadge(
+ badge,
+ { x: mockNoteNotAuthorised.x, y: mockNoteNotAuthorised.y },
+ { x: 20, y: 20 },
+ ).then(() => {
+ expect(wrapper.vm.movingNoteStartPosition).toBeNull();
+ expect(findFirstBadge().attributes().style).toBe('left: 1px; top: 80px;');
+ });
+ });
+ });
+
+ describe('with a new form', () => {
+ it('should render a new comment badge', () => {
+ createComponent({
+ currentCommentForm: {
+ ...notes[0].position,
+ },
+ });
+
+ expect(findCommentBadge().exists()).toBe(true);
+ expect(findCommentBadge().attributes().style).toBe('left: 10px; top: 15px;');
+ });
+
+ describe('when moving the comment badge', () => {
+ it('should update badge style to reflect new position', () => {
+ const { position } = notes[0];
+
+ createComponent({
+ currentCommentForm: {
+ ...position,
+ },
+ });
+
+ return clickAndDragBadge(
+ findCommentBadge(),
+ { x: position.x, y: position.y },
+ { x: 20, y: 20 },
+ ).then(() => {
+ expect(findCommentBadge().attributes().style).toBe(
+ 'left: 20px; top: 20px; cursor: move;',
+ );
+ });
+ });
+
+ it('should update badge style when note-moving action ends', () => {
+ const { position } = notes[0];
+ createComponent({
+ currentCommentForm: {
+ ...position,
+ },
+ });
+
+ const commentBadge = findCommentBadge();
+ const toPoint = { x: 20, y: 20 };
+
+ return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint)
+ .then(() => {
+ commentBadge.trigger('mouseup');
+ // simulates the currentCommentForm being updated in index.vue component, and
+ // propagated back down to this prop
+ wrapper.setProps({
+ currentCommentForm: { height: position.height, width: position.width, ...toPoint },
+ });
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;');
+ });
+ });
+
+ it.each`
+ element | getElementFunc | event
+ ${'overlay'} | ${findOverlay} | ${'mouseleave'}
+ ${'comment badge'} | ${findCommentBadge} | ${'mouseup'}
+ `(
+ 'should emit `openCommentForm` event when $event fired on $element element',
+ ({ getElementFunc, event }) => {
+ createComponent({
+ notes,
+ currentCommentForm: {
+ ...notes[0].position,
+ },
+ });
+
+ const newCoordinates = { x: 20, y: 20 };
+ wrapper.setData({
+ movingNoteStartPosition: {
+ ...notes[0].position,
+ },
+ movingNoteNewPosition: {
+ ...notes[0].position,
+ ...newCoordinates,
+ },
+ });
+
+ getElementFunc().trigger(event);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]);
+ });
+ },
+ );
+ });
+ });
+
+ describe('getMovingNotePositionDelta', () => {
+ it('should calculate delta correctly from state', () => {
+ createComponent();
+
+ wrapper.setData({
+ movingNoteStartPosition: {
+ clientX: 10,
+ clientY: 20,
+ },
+ });
+
+ const mockMouseEvent = {
+ clientX: 30,
+ clientY: 10,
+ };
+
+ expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({
+ deltaX: 20,
+ deltaY: -10,
+ });
+ });
+ });
+
+ describe('isPositionInOverlay', () => {
+ createComponent({ dimensions: mockDimensions });
+
+ it.each`
+ test | coordinates | expectedResult
+ ${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true}
+ ${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false}
+ `('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => {
+ const position = { ...mockDimensions, ...coordinates };
+
+ expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult);
+ });
+ });
+
+ describe('getNoteRelativePosition', () => {
+ it('calculates position correctly', () => {
+ createComponent({ dimensions: mockDimensions });
+ const position = { x: 50, y: 50, width: 200, height: 200 };
+
+ expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 });
+ });
+ });
+
+ describe('canMoveNote', () => {
+ it.each`
+ adminNotePermission | canMoveNoteResult
+ ${true} | ${true}
+ ${false} | ${false}
+ ${undefined} | ${false}
+ `(
+ 'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]',
+ ({ adminNotePermission, canMoveNoteResult }) => {
+ createComponent();
+
+ const note = {
+ userPermissions: {
+ adminNote: adminNotePermission,
+ },
+ };
+ expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js
new file mode 100644
index 00000000000..8a709393d92
--- /dev/null
+++ b/spec/frontend/design_management/components/design_presentation_spec.js
@@ -0,0 +1,546 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignPresentation from '~/design_management/components/design_presentation.vue';
+import DesignOverlay from '~/design_management/components/design_overlay.vue';
+
+const mockOverlayData = {
+ overlayDimensions: {
+ width: 100,
+ height: 100,
+ },
+ overlayPosition: {
+ top: '0',
+ left: '0',
+ },
+};
+
+describe('Design management design presentation component', () => {
+ let wrapper;
+
+ function createComponent(
+ { image, imageName, discussions = [], isAnnotating = false } = {},
+ data = {},
+ stubs = {},
+ ) {
+ wrapper = shallowMount(DesignPresentation, {
+ propsData: {
+ image,
+ imageName,
+ discussions,
+ isAnnotating,
+ },
+ stubs,
+ });
+
+ wrapper.setData(data);
+ wrapper.element.scrollTo = jest.fn();
+ }
+
+ const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment');
+
+ /**
+ * Spy on $refs and mock given values
+ * @param {Object} viewportDimensions {width, height}
+ * @param {Object} childDimensions {width, height}
+ * @param {Float} scrollTopPerc 0 < x < 1
+ * @param {Float} scrollLeftPerc 0 < x < 1
+ */
+ function mockRefDimensions(
+ ref,
+ viewportDimensions,
+ childDimensions,
+ scrollTopPerc,
+ scrollLeftPerc,
+ ) {
+ jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width);
+ jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height);
+ jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width);
+ jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height);
+ jest
+ .spyOn(ref, 'scrollLeft', 'get')
+ .mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc);
+ jest
+ .spyOn(ref, 'scrollTop', 'get')
+ .mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc);
+ }
+
+ function clickDragExplore(startCoords, endCoords, { useTouchEvents, mouseup } = {}) {
+ const event = useTouchEvents
+ ? {
+ mousedown: 'touchstart',
+ mousemove: 'touchmove',
+ mouseup: 'touchend',
+ }
+ : {
+ mousedown: 'mousedown',
+ mousemove: 'mousemove',
+ mouseup: 'mouseup',
+ };
+
+ const addCommentOverlay = findOverlayCommentButton();
+
+ // triggering mouse events on this element best simulates
+ // reality, as it is the lowest-level node that needs to
+ // respond to mouse events
+ addCommentOverlay.trigger(event.mousedown, {
+ clientX: startCoords.clientX,
+ clientY: startCoords.clientY,
+ });
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ addCommentOverlay.trigger(event.mousemove, {
+ clientX: endCoords.clientX,
+ clientY: endCoords.clientY,
+ });
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ if (mouseup) {
+ addCommentOverlay.trigger(event.mouseup);
+ return wrapper.vm.$nextTick();
+ }
+
+ return undefined;
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders image and overlay when image provided', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders empty state when no image provided', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('openCommentForm event emits correct data', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+
+ wrapper.vm.openCommentForm({ x: 1, y: 1 });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('openCommentForm')).toEqual([
+ [{ ...mockOverlayData.overlayDimensions, x: 1, y: 1 }],
+ ]);
+ });
+ });
+
+ describe('currentCommentForm', () => {
+ it('is null when isAnnotating is false', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.currentCommentForm).toBeNull();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('is null when isAnnotating is true but annotation position is falsey', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ isAnnotating: true,
+ },
+ mockOverlayData,
+ );
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.currentCommentForm).toBeNull();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('is equal to current annotation position when isAnnotating is true', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ isAnnotating: true,
+ },
+ {
+ ...mockOverlayData,
+ currentAnnotationPosition: {
+ x: 1,
+ y: 1,
+ width: 100,
+ height: 100,
+ },
+ },
+ );
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.currentCommentForm).toEqual({
+ x: 1,
+ y: 1,
+ width: 100,
+ height: 100,
+ });
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('setOverlayPosition', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('sets overlay position correctly when overlay is smaller than viewport', () => {
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
+
+ wrapper.vm.setOverlayPosition();
+ expect(wrapper.vm.overlayPosition).toEqual({
+ left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
+ top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
+ });
+ });
+
+ it('sets overlay position correctly when overlay width is larger than viewports', () => {
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50);
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
+
+ wrapper.vm.setOverlayPosition();
+ expect(wrapper.vm.overlayPosition).toEqual({
+ left: '0',
+ top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
+ });
+ });
+
+ it('sets overlay position correctly when overlay height is larger than viewports', () => {
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50);
+
+ wrapper.vm.setOverlayPosition();
+ expect(wrapper.vm.overlayPosition).toEqual({
+ left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
+ top: '0',
+ });
+ });
+ });
+
+ describe('getViewportCenter', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+ });
+
+ it('calculate center correctly with no scroll', () => {
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 10, height: 10 },
+ { width: 20, height: 20 },
+ 0,
+ 0,
+ );
+
+ expect(wrapper.vm.getViewportCenter()).toEqual({
+ x: 5,
+ y: 5,
+ });
+ });
+
+ it('calculate center correctly with some scroll', () => {
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 10, height: 10 },
+ { width: 20, height: 20 },
+ 0.5,
+ 0.5,
+ );
+
+ expect(wrapper.vm.getViewportCenter()).toEqual({
+ x: 10,
+ y: 10,
+ });
+ });
+
+ it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => {
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 20, height: 20 },
+ { width: 20, height: 20 },
+ 0.5,
+ 0.5,
+ );
+
+ expect(wrapper.vm.getViewportCenter()).toEqual({
+ x: 10,
+ y: 10,
+ });
+ });
+ });
+
+ describe('scaleZoomFocalPoint', () => {
+ it('scales focal point correctly when zooming in', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ {
+ ...mockOverlayData,
+ zoomFocalPoint: {
+ x: 5,
+ y: 5,
+ width: 50,
+ height: 50,
+ },
+ },
+ );
+
+ wrapper.vm.scaleZoomFocalPoint();
+ expect(wrapper.vm.zoomFocalPoint).toEqual({
+ x: 10,
+ y: 10,
+ width: 100,
+ height: 100,
+ });
+ });
+
+ it('scales focal point correctly when zooming out', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ {
+ ...mockOverlayData,
+ zoomFocalPoint: {
+ x: 10,
+ y: 10,
+ width: 200,
+ height: 200,
+ },
+ },
+ );
+
+ wrapper.vm.scaleZoomFocalPoint();
+ expect(wrapper.vm.zoomFocalPoint).toEqual({
+ x: 5,
+ y: 5,
+ width: 100,
+ height: 100,
+ });
+ });
+ });
+
+ describe('onImageResize', () => {
+ it('sets zoom focal point on initial load', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+
+ wrapper.setMethods({
+ shiftZoomFocalPoint: jest.fn(),
+ scaleZoomFocalPoint: jest.fn(),
+ scrollToFocalPoint: jest.fn(),
+ });
+
+ wrapper.vm.onImageResize({ width: 10, height: 10 });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled();
+ expect(wrapper.vm.initialLoad).toBe(false);
+ });
+ });
+
+ it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => {
+ wrapper.vm.onImageResize({ width: 10, height: 10 });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled();
+ expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('onPresentationMousedown', () => {
+ it.each`
+ scenario | width | height
+ ${'width overflows'} | ${101} | ${100}
+ ${'height overflows'} | ${100} | ${101}
+ ${'width and height overflows'} | ${200} | ${200}
+ `('sets lastDragPosition when design $scenario', ({ width, height }) => {
+ createComponent();
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 100, height: 100 },
+ { width, height },
+ );
+
+ const newLastDragPosition = { x: 2, y: 2 };
+ wrapper.vm.onPresentationMousedown({
+ clientX: newLastDragPosition.x,
+ clientY: newLastDragPosition.y,
+ });
+
+ expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition);
+ });
+
+ it('does not set lastDragPosition if design does not overflow', () => {
+ const lastDragPosition = { x: 1, y: 1 };
+
+ createComponent({}, { lastDragPosition });
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 100, height: 100 },
+ { width: 50, height: 50 },
+ );
+
+ wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 });
+
+ // check lastDragPosition is unchanged
+ expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition);
+ });
+ });
+
+ describe('getAnnotationPositon', () => {
+ it.each`
+ coordinates | overlayDimensions | position
+ ${{ x: 100, y: 100 }} | ${{ width: 50, height: 50 }} | ${{ x: 100, y: 100, width: 50, height: 50 }}
+ ${{ x: 100.2, y: 100.5 }} | ${{ width: 50.6, height: 50.0 }} | ${{ x: 100, y: 101, width: 51, height: 50 }}
+ `('returns correct annotation position', ({ coordinates, overlayDimensions, position }) => {
+ createComponent(undefined, {
+ overlayDimensions: {
+ width: overlayDimensions.width,
+ height: overlayDimensions.height,
+ },
+ });
+
+ expect(wrapper.vm.getAnnotationPositon(coordinates)).toStrictEqual(position);
+ });
+ });
+
+ describe('when design is overflowing', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ {
+ 'design-overlay': DesignOverlay,
+ },
+ );
+
+ // mock a design that overflows
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 10, height: 10 },
+ { width: 20, height: 20 },
+ 0,
+ 0,
+ );
+ });
+
+ it('opens a comment form if design was not dragged', () => {
+ const addCommentOverlay = findOverlayCommentButton();
+ const startCoords = {
+ clientX: 1,
+ clientY: 1,
+ };
+
+ addCommentOverlay.trigger('mousedown', {
+ clientX: startCoords.clientX,
+ clientY: startCoords.clientY,
+ });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ addCommentOverlay.trigger('mouseup');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.emitted('openCommentForm')).toBeDefined();
+ });
+ });
+
+ describe('when clicking and dragging', () => {
+ it.each`
+ description | useTouchEvents
+ ${'with touch events'} | ${true}
+ ${'without touch events'} | ${false}
+ `('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => {
+ return clickDragExplore(
+ { clientX: 0, clientY: 0 },
+ { clientX: 10, clientY: 10 },
+ { useTouchEvents },
+ ).then(() => {
+ expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1);
+ expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10);
+ });
+ });
+
+ it('does not open a comment form when drag position exceeds buffer', () => {
+ return clickDragExplore(
+ { clientX: 0, clientY: 0 },
+ { clientX: 10, clientY: 10 },
+ { mouseup: true },
+ ).then(() => {
+ expect(wrapper.emitted('openCommentForm')).toBeFalsy();
+ });
+ });
+
+ it('opens a comment form when drag position is within buffer', () => {
+ return clickDragExplore(
+ { clientX: 0, clientY: 0 },
+ { clientX: 1, clientY: 0 },
+ { mouseup: true },
+ ).then(() => {
+ expect(wrapper.emitted('openCommentForm')).toBeDefined();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js
new file mode 100644
index 00000000000..b06d2f924df
--- /dev/null
+++ b/spec/frontend/design_management/components/design_scaler_spec.js
@@ -0,0 +1,67 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignScaler from '~/design_management/components/design_scaler.vue';
+
+describe('Design management design scaler component', () => {
+ let wrapper;
+
+ function createComponent(propsData, data = {}) {
+ wrapper = shallowMount(DesignScaler, {
+ propsData,
+ });
+ wrapper.setData(data);
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const getButton = type => {
+ const buttonTypeOrder = ['minus', 'reset', 'plus'];
+ const buttons = wrapper.findAll('button');
+ return buttons.at(buttonTypeOrder.indexOf(type));
+ };
+
+ it('emits @scale event when "plus" button clicked', () => {
+ createComponent();
+
+ getButton('plus').trigger('click');
+ expect(wrapper.emitted('scale')).toEqual([[1.2]]);
+ });
+
+ it('emits @scale event when "reset" button clicked (scale > 1)', () => {
+ createComponent({}, { scale: 1.6 });
+ return wrapper.vm.$nextTick().then(() => {
+ getButton('reset').trigger('click');
+ expect(wrapper.emitted('scale')).toEqual([[1]]);
+ });
+ });
+
+ it('emits @scale event when "minus" button clicked (scale > 1)', () => {
+ createComponent({}, { scale: 1.6 });
+
+ return wrapper.vm.$nextTick().then(() => {
+ getButton('minus').trigger('click');
+ expect(wrapper.emitted('scale')).toEqual([[1.4]]);
+ });
+ });
+
+ it('minus and reset buttons are disabled when scale === 1', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('minus and reset buttons are enabled when scale > 1', () => {
+ createComponent({}, { scale: 1.2 });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('plus button is disabled when scale === 2', () => {
+ createComponent({}, { scale: 2 });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js
new file mode 100644
index 00000000000..52d60b04a8a
--- /dev/null
+++ b/spec/frontend/design_management/components/image_spec.js
@@ -0,0 +1,133 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import DesignImage from '~/design_management/components/image.vue';
+
+describe('Design management large image component', () => {
+ let wrapper;
+
+ function createComponent(propsData, data = {}) {
+ wrapper = shallowMount(DesignImage, {
+ propsData,
+ });
+ wrapper.setData(data);
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders loading state', () => {
+ createComponent({
+ isLoading: true,
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders image', () => {
+ createComponent({
+ isLoading: false,
+ image: 'test.jpg',
+ name: 'test',
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('sets correct classes and styles if imageStyle is set', () => {
+ createComponent(
+ {
+ isLoading: false,
+ image: 'test.jpg',
+ name: 'test',
+ },
+ {
+ imageStyle: {
+ width: '100px',
+ height: '100px',
+ },
+ },
+ );
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders media broken icon on error', () => {
+ createComponent({
+ isLoading: false,
+ image: 'test.jpg',
+ name: 'test',
+ });
+
+ const image = wrapper.find('img');
+ image.trigger('error');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(image.isVisible()).toBe(false);
+ expect(wrapper.find(GlIcon).element).toMatchSnapshot();
+ });
+ });
+
+ describe('zoom', () => {
+ const baseImageWidth = 100;
+ const baseImageHeight = 100;
+
+ beforeEach(() => {
+ createComponent(
+ {
+ isLoading: false,
+ image: 'test.jpg',
+ name: 'test',
+ },
+ {
+ imageStyle: {
+ width: `${baseImageWidth}px`,
+ height: `${baseImageHeight}px`,
+ },
+ baseImageSize: {
+ width: baseImageWidth,
+ height: baseImageHeight,
+ },
+ },
+ );
+
+ jest.spyOn(wrapper.vm.$refs.contentImg, 'offsetWidth', 'get').mockReturnValue(baseImageWidth);
+ jest
+ .spyOn(wrapper.vm.$refs.contentImg, 'offsetHeight', 'get')
+ .mockReturnValue(baseImageHeight);
+ });
+
+ it('emits @resize event on zoom', () => {
+ const zoomAmount = 2;
+ wrapper.vm.zoom(zoomAmount);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('resize')).toEqual([
+ [{ width: baseImageWidth * zoomAmount, height: baseImageHeight * zoomAmount }],
+ ]);
+ });
+ });
+
+ it('emits @resize event with base image size when scale=1', () => {
+ wrapper.vm.zoom(1);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('resize')).toEqual([
+ [{ width: baseImageWidth, height: baseImageHeight }],
+ ]);
+ });
+ });
+
+ it('sets image style when zoomed', () => {
+ const zoomAmount = 2;
+ wrapper.vm.zoom(zoomAmount);
+ expect(wrapper.vm.imageStyle).toEqual({
+ width: `${baseImageWidth * zoomAmount}px`,
+ height: `${baseImageHeight * zoomAmount}px`,
+ });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
new file mode 100644
index 00000000000..9cd427f6aae
--- /dev/null
+++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
@@ -0,0 +1,472 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management list item component when item appears in view after image is loaded renders media broken icon when image onerror triggered 1`] = `
+<gl-icon-stub
+ class="text-secondary"
+ name="media-broken"
+ size="32"
+/>
+`;
+
+exports[`Design management list item component with no notes renders item with correct status icon for creation event 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Added in this version"
+ title="Added in this version"
+ >
+ <icon-stub
+ class="text-success-500"
+ name="file-addition-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with correct status icon for deletion event 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Deleted in this version"
+ title="Deleted in this version"
+ >
+ <icon-stub
+ class="text-danger-500"
+ name="file-deletion-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with correct status icon for modification event 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Modified in this version"
+ title="Modified in this version"
+ >
+ <icon-stub
+ class="text-primary-500"
+ name="file-modified-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with no status icon for none event 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <!---->
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders loading spinner when isUploading is true 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <!---->
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <gl-loading-icon-stub
+ color="orange"
+ label="Loading"
+ size="md"
+ />
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ style="display: none;"
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with notes renders item with multiple comments 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <!---->
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <div
+ class="ml-auto d-flex align-items-center text-secondary"
+ >
+ <icon-stub
+ class="ml-1"
+ name="comments"
+ size="16"
+ />
+
+ <span
+ aria-label="2 comments"
+ class="ml-1"
+ >
+
+ 2
+
+ </span>
+ </div>
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with notes renders item with single comment 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <!---->
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <div
+ class="ml-auto d-flex align-items-center text-secondary"
+ >
+ <icon-stub
+ class="ml-1"
+ name="comments"
+ size="16"
+ />
+
+ <span
+ aria-label="1 comment"
+ class="ml-1"
+ >
+
+ 1
+
+ </span>
+ </div>
+ </div>
+</router-link-stub>
+`;
diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js
new file mode 100644
index 00000000000..705b532454f
--- /dev/null
+++ b/spec/frontend/design_management/components/list/item_spec.js
@@ -0,0 +1,168 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
+import VueRouter from 'vue-router';
+import Item from '~/design_management/components/list/item.vue';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+const router = new VueRouter();
+
+// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent
+const DESIGN_VERSION_EVENT = {
+ CREATION: 'CREATION',
+ DELETION: 'DELETION',
+ MODIFICATION: 'MODIFICATION',
+ NO_CHANGE: 'NONE',
+};
+
+describe('Design management list item component', () => {
+ let wrapper;
+
+ function createComponent({
+ notesCount = 0,
+ event = DESIGN_VERSION_EVENT.NO_CHANGE,
+ isUploading = false,
+ imageLoading = false,
+ } = {}) {
+ wrapper = shallowMount(Item, {
+ localVue,
+ router,
+ propsData: {
+ id: 1,
+ filename: 'test',
+ image: 'http://via.placeholder.com/300',
+ isUploading,
+ event,
+ notesCount,
+ updatedAt: '01-01-2019',
+ },
+ data() {
+ return {
+ imageLoading,
+ };
+ },
+ stubs: ['router-link'],
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when item is not in view', () => {
+ it('image is not rendered', () => {
+ createComponent();
+
+ const image = wrapper.find('img');
+ expect(image.attributes('src')).toBe('');
+ });
+ });
+
+ describe('when item appears in view', () => {
+ let image;
+ let glIntersectionObserver;
+
+ beforeEach(() => {
+ createComponent();
+ image = wrapper.find('img');
+ glIntersectionObserver = wrapper.find(GlIntersectionObserver);
+
+ glIntersectionObserver.vm.$emit('appear');
+ return wrapper.vm.$nextTick();
+ });
+
+ describe('before image is loaded', () => {
+ it('renders loading spinner', () => {
+ expect(wrapper.find(GlLoadingIcon)).toExist();
+ });
+ });
+
+ describe('after image is loaded', () => {
+ beforeEach(() => {
+ image.trigger('load');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders an image', () => {
+ expect(image.attributes('src')).toBe('http://via.placeholder.com/300');
+ expect(image.isVisible()).toBe(true);
+ });
+
+ it('renders media broken icon when image onerror triggered', () => {
+ image.trigger('error');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(image.isVisible()).toBe(false);
+ expect(wrapper.find(GlIcon).element).toMatchSnapshot();
+ });
+ });
+
+ describe('when imageV432x230 and image provided', () => {
+ it('renders imageV432x230 image', () => {
+ const mockSrc = 'mock-imageV432x230-url';
+ wrapper.setProps({ imageV432x230: mockSrc });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(image.attributes('src')).toBe(mockSrc);
+ });
+ });
+ });
+
+ describe('when image disappears from view and then reappears', () => {
+ beforeEach(() => {
+ glIntersectionObserver.vm.$emit('appear');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders an image', () => {
+ expect(image.isVisible()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('with notes', () => {
+ it('renders item with single comment', () => {
+ createComponent({ notesCount: 1 });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with multiple comments', () => {
+ createComponent({ notesCount: 2 });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('with no notes', () => {
+ it('renders item with no status icon for none event', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for modification event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for deletion event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.DELETION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for creation event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.CREATION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders loading spinner when isUploading is true', () => {
+ createComponent({ isUploading: true });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
new file mode 100644
index 00000000000..e55cff8de3d
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management toolbar component renders design and updated data 1`] = `
+<header
+ class="d-flex p-2 bg-white align-items-center js-design-header"
+>
+ <a
+ aria-label="Go back to designs"
+ class="mr-3 text-plain d-flex justify-content-center align-items-center"
+ >
+ <icon-stub
+ name="close"
+ size="18"
+ />
+ </a>
+
+ <div
+ class="overflow-hidden d-flex align-items-center"
+ >
+ <h2
+ class="m-0 str-truncated-100 gl-font-base"
+ >
+ test.jpg
+ </h2>
+
+ <small
+ class="text-secondary"
+ >
+ Updated 1 hour ago by Test Name
+ </small>
+ </div>
+
+ <pagination-stub
+ class="ml-auto flex-shrink-0"
+ id="1"
+ />
+
+ <gl-deprecated-button-stub
+ class="mr-2"
+ href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d"
+ size="md"
+ variant="secondary"
+ >
+ <icon-stub
+ name="download"
+ size="18"
+ />
+ </gl-deprecated-button-stub>
+
+ <delete-button-stub
+ buttonclass=""
+ buttonvariant="danger"
+ hasselecteddesigns="true"
+ >
+ <icon-stub
+ name="remove"
+ size="18"
+ />
+ </delete-button-stub>
+</header>
+`;
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap
new file mode 100644
index 00000000000..08662a04f15
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap
@@ -0,0 +1,28 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management pagination button component disables button when no design is passed 1`] = `
+<router-link-stub
+ aria-label="Test title"
+ class="btn btn-default disabled"
+ disabled="true"
+ to="[object Object]"
+>
+ <icon-stub
+ name="angle-right"
+ size="16"
+ />
+</router-link-stub>
+`;
+
+exports[`Design management pagination button component renders router-link 1`] = `
+<router-link-stub
+ aria-label="Test title"
+ class="btn btn-default"
+ to="[object Object]"
+>
+ <icon-stub
+ name="angle-right"
+ size="16"
+ />
+</router-link-stub>
+`;
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap
new file mode 100644
index 00000000000..0197b4bff79
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management pagination component hides components when designs are empty 1`] = `<!---->`;
+
+exports[`Design management pagination component renders pagination buttons 1`] = `
+<div
+ class="d-flex align-items-center"
+>
+
+ 0 of 2
+
+ <div
+ class="btn-group ml-3 mr-3"
+ >
+ <pagination-button-stub
+ class="js-previous-design"
+ iconname="angle-left"
+ title="Go to previous design"
+ />
+
+ <pagination-button-stub
+ class="js-next-design"
+ design="[object Object]"
+ iconname="angle-right"
+ title="Go to next design"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js
new file mode 100644
index 00000000000..2910b2f62ba
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/index_spec.js
@@ -0,0 +1,123 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import Toolbar from '~/design_management/components/toolbar/index.vue';
+import DeleteButton from '~/design_management/components/delete_button.vue';
+import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+import { GlDeprecatedButton } from '@gitlab/ui';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+const router = new VueRouter();
+
+const RouterLinkStub = {
+ props: {
+ to: {
+ type: Object,
+ },
+ },
+ render(createElement) {
+ return createElement('a', {}, this.$slots.default);
+ },
+};
+
+describe('Design management toolbar component', () => {
+ let wrapper;
+
+ function createComponent(isLoading = false, createDesign = true, props) {
+ const updatedAt = new Date();
+ updatedAt.setHours(updatedAt.getHours() - 1);
+
+ wrapper = shallowMount(Toolbar, {
+ localVue,
+ router,
+ propsData: {
+ id: '1',
+ isLatestVersion: true,
+ isLoading,
+ isDeleting: false,
+ filename: 'test.jpg',
+ updatedAt: updatedAt.toString(),
+ updatedBy: {
+ name: 'Test Name',
+ },
+ image: '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
+ ...props,
+ },
+ stubs: {
+ 'router-link': RouterLinkStub,
+ },
+ });
+
+ wrapper.setData({
+ permissions: {
+ createDesign,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders design and updated data', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('links back to designs list', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ const link = wrapper.find('a');
+
+ expect(link.props('to')).toEqual({
+ name: DESIGNS_ROUTE_NAME,
+ query: {
+ version: undefined,
+ },
+ });
+ });
+ });
+
+ it('renders delete button on latest designs version with logged in user', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DeleteButton).exists()).toBe(true);
+ });
+ });
+
+ it('does not render delete button on non-latest version', () => {
+ createComponent(false, true, { isLatestVersion: false });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DeleteButton).exists()).toBe(false);
+ });
+ });
+
+ it('does not render delete button when user is not logged in', () => {
+ createComponent(false, false);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DeleteButton).exists()).toBe(false);
+ });
+ });
+
+ it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns');
+ expect(wrapper.emitted().delete).toBeTruthy();
+ });
+ });
+
+ it('renders download button with correct link', () => {
+ expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(
+ '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
+ );
+ });
+});
diff --git a/spec/frontend/design_management/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js
new file mode 100644
index 00000000000..b7df201795b
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js
@@ -0,0 +1,61 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import PaginationButton from '~/design_management/components/toolbar/pagination_button.vue';
+import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+const router = new VueRouter();
+
+describe('Design management pagination button component', () => {
+ let wrapper;
+
+ function createComponent(design = null) {
+ wrapper = shallowMount(PaginationButton, {
+ localVue,
+ router,
+ propsData: {
+ design,
+ title: 'Test title',
+ iconName: 'angle-right',
+ },
+ stubs: ['router-link'],
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('disables button when no design is passed', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders router-link', () => {
+ createComponent({ id: '2' });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('designLink', () => {
+ it('returns empty link when design is null', () => {
+ createComponent();
+
+ expect(wrapper.vm.designLink).toEqual({});
+ });
+
+ it('returns design link', () => {
+ createComponent({ id: '2', filename: 'test' });
+
+ wrapper.vm.$router.replace('/root/test-project/issues/1/designs/test?version=1');
+
+ expect(wrapper.vm.designLink).toEqual({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: 'test' },
+ query: { version: '1' },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/toolbar/pagination_spec.js b/spec/frontend/design_management/components/toolbar/pagination_spec.js
new file mode 100644
index 00000000000..db5a36dadf6
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/pagination_spec.js
@@ -0,0 +1,79 @@
+/* global Mousetrap */
+import 'mousetrap';
+import { shallowMount } from '@vue/test-utils';
+import Pagination from '~/design_management/components/toolbar/pagination.vue';
+import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants';
+
+const push = jest.fn();
+const $router = {
+ push,
+};
+
+const $route = {
+ path: '/designs/design-2',
+ query: {},
+};
+
+describe('Design management pagination component', () => {
+ let wrapper;
+
+ function createComponent() {
+ wrapper = shallowMount(Pagination, {
+ propsData: {
+ id: '2',
+ },
+ mocks: {
+ $router,
+ $route,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('hides components when designs are empty', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders pagination buttons', () => {
+ wrapper.setData({
+ designs: [{ id: '1' }, { id: '2' }],
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('keyboard buttons navigation', () => {
+ beforeEach(() => {
+ wrapper.setData({
+ designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }],
+ });
+ });
+
+ it('routes to previous design on Left button', () => {
+ Mousetrap.trigger('left');
+ expect(push).toHaveBeenCalledWith({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: '1' },
+ query: {},
+ });
+ });
+
+ it('routes to next design on Right button', () => {
+ Mousetrap.trigger('right');
+ expect(push).toHaveBeenCalledWith({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: '3' },
+ query: {},
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
new file mode 100644
index 00000000000..185bf4a48f7
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
@@ -0,0 +1,79 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management upload button component renders inverted upload design button 1`] = `
+<div
+ isinverted="true"
+>
+ <gl-deprecated-button-stub
+ size="md"
+ title="Adding a design with the same filename replaces the file in a new version."
+ variant="success"
+ >
+
+ Add designs
+
+ <!---->
+ </gl-deprecated-button-stub>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+</div>
+`;
+
+exports[`Design management upload button component renders loading icon 1`] = `
+<div>
+ <gl-deprecated-button-stub
+ disabled="true"
+ size="md"
+ title="Adding a design with the same filename replaces the file in a new version."
+ variant="success"
+ >
+
+ Add designs
+
+ <gl-loading-icon-stub
+ class="ml-1"
+ color="orange"
+ inline="true"
+ label="Loading"
+ size="sm"
+ />
+ </gl-deprecated-button-stub>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+</div>
+`;
+
+exports[`Design management upload button component renders upload design button 1`] = `
+<div>
+ <gl-deprecated-button-stub
+ size="md"
+ title="Adding a design with the same filename replaces the file in a new version."
+ variant="success"
+ >
+
+ Add designs
+
+ <!---->
+ </gl-deprecated-button-stub>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+</div>
+`;
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap
new file mode 100644
index 00000000000..0737b9729a2
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap
@@ -0,0 +1,455 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style=""
+ >
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style=""
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style=""
+ >
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style=""
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style=""
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style=""
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style="display: none;"
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style="display: none;"
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <div>
+ dropzone slot
+ </div>
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style="display: none;"
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
new file mode 100644
index 00000000000..00f1a40dfb2
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
@@ -0,0 +1,111 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management design version dropdown component renders design version dropdown button 1`] = `
+<gl-dropdown-stub
+ class="design-version-dropdown"
+ issueiid=""
+ projectpath=""
+ text="Showing Latest Version"
+ variant="link"
+>
+ <gl-dropdown-item-stub>
+ <router-link-stub
+ class="d-flex js-version-link"
+ to="[object Object]"
+ >
+ <div
+ class="flex-grow-1 ml-2"
+ >
+ <div>
+ <strong>
+ Version 2
+
+ <span>
+ (latest)
+ </span>
+ </strong>
+ </div>
+ </div>
+
+ <i
+ class="fa fa-check pull-right"
+ />
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+ <gl-dropdown-item-stub>
+ <router-link-stub
+ class="d-flex js-version-link"
+ to="[object Object]"
+ >
+ <div
+ class="flex-grow-1 ml-2"
+ >
+ <div>
+ <strong>
+ Version 1
+
+ <!---->
+ </strong>
+ </div>
+ </div>
+
+ <!---->
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+</gl-dropdown-stub>
+`;
+
+exports[`Design management design version dropdown component renders design version list 1`] = `
+<gl-dropdown-stub
+ class="design-version-dropdown"
+ issueiid=""
+ projectpath=""
+ text="Showing Latest Version"
+ variant="link"
+>
+ <gl-dropdown-item-stub>
+ <router-link-stub
+ class="d-flex js-version-link"
+ to="[object Object]"
+ >
+ <div
+ class="flex-grow-1 ml-2"
+ >
+ <div>
+ <strong>
+ Version 2
+
+ <span>
+ (latest)
+ </span>
+ </strong>
+ </div>
+ </div>
+
+ <i
+ class="fa fa-check pull-right"
+ />
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+ <gl-dropdown-item-stub>
+ <router-link-stub
+ class="d-flex js-version-link"
+ to="[object Object]"
+ >
+ <div
+ class="flex-grow-1 ml-2"
+ >
+ <div>
+ <strong>
+ Version 1
+
+ <!---->
+ </strong>
+ </div>
+ </div>
+
+ <!---->
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+</gl-dropdown-stub>
+`;
diff --git a/spec/frontend/design_management/components/upload/button_spec.js b/spec/frontend/design_management/components/upload/button_spec.js
new file mode 100644
index 00000000000..c0a9693dc37
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/button_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import UploadButton from '~/design_management/components/upload/button.vue';
+
+describe('Design management upload button component', () => {
+ let wrapper;
+
+ function createComponent(isSaving = false, isInverted = false) {
+ wrapper = shallowMount(UploadButton, {
+ propsData: {
+ isSaving,
+ isInverted,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders upload design button', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders inverted upload design button', () => {
+ createComponent(false, true);
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders loading icon', () => {
+ createComponent(true);
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('onFileUploadChange', () => {
+ it('emits upload event', () => {
+ createComponent();
+
+ wrapper.vm.onFileUploadChange({ target: { files: 'test' } });
+
+ expect(wrapper.emitted().upload[0]).toEqual(['test']);
+ });
+ });
+
+ describe('openFileUpload', () => {
+ it('triggers click on input', () => {
+ createComponent();
+
+ const clickSpy = jest.spyOn(wrapper.find('input').element, 'click');
+
+ wrapper.vm.openFileUpload();
+
+ expect(clickSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/design_dropzone_spec.js b/spec/frontend/design_management/components/upload/design_dropzone_spec.js
new file mode 100644
index 00000000000..9b86b5b2878
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/design_dropzone_spec.js
@@ -0,0 +1,132 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
+
+describe('Design management dropzone component', () => {
+ let wrapper;
+
+ const mockDragEvent = ({ types = ['Files'], files = [] }) => {
+ return { dataTransfer: { types, files } };
+ };
+
+ const findDropzoneCard = () => wrapper.find('.design-dropzone-card');
+
+ function createComponent({ slots = {}, data = {} } = {}) {
+ wrapper = shallowMount(DesignDropzone, {
+ slots,
+ data() {
+ return data;
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when slot provided', () => {
+ it('renders dropzone with slot content', () => {
+ createComponent({
+ slots: {
+ default: ['<div>dropzone slot</div>'],
+ },
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('when no slot provided', () => {
+ it('renders default dropzone card', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('triggers click event on file input element when clicked', () => {
+ createComponent();
+ const clickSpy = jest.spyOn(wrapper.find('input').element, 'click');
+
+ findDropzoneCard().trigger('click');
+ expect(clickSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('when dragging', () => {
+ it.each`
+ description | eventPayload
+ ${'is empty'} | ${{}}
+ ${'contains text'} | ${mockDragEvent({ types: ['text'] })}
+ ${'contains files and text'} | ${mockDragEvent({ types: ['Files', 'text'] })}
+ ${'contains files'} | ${mockDragEvent({ types: ['Files'] })}
+ `('renders correct template when drag event $description', ({ eventPayload }) => {
+ createComponent();
+
+ wrapper.trigger('dragenter', eventPayload);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders correct template when dragging stops', () => {
+ createComponent();
+
+ wrapper.trigger('dragenter');
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ wrapper.trigger('dragleave');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('when dropping', () => {
+ it('emits upload event', () => {
+ createComponent();
+ const mockFile = { name: 'test', type: 'image/jpg' };
+ const mockEvent = mockDragEvent({ files: [mockFile] });
+
+ wrapper.trigger('dragenter', mockEvent);
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ wrapper.trigger('drop', mockEvent);
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
+ });
+ });
+ });
+
+ describe('ondrop', () => {
+ const mockData = { dragCounter: 1, isDragDataValid: true };
+
+ describe('when drag data is valid', () => {
+ it('emits upload event for valid files', () => {
+ createComponent({ data: mockData });
+
+ const mockFile = { type: 'image/jpg' };
+ const mockEvent = mockDragEvent({ files: [mockFile] });
+
+ wrapper.vm.ondrop(mockEvent);
+ expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
+ });
+
+ it('calls createFlash when files are invalid', () => {
+ createComponent({ data: mockData });
+
+ const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] });
+
+ wrapper.vm.ondrop(mockEvent);
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
new file mode 100644
index 00000000000..7521b9fad2a
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
@@ -0,0 +1,114 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import mockAllVersions from './mock_data/all_versions';
+
+const LATEST_VERSION_ID = 3;
+const PREVIOUS_VERSION_ID = 2;
+
+const designRouteFactory = versionId => ({
+ path: `/designs?version=${versionId}`,
+ query: {
+ version: `${versionId}`,
+ },
+});
+
+const MOCK_ROUTE = {
+ path: '/designs',
+ query: {},
+};
+
+describe('Design management design version dropdown component', () => {
+ let wrapper;
+
+ function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) {
+ wrapper = shallowMount(DesignVersionDropdown, {
+ propsData: {
+ projectPath: '',
+ issueIid: '',
+ },
+ mocks: {
+ $route,
+ },
+ stubs: ['router-link'],
+ });
+
+ wrapper.setData({
+ allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions,
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findVersionLink = index => wrapper.findAll('.js-version-link').at(index);
+
+ it('renders design version dropdown button', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders design version list', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('selected version name', () => {
+ it('has "latest" on most recent version item', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findVersionLink(0).text()).toContain('latest');
+ });
+ });
+ });
+
+ describe('versions list', () => {
+ it('displays latest version text by default', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
+ });
+ });
+
+ it('displays latest version text when only 1 version is present', () => {
+ createComponent({ maxVersions: 1 });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
+ });
+ });
+
+ it('displays version text when the current version is not the latest', () => {
+ createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe(`Showing Version #1`);
+ });
+ });
+
+ it('displays latest version text when the current version is the latest', () => {
+ createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
+ });
+ });
+
+ it('should have the same length as apollo query', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
new file mode 100644
index 00000000000..e76bbd261bd
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
@@ -0,0 +1,14 @@
+export default [
+ {
+ node: {
+ id: 'gid://gitlab/DesignManagement::Version/3',
+ sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55',
+ },
+ },
+ {
+ node: {
+ id: 'gid://gitlab/DesignManagement::Version/2',
+ sha: '5b063fef0cd7213b312db65b30e24f057df21b20',
+ },
+ },
+];
diff --git a/spec/frontend/design_management/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js
new file mode 100644
index 00000000000..c389fdb8747
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/all_versions.js
@@ -0,0 +1,8 @@
+export default [
+ {
+ node: {
+ id: 'gid://gitlab/DesignManagement::Version/1',
+ sha: 'b389071a06c153509e11da1f582005b316667001',
+ },
+ },
+];
diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js
new file mode 100644
index 00000000000..34e3077f4a2
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/design.js
@@ -0,0 +1,54 @@
+export default {
+ id: 'design-id',
+ filename: 'test.jpg',
+ fullPath: 'full-design-path',
+ image: 'test.jpg',
+ updatedAt: '01-01-2019',
+ updatedBy: {
+ name: 'test',
+ },
+ issue: {
+ title: 'My precious issue',
+ webPath: 'full-issue-path',
+ webUrl: 'full-issue-url',
+ participants: {
+ edges: [
+ {
+ node: {
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'link-to-author',
+ avatarUrl: 'link-to-avatar',
+ },
+ },
+ ],
+ },
+ },
+ discussions: {
+ nodes: [
+ {
+ id: 'discussion-id',
+ replyId: 'discussion-reply-id',
+ notes: {
+ nodes: [
+ {
+ id: 'note-id',
+ body: '123',
+ author: {
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'link-to-author',
+ avatarUrl: 'link-to-avatar',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ diffRefs: {
+ headSha: 'headSha',
+ baseSha: 'baseSha',
+ startSha: 'startSha',
+ },
+};
diff --git a/spec/frontend/design_management/mock_data/designs.js b/spec/frontend/design_management/mock_data/designs.js
new file mode 100644
index 00000000000..07f5c1b7457
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/designs.js
@@ -0,0 +1,17 @@
+import design from './design';
+
+export default {
+ project: {
+ issue: {
+ designCollection: {
+ designs: {
+ edges: [
+ {
+ node: design,
+ },
+ ],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/design_management/mock_data/no_designs.js b/spec/frontend/design_management/mock_data/no_designs.js
new file mode 100644
index 00000000000..9db0ffcade2
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/no_designs.js
@@ -0,0 +1,11 @@
+export default {
+ project: {
+ issue: {
+ designCollection: {
+ designs: {
+ edges: [],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js
new file mode 100644
index 00000000000..db4624c8524
--- /dev/null
+++ b/spec/frontend/design_management/mock_data/notes.js
@@ -0,0 +1,32 @@
+export default [
+ {
+ id: 'note-id-1',
+ position: {
+ height: 100,
+ width: 100,
+ x: 10,
+ y: 15,
+ },
+ userPermissions: {
+ adminNote: true,
+ },
+ discussion: {
+ id: 'discussion-id-1',
+ },
+ },
+ {
+ id: 'note-id-2',
+ position: {
+ height: 50,
+ width: 50,
+ x: 25,
+ y: 25,
+ },
+ userPermissions: {
+ adminNote: true,
+ },
+ discussion: {
+ id: 'discussion-id-2',
+ },
+ },
+];
diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
new file mode 100644
index 00000000000..3ba63fd14f0
--- /dev/null
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -0,0 +1,263 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management index page designs does not render toolbar when there is no permission 1`] = `
+<div>
+ <!---->
+
+ <div
+ class="mt-4"
+ >
+ <ol
+ class="list-unstyled row"
+ >
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub
+ class="design-list-item"
+ />
+ </li>
+
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub>
+ <design-stub
+ event="NONE"
+ filename="design-1-name"
+ id="design-1"
+ image="design-1-image"
+ notescount="0"
+ />
+ </design-dropzone-stub>
+
+ <!---->
+ </li>
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub>
+ <design-stub
+ event="NONE"
+ filename="design-2-name"
+ id="design-2"
+ image="design-2-image"
+ notescount="1"
+ />
+ </design-dropzone-stub>
+
+ <!---->
+ </li>
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub>
+ <design-stub
+ event="NONE"
+ filename="design-3-name"
+ id="design-3"
+ image="design-3-image"
+ notescount="0"
+ />
+ </design-dropzone-stub>
+
+ <!---->
+ </li>
+ </ol>
+ </div>
+
+ <router-view-stub
+ name="default"
+ />
+</div>
+`;
+
+exports[`Design management index page designs renders designs list and header with upload button 1`] = `
+<div>
+ <header
+ class="row-content-block border-top-0 p-2 d-flex"
+ >
+ <div
+ class="d-flex justify-content-between align-items-center w-100"
+ >
+ <design-version-dropdown-stub />
+
+ <div
+ class="qa-selector-toolbar d-flex"
+ >
+ <gl-deprecated-button-stub
+ class="mr-2 js-select-all"
+ size="md"
+ variant="link"
+ >
+ Select all
+ </gl-deprecated-button-stub>
+
+ <div>
+ <delete-button-stub
+ buttonclass="btn-danger btn-inverted mr-2"
+ buttonvariant=""
+ >
+
+ Delete selected
+
+ <!---->
+ </delete-button-stub>
+ </div>
+
+ <upload-button-stub />
+ </div>
+ </div>
+ </header>
+
+ <div
+ class="mt-4"
+ >
+ <ol
+ class="list-unstyled row"
+ >
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub
+ class="design-list-item"
+ />
+ </li>
+
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub>
+ <design-stub
+ event="NONE"
+ filename="design-1-name"
+ id="design-1"
+ image="design-1-image"
+ notescount="0"
+ />
+ </design-dropzone-stub>
+
+ <input
+ class="design-checkbox"
+ type="checkbox"
+ />
+ </li>
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub>
+ <design-stub
+ event="NONE"
+ filename="design-2-name"
+ id="design-2"
+ image="design-2-image"
+ notescount="1"
+ />
+ </design-dropzone-stub>
+
+ <input
+ class="design-checkbox"
+ type="checkbox"
+ />
+ </li>
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub>
+ <design-stub
+ event="NONE"
+ filename="design-3-name"
+ id="design-3"
+ image="design-3-image"
+ notescount="0"
+ />
+ </design-dropzone-stub>
+
+ <input
+ class="design-checkbox"
+ type="checkbox"
+ />
+ </li>
+ </ol>
+ </div>
+
+ <router-view-stub
+ name="default"
+ />
+</div>
+`;
+
+exports[`Design management index page designs renders error 1`] = `
+<div>
+ <!---->
+
+ <div
+ class="mt-4"
+ >
+ <gl-alert-stub
+ dismisslabel="Dismiss"
+ primarybuttonlink=""
+ primarybuttontext=""
+ secondarybuttonlink=""
+ secondarybuttontext=""
+ title=""
+ variant="danger"
+ >
+
+ An error occurred while loading designs. Please try again.
+
+ </gl-alert-stub>
+ </div>
+
+ <router-view-stub
+ name="default"
+ />
+</div>
+`;
+
+exports[`Design management index page designs renders loading icon 1`] = `
+<div>
+ <!---->
+
+ <div
+ class="mt-4"
+ >
+ <gl-loading-icon-stub
+ color="orange"
+ label="Loading"
+ size="md"
+ />
+ </div>
+
+ <router-view-stub
+ name="default"
+ />
+</div>
+`;
+
+exports[`Design management index page when has no designs renders empty text 1`] = `
+<div>
+ <!---->
+
+ <div
+ class="mt-4"
+ >
+ <ol
+ class="list-unstyled row"
+ >
+ <li
+ class="col-md-6 col-lg-4 mb-3"
+ >
+ <design-dropzone-stub
+ class="design-list-item"
+ />
+ </li>
+
+ </ol>
+ </div>
+
+ <router-view-stub
+ name="default"
+ />
+</div>
+`;
diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
new file mode 100644
index 00000000000..76e481ee518
--- /dev/null
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -0,0 +1,184 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management design index page renders design index 1`] = `
+<div
+ class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
+>
+ <div
+ class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"
+ >
+ <design-destroyer-stub
+ filenames="test.jpg"
+ iid="1"
+ projectpath=""
+ />
+
+ <!---->
+
+ <design-presentation-stub
+ discussions="[object Object]"
+ image="test.jpg"
+ imagename="test.jpg"
+ scale="1"
+ />
+
+ <div
+ class="design-scaler-wrapper position-absolute mb-4 d-flex-center"
+ >
+ <design-scaler-stub />
+ </div>
+ </div>
+
+ <div
+ class="image-notes"
+ >
+ <h2
+ class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0"
+ >
+
+ My precious issue
+
+ </h2>
+
+ <a
+ class="text-tertiary text-decoration-none mb-3 d-block"
+ href="full-issue-url"
+ >
+ ull-issue-path
+ </a>
+
+ <participants-stub
+ class="mb-4"
+ numberoflessparticipants="7"
+ participants="[object Object]"
+ />
+
+ <div
+ class="design-discussion-wrapper"
+ >
+ <div
+ class="badge badge-pill"
+ type="button"
+ >
+ 1
+ </div>
+
+ <div
+ class="design-discussion bordered-box position-relative"
+ data-qa-selector="design_discussion_content"
+ >
+ <design-note-stub
+ class=""
+ markdownpreviewpath="//preview_markdown?target_type=Issue"
+ note="[object Object]"
+ />
+
+ <div
+ class="reply-wrapper"
+ >
+ <reply-placeholder-stub
+ buttontext="Reply..."
+ class="qa-discussion-reply"
+ />
+ </div>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+</div>
+`;
+
+exports[`Design management design index page sets loading state 1`] = `
+<div
+ class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
+>
+ <gl-loading-icon-stub
+ class="align-self-center"
+ color="orange"
+ label="Loading"
+ size="xl"
+ />
+</div>
+`;
+
+exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = `
+<div
+ class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
+>
+ <div
+ class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"
+ >
+ <design-destroyer-stub
+ filenames="test.jpg"
+ iid="1"
+ projectpath=""
+ />
+
+ <div
+ class="p-3"
+ >
+ <gl-alert-stub
+ dismissible="true"
+ dismisslabel="Dismiss"
+ primarybuttonlink=""
+ primarybuttontext=""
+ secondarybuttonlink=""
+ secondarybuttontext=""
+ title=""
+ variant="danger"
+ >
+
+ woops
+
+ </gl-alert-stub>
+ </div>
+
+ <design-presentation-stub
+ discussions=""
+ image="test.jpg"
+ imagename="test.jpg"
+ scale="1"
+ />
+
+ <div
+ class="design-scaler-wrapper position-absolute mb-4 d-flex-center"
+ >
+ <design-scaler-stub />
+ </div>
+ </div>
+
+ <div
+ class="image-notes"
+ >
+ <h2
+ class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0"
+ >
+
+ My precious issue
+
+ </h2>
+
+ <a
+ class="text-tertiary text-decoration-none mb-3 d-block"
+ href="full-issue-url"
+ >
+ ull-issue-path
+ </a>
+
+ <participants-stub
+ class="mb-4"
+ numberoflessparticipants="7"
+ participants="[object Object]"
+ />
+
+ <h2
+ class="new-discussion-disclaimer gl-font-base m-0"
+ >
+
+ Click the image where you'd like to start a new discussion
+
+ </h2>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js
new file mode 100644
index 00000000000..9e2f071a983
--- /dev/null
+++ b/spec/frontend/design_management/pages/design/index_spec.js
@@ -0,0 +1,301 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import { ApolloMutation } from 'vue-apollo';
+import createFlash from '~/flash';
+import DesignIndex from '~/design_management/pages/design/index.vue';
+import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+import Participants from '~/sidebar/components/participants/participants.vue';
+import createImageDiffNoteMutation from '~/design_management/graphql/mutations/createImageDiffNote.mutation.graphql';
+import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
+import design from '../../mock_data/design';
+import mockResponseWithDesigns from '../../mock_data/designs';
+import mockResponseNoDesigns from '../../mock_data/no_designs';
+import mockAllVersions from '../../mock_data/all_versions';
+import {
+ DESIGN_NOT_FOUND_ERROR,
+ DESIGN_VERSION_NOT_EXIST_ERROR,
+} from '~/design_management/utils/error_messages';
+import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+
+jest.mock('~/flash');
+jest.mock('mousetrap', () => ({
+ bind: jest.fn(),
+ unbind: jest.fn(),
+}));
+
+describe('Design management design index page', () => {
+ let wrapper;
+ const newComment = 'new comment';
+ const annotationCoordinates = {
+ x: 10,
+ y: 10,
+ width: 100,
+ height: 100,
+ };
+ const createDiscussionMutationVariables = {
+ mutation: createImageDiffNoteMutation,
+ update: expect.anything(),
+ variables: {
+ input: {
+ body: newComment,
+ noteableId: design.id,
+ position: {
+ headSha: 'headSha',
+ baseSha: 'baseSha',
+ startSha: 'startSha',
+ paths: {
+ newPath: 'full-design-path',
+ },
+ ...annotationCoordinates,
+ },
+ },
+ },
+ };
+
+ const updateActiveDiscussionMutationVariables = {
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id: design.discussions.nodes[0].notes.nodes[0].id,
+ source: 'discussion',
+ },
+ };
+
+ const mutate = jest.fn().mockResolvedValue();
+ const routerPush = jest.fn();
+
+ const findDiscussions = () => wrapper.findAll(DesignDiscussion);
+ const findDiscussionForm = () => wrapper.find(DesignReplyForm);
+ const findParticipants = () => wrapper.find(Participants);
+ const findDiscussionsWrapper = () => wrapper.find('.image-notes');
+
+ function createComponent(loading = false, data = {}, { routeQuery = {} } = {}) {
+ const $apollo = {
+ queries: {
+ design: {
+ loading,
+ },
+ },
+ mutate,
+ };
+
+ const $router = {
+ push: routerPush,
+ };
+
+ const $route = {
+ query: routeQuery,
+ };
+
+ wrapper = shallowMount(DesignIndex, {
+ propsData: { id: '1' },
+ mocks: { $apollo, $router, $route },
+ stubs: {
+ ApolloMutation,
+ DesignDiscussion,
+ },
+ data() {
+ return {
+ issueIid: '1',
+ activeDiscussion: {
+ id: null,
+ source: null,
+ },
+ ...data,
+ };
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('sets loading state', () => {
+ createComponent(true);
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders design index', () => {
+ createComponent(false, { design });
+
+ expect(wrapper.element).toMatchSnapshot();
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
+ });
+
+ it('renders participants', () => {
+ createComponent(false, { design });
+
+ expect(findParticipants().exists()).toBe(true);
+ });
+
+ it('passes the correct amount of participants to the Participants component', () => {
+ createComponent(false, { design });
+
+ expect(findParticipants().props('participants')).toHaveLength(1);
+ });
+
+ describe('when has no discussions', () => {
+ beforeEach(() => {
+ createComponent(false, {
+ design: {
+ ...design,
+ discussions: {
+ nodes: [],
+ },
+ },
+ });
+ });
+
+ it('does not render discussions', () => {
+ expect(findDiscussions().exists()).toBe(false);
+ });
+
+ it('renders a message about possibility to create a new discussion', () => {
+ expect(wrapper.find('.new-discussion-disclaimer').exists()).toBe(true);
+ });
+ });
+
+ describe('when has discussions', () => {
+ beforeEach(() => {
+ createComponent(false, { design });
+ });
+
+ it('renders correct amount of discussions', () => {
+ expect(findDiscussions()).toHaveLength(1);
+ });
+
+ it('sends a mutation to set an active discussion when clicking on a discussion', () => {
+ findDiscussions()
+ .at(0)
+ .trigger('click');
+
+ expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables);
+ });
+
+ it('sends a mutation to reset an active discussion when clicking outside of discussion', () => {
+ findDiscussionsWrapper().trigger('click');
+
+ expect(mutate).toHaveBeenCalledWith({
+ ...updateActiveDiscussionMutationVariables,
+ variables: { id: undefined, source: 'discussion' },
+ });
+ });
+ });
+
+ it('opens a new discussion form', () => {
+ createComponent(false, {
+ design: {
+ ...design,
+ discussions: {
+ nodes: [],
+ },
+ },
+ });
+
+ wrapper.vm.openCommentForm({ x: 0, y: 0 });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDiscussionForm().exists()).toBe(true);
+ });
+ });
+
+ it('sends a mutation on submitting form and closes form', () => {
+ createComponent(false, {
+ design: {
+ ...design,
+ discussions: {
+ nodes: [],
+ },
+ },
+ annotationCoordinates,
+ comment: newComment,
+ });
+
+ findDiscussionForm().vm.$emit('submitForm');
+ expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables);
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ return mutate({ variables: createDiscussionMutationVariables });
+ })
+ .then(() => {
+ expect(findDiscussionForm().exists()).toBe(false);
+ });
+ });
+
+ it('closes the form and clears the comment on canceling form', () => {
+ createComponent(false, {
+ design: {
+ ...design,
+ discussions: {
+ nodes: [],
+ },
+ },
+ annotationCoordinates,
+ comment: newComment,
+ });
+
+ findDiscussionForm().vm.$emit('cancelForm');
+
+ expect(wrapper.vm.comment).toBe('');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDiscussionForm().exists()).toBe(false);
+ });
+ });
+
+ describe('with error', () => {
+ beforeEach(() => {
+ createComponent(false, {
+ design: {
+ ...design,
+ discussions: {
+ nodes: [],
+ },
+ },
+ errorMessage: 'woops',
+ });
+ });
+
+ it('GlAlert is rendered in correct position with correct content', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('onDesignQueryResult', () => {
+ describe('with no designs', () => {
+ it('redirects to /designs', () => {
+ createComponent(true);
+
+ wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR);
+ expect(routerPush).toHaveBeenCalledTimes(1);
+ expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
+ });
+ });
+ });
+
+ describe('when no design exists for given version', () => {
+ it('redirects to /designs', () => {
+ // attempt to query for a version of the design that doesn't exist
+ createComponent(true, {}, { routeQuery: { version: '999' } });
+ wrapper.setData({
+ allVersions: mockAllVersions,
+ });
+
+ wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(DESIGN_VERSION_NOT_EXIST_ERROR);
+ expect(routerPush).toHaveBeenCalledTimes(1);
+ expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
new file mode 100644
index 00000000000..2299b858da9
--- /dev/null
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -0,0 +1,533 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { ApolloMutation } from 'vue-apollo';
+import VueRouter from 'vue-router';
+import { GlEmptyState } from '@gitlab/ui';
+
+import Index from '~/design_management/pages/index.vue';
+import uploadDesignQuery from '~/design_management/graphql/mutations/uploadDesign.mutation.graphql';
+import DesignDestroyer from '~/design_management/components/design_destroyer.vue';
+import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
+import DeleteButton from '~/design_management/components/delete_button.vue';
+import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+import {
+ EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
+ EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
+} from '~/design_management/utils/error_messages';
+import createFlash from '~/flash';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+const router = new VueRouter({
+ routes: [
+ {
+ name: DESIGNS_ROUTE_NAME,
+ path: '/designs',
+ component: Index,
+ },
+ ],
+});
+
+jest.mock('~/flash.js');
+
+const mockDesigns = [
+ {
+ id: 'design-1',
+ image: 'design-1-image',
+ filename: 'design-1-name',
+ event: 'NONE',
+ notesCount: 0,
+ },
+ {
+ id: 'design-2',
+ image: 'design-2-image',
+ filename: 'design-2-name',
+ event: 'NONE',
+ notesCount: 1,
+ },
+ {
+ id: 'design-3',
+ image: 'design-3-image',
+ filename: 'design-3-name',
+ event: 'NONE',
+ notesCount: 0,
+ },
+];
+
+const mockVersion = {
+ node: {
+ id: 'gid://gitlab/DesignManagement::Version/1',
+ },
+};
+
+describe('Design management index page', () => {
+ let mutate;
+ let wrapper;
+
+ const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
+ const findSelectAllButton = () => wrapper.find('.js-select-all');
+ const findToolbar = () => wrapper.find('.qa-selector-toolbar');
+ const findDeleteButton = () => wrapper.find(DeleteButton);
+ const findDropzone = () => wrapper.findAll(DesignDropzone).at(0);
+ const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
+
+ function createComponent({
+ loading = false,
+ designs = [],
+ allVersions = [],
+ createDesign = true,
+ stubs = {},
+ mockMutate = jest.fn().mockResolvedValue(),
+ } = {}) {
+ mutate = mockMutate;
+ const $apollo = {
+ queries: {
+ designs: {
+ loading,
+ },
+ permissions: {
+ loading,
+ },
+ },
+ mutate,
+ };
+
+ wrapper = shallowMount(Index, {
+ mocks: { $apollo },
+ localVue,
+ router,
+ stubs: { DesignDestroyer, ApolloMutation, ...stubs },
+ attachToDocument: true,
+ });
+
+ wrapper.setData({
+ designs,
+ allVersions,
+ issueIid: '1',
+ permissions: {
+ createDesign,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('designs', () => {
+ it('renders loading icon', () => {
+ createComponent({ loading: true });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders error', () => {
+ createComponent();
+
+ wrapper.setData({ error: true });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders a toolbar with buttons when there are designs', () => {
+ createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findToolbar().exists()).toBe(true);
+ });
+ });
+
+ it('renders designs list and header with upload button', () => {
+ createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('does not render toolbar when there is no permission', () => {
+ createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('when has no designs', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty text', () =>
+ wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ }));
+
+ it('does not render a toolbar with buttons', () =>
+ wrapper.vm.$nextTick().then(() => {
+ expect(findToolbar().exists()).toBe(false);
+ }));
+ });
+
+ describe('uploading designs', () => {
+ it('calls mutation on upload', () => {
+ createComponent({ stubs: { GlEmptyState } });
+
+ const mutationVariables = {
+ update: expect.anything(),
+ context: {
+ hasUpload: true,
+ },
+ mutation: uploadDesignQuery,
+ variables: {
+ files: [{ name: 'test' }],
+ projectPath: '',
+ iid: '1',
+ },
+ optimisticResponse: {
+ __typename: 'Mutation',
+ designManagementUpload: {
+ __typename: 'DesignManagementUploadPayload',
+ designs: [
+ {
+ __typename: 'Design',
+ id: expect.anything(),
+ image: '',
+ imageV432x230: '',
+ filename: 'test',
+ fullPath: '',
+ event: 'NONE',
+ notesCount: 0,
+ diffRefs: {
+ __typename: 'DiffRefs',
+ baseSha: '',
+ startSha: '',
+ headSha: '',
+ },
+ discussions: {
+ __typename: 'DesignDiscussion',
+ nodes: [],
+ },
+ versions: {
+ __typename: 'DesignVersionConnection',
+ edges: {
+ __typename: 'DesignVersionEdge',
+ node: {
+ __typename: 'DesignVersion',
+ id: expect.anything(),
+ sha: expect.anything(),
+ },
+ },
+ },
+ },
+ ],
+ skippedDesigns: [],
+ errors: [],
+ },
+ },
+ };
+
+ return wrapper.vm.$nextTick().then(() => {
+ findDropzone().vm.$emit('change', [{ name: 'test' }]);
+ expect(mutate).toHaveBeenCalledWith(mutationVariables);
+ expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]);
+ expect(wrapper.vm.isSaving).toBeTruthy();
+ });
+ });
+
+ it('sets isSaving', () => {
+ createComponent();
+
+ const uploadDesign = wrapper.vm.onUploadDesign([
+ {
+ name: 'test',
+ },
+ ]);
+
+ expect(wrapper.vm.isSaving).toBe(true);
+
+ return uploadDesign.then(() => {
+ expect(wrapper.vm.isSaving).toBe(false);
+ });
+ });
+
+ it('updates state appropriately after upload complete', () => {
+ createComponent({ stubs: { GlEmptyState } });
+ wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
+
+ wrapper.vm.onUploadDesignDone();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.filesToBeSaved).toEqual([]);
+ expect(wrapper.vm.isSaving).toBeFalsy();
+ expect(wrapper.vm.isLatestVersion).toBe(true);
+ });
+ });
+
+ it('updates state appropriately after upload error', () => {
+ createComponent({ stubs: { GlEmptyState } });
+ wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
+
+ wrapper.vm.onUploadDesignError();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.filesToBeSaved).toEqual([]);
+ expect(wrapper.vm.isSaving).toBeFalsy();
+ expect(createFlash).toHaveBeenCalled();
+
+ createFlash.mockReset();
+ });
+ });
+
+ it('does not call mutation if createDesign is false', () => {
+ createComponent({ createDesign: false });
+
+ wrapper.vm.onUploadDesign([]);
+
+ expect(mutate).not.toHaveBeenCalled();
+ });
+
+ describe('upload count limit', () => {
+ const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
+
+ afterEach(() => {
+ createFlash.mockReset();
+ });
+
+ it('does not warn when the max files are uploaded', () => {
+ createComponent();
+
+ wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0]));
+
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+
+ it('warns when too many files are uploaded', () => {
+ createComponent();
+
+ wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0]));
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+
+ it('flashes warning if designs are skipped', () => {
+ createComponent({
+ mockMutate: () =>
+ Promise.resolve({
+ data: { designManagementUpload: { skippedDesigns: [{ filename: 'test.jpg' }] } },
+ }),
+ });
+
+ const uploadDesign = wrapper.vm.onUploadDesign([
+ {
+ name: 'test',
+ },
+ ]);
+
+ return uploadDesign.then(() => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(
+ 'Upload skipped. test.jpg did not change.',
+ 'warning',
+ );
+ });
+ });
+
+ describe('dragging onto an existing design', () => {
+ beforeEach(() => {
+ createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ });
+
+ it('calls onUploadDesign with valid upload', () => {
+ wrapper.setMethods({
+ onUploadDesign: jest.fn(),
+ });
+
+ const mockUploadPayload = [
+ {
+ name: mockDesigns[0].filename,
+ },
+ ];
+
+ const designDropzone = findFirstDropzoneWithDesign();
+ designDropzone.vm.$emit('change', mockUploadPayload);
+
+ expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith(mockUploadPayload);
+ });
+
+ it.each`
+ description | eventPayload | message
+ ${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE}
+ ${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE}
+ `('calls createFlash when upload has $description', ({ eventPayload, message }) => {
+ const designDropzone = findFirstDropzoneWithDesign();
+ designDropzone.vm.$emit('change', eventPayload);
+
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(message);
+ });
+ });
+ });
+
+ describe('on latest version when has designs', () => {
+ beforeEach(() => {
+ createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ });
+
+ it('renders design checkboxes', () => {
+ expect(findDesignCheckboxes()).toHaveLength(mockDesigns.length);
+ });
+
+ it('renders toolbar buttons', () => {
+ expect(findToolbar().exists()).toBe(true);
+ expect(findToolbar().classes()).toContain('d-flex');
+ expect(findToolbar().classes()).not.toContain('d-none');
+ });
+
+ it('adds two designs to selected designs when their checkboxes are checked', () => {
+ findDesignCheckboxes()
+ .at(0)
+ .trigger('click');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ findDesignCheckboxes()
+ .at(1)
+ .trigger('click');
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(findDeleteButton().exists()).toBe(true);
+ expect(findSelectAllButton().text()).toBe('Deselect all');
+ findDeleteButton().vm.$emit('deleteSelectedDesigns');
+ const [{ variables }] = mutate.mock.calls[0];
+ expect(variables.filenames).toStrictEqual([
+ mockDesigns[0].filename,
+ mockDesigns[1].filename,
+ ]);
+ });
+ });
+
+ it('adds all designs to selected designs when Select All button is clicked', () => {
+ findSelectAllButton().vm.$emit('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDeleteButton().props().hasSelectedDesigns).toBe(true);
+ expect(findSelectAllButton().text()).toBe('Deselect all');
+ expect(wrapper.vm.selectedDesigns).toEqual(mockDesigns.map(design => design.filename));
+ });
+ });
+
+ it('removes all designs from selected designs when at least one design was selected', () => {
+ findDesignCheckboxes()
+ .at(0)
+ .trigger('click');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ findSelectAllButton().vm.$emit('click');
+ })
+ .then(() => {
+ expect(findDeleteButton().props().hasSelectedDesigns).toBe(false);
+ expect(findSelectAllButton().text()).toBe('Select all');
+ expect(wrapper.vm.selectedDesigns).toEqual([]);
+ });
+ });
+ });
+
+ it('on latest version when has no designs does not render toolbar buttons', () => {
+ createComponent({ designs: [], allVersions: [mockVersion] });
+ expect(findToolbar().exists()).toBe(false);
+ });
+
+ describe('on non-latest version', () => {
+ beforeEach(() => {
+ createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+
+ router.replace({
+ name: DESIGNS_ROUTE_NAME,
+ query: {
+ version: '2',
+ },
+ });
+ });
+
+ it('does not render design checkboxes', () => {
+ expect(findDesignCheckboxes()).toHaveLength(0);
+ });
+
+ it('does not render Delete selected button', () => {
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+
+ it('does not render Select All button', () => {
+ expect(findSelectAllButton().exists()).toBe(false);
+ });
+ });
+
+ describe('pasting a design', () => {
+ let event;
+ beforeEach(() => {
+ createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+
+ wrapper.setMethods({
+ onUploadDesign: jest.fn(),
+ });
+
+ event = new Event('paste');
+
+ router.replace({
+ name: DESIGNS_ROUTE_NAME,
+ query: {
+ version: '2',
+ },
+ });
+ });
+
+ it('calls onUploadDesign with valid paste', () => {
+ event.clipboardData = {
+ files: [{ name: 'image.png', type: 'image/png' }],
+ getData: () => 'test.png',
+ };
+
+ document.dispatchEvent(event);
+
+ expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([
+ new File([{ name: 'image.png' }], 'test.png'),
+ ]);
+ });
+
+ it('renames a design if it has an image.png filename', () => {
+ event.clipboardData = {
+ files: [{ name: 'image.png', type: 'image/png' }],
+ getData: () => 'image.png',
+ };
+
+ document.dispatchEvent(event);
+
+ expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([
+ new File([{ name: 'image.png' }], `design_${Date.now()}.png`),
+ ]);
+ });
+
+ it('does not call onUploadDesign with invalid paste', () => {
+ event.clipboardData = {
+ items: [{ type: 'text/plain' }, { type: 'text' }],
+ files: [],
+ };
+
+ document.dispatchEvent(event);
+
+ expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js
new file mode 100644
index 00000000000..0f4afa5e288
--- /dev/null
+++ b/spec/frontend/design_management/router_spec.js
@@ -0,0 +1,81 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueRouter from 'vue-router';
+import App from '~/design_management/components/app.vue';
+import Designs from '~/design_management/pages/index.vue';
+import DesignDetail from '~/design_management/pages/design/index.vue';
+import createRouter from '~/design_management/router';
+import {
+ ROOT_ROUTE_NAME,
+ DESIGNS_ROUTE_NAME,
+ DESIGN_ROUTE_NAME,
+} from '~/design_management/router/constants';
+import '~/commons/bootstrap';
+
+function factory(routeArg) {
+ const localVue = createLocalVue();
+ localVue.use(VueRouter);
+
+ window.gon = { sprite_icons: '' };
+
+ const router = createRouter('/');
+ if (routeArg !== undefined) {
+ router.push(routeArg);
+ }
+
+ return mount(App, {
+ localVue,
+ router,
+ mocks: {
+ $apollo: {
+ queries: {
+ designs: { loading: true },
+ design: { loading: true },
+ permissions: { loading: true },
+ },
+ },
+ },
+ });
+}
+
+jest.mock('mousetrap', () => ({
+ bind: jest.fn(),
+ unbind: jest.fn(),
+}));
+
+describe('Design management router', () => {
+ afterEach(() => {
+ window.location.hash = '';
+ });
+
+ describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', routeArg => {
+ it('pushes home component', () => {
+ const wrapper = factory(routeArg);
+
+ expect(wrapper.find(Designs).exists()).toBe(true);
+ });
+ });
+
+ describe.each([['/designs'], [{ name: DESIGNS_ROUTE_NAME }]])('designs route', routeArg => {
+ it('pushes designs root component', () => {
+ const wrapper = factory(routeArg);
+
+ expect(wrapper.find(Designs).exists()).toBe(true);
+ });
+ });
+
+ describe.each([['/designs/1'], [{ name: DESIGN_ROUTE_NAME, params: { id: '1' } }]])(
+ 'designs detail route',
+ routeArg => {
+ it('pushes designs detail component', () => {
+ const wrapper = factory(routeArg);
+
+ return nextTick().then(() => {
+ const detail = wrapper.find(DesignDetail);
+ expect(detail.exists()).toBe(true);
+ expect(detail.props('id')).toEqual('1');
+ });
+ });
+ },
+ );
+});
diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js
new file mode 100644
index 00000000000..641d35ff9ff
--- /dev/null
+++ b/spec/frontend/design_management/utils/cache_update_spec.js
@@ -0,0 +1,44 @@
+import { InMemoryCache } from 'apollo-cache-inmemory';
+import {
+ updateStoreAfterDesignsDelete,
+ updateStoreAfterAddDiscussionComment,
+ updateStoreAfterAddImageDiffNote,
+ updateStoreAfterUploadDesign,
+ updateStoreAfterUpdateImageDiffNote,
+} from '~/design_management/utils/cache_update';
+import {
+ designDeletionError,
+ ADD_DISCUSSION_COMMENT_ERROR,
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+} from '~/design_management/utils/error_messages';
+import design from '../mock_data/design';
+import createFlash from '~/flash';
+
+jest.mock('~/flash.js');
+
+describe('Design Management cache update', () => {
+ const mockErrors = ['code red!'];
+
+ let mockStore;
+
+ beforeEach(() => {
+ mockStore = new InMemoryCache();
+ });
+
+ describe('error handling', () => {
+ it.each`
+ fnName | subject | errorMessage | extraArgs
+ ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]}
+ ${'updateStoreAfterAddDiscussionComment'} | ${updateStoreAfterAddDiscussionComment} | ${ADD_DISCUSSION_COMMENT_ERROR} | ${[]}
+ ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]}
+ ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]}
+ ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]}
+ `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => {
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow();
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(errorMessage);
+ });
+ });
+});
diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js
new file mode 100644
index 00000000000..af631073df6
--- /dev/null
+++ b/spec/frontend/design_management/utils/design_management_utils_spec.js
@@ -0,0 +1,176 @@
+import {
+ extractCurrentDiscussion,
+ extractDiscussions,
+ findVersionId,
+ designUploadOptimisticResponse,
+ updateImageDiffNoteOptimisticResponse,
+ isValidDesignFile,
+ extractDesign,
+} from '~/design_management/utils/design_management_utils';
+import mockResponseNoDesigns from '../mock_data/no_designs';
+import mockResponseWithDesigns from '../mock_data/designs';
+import mockDesign from '../mock_data/design';
+
+jest.mock('lodash/uniqueId', () => () => 1);
+
+describe('extractCurrentDiscussion', () => {
+ let discussions;
+
+ beforeEach(() => {
+ discussions = {
+ nodes: [
+ { id: 101, payload: 'w' },
+ { id: 102, payload: 'x' },
+ { id: 103, payload: 'y' },
+ { id: 104, payload: 'z' },
+ ],
+ };
+ });
+
+ it('finds the relevant discussion if it exists', () => {
+ const id = 103;
+ expect(extractCurrentDiscussion(discussions, id)).toEqual({ id, payload: 'y' });
+ });
+
+ it('returns null if the relevant discussion does not exist', () => {
+ expect(extractCurrentDiscussion(discussions, 0)).not.toBeDefined();
+ });
+});
+
+describe('extractDiscussions', () => {
+ let discussions;
+
+ beforeEach(() => {
+ discussions = {
+ nodes: [
+ { id: 1, notes: { nodes: ['a'] } },
+ { id: 2, notes: { nodes: ['b'] } },
+ { id: 3, notes: { nodes: ['c'] } },
+ { id: 4, notes: { nodes: ['d'] } },
+ ],
+ };
+ });
+
+ it('discards the edges.node artifacts of GraphQL', () => {
+ expect(extractDiscussions(discussions)).toEqual([
+ { id: 1, notes: ['a'] },
+ { id: 2, notes: ['b'] },
+ { id: 3, notes: ['c'] },
+ { id: 4, notes: ['d'] },
+ ]);
+ });
+});
+
+describe('version parser', () => {
+ it('correctly extracts version ID from a valid version string', () => {
+ const testVersionId = '123';
+ const testVersionString = `gid://gitlab/DesignManagement::Version/${testVersionId}`;
+
+ expect(findVersionId(testVersionString)).toEqual(testVersionId);
+ });
+
+ it('fails to extract version ID from an invalid version string', () => {
+ const testInvalidVersionString = `gid://gitlab/DesignManagement::Version`;
+
+ expect(findVersionId(testInvalidVersionString)).toBeUndefined();
+ });
+});
+
+describe('optimistic responses', () => {
+ it('correctly generated for designManagementUpload', () => {
+ const expectedResponse = {
+ __typename: 'Mutation',
+ designManagementUpload: {
+ __typename: 'DesignManagementUploadPayload',
+ designs: [
+ {
+ __typename: 'Design',
+ id: -1,
+ image: '',
+ imageV432x230: '',
+ filename: 'test',
+ fullPath: '',
+ notesCount: 0,
+ event: 'NONE',
+ diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' },
+ discussions: { __typename: 'DesignDiscussion', nodes: [] },
+ versions: {
+ __typename: 'DesignVersionConnection',
+ edges: {
+ __typename: 'DesignVersionEdge',
+ node: { __typename: 'DesignVersion', id: -1, sha: -1 },
+ },
+ },
+ },
+ ],
+ errors: [],
+ skippedDesigns: [],
+ },
+ };
+ expect(designUploadOptimisticResponse([{ name: 'test' }])).toEqual(expectedResponse);
+ });
+
+ it('correctly generated for updateImageDiffNoteOptimisticResponse', () => {
+ const mockNote = {
+ id: 'test-note-id',
+ };
+
+ const mockPosition = {
+ x: 10,
+ y: 10,
+ width: 10,
+ height: 10,
+ };
+
+ const expectedResponse = {
+ __typename: 'Mutation',
+ updateImageDiffNote: {
+ __typename: 'UpdateImageDiffNotePayload',
+ note: {
+ ...mockNote,
+ position: mockPosition,
+ },
+ errors: [],
+ },
+ };
+ expect(updateImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual(
+ expectedResponse,
+ );
+ });
+});
+
+describe('isValidDesignFile', () => {
+ // test every filetype that Design Management supports
+ // https://docs.gitlab.com/ee/user/project/issues/design_management.html#limitations
+ it.each`
+ mimetype | isValid
+ ${'image/svg'} | ${true}
+ ${'image/png'} | ${true}
+ ${'image/jpg'} | ${true}
+ ${'image/jpeg'} | ${true}
+ ${'image/gif'} | ${true}
+ ${'image/bmp'} | ${true}
+ ${'image/tiff'} | ${true}
+ ${'image/ico'} | ${true}
+ ${'image/svg'} | ${true}
+ ${'video/mpeg'} | ${false}
+ ${'audio/midi'} | ${false}
+ ${'application/octet-stream'} | ${false}
+ `('returns $isValid for file type $mimetype', ({ mimetype, isValid }) => {
+ expect(isValidDesignFile({ type: mimetype })).toBe(isValid);
+ });
+});
+
+describe('extractDesign', () => {
+ describe('with no designs', () => {
+ it('returns undefined', () => {
+ expect(extractDesign(mockResponseNoDesigns)).toBeUndefined();
+ });
+ });
+
+ describe('with designs', () => {
+ it('returns the first design available', () => {
+ expect(extractDesign(mockResponseWithDesigns)).toEqual(mockDesign);
+ });
+ });
+});
diff --git a/spec/frontend/design_management/utils/error_messages_spec.js b/spec/frontend/design_management/utils/error_messages_spec.js
new file mode 100644
index 00000000000..635ff931d7d
--- /dev/null
+++ b/spec/frontend/design_management/utils/error_messages_spec.js
@@ -0,0 +1,62 @@
+import {
+ designDeletionError,
+ designUploadSkippedWarning,
+} from '~/design_management/utils/error_messages';
+
+const mockFilenames = n =>
+ Array(n)
+ .fill(0)
+ .map((_, i) => ({ filename: `${i + 1}.jpg` }));
+
+describe('Error message', () => {
+ describe('designDeletionError', () => {
+ const singularMsg = 'Could not delete a design. Please try again.';
+ const pluralMsg = 'Could not delete designs. Please try again.';
+
+ describe('when [singular=true]', () => {
+ it.each([[undefined], [true]])('uses singular grammar', singularOption => {
+ expect(designDeletionError({ singular: singularOption })).toEqual(singularMsg);
+ });
+ });
+
+ describe('when [singular=false]', () => {
+ it('uses plural grammar', () => {
+ expect(designDeletionError({ singular: false })).toEqual(pluralMsg);
+ });
+ });
+ });
+
+ describe.each([
+ [[], [], null],
+ [mockFilenames(1), mockFilenames(1), 'Upload skipped. 1.jpg did not change.'],
+ [
+ mockFilenames(2),
+ mockFilenames(2),
+ 'Upload skipped. The designs you tried uploading did not change.',
+ ],
+ [
+ mockFilenames(2),
+ mockFilenames(1),
+ 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg.',
+ ],
+ [
+ mockFilenames(6),
+ mockFilenames(5),
+ 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg.',
+ ],
+ [
+ mockFilenames(7),
+ mockFilenames(6),
+ 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 1 more.',
+ ],
+ [
+ mockFilenames(8),
+ mockFilenames(7),
+ 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 2 more.',
+ ],
+ ])('designUploadSkippedWarning', (uploadedFiles, skippedFiles, expected) => {
+ test('returns expected warning message', () => {
+ expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/frontend/design_management/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js
new file mode 100644
index 00000000000..9fa5eae55b3
--- /dev/null
+++ b/spec/frontend/design_management/utils/tracking_spec.js
@@ -0,0 +1,53 @@
+import { mockTracking } from 'helpers/tracking_helper';
+import { trackDesignDetailView } from '~/design_management/utils/tracking';
+
+function getTrackingSpy(key) {
+ return mockTracking(key, undefined, jest.spyOn);
+}
+
+describe('Tracking Events', () => {
+ describe('trackDesignDetailView', () => {
+ const eventKey = 'projects:issues:design';
+ const eventName = 'design_viewed';
+
+ it('trackDesignDetailView fires a tracking event when called', () => {
+ const trackingSpy = getTrackingSpy(eventKey);
+
+ trackDesignDetailView();
+
+ expect(trackingSpy).toHaveBeenCalledWith(
+ eventKey,
+ eventName,
+ expect.objectContaining({
+ label: eventName,
+ value: {
+ 'internal-object-refrerer': '',
+ 'design-collection-owner': '',
+ 'design-version-number': 1,
+ 'design-is-current-version': false,
+ },
+ }),
+ );
+ });
+
+ it('trackDesignDetailView allows to customize the value payload', () => {
+ const trackingSpy = getTrackingSpy(eventKey);
+
+ trackDesignDetailView('from-a-test', 'test', 100, true);
+
+ expect(trackingSpy).toHaveBeenCalledWith(
+ eventKey,
+ eventName,
+ expect.objectContaining({
+ label: eventName,
+ value: {
+ 'internal-object-refrerer': 'from-a-test',
+ 'design-collection-owner': 'test',
+ 'design-version-number': 100,
+ 'design-is-current-version': true,
+ },
+ }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/diff_comments_store_spec.js b/spec/frontend/diff_comments_store_spec.js
new file mode 100644
index 00000000000..6f25c9dd3bc
--- /dev/null
+++ b/spec/frontend/diff_comments_store_spec.js
@@ -0,0 +1,136 @@
+/* global CommentsStore */
+
+import '~/diff_notes/models/discussion';
+import '~/diff_notes/models/note';
+import '~/diff_notes/stores/comments';
+
+function createDiscussion(noteId = 1, resolved = true) {
+ CommentsStore.create({
+ discussionId: 'a',
+ noteId,
+ canResolve: true,
+ resolved,
+ resolvedBy: 'test',
+ authorName: 'test',
+ authorAvatar: 'test',
+ noteTruncated: 'test...',
+ });
+}
+
+beforeEach(() => {
+ CommentsStore.state = {};
+});
+
+describe('New discussion', () => {
+ it('creates new discussion', () => {
+ expect(Object.keys(CommentsStore.state).length).toBe(0);
+ createDiscussion();
+
+ expect(Object.keys(CommentsStore.state).length).toBe(1);
+ });
+
+ it('creates new note in discussion', () => {
+ createDiscussion();
+ createDiscussion(2);
+
+ const discussion = CommentsStore.state.a;
+
+ expect(Object.keys(discussion.notes).length).toBe(2);
+ });
+});
+
+describe('Get note', () => {
+ beforeEach(() => {
+ createDiscussion();
+ });
+
+ it('gets note by ID', () => {
+ const note = CommentsStore.get('a', 1);
+
+ expect(note).toBeDefined();
+ expect(note.id).toBe(1);
+ });
+});
+
+describe('Delete discussion', () => {
+ beforeEach(() => {
+ createDiscussion();
+ });
+
+ it('deletes discussion by ID', () => {
+ CommentsStore.delete('a', 1);
+
+ expect(Object.keys(CommentsStore.state).length).toBe(0);
+ });
+
+ it('deletes discussion when no more notes', () => {
+ createDiscussion();
+ createDiscussion(2);
+
+ expect(Object.keys(CommentsStore.state).length).toBe(1);
+ expect(Object.keys(CommentsStore.state.a.notes).length).toBe(2);
+
+ CommentsStore.delete('a', 1);
+ CommentsStore.delete('a', 2);
+
+ expect(Object.keys(CommentsStore.state).length).toBe(0);
+ });
+});
+
+describe('Update note', () => {
+ beforeEach(() => {
+ createDiscussion();
+ });
+
+ it('updates note to be unresolved', () => {
+ CommentsStore.update('a', 1, false, 'test');
+
+ const note = CommentsStore.get('a', 1);
+
+ expect(note.resolved).toBe(false);
+ });
+});
+
+describe('Discussion resolved', () => {
+ beforeEach(() => {
+ createDiscussion();
+ });
+
+ it('is resolved with single note', () => {
+ const discussion = CommentsStore.state.a;
+
+ expect(discussion.isResolved()).toBe(true);
+ });
+
+ it('is unresolved with 2 notes', () => {
+ const discussion = CommentsStore.state.a;
+ createDiscussion(2, false);
+
+ expect(discussion.isResolved()).toBe(false);
+ });
+
+ it('is resolved with 2 notes', () => {
+ const discussion = CommentsStore.state.a;
+ createDiscussion(2);
+
+ expect(discussion.isResolved()).toBe(true);
+ });
+
+ it('resolve all notes', () => {
+ const discussion = CommentsStore.state.a;
+ createDiscussion(2, false);
+
+ discussion.resolveAllNotes();
+
+ expect(discussion.isResolved()).toBe(true);
+ });
+
+ it('unresolve all notes', () => {
+ const discussion = CommentsStore.state.a;
+ createDiscussion(2);
+
+ discussion.unResolveAllNotes();
+
+ expect(discussion.isResolved()).toBe(false);
+ });
+});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 3a0354205f8..57e3a93c6f4 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -14,10 +14,13 @@ import TreeList from '~/diffs/components/tree_list.vue';
import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants';
import createDiffsStore from '../create_diffs_store';
import axios from '~/lib/utils/axios_utils';
+import * as urlUtils from '~/lib/utils/url_utility';
import diffsMockData from '../mock_data/merge_request_diffs';
const mergeRequestDiff = { version_index: 1 };
const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`;
+const COMMIT_URL = '[BASE URL]/OLD';
+const UPDATED_COMMIT_URL = '[BASE URL]/NEW';
describe('diffs/components/app', () => {
const oldMrTabs = window.mrTabs;
@@ -25,8 +28,14 @@ describe('diffs/components/app', () => {
let wrapper;
let mock;
- function createComponent(props = {}, extendStore = () => {}) {
+ function createComponent(props = {}, extendStore = () => {}, provisions = {}) {
const localVue = createLocalVue();
+ const provide = {
+ ...provisions,
+ glFeatures: {
+ ...(provisions.glFeatures || {}),
+ },
+ };
localVue.use(Vuex);
@@ -49,6 +58,7 @@ describe('diffs/components/app', () => {
showSuggestPopover: true,
...props,
},
+ provide,
store,
methods: {
isLatestVersion() {
@@ -79,7 +89,10 @@ describe('diffs/components/app', () => {
window.mrTabs = oldMrTabs;
// reset component
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
mock.restore();
});
@@ -452,76 +465,109 @@ describe('diffs/components/app', () => {
});
describe('keyboard shortcut navigation', () => {
- const mappings = {
- '[': -1,
- k: -1,
- ']': +1,
- j: +1,
- };
- let spy;
+ let spies = [];
+ let jumpSpy;
+ let moveSpy;
+
+ function setup(componentProps, featureFlags) {
+ createComponent(
+ componentProps,
+ ({ state }) => {
+ state.diffs.commit = { id: 'SHA123' };
+ },
+ { glFeatures: { mrCommitNeighborNav: true, ...featureFlags } },
+ );
+
+ moveSpy = jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ jumpSpy = jest.fn();
+ spies = [jumpSpy, moveSpy];
+ wrapper.setMethods({
+ jumpToFile: jumpSpy,
+ });
+ }
describe('visible app', () => {
- beforeEach(() => {
- spy = jest.fn();
+ it.each`
+ key | name | spy | args | featureFlags
+ ${'['} | ${'jumpToFile'} | ${0} | ${[-1]} | ${{}}
+ ${'k'} | ${'jumpToFile'} | ${0} | ${[-1]} | ${{}}
+ ${']'} | ${'jumpToFile'} | ${0} | ${[+1]} | ${{}}
+ ${'j'} | ${'jumpToFile'} | ${0} | ${[+1]} | ${{}}
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1} | ${[{ direction: 'previous' }]} | ${{ mrCommitNeighborNav: true }}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1} | ${[{ direction: 'next' }]} | ${{ mrCommitNeighborNav: true }}
+ `(
+ 'calls `$name()` with correct parameters whenever the "$key" key is pressed',
+ ({ key, spy, args, featureFlags }) => {
+ setup({ shouldShow: true }, featureFlags);
- createComponent({
- shouldShow: true,
- });
- wrapper.setMethods({
- jumpToFile: spy,
- });
- });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(spies[spy]).not.toHaveBeenCalled();
+
+ Mousetrap.trigger(key);
+
+ expect(spies[spy]).toHaveBeenCalledWith(...args);
+ });
+ },
+ );
+
+ it.each`
+ key | name | spy | featureFlags
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1} | ${{ mrCommitNeighborNav: false }}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1} | ${{ mrCommitNeighborNav: false }}
+ `(
+ 'does not call `$name()` even when the correct key is pressed if the feature flag is disabled',
+ ({ key, spy, featureFlags }) => {
+ setup({ shouldShow: true }, featureFlags);
- it.each(Object.keys(mappings))(
- 'calls `jumpToFile()` with correct parameter whenever pre-defined %s is pressed',
- key => {
return wrapper.vm.$nextTick().then(() => {
- expect(spy).not.toHaveBeenCalled();
+ expect(spies[spy]).not.toHaveBeenCalled();
Mousetrap.trigger(key);
- expect(spy).toHaveBeenCalledWith(mappings[key]);
+ expect(spies[spy]).not.toHaveBeenCalled();
});
},
);
- it('does not call `jumpToFile()` when unknown key is pressed', done => {
- wrapper.vm
- .$nextTick()
- .then(() => {
- Mousetrap.trigger('d');
+ it.each`
+ key | name | spy | allowed
+ ${'d'} | ${'jumpToFile'} | ${0} | ${['[', ']', 'j', 'k']}
+ ${'r'} | ${'moveToNeighboringCommit'} | ${1} | ${['x', 'c']}
+ `(
+ `does not call \`$name()\` when a key that is not one of \`$allowed\` is pressed`,
+ ({ key, spy }) => {
+ setup({ shouldShow: true }, { mrCommitNeighborNav: true });
- expect(spy).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
+ return wrapper.vm.$nextTick().then(() => {
+ Mousetrap.trigger(key);
+
+ expect(spies[spy]).not.toHaveBeenCalled();
+ });
+ },
+ );
});
- describe('hideen app', () => {
+ describe('hidden app', () => {
beforeEach(() => {
- spy = jest.fn();
+ setup({ shouldShow: false }, { mrCommitNeighborNav: true });
- createComponent({
- shouldShow: false,
- });
- wrapper.setMethods({
- jumpToFile: spy,
+ return wrapper.vm.$nextTick().then(() => {
+ Mousetrap.reset();
});
});
- it('stops calling `jumpToFile()` when application is hidden', done => {
- wrapper.vm
- .$nextTick()
- .then(() => {
- Object.keys(mappings).forEach(key => {
- Mousetrap.trigger(key);
+ it.each`
+ key | name | spy
+ ${'['} | ${'jumpToFile'} | ${0}
+ ${'k'} | ${'jumpToFile'} | ${0}
+ ${']'} | ${'jumpToFile'} | ${0}
+ ${'j'} | ${'jumpToFile'} | ${0}
+ ${'x'} | ${'moveToNeighboringCommit'} | ${1}
+ ${'c'} | ${'moveToNeighboringCommit'} | ${1}
+ `('stops calling `$name()` when the app is hidden', ({ key, spy }) => {
+ Mousetrap.trigger(key);
- expect(spy).not.toHaveBeenCalled();
- });
- })
- .then(done)
- .catch(done.fail);
+ expect(spies[spy]).not.toHaveBeenCalled();
});
});
});
@@ -602,6 +648,70 @@ describe('diffs/components/app', () => {
});
});
+ describe('commit watcher', () => {
+ const spy = () => {
+ jest.spyOn(wrapper.vm, 'refetchDiffData').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm, 'adjustView').mockImplementation(() => {});
+ };
+ let location;
+
+ beforeAll(() => {
+ location = window.location;
+ delete window.location;
+ window.location = COMMIT_URL;
+ document.title = 'My Title';
+ });
+
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'updateHistory');
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ it('when the commit changes and the app is not loading it should update the history, refetch the diff data, and update the view', () => {
+ createComponent({}, ({ state }) => {
+ state.diffs.commit = { ...state.diffs.commit, id: 'OLD' };
+ });
+ spy();
+
+ store.state.diffs.commit = { id: 'NEW' };
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(urlUtils.updateHistory).toHaveBeenCalledWith({
+ title: document.title,
+ url: UPDATED_COMMIT_URL,
+ });
+ expect(wrapper.vm.refetchDiffData).toHaveBeenCalled();
+ expect(wrapper.vm.adjustView).toHaveBeenCalled();
+ });
+ });
+
+ it.each`
+ isLoading | oldSha | newSha
+ ${true} | ${'OLD'} | ${'NEW'}
+ ${false} | ${'NEW'} | ${'NEW'}
+ `(
+ 'given `{ "isLoading": $isLoading, "oldSha": "$oldSha", "newSha": "$newSha" }`, nothing should happen',
+ ({ isLoading, oldSha, newSha }) => {
+ createComponent({}, ({ state }) => {
+ state.diffs.isLoading = isLoading;
+ state.diffs.commit = { ...state.diffs.commit, id: oldSha };
+ });
+ spy();
+
+ store.state.diffs.commit = { id: newSha };
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(urlUtils.updateHistory).not.toHaveBeenCalled();
+ expect(wrapper.vm.refetchDiffData).not.toHaveBeenCalled();
+ expect(wrapper.vm.adjustView).not.toHaveBeenCalled();
+ });
+ },
+ );
+ });
+
describe('diffs', () => {
it('should render compare versions component', () => {
createComponent({}, ({ state }) => {
diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js
index 6bb3a0dcf21..0df951d43a7 100644
--- a/spec/frontend/diffs/components/commit_item_spec.js
+++ b/spec/frontend/diffs/components/commit_item_spec.js
@@ -13,6 +13,8 @@ const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com';
const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`;
const TEST_SIGNATURE_HTML = '<a>Legit commit</a>';
const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`;
+const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`;
+const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`;
describe('diffs/components/commit_item', () => {
let wrapper;
@@ -30,12 +32,24 @@ describe('diffs/components/commit_item', () => {
const getCommitActionsElement = () => wrapper.find('.commit-actions');
const getCommitPipelineStatus = () => wrapper.find(CommitPipelineStatus);
- const mountComponent = propsData => {
+ const getCommitNavButtonsElement = () => wrapper.find('.commit-nav-buttons');
+ const getNextCommitNavElement = () =>
+ getCommitNavButtonsElement().find('.btn-group > *:last-child');
+ const getPrevCommitNavElement = () =>
+ getCommitNavButtonsElement().find('.btn-group > *:first-child');
+
+ const mountComponent = (propsData, featureFlags = {}) => {
wrapper = mount(Component, {
propsData: {
commit,
...propsData,
},
+ provide: {
+ glFeatures: {
+ mrCommitNeighborNav: true,
+ ...featureFlags,
+ },
+ },
stubs: {
CommitPipelineStatus: true,
},
@@ -173,4 +187,132 @@ describe('diffs/components/commit_item', () => {
expect(getCommitPipelineStatus().exists()).toBe(true);
});
});
+
+ describe('without neighbor commits', () => {
+ beforeEach(() => {
+ mountComponent({ commit: { ...commit, prev_commit_id: null, next_commit_id: null } });
+ });
+
+ it('does not render any navigation buttons', () => {
+ expect(getCommitNavButtonsElement().exists()).toEqual(false);
+ });
+ });
+
+ describe('with neighbor commits', () => {
+ let mrCommit;
+
+ beforeEach(() => {
+ mrCommit = {
+ ...commit,
+ next_commit_id: 'next',
+ prev_commit_id: 'prev',
+ };
+
+ mountComponent({ commit: mrCommit });
+ });
+
+ it('renders the commit navigation buttons', () => {
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+
+ mountComponent({
+ commit: { ...mrCommit, next_commit_id: null },
+ });
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+
+ mountComponent({
+ commit: { ...mrCommit, prev_commit_id: null },
+ });
+ expect(getCommitNavButtonsElement().exists()).toEqual(true);
+ });
+
+ it('does not render the commit navigation buttons if the `mrCommitNeighborNav` feature flag is disabled', () => {
+ mountComponent({ commit: mrCommit }, { mrCommitNeighborNav: false });
+
+ expect(getCommitNavButtonsElement().exists()).toEqual(false);
+ });
+
+ describe('prev commit', () => {
+ const { location } = window;
+
+ beforeAll(() => {
+ delete window.location;
+ window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` };
+ });
+
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ it('uses the correct href', () => {
+ const link = getPrevCommitNavElement();
+
+ expect(link.element.getAttribute('href')).toEqual(PREV_COMMIT_URL);
+ });
+
+ it('triggers the correct Vuex action on click', () => {
+ const link = getPrevCommitNavElement();
+
+ link.trigger('click');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({
+ direction: 'previous',
+ });
+ });
+ });
+
+ it('renders a disabled button when there is no prev commit', () => {
+ mountComponent({ commit: { ...mrCommit, prev_commit_id: null } });
+
+ const button = getPrevCommitNavElement();
+
+ expect(button.element.tagName).toEqual('BUTTON');
+ expect(button.element.hasAttribute('disabled')).toEqual(true);
+ });
+ });
+
+ describe('next commit', () => {
+ const { location } = window;
+
+ beforeAll(() => {
+ delete window.location;
+ window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` };
+ });
+
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {});
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ it('uses the correct href', () => {
+ const link = getNextCommitNavElement();
+
+ expect(link.element.getAttribute('href')).toEqual(NEXT_COMMIT_URL);
+ });
+
+ it('triggers the correct Vuex action on click', () => {
+ const link = getNextCommitNavElement();
+
+ link.trigger('click');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ direction: 'next' });
+ });
+ });
+
+ it('renders a disabled button when there is no next commit', () => {
+ mountComponent({ commit: { ...mrCommit, next_commit_id: null } });
+
+ const button = getNextCommitNavElement();
+
+ expect(button.element.tagName).toEqual('BUTTON');
+ expect(button.element.hasAttribute('disabled')).toEqual(true);
+ });
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js
index 979c67787f7..b78895f9e55 100644
--- a/spec/frontend/diffs/components/diff_content_spec.js
+++ b/spec/frontend/diffs/components/diff_content_spec.js
@@ -10,7 +10,7 @@ import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import NoteForm from '~/notes/components/note_form.vue';
import DiffDiscussions from '~/diffs/components/diff_discussions.vue';
import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
-import diffFileMockData from '../../../javascripts/diffs/mock_data/diff_file';
+import diffFileMockData from '../mock_data/diff_file';
import { diffViewerModes } from '~/ide/constants';
const localVue = createLocalVue();
diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js
index ba5a4f96204..83becc7a20a 100644
--- a/spec/frontend/diffs/components/diff_discussions_spec.js
+++ b/spec/frontend/diffs/components/diff_discussions_spec.js
@@ -13,7 +13,7 @@ const localVue = createLocalVue();
describe('DiffDiscussions', () => {
let store;
let wrapper;
- const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
+ const getDiscussionsMockData = () => [{ ...discussionsMockData }];
const createComponent = props => {
store = createStore();
diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
index 31c6a4d5b60..0504f3933e0 100644
--- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js
+++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
@@ -81,7 +81,7 @@ describe('DiffExpansionCell', () => {
isTop: false,
isBottom: false,
};
- const props = Object.assign({}, defaults, options);
+ const props = { ...defaults, ...options };
vm = createComponentWithStore(cmp, store, props).$mount();
};
diff --git a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
index 4d8345d494d..da18d8e7894 100644
--- a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
+++ b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js
@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
import discussionsMockData from '../mock_data/diff_discussions';
-const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
+const getDiscussionsMockData = () => [{ ...discussionsMockData }];
describe('DiffGutterAvatars', () => {
let wrapper;
diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js
index 9b032d10fdc..3e0acd0dace 100644
--- a/spec/frontend/diffs/components/diff_line_note_form_spec.js
+++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js
@@ -9,7 +9,7 @@ describe('DiffLineNoteForm', () => {
let wrapper;
let diffFile;
let diffLines;
- const getDiffFileMock = () => Object.assign({}, diffFileMockData);
+ const getDiffFileMock = () => ({ ...diffFileMockData });
beforeEach(() => {
diffFile = getDiffFileMock();
diff --git a/spec/frontend/diffs/components/edit_button_spec.js b/spec/frontend/diffs/components/edit_button_spec.js
index f9a1d4a84a8..71512c1c4af 100644
--- a/spec/frontend/diffs/components/edit_button_spec.js
+++ b/spec/frontend/diffs/components/edit_button_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlDeprecatedButton } from '@gitlab/ui';
import EditButton from '~/diffs/components/edit_button.vue';
const editPath = 'test-path';
@@ -22,7 +23,7 @@ describe('EditButton', () => {
canCurrentUserFork: false,
});
- expect(wrapper.attributes('href')).toBe(editPath);
+ expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(editPath);
});
it('emits a show fork message event if current user can fork', () => {
@@ -30,7 +31,7 @@ describe('EditButton', () => {
editPath,
canCurrentUserFork: true,
});
- wrapper.trigger('click');
+ wrapper.find(GlDeprecatedButton).trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('showForkMessage')).toBeTruthy();
@@ -42,7 +43,7 @@ describe('EditButton', () => {
editPath,
canCurrentUserFork: false,
});
- wrapper.trigger('click');
+ wrapper.find(GlDeprecatedButton).trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('showForkMessage')).toBeFalsy();
@@ -55,10 +56,20 @@ describe('EditButton', () => {
canCurrentUserFork: true,
canModifyBlob: true,
});
- wrapper.trigger('click');
+ wrapper.find(GlDeprecatedButton).trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('showForkMessage')).toBeFalsy();
});
});
+
+ it('disables button if editPath is empty', () => {
+ createComponent({
+ editPath: '',
+ canCurrentUserFork: true,
+ canModifyBlob: true,
+ });
+
+ expect(wrapper.find(GlDeprecatedButton).attributes('disabled')).toBe('true');
+ });
});
diff --git a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
index f423c3b111e..90f012fbafe 100644
--- a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
@@ -16,7 +16,7 @@ describe('InlineDiffExpansionRow', () => {
isTop: false,
isBottom: false,
};
- const props = Object.assign({}, defaults, options);
+ const props = { ...defaults, ...options };
return createComponentWithStore(cmp, createStore(), props).$mount();
};
diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js
index a63c13fb271..9b0cf6a84d9 100644
--- a/spec/frontend/diffs/components/inline_diff_view_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_view_spec.js
@@ -8,8 +8,8 @@ import discussionsMockData from '../mock_data/diff_discussions';
describe('InlineDiffView', () => {
let component;
- const getDiffFileMock = () => Object.assign({}, diffFileMockData);
- const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
+ const getDiffFileMock = () => ({ ...diffFileMockData });
+ const getDiscussionsMockData = () => [{ ...discussionsMockData }];
const notesLength = getDiscussionsMockData()[0].notes.length;
beforeEach(done => {
diff --git a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js b/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js
index 15b2a824697..38112445e8d 100644
--- a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js
+++ b/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js
@@ -16,7 +16,7 @@ describe('ParallelDiffExpansionRow', () => {
isTop: false,
isBottom: false,
};
- const props = Object.assign({}, defaults, options);
+ const props = { ...defaults, ...options };
return createComponentWithStore(cmp, createStore(), props).$mount();
};
diff --git a/spec/frontend/diffs/components/parallel_diff_view_spec.js b/spec/frontend/diffs/components/parallel_diff_view_spec.js
index 0eefbc7ec08..03cf1b72b62 100644
--- a/spec/frontend/diffs/components/parallel_diff_view_spec.js
+++ b/spec/frontend/diffs/components/parallel_diff_view_spec.js
@@ -7,7 +7,7 @@ import diffFileMockData from '../mock_data/diff_file';
describe('ParallelDiffView', () => {
let component;
- const getDiffFileMock = () => Object.assign({}, diffFileMockData);
+ const getDiffFileMock = () => ({ ...diffFileMockData });
beforeEach(() => {
const diffFile = getDiffFileMock();
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index ceccce6312f..3fba661da44 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -40,9 +40,12 @@ import {
receiveFullDiffError,
fetchFullDiff,
toggleFullDiff,
+ switchToFullDiffFromRenamedFile,
setFileCollapsed,
setExpandedDiffLines,
setSuggestPopoverDismissed,
+ changeCurrentCommit,
+ moveToNeighboringCommit,
} from '~/diffs/store/actions';
import eventHub from '~/notes/event_hub';
import * as types from '~/diffs/store/mutation_types';
@@ -312,7 +315,7 @@ describe('DiffsStoreActions', () => {
describe('fetchDiffFilesMeta', () => {
it('should fetch diff meta information', done => {
- const endpointMetadata = '/fetch/diffs_meta?';
+ const endpointMetadata = '/fetch/diffs_meta';
const mock = new MockAdapter(axios);
const data = { diff_files: [] };
const res = { data };
@@ -1250,6 +1253,87 @@ describe('DiffsStoreActions', () => {
});
});
+ describe('switchToFullDiffFromRenamedFile', () => {
+ const SUCCESS_URL = 'fakehost/context.success';
+ const ERROR_URL = 'fakehost/context.error';
+ const testFilePath = 'testpath';
+ const updatedViewerName = 'testviewer';
+ const preparedLine = { prepared: 'in-a-test' };
+ const testFile = {
+ file_path: testFilePath,
+ file_hash: 'testhash',
+ alternate_viewer: { name: updatedViewerName },
+ };
+ const updatedViewer = { name: updatedViewerName, collapsed: false };
+ const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }];
+ let renamedFile;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(utils, 'prepareLineForRenamedFile').mockImplementation(() => preparedLine);
+ });
+
+ afterEach(() => {
+ renamedFile = null;
+ mock.restore();
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ renamedFile = { ...testFile, context_lines_path: SUCCESS_URL };
+ mock.onGet(SUCCESS_URL).replyOnce(200, testData);
+ });
+
+ it.each`
+ diffViewType
+ ${INLINE_DIFF_VIEW_TYPE}
+ ${PARALLEL_DIFF_VIEW_TYPE}
+ `(
+ 'performs the correct mutations and starts a render queue for view type $diffViewType',
+ ({ diffViewType }) => {
+ return testAction(
+ switchToFullDiffFromRenamedFile,
+ { diffFile: renamedFile },
+ { diffViewType },
+ [
+ {
+ type: types.SET_DIFF_FILE_VIEWER,
+ payload: { filePath: testFilePath, viewer: updatedViewer },
+ },
+ {
+ type: types.SET_CURRENT_VIEW_DIFF_FILE_LINES,
+ payload: { filePath: testFilePath, lines: [preparedLine, preparedLine] },
+ },
+ ],
+ [{ type: 'startRenderDiffsQueue' }],
+ );
+ },
+ );
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ renamedFile = { ...testFile, context_lines_path: ERROR_URL };
+ mock.onGet(ERROR_URL).reply(500);
+ });
+
+ it('dispatches the error handling action', () => {
+ const rejected = testAction(
+ switchToFullDiffFromRenamedFile,
+ { diffFile: renamedFile },
+ null,
+ [],
+ [{ type: 'receiveFullDiffError', payload: testFilePath }],
+ );
+
+ return rejected.catch(error =>
+ expect(error).toEqual(new Error('Request failed with status code 500')),
+ );
+ });
+ });
+ });
+
describe('setFileCollapsed', () => {
it('commits SET_FILE_COLLAPSED', done => {
testAction(
@@ -1347,4 +1431,102 @@ describe('DiffsStoreActions', () => {
);
});
});
+
+ describe('changeCurrentCommit', () => {
+ it('commits the new commit information and re-requests the diff metadata for the commit', () => {
+ return testAction(
+ changeCurrentCommit,
+ { commitId: 'NEW' },
+ {
+ commit: {
+ id: 'OLD',
+ },
+ endpoint: 'URL/OLD',
+ endpointBatch: 'URL/OLD',
+ endpointMetadata: 'URL/OLD',
+ },
+ [
+ { type: types.SET_DIFF_FILES, payload: [] },
+ {
+ type: types.SET_BASE_CONFIG,
+ payload: {
+ commit: {
+ id: 'OLD', // Not a typo: the action fired next will overwrite all of the `commit` in state
+ },
+ endpoint: 'URL/NEW',
+ endpointBatch: 'URL/NEW',
+ endpointMetadata: 'URL/NEW',
+ },
+ },
+ ],
+ [{ type: 'fetchDiffFilesMeta' }],
+ );
+ });
+
+ it.each`
+ commitId | commit | msg
+ ${undefined} | ${{ id: 'OLD' }} | ${'`commitId` is a required argument'}
+ ${'NEW'} | ${null} | ${'`state` must already contain a valid `commit`'}
+ ${undefined} | ${null} | ${'`commitId` is a required argument'}
+ `(
+ 'returns a rejected promise with the error message $msg given `{ "commitId": $commitId, "state.commit": $commit }`',
+ ({ commitId, commit, msg }) => {
+ const err = new Error(msg);
+ const actionReturn = testAction(
+ changeCurrentCommit,
+ { commitId },
+ {
+ endpoint: 'URL/OLD',
+ endpointBatch: 'URL/OLD',
+ endpointMetadata: 'URL/OLD',
+ commit,
+ },
+ [],
+ [],
+ );
+
+ return expect(actionReturn).rejects.toStrictEqual(err);
+ },
+ );
+ });
+
+ describe('moveToNeighboringCommit', () => {
+ it.each`
+ direction | expected | currentCommit
+ ${'next'} | ${'NEXTSHA'} | ${{ next_commit_id: 'NEXTSHA' }}
+ ${'previous'} | ${'PREVIOUSSHA'} | ${{ prev_commit_id: 'PREVIOUSSHA' }}
+ `(
+ 'for the direction "$direction", dispatches the action to move to the SHA "$expected"',
+ ({ direction, expected, currentCommit }) => {
+ return testAction(
+ moveToNeighboringCommit,
+ { direction },
+ { commit: currentCommit },
+ [],
+ [{ type: 'changeCurrentCommit', payload: { commitId: expected } }],
+ );
+ },
+ );
+
+ it.each`
+ direction | diffsAreLoading | currentCommit
+ ${'next'} | ${false} | ${{ prev_commit_id: 'PREVIOUSSHA' }}
+ ${'next'} | ${true} | ${{ prev_commit_id: 'PREVIOUSSHA' }}
+ ${'next'} | ${false} | ${undefined}
+ ${'previous'} | ${false} | ${{ next_commit_id: 'NEXTSHA' }}
+ ${'previous'} | ${true} | ${{ next_commit_id: 'NEXTSHA' }}
+ ${'previous'} | ${false} | ${undefined}
+ `(
+ 'given `{ "isloading": $diffsAreLoading, "commit": $currentCommit }` in state, no actions are dispatched',
+ ({ direction, diffsAreLoading, currentCommit }) => {
+ return testAction(
+ moveToNeighboringCommit,
+ { direction },
+ { commit: currentCommit, isLoading: diffsAreLoading },
+ [],
+ [],
+ );
+ },
+ );
+ });
});
diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js
index ca47f51cb15..dac5be2d656 100644
--- a/spec/frontend/diffs/store/getters_spec.js
+++ b/spec/frontend/diffs/store/getters_spec.js
@@ -14,10 +14,10 @@ describe('Diffs Module Getters', () => {
beforeEach(() => {
localState = state();
- discussionMock = Object.assign({}, discussion);
+ discussionMock = { ...discussion };
discussionMock.diff_file.file_hash = diffFileMock.fileHash;
- discussionMock1 = Object.assign({}, discussion);
+ discussionMock1 = { ...discussion };
discussionMock1.diff_file.file_hash = diffFileMock.fileHash;
});
diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
index eb0f2364a50..0343ef75732 100644
--- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
+++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
@@ -18,7 +18,6 @@ describe('Compare diff version dropdowns', () => {
};
localState.targetBranchName = 'baseVersion';
localState.mergeRequestDiffs = diffsMockData;
- gon.features = { diffCompareWithHead: true };
});
describe('selectedTargetIndex', () => {
@@ -129,14 +128,6 @@ describe('Compare diff version dropdowns', () => {
});
assertVersions(targetVersions);
});
-
- it('does not list head version if feature flag is not enabled', () => {
- gon.features = { diffCompareWithHead: false };
- setupTest();
- const targetVersions = getters.diffCompareDropdownTargetVersions(localState, getters);
-
- expect(targetVersions.find(version => version.isHead)).toBeUndefined();
- });
});
it('diffCompareDropdownSourceVersions', () => {
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index 858ab5be167..c24d406fef3 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -1041,6 +1041,36 @@ describe('DiffsStoreMutations', () => {
});
});
+ describe('SET_DIFF_FILE_VIEWER', () => {
+ it("should update the correct diffFile's viewer property", () => {
+ const state = {
+ diffFiles: [
+ { file_path: 'SearchString', viewer: 'OLD VIEWER' },
+ { file_path: 'OtherSearchString' },
+ { file_path: 'SomeOtherString' },
+ ],
+ };
+
+ mutations[types.SET_DIFF_FILE_VIEWER](state, {
+ filePath: 'SearchString',
+ viewer: 'NEW VIEWER',
+ });
+
+ expect(state.diffFiles[0].viewer).toEqual('NEW VIEWER');
+ expect(state.diffFiles[1].viewer).not.toBeDefined();
+ expect(state.diffFiles[2].viewer).not.toBeDefined();
+
+ mutations[types.SET_DIFF_FILE_VIEWER](state, {
+ filePath: 'OtherSearchString',
+ viewer: 'NEW VIEWER',
+ });
+
+ expect(state.diffFiles[0].viewer).toEqual('NEW VIEWER');
+ expect(state.diffFiles[1].viewer).toEqual('NEW VIEWER');
+ expect(state.diffFiles[2].viewer).not.toBeDefined();
+ });
+ });
+
describe('SET_SHOW_SUGGEST_POPOVER', () => {
it('sets showSuggestPopover to false', () => {
const state = { showSuggestPopover: true };
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 1adcdab272a..641373e666f 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -361,6 +361,72 @@ describe('DiffsStoreUtils', () => {
});
});
+ describe('prepareLineForRenamedFile', () => {
+ const diffFile = {
+ file_hash: 'file-hash',
+ };
+ const lineIndex = 4;
+ const sourceLine = {
+ foo: 'test',
+ rich_text: ' <p>rich</p>', // Note the leading space
+ };
+ const correctLine = {
+ foo: 'test',
+ line_code: 'file-hash_5_5',
+ old_line: 5,
+ new_line: 5,
+ rich_text: '<p>rich</p>', // Note no leading space
+ discussionsExpanded: true,
+ discussions: [],
+ hasForm: false,
+ text: undefined,
+ alreadyPrepared: true,
+ };
+ let preppedLine;
+
+ beforeEach(() => {
+ preppedLine = utils.prepareLineForRenamedFile({
+ diffViewType: INLINE_DIFF_VIEW_TYPE,
+ line: sourceLine,
+ index: lineIndex,
+ diffFile,
+ });
+ });
+
+ it('copies over the original line object to the new prepared line', () => {
+ expect(preppedLine).toEqual(
+ expect.objectContaining({
+ foo: correctLine.foo,
+ rich_text: correctLine.rich_text,
+ }),
+ );
+ });
+
+ it('correctly sets the old and new lines, plus a line code', () => {
+ expect(preppedLine.old_line).toEqual(correctLine.old_line);
+ expect(preppedLine.new_line).toEqual(correctLine.new_line);
+ expect(preppedLine.line_code).toEqual(correctLine.line_code);
+ });
+
+ it('returns a single object with the correct structure for `inline` lines', () => {
+ expect(preppedLine).toEqual(correctLine);
+ });
+
+ it('returns a nested object with "left" and "right" lines + the line code for `parallel` lines', () => {
+ preppedLine = utils.prepareLineForRenamedFile({
+ diffViewType: PARALLEL_DIFF_VIEW_TYPE,
+ line: sourceLine,
+ index: lineIndex,
+ diffFile,
+ });
+
+ expect(Object.keys(preppedLine)).toEqual(['left', 'right', 'line_code']);
+ expect(preppedLine.left).toEqual(correctLine);
+ expect(preppedLine.right).toEqual(correctLine);
+ expect(preppedLine.line_code).toEqual(correctLine.line_code);
+ });
+ });
+
describe('prepareDiffData', () => {
let mock;
let preparedDiff;
@@ -372,13 +438,13 @@ describe('DiffsStoreUtils', () => {
mock = getDiffFileMock();
preparedDiff = { diff_files: [mock] };
splitInlineDiff = {
- diff_files: [Object.assign({}, mock, { parallel_diff_lines: undefined })],
+ diff_files: [{ ...mock, parallel_diff_lines: undefined }],
};
splitParallelDiff = {
- diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })],
+ diff_files: [{ ...mock, highlighted_diff_lines: undefined }],
};
completedDiff = {
- diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })],
+ diff_files: [{ ...mock, highlighted_diff_lines: undefined }],
};
preparedDiff.diff_files = utils.prepareDiffData(preparedDiff);
@@ -503,11 +569,16 @@ describe('DiffsStoreUtils', () => {
},
};
+ // When multi line comments are fully implemented `line_code` will be
+ // included in all requests. Until then we need to ensure the logic does
+ // not change when it is included only in the "comparison" argument.
+ const lineRange = { start_line_code: 'abc_1_1', end_line_code: 'abc_1_2' };
+
it('returns true when the discussion is up to date', () => {
expect(
utils.isDiscussionApplicableToLine({
discussion: discussions.upToDateDiscussion1,
- diffPosition,
+ diffPosition: { ...diffPosition, line_range: lineRange },
latestDiff: true,
}),
).toBe(true);
@@ -517,7 +588,7 @@ describe('DiffsStoreUtils', () => {
expect(
utils.isDiscussionApplicableToLine({
discussion: discussions.outDatedDiscussion1,
- diffPosition,
+ diffPosition: { ...diffPosition, line_range: lineRange },
latestDiff: true,
}),
).toBe(false);
@@ -534,6 +605,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
lineCode: 'ABC_1',
+ line_range: lineRange,
},
latestDiff: true,
}),
@@ -551,6 +623,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
line_code: 'ABC_1',
+ line_range: lineRange,
},
latestDiff: true,
}),
@@ -568,6 +641,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
lineCode: 'ABC_1',
+ line_range: lineRange,
},
latestDiff: false,
}),
diff --git a/spec/frontend/dirty_submit/dirty_submit_collection_spec.js b/spec/frontend/dirty_submit/dirty_submit_collection_spec.js
new file mode 100644
index 00000000000..170d581be23
--- /dev/null
+++ b/spec/frontend/dirty_submit/dirty_submit_collection_spec.js
@@ -0,0 +1,22 @@
+import DirtySubmitCollection from '~/dirty_submit/dirty_submit_collection';
+import { setInputValue, createForm } from './helper';
+
+jest.mock('lodash/throttle', () => jest.fn(fn => fn));
+
+describe('DirtySubmitCollection', () => {
+ const testElementsCollection = [createForm(), createForm()];
+ const forms = testElementsCollection.map(testElements => testElements.form);
+
+ new DirtySubmitCollection(forms); // eslint-disable-line no-new
+
+ it.each(testElementsCollection)('disables submits until there are changes', testElements => {
+ const { input, submit } = testElements;
+ const originalValue = input.value;
+
+ expect(submit.disabled).toBe(true);
+ setInputValue(input, `${originalValue} changes`);
+ expect(submit.disabled).toBe(false);
+ setInputValue(input, originalValue);
+ expect(submit.disabled).toBe(true);
+ });
+});
diff --git a/spec/frontend/dirty_submit/dirty_submit_factory_spec.js b/spec/frontend/dirty_submit/dirty_submit_factory_spec.js
new file mode 100644
index 00000000000..40843a68582
--- /dev/null
+++ b/spec/frontend/dirty_submit/dirty_submit_factory_spec.js
@@ -0,0 +1,18 @@
+import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
+import DirtySubmitForm from '~/dirty_submit/dirty_submit_form';
+import DirtySubmitCollection from '~/dirty_submit/dirty_submit_collection';
+import { createForm } from './helper';
+
+describe('DirtySubmitCollection', () => {
+ it('returns a DirtySubmitForm instance for single form elements', () => {
+ const { form } = createForm();
+
+ expect(dirtySubmitFactory(form) instanceof DirtySubmitForm).toBe(true);
+ });
+
+ it('returns a DirtySubmitCollection instance for a collection of form elements', () => {
+ const forms = [createForm().form, createForm().form];
+
+ expect(dirtySubmitFactory(forms) instanceof DirtySubmitCollection).toBe(true);
+ });
+});
diff --git a/spec/frontend/dirty_submit/dirty_submit_form_spec.js b/spec/frontend/dirty_submit/dirty_submit_form_spec.js
new file mode 100644
index 00000000000..d7f690df1f3
--- /dev/null
+++ b/spec/frontend/dirty_submit/dirty_submit_form_spec.js
@@ -0,0 +1,97 @@
+import { range as rge, throttle } from 'lodash';
+import DirtySubmitForm from '~/dirty_submit/dirty_submit_form';
+import { getInputValue, setInputValue, createForm } from './helper';
+
+jest.mock('lodash/throttle', () => jest.fn(fn => fn));
+const lodash = jest.requireActual('lodash');
+
+function expectToToggleDisableOnDirtyUpdate(submit, input) {
+ const originalValue = getInputValue(input);
+
+ expect(submit.disabled).toBe(true);
+
+ setInputValue(input, `${originalValue} changes`);
+ expect(submit.disabled).toBe(false);
+ setInputValue(input, originalValue);
+ expect(submit.disabled).toBe(true);
+}
+
+describe('DirtySubmitForm', () => {
+ describe('submit button tests', () => {
+ it('disables submit until there are changes', () => {
+ const { form, input, submit } = createForm();
+
+ new DirtySubmitForm(form); // eslint-disable-line no-new
+
+ expectToToggleDisableOnDirtyUpdate(submit, input);
+ });
+
+ it('disables submit until there are changes when initializing with a falsy value', () => {
+ const { form, input, submit } = createForm();
+ input.value = '';
+
+ new DirtySubmitForm(form); // eslint-disable-line no-new
+
+ expectToToggleDisableOnDirtyUpdate(submit, input);
+ });
+
+ it('disables submit until there are changes for radio inputs', () => {
+ const { form, input, submit } = createForm('radio');
+
+ new DirtySubmitForm(form); // eslint-disable-line no-new
+
+ expectToToggleDisableOnDirtyUpdate(submit, input);
+ });
+
+ it('disables submit until there are changes for checkbox inputs', () => {
+ const { form, input, submit } = createForm('checkbox');
+
+ new DirtySubmitForm(form); // eslint-disable-line no-new
+
+ expectToToggleDisableOnDirtyUpdate(submit, input);
+ });
+ });
+
+ describe('throttling tests', () => {
+ beforeEach(() => {
+ throttle.mockImplementation(lodash.throttle);
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ throttle.mockReset();
+ });
+
+ it('throttles updates when rapid changes are made to a single form element', () => {
+ const { form, input } = createForm();
+ const updateDirtyInputSpy = jest.spyOn(new DirtySubmitForm(form), 'updateDirtyInput');
+
+ rge(10).forEach(i => {
+ setInputValue(input, `change ${i}`, false);
+ });
+
+ jest.runOnlyPendingTimers();
+
+ expect(updateDirtyInputSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not throttle updates when rapid changes are made to different form elements', () => {
+ const form = document.createElement('form');
+ const range = rge(10);
+ range.forEach(i => {
+ form.innerHTML += `<input type="text" name="input-${i}" class="js-input-${i}"/>`;
+ });
+
+ const updateDirtyInputSpy = jest.spyOn(new DirtySubmitForm(form), 'updateDirtyInput');
+
+ range.forEach(i => {
+ const input = form.querySelector(`.js-input-${i}`);
+ setInputValue(input, `change`, false);
+ });
+
+ jest.runOnlyPendingTimers();
+
+ expect(updateDirtyInputSpy).toHaveBeenCalledTimes(range.length);
+ });
+ });
+});
diff --git a/spec/frontend/dirty_submit/helper.js b/spec/frontend/dirty_submit/helper.js
new file mode 100644
index 00000000000..c02512b7671
--- /dev/null
+++ b/spec/frontend/dirty_submit/helper.js
@@ -0,0 +1,43 @@
+function isCheckableType(type) {
+ return /^(radio|checkbox)$/.test(type);
+}
+
+export function setInputValue(element, value) {
+ const { type } = element;
+ let eventType;
+
+ if (isCheckableType(type)) {
+ element.checked = !element.checked;
+ eventType = 'change';
+ } else {
+ element.value = value;
+ eventType = 'input';
+ }
+
+ element.dispatchEvent(
+ new Event(eventType, {
+ bubbles: true,
+ }),
+ );
+}
+
+export function getInputValue(input) {
+ return isCheckableType(input.type) ? input.checked : input.value;
+}
+
+export function createForm(type = 'text') {
+ const form = document.createElement('form');
+ form.innerHTML = `
+ <input type="${type}" name="${type}" class="js-input"/>
+ <button type="submit" class="js-dirty-submit"></button>
+ `;
+
+ const input = form.querySelector('.js-input');
+ const submit = form.querySelector('.js-dirty-submit');
+
+ return {
+ form,
+ input,
+ submit,
+ };
+}
diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js
new file mode 100644
index 00000000000..cb07bcf8f28
--- /dev/null
+++ b/spec/frontend/editor/editor_lite_spec.js
@@ -0,0 +1,177 @@
+import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
+import Editor from '~/editor/editor_lite';
+import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
+
+describe('Base editor', () => {
+ let editorEl;
+ let editor;
+ const blobContent = 'Foo Bar';
+ const blobPath = 'test.md';
+ const uri = new Uri('gitlab', false, blobPath);
+ const fakeModel = { foo: 'bar' };
+
+ beforeEach(() => {
+ setFixtures('<div id="editor" data-editor-loading></div>');
+ editorEl = document.getElementById('editor');
+ editor = new Editor();
+ });
+
+ afterEach(() => {
+ editor.dispose();
+ editorEl.remove();
+ });
+
+ it('initializes Editor with basic properties', () => {
+ expect(editor).toBeDefined();
+ expect(editor.editorEl).toBe(null);
+ expect(editor.blobContent).toEqual('');
+ expect(editor.blobPath).toEqual('');
+ });
+
+ it('removes `editor-loading` data attribute from the target DOM element', () => {
+ editor.createInstance({ el: editorEl });
+
+ expect(editorEl.dataset.editorLoading).toBeUndefined();
+ });
+
+ describe('instance of the Editor', () => {
+ let modelSpy;
+ let instanceSpy;
+ let setModel;
+ let dispose;
+
+ beforeEach(() => {
+ setModel = jest.fn();
+ dispose = jest.fn();
+ modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => fakeModel);
+ instanceSpy = jest.spyOn(monacoEditor, 'create').mockImplementation(() => ({
+ setModel,
+ dispose,
+ }));
+ });
+
+ it('does nothing if no dom element is supplied', () => {
+ editor.createInstance();
+
+ expect(editor.editorEl).toBe(null);
+ expect(editor.blobContent).toEqual('');
+ expect(editor.blobPath).toEqual('');
+
+ expect(modelSpy).not.toHaveBeenCalled();
+ expect(instanceSpy).not.toHaveBeenCalled();
+ expect(setModel).not.toHaveBeenCalled();
+ });
+
+ it('creates model to be supplied to Monaco editor', () => {
+ editor.createInstance({ el: editorEl, blobPath, blobContent });
+
+ expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, uri);
+ expect(setModel).toHaveBeenCalledWith(fakeModel);
+ });
+
+ it('initializes the instance on a supplied DOM node', () => {
+ editor.createInstance({ el: editorEl });
+
+ expect(editor.editorEl).not.toBe(null);
+ expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything());
+ });
+ });
+
+ describe('implementation', () => {
+ beforeEach(() => {
+ editor.createInstance({ el: editorEl, blobPath, blobContent });
+ });
+
+ afterEach(() => {
+ editor.model.dispose();
+ });
+
+ it('correctly proxies value from the model', () => {
+ expect(editor.getValue()).toEqual(blobContent);
+ });
+
+ it('is capable of changing the language of the model', () => {
+ // ignore warnings and errors Monaco posts during setup
+ // (due to being called from Jest/Node.js environment)
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ const blobRenamedPath = 'test.js';
+
+ expect(editor.model.getLanguageIdentifier().language).toEqual('markdown');
+ editor.updateModelLanguage(blobRenamedPath);
+
+ expect(editor.model.getLanguageIdentifier().language).toEqual('javascript');
+ });
+
+ it('falls back to plaintext if there is no language associated with an extension', () => {
+ const blobRenamedPath = 'test.myext';
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ editor.updateModelLanguage(blobRenamedPath);
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(editor.model.getLanguageIdentifier().language).toEqual('plaintext');
+ });
+ });
+
+ describe('languages', () => {
+ it('registers custom languages defined with Monaco', () => {
+ expect(monacoLanguages.getLanguages()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'vue',
+ }),
+ ]),
+ );
+ });
+ });
+
+ describe('syntax highlighting theme', () => {
+ let themeDefineSpy;
+ let themeSetSpy;
+ let defaultScheme;
+
+ beforeEach(() => {
+ themeDefineSpy = jest.spyOn(monacoEditor, 'defineTheme').mockImplementation(() => {});
+ themeSetSpy = jest.spyOn(monacoEditor, 'setTheme').mockImplementation(() => {});
+ defaultScheme = window.gon.user_color_scheme;
+ });
+
+ afterEach(() => {
+ window.gon.user_color_scheme = defaultScheme;
+ });
+
+ it('sets default syntax highlighting theme', () => {
+ const expectedTheme = themes.find(t => t.name === DEFAULT_THEME);
+
+ editor = new Editor();
+
+ expect(themeDefineSpy).toHaveBeenCalledWith(DEFAULT_THEME, expectedTheme.data);
+ expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME);
+ });
+
+ it('sets correct theme if it is set in users preferences', () => {
+ const expectedTheme = themes.find(t => t.name !== DEFAULT_THEME);
+
+ expect(expectedTheme.name).not.toBe(DEFAULT_THEME);
+
+ window.gon.user_color_scheme = expectedTheme.name;
+ editor = new Editor();
+
+ expect(themeDefineSpy).toHaveBeenCalledWith(expectedTheme.name, expectedTheme.data);
+ expect(themeSetSpy).toHaveBeenCalledWith(expectedTheme.name);
+ });
+
+ it('falls back to default theme if a selected one is not supported yet', () => {
+ const name = 'non-existent-theme';
+ const nonExistentTheme = { name };
+
+ window.gon.user_color_scheme = nonExistentTheme.name;
+ editor = new Editor();
+
+ expect(themeDefineSpy).not.toHaveBeenCalled();
+ expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME);
+ });
+ });
+});
diff --git a/spec/frontend/emoji_spec.js b/spec/frontend/emoji_spec.js
new file mode 100644
index 00000000000..25bc95e0dd6
--- /dev/null
+++ b/spec/frontend/emoji_spec.js
@@ -0,0 +1,485 @@
+import { glEmojiTag } from '~/emoji';
+import isEmojiUnicodeSupported, {
+ isFlagEmoji,
+ isRainbowFlagEmoji,
+ isKeycapEmoji,
+ isSkinToneComboEmoji,
+ isHorceRacingSkinToneComboEmoji,
+ isPersonZwjEmoji,
+} from '~/emoji/support/is_emoji_unicode_supported';
+
+const emptySupportMap = {
+ personZwj: false,
+ horseRacing: false,
+ flag: false,
+ skinToneModifier: false,
+ '9.0': false,
+ '8.0': false,
+ '7.0': false,
+ 6.1: false,
+ '6.0': false,
+ 5.2: false,
+ 5.1: false,
+ 4.1: false,
+ '4.0': false,
+ 3.2: false,
+ '3.0': false,
+ 1.1: false,
+};
+
+const emojiFixtureMap = {
+ bomb: {
+ name: 'bomb',
+ moji: '💣',
+ unicodeVersion: '6.0',
+ },
+ construction_worker_tone5: {
+ name: 'construction_worker_tone5',
+ moji: '👷🏿',
+ unicodeVersion: '8.0',
+ },
+ five: {
+ name: 'five',
+ moji: '5️⃣',
+ unicodeVersion: '3.0',
+ },
+ grey_question: {
+ name: 'grey_question',
+ moji: '❔',
+ unicodeVersion: '6.0',
+ },
+};
+
+function markupToDomElement(markup) {
+ const div = document.createElement('div');
+ div.innerHTML = markup;
+ return div.firstElementChild;
+}
+
+function testGlEmojiImageFallback(element, name, src) {
+ expect(element.tagName.toLowerCase()).toBe('img');
+ expect(element.getAttribute('src')).toBe(src);
+ expect(element.getAttribute('title')).toBe(`:${name}:`);
+ expect(element.getAttribute('alt')).toBe(`:${name}:`);
+}
+
+const defaults = {
+ forceFallback: false,
+ sprite: false,
+};
+
+function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) {
+ const opts = { ...defaults, ...options };
+ expect(element.tagName.toLowerCase()).toBe('gl-emoji');
+ expect(element.dataset.name).toBe(name);
+ expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0);
+ expect(element.dataset.unicodeVersion).toBe(unicodeVersion);
+
+ const fallbackSpriteClass = `emoji-${name}`;
+ if (opts.sprite) {
+ expect(element.dataset.fallbackSpriteClass).toBe(fallbackSpriteClass);
+ }
+
+ if (opts.forceFallback && opts.sprite) {
+ expect(element.getAttribute('class')).toBe(`emoji-icon ${fallbackSpriteClass}`);
+ }
+
+ if (opts.forceFallback && !opts.sprite) {
+ // Check for image fallback
+ testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc);
+ } else {
+ // Otherwise make sure things are still unicode text
+ expect(element.textContent.trim()).toBe(unicodeMoji);
+ }
+}
+
+describe('gl_emoji', () => {
+ describe('glEmojiTag', () => {
+ it('bomb emoji', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ );
+ });
+
+ it('bomb emoji with image fallback', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ forceFallback: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ },
+ );
+ });
+
+ it('bomb emoji with sprite fallback readiness', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ sprite: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ sprite: true,
+ },
+ );
+ });
+
+ it('bomb emoji with sprite fallback', () => {
+ const emojiKey = 'bomb';
+ const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
+ forceFallback: true,
+ sprite: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ sprite: true,
+ },
+ );
+ });
+
+ it('question mark when invalid emoji name given', () => {
+ const name = 'invalid_emoji';
+ const emojiKey = 'grey_question';
+ const markup = glEmojiTag(name);
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ );
+ });
+
+ it('question mark with image fallback when invalid emoji name given', () => {
+ const name = 'invalid_emoji';
+ const emojiKey = 'grey_question';
+ const markup = glEmojiTag(name, {
+ forceFallback: true,
+ });
+ const glEmojiElement = markupToDomElement(markup);
+ testGlEmojiElement(
+ glEmojiElement,
+ emojiFixtureMap[emojiKey].name,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ emojiFixtureMap[emojiKey].moji,
+ {
+ forceFallback: true,
+ },
+ );
+ });
+ });
+
+ describe('isFlagEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isFlagEmoji('')).toBeFalsy();
+ });
+
+ it('should detect flag_ac', () => {
+ expect(isFlagEmoji('🇦🇨')).toBeTruthy();
+ });
+
+ it('should detect flag_us', () => {
+ expect(isFlagEmoji('🇺🇸')).toBeTruthy();
+ });
+
+ it('should detect flag_zw', () => {
+ expect(isFlagEmoji('🇿🇼')).toBeTruthy();
+ });
+
+ it('should not detect flags', () => {
+ expect(isFlagEmoji('🎏')).toBeFalsy();
+ });
+
+ it('should not detect triangular_flag_on_post', () => {
+ expect(isFlagEmoji('🚩')).toBeFalsy();
+ });
+
+ it('should not detect single letter', () => {
+ expect(isFlagEmoji('🇦')).toBeFalsy();
+ });
+
+ it('should not detect >2 letters', () => {
+ expect(isFlagEmoji('🇦🇧🇨')).toBeFalsy();
+ });
+ });
+
+ describe('isRainbowFlagEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isRainbowFlagEmoji('')).toBeFalsy();
+ });
+
+ it('should detect rainbow_flag', () => {
+ expect(isRainbowFlagEmoji('🏳🌈')).toBeTruthy();
+ });
+
+ it("should not detect flag_white on its' own", () => {
+ expect(isRainbowFlagEmoji('🏳')).toBeFalsy();
+ });
+
+ it("should not detect rainbow on its' own", () => {
+ expect(isRainbowFlagEmoji('🌈')).toBeFalsy();
+ });
+
+ it('should not detect flag_white with something else', () => {
+ expect(isRainbowFlagEmoji('🏳🔵')).toBeFalsy();
+ });
+ });
+
+ describe('isKeycapEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isKeycapEmoji('')).toBeFalsy();
+ });
+
+ it('should detect one(keycap)', () => {
+ expect(isKeycapEmoji('1️⃣')).toBeTruthy();
+ });
+
+ it('should detect nine(keycap)', () => {
+ expect(isKeycapEmoji('9️⃣')).toBeTruthy();
+ });
+
+ it('should not detect ten(keycap)', () => {
+ expect(isKeycapEmoji('🔟')).toBeFalsy();
+ });
+
+ it('should not detect hash(keycap)', () => {
+ expect(isKeycapEmoji('#⃣')).toBeFalsy();
+ });
+ });
+
+ describe('isSkinToneComboEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isSkinToneComboEmoji('')).toBeFalsy();
+ });
+
+ it('should detect hand_splayed_tone5', () => {
+ expect(isSkinToneComboEmoji('🖐🏿')).toBeTruthy();
+ });
+
+ it('should not detect hand_splayed', () => {
+ expect(isSkinToneComboEmoji('🖐')).toBeFalsy();
+ });
+
+ it('should detect lifter_tone1', () => {
+ expect(isSkinToneComboEmoji('🏋🏻')).toBeTruthy();
+ });
+
+ it('should not detect lifter', () => {
+ expect(isSkinToneComboEmoji('🏋')).toBeFalsy();
+ });
+
+ it('should detect rowboat_tone4', () => {
+ expect(isSkinToneComboEmoji('🚣🏾')).toBeTruthy();
+ });
+
+ it('should not detect rowboat', () => {
+ expect(isSkinToneComboEmoji('🚣')).toBeFalsy();
+ });
+
+ it('should not detect individual tone emoji', () => {
+ expect(isSkinToneComboEmoji('🏻')).toBeFalsy();
+ });
+ });
+
+ describe('isHorceRacingSkinToneComboEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isHorceRacingSkinToneComboEmoji('')).toBeFalsy();
+ });
+
+ it('should detect horse_racing_tone2', () => {
+ expect(isHorceRacingSkinToneComboEmoji('🏇🏼')).toBeTruthy();
+ });
+
+ it('should not detect horse_racing', () => {
+ expect(isHorceRacingSkinToneComboEmoji('🏇')).toBeFalsy();
+ });
+ });
+
+ describe('isPersonZwjEmoji', () => {
+ it('should gracefully handle empty string', () => {
+ expect(isPersonZwjEmoji('')).toBeFalsy();
+ });
+
+ it('should detect couple_mm', () => {
+ expect(isPersonZwjEmoji('👨‍❤️‍👨')).toBeTruthy();
+ });
+
+ it('should not detect couple_with_heart', () => {
+ expect(isPersonZwjEmoji('💑')).toBeFalsy();
+ });
+
+ it('should not detect couplekiss', () => {
+ expect(isPersonZwjEmoji('💏')).toBeFalsy();
+ });
+
+ it('should detect family_mmb', () => {
+ expect(isPersonZwjEmoji('👨‍👨‍👦')).toBeTruthy();
+ });
+
+ it('should detect family_mwgb', () => {
+ expect(isPersonZwjEmoji('👨‍👩‍👧‍👦')).toBeTruthy();
+ });
+
+ it('should not detect family', () => {
+ expect(isPersonZwjEmoji('👪')).toBeFalsy();
+ });
+
+ it('should detect kiss_ww', () => {
+ expect(isPersonZwjEmoji('👩‍❤️‍💋‍👩')).toBeTruthy();
+ });
+
+ it('should not detect girl', () => {
+ expect(isPersonZwjEmoji('👧')).toBeFalsy();
+ });
+
+ it('should not detect girl_tone5', () => {
+ expect(isPersonZwjEmoji('👧🏿')).toBeFalsy();
+ });
+
+ it('should not detect man', () => {
+ expect(isPersonZwjEmoji('👨')).toBeFalsy();
+ });
+
+ it('should not detect woman', () => {
+ expect(isPersonZwjEmoji('👩')).toBeFalsy();
+ });
+ });
+
+ describe('isEmojiUnicodeSupported', () => {
+ it('should gracefully handle empty string with unicode support', () => {
+ const isSupported = isEmojiUnicodeSupported({ '1.0': true }, '', '1.0');
+
+ expect(isSupported).toBeTruthy();
+ });
+
+ it('should gracefully handle empty string without unicode support', () => {
+ const isSupported = isEmojiUnicodeSupported({}, '', '1.0');
+
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('bomb(6.0) with 6.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = { ...emptySupportMap, '6.0': true };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeTruthy();
+ });
+
+ it('bomb(6.0) without 6.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = emptySupportMap;
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('bomb(6.0) without 6.0 but with 9.0 support', () => {
+ const emojiKey = 'bomb';
+ const unicodeSupportMap = { ...emptySupportMap, '9.0': true };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('construction_worker_tone5(8.0) without skin tone modifier support', () => {
+ const emojiKey = 'construction_worker_tone5';
+ const unicodeSupportMap = {
+ ...emptySupportMap,
+ skinToneModifier: false,
+ '9.0': true,
+ '8.0': true,
+ '7.0': true,
+ 6.1: true,
+ '6.0': true,
+ 5.2: true,
+ 5.1: true,
+ 4.1: true,
+ '4.0': true,
+ 3.2: true,
+ '3.0': true,
+ 1.1: true,
+ };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeFalsy();
+ });
+
+ it('use native keycap on >=57 chrome', () => {
+ const emojiKey = 'five';
+ const unicodeSupportMap = {
+ ...emptySupportMap,
+ '3.0': true,
+ meta: {
+ isChrome: true,
+ chromeVersion: 57,
+ },
+ };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeTruthy();
+ });
+
+ it('fallback keycap on <57 chrome', () => {
+ const emojiKey = 'five';
+ const unicodeSupportMap = {
+ ...emptySupportMap,
+ '3.0': true,
+ meta: {
+ isChrome: true,
+ chromeVersion: 50,
+ },
+ };
+ const isSupported = isEmojiUnicodeSupported(
+ unicodeSupportMap,
+ emojiFixtureMap[emojiKey].moji,
+ emojiFixtureMap[emojiKey].unicodeVersion,
+ );
+
+ expect(isSupported).toBeFalsy();
+ });
+ });
+});
diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
new file mode 100644
index 00000000000..2c3c3e3267a
--- /dev/null
+++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
@@ -0,0 +1,62 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import { getSelector, dismiss, inserted } from '~/feature_highlight/feature_highlight_helper';
+import { togglePopover } from '~/shared/popover';
+
+describe('feature highlight helper', () => {
+ describe('getSelector', () => {
+ it('returns js-feature-highlight selector', () => {
+ const highlightId = 'highlightId';
+
+ expect(getSelector(highlightId)).toEqual(
+ `.js-feature-highlight[data-highlight=${highlightId}]`,
+ );
+ });
+ });
+
+ describe('dismiss', () => {
+ const context = {
+ hide: () => {},
+ attr: () => '/-/callouts/dismiss',
+ };
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'post').mockResolvedValue();
+ jest.spyOn(togglePopover, 'call').mockImplementation(() => {});
+ jest.spyOn(context, 'hide').mockImplementation(() => {});
+ dismiss.call(context);
+ });
+
+ it('calls persistent dismissal endpoint', () => {
+ expect(axios.post).toHaveBeenCalledWith(
+ '/-/callouts/dismiss',
+ expect.objectContaining({ feature_name: undefined }),
+ );
+ });
+
+ it('calls hide popover', () => {
+ expect(togglePopover.call).toHaveBeenCalledWith(context, false);
+ });
+
+ it('calls hide', () => {
+ expect(context.hide).toHaveBeenCalled();
+ });
+ });
+
+ describe('inserted', () => {
+ it('registers click event callback', done => {
+ const context = {
+ getAttribute: () => 'popoverId',
+ dataset: {
+ highlight: 'some-feature',
+ },
+ };
+
+ jest.spyOn($.fn, 'on').mockImplementation(event => {
+ expect(event).toEqual('click');
+ done();
+ });
+ inserted.call(context);
+ });
+ });
+});
diff --git a/spec/frontend/feature_highlight/feature_highlight_options_spec.js b/spec/frontend/feature_highlight/feature_highlight_options_spec.js
index 8b75c46fd4c..f82f984cb7f 100644
--- a/spec/frontend/feature_highlight/feature_highlight_options_spec.js
+++ b/spec/frontend/feature_highlight/feature_highlight_options_spec.js
@@ -3,34 +3,20 @@ import domContentLoaded from '~/feature_highlight/feature_highlight_options';
describe('feature highlight options', () => {
describe('domContentLoaded', () => {
- it('should not call highlightFeatures when breakpoint is xs', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs');
-
- expect(domContentLoaded()).toBe(false);
- });
-
- it('should not call highlightFeatures when breakpoint is sm', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm');
-
- expect(domContentLoaded()).toBe(false);
- });
-
- it('should not call highlightFeatures when breakpoint is md', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
-
- expect(domContentLoaded()).toBe(false);
- });
-
- it('should not call highlightFeatures when breakpoint is not xl', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg');
-
- expect(domContentLoaded()).toBe(false);
- });
-
- it('should call highlightFeatures when breakpoint is xl', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
-
- expect(domContentLoaded()).toBe(true);
- });
+ it.each`
+ breakPoint | shouldCall
+ ${'xs'} | ${false}
+ ${'sm'} | ${false}
+ ${'md'} | ${false}
+ ${'lg'} | ${false}
+ ${'xl'} | ${true}
+ `(
+ 'when breakpoint is $breakPoint should call highlightFeatures is $shouldCall',
+ ({ breakPoint, shouldCall }) => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue(breakPoint);
+
+ expect(domContentLoaded()).toBe(shouldCall);
+ },
+ );
});
});
diff --git a/spec/frontend/feature_highlight/feature_highlight_spec.js b/spec/frontend/feature_highlight/feature_highlight_spec.js
new file mode 100644
index 00000000000..79c4050c8c4
--- /dev/null
+++ b/spec/frontend/feature_highlight/feature_highlight_spec.js
@@ -0,0 +1,120 @@
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import * as featureHighlight from '~/feature_highlight/feature_highlight';
+import * as popover from '~/shared/popover';
+import axios from '~/lib/utils/axios_utils';
+
+jest.mock('~/shared/popover');
+
+describe('feature highlight', () => {
+ beforeEach(() => {
+ setFixtures(`
+ <div>
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" data-dismiss-endpoint="/test" disabled>
+ Trigger
+ </div>
+ </div>
+ <div class="feature-highlight-popover-content">
+ Content
+ <div class="dismiss-feature-highlight">
+ Dismiss
+ </div>
+ </div>
+ `);
+ });
+
+ describe('setupFeatureHighlightPopover', () => {
+ let mock;
+ const selector = '.js-feature-highlight[data-highlight=test]';
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet('/test').reply(200);
+ jest.spyOn(window, 'addEventListener').mockImplementation(() => {});
+ featureHighlight.setupFeatureHighlightPopover('test', 0);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('setup popover content', () => {
+ const $popoverContent = $('.feature-highlight-popover-content');
+ const outerHTML = $popoverContent.prop('outerHTML');
+
+ expect($(selector).data('content')).toEqual(outerHTML);
+ });
+
+ it('setup mouseenter', () => {
+ $(selector).trigger('mouseenter');
+
+ expect(popover.mouseenter).toHaveBeenCalledWith(expect.any(Object));
+ });
+
+ it('setup debounced mouseleave', () => {
+ $(selector).trigger('mouseleave');
+
+ expect(popover.debouncedMouseleave).toHaveBeenCalled();
+ });
+
+ it('setup show.bs.popover', () => {
+ $(selector).trigger('show.bs.popover');
+
+ expect(window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), {
+ once: true,
+ });
+ });
+
+ it('removes disabled attribute', () => {
+ expect($('.js-feature-highlight').is(':disabled')).toEqual(false);
+ });
+ });
+
+ describe('findHighestPriorityFeature', () => {
+ beforeEach(() => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+ });
+
+ it('should pick the highest priority feature highlight', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+
+ expect($('.js-feature-highlight').length).toBeGreaterThan(1);
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
+ });
+
+ it('should work when no priority is set', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test" disabled></div>
+ `);
+
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test');
+ });
+
+ it('should pick the highest priority feature highlight when some have no priority set', () => {
+ setFixtures(`
+ <div class="js-feature-highlight" data-highlight="test-no-priority1" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-no-priority2" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
+ <div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
+ `);
+
+ expect($('.js-feature-highlight').length).toBeGreaterThan(1);
+ expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
+ });
+ });
+
+ describe('highlightFeatures', () => {
+ it('calls setupFeatureHighlightPopover', () => {
+ expect(featureHighlight.highlightFeatures()).toEqual('test');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js
new file mode 100644
index 00000000000..3320b6b0942
--- /dev/null
+++ b/spec/frontend/filtered_search/dropdown_utils_spec.js
@@ -0,0 +1,374 @@
+import DropdownUtils from '~/filtered_search/dropdown_utils';
+import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
+import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
+
+describe('Dropdown Utils', () => {
+ const issueListFixture = 'issues/issue_list.html';
+ preloadFixtures(issueListFixture);
+
+ describe('getEscapedText', () => {
+ it('should return same word when it has no space', () => {
+ const escaped = DropdownUtils.getEscapedText('textWithoutSpace');
+
+ expect(escaped).toBe('textWithoutSpace');
+ });
+
+ it('should escape with double quotes', () => {
+ let escaped = DropdownUtils.getEscapedText('text with space');
+
+ expect(escaped).toBe('"text with space"');
+
+ escaped = DropdownUtils.getEscapedText("won't fix");
+
+ expect(escaped).toBe('"won\'t fix"');
+ });
+
+ it('should escape with single quotes', () => {
+ const escaped = DropdownUtils.getEscapedText('won"t fix');
+
+ expect(escaped).toBe("'won\"t fix'");
+ });
+
+ it('should escape with single quotes by default', () => {
+ const escaped = DropdownUtils.getEscapedText('won"t\' fix');
+
+ expect(escaped).toBe("'won\"t' fix'");
+ });
+ });
+
+ describe('filterWithSymbol', () => {
+ let input;
+ const item = {
+ title: '@root',
+ };
+
+ beforeEach(() => {
+ setFixtures(`
+ <input type="text" id="test" />
+ `);
+
+ input = document.getElementById('test');
+ });
+
+ it('should filter without symbol', () => {
+ input.value = 'roo';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('@', input, item);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with symbol', () => {
+ input.value = '@roo';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('@', input, item);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ describe('filters multiple word title', () => {
+ const multipleWordItem = {
+ title: 'Community Contributions',
+ };
+
+ it('should filter with double quote', () => {
+ input.value = '"';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with double quote and symbol', () => {
+ input.value = '~"';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with double quote and multiple words', () => {
+ input.value = '"community con';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with double quote, symbol and multiple words', () => {
+ input.value = '~"community con';
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with single quote', () => {
+ input.value = "'";
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with single quote and symbol', () => {
+ input.value = "~'";
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with single quote and multiple words', () => {
+ input.value = "'community con";
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with single quote, symbol and multiple words', () => {
+ input.value = "~'community con";
+
+ const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+ });
+ });
+
+ describe('filterHint', () => {
+ let input;
+ let allowedKeys;
+
+ beforeEach(() => {
+ setFixtures(`
+ <ul class="tokens-container">
+ <li class="input-token">
+ <input class="filtered-search" type="text" id="test" />
+ </li>
+ </ul>
+ `);
+
+ input = document.getElementById('test');
+ allowedKeys = IssuableFilteredSearchTokenKeys.getKeys();
+ });
+
+ function config() {
+ return {
+ input,
+ allowedKeys,
+ };
+ }
+
+ it('should filter', () => {
+ input.value = 'l';
+ let updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'label',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+
+ input.value = 'o';
+ updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'label',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(true);
+ });
+
+ it('should return droplab_hidden false when item has no hint', () => {
+ const updatedItem = DropdownUtils.filterHint(config(), {}, '');
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should allow multiple if item.type is array', () => {
+ input.value = 'label:~first la';
+ const updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'label',
+ type: 'array',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should prevent multiple if item.type is not array', () => {
+ input.value = 'milestone:~first mile';
+ let updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'milestone',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(true);
+
+ updatedItem = DropdownUtils.filterHint(config(), {
+ hint: 'milestone',
+ type: 'string',
+ });
+
+ expect(updatedItem.droplab_hidden).toBe(true);
+ });
+ });
+
+ describe('setDataValueIfSelected', () => {
+ beforeEach(() => {
+ jest.spyOn(FilteredSearchDropdownManager, 'addWordToInput').mockImplementation(() => {});
+ });
+
+ it('calls addWordToInput when dataValue exists', () => {
+ const selected = {
+ getAttribute: () => 'value',
+ hasAttribute: () => false,
+ };
+
+ DropdownUtils.setDataValueIfSelected(null, '=', selected);
+
+ expect(FilteredSearchDropdownManager.addWordToInput.mock.calls.length).toEqual(1);
+ });
+
+ it('returns true when dataValue exists', () => {
+ const selected = {
+ getAttribute: () => 'value',
+ hasAttribute: () => false,
+ };
+
+ const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
+ const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
+
+ expect(result).toBe(true);
+ expect(result2).toBe(true);
+ });
+
+ it('returns false when dataValue does not exist', () => {
+ const selected = {
+ getAttribute: () => null,
+ };
+
+ const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
+ const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
+
+ expect(result).toBe(false);
+ expect(result2).toBe(false);
+ });
+ });
+
+ describe('getInputSelectionPosition', () => {
+ describe('word with trailing spaces', () => {
+ const value = 'label:none ';
+
+ it('should return selectionStart when cursor is at the trailing space', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 11,
+ value,
+ });
+
+ expect(left).toBe(11);
+ expect(right).toBe(11);
+ });
+
+ it('should return input when cursor is at the start of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 0,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(10);
+ });
+
+ it('should return input when cursor is at the middle of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 7,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(10);
+ });
+
+ it('should return input when cursor is at the end of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 10,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(10);
+ });
+ });
+
+ describe('multiple words', () => {
+ const value = 'label:~"Community Contribution"';
+
+ it('should return input when cursor is after the first word', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 17,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(31);
+ });
+
+ it('should return input when cursor is before the second word', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 18,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(31);
+ });
+ });
+
+ describe('incomplete multiple words', () => {
+ const value = 'label:~"Community Contribution';
+
+ it('should return entire input when cursor is at the start of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 0,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(30);
+ });
+
+ it('should return entire input when cursor is at the end of input', () => {
+ const { left, right } = DropdownUtils.getInputSelectionPosition({
+ selectionStart: 30,
+ value,
+ });
+
+ expect(left).toBe(0);
+ expect(right).toBe(30);
+ });
+ });
+ });
+
+ describe('getSearchQuery', () => {
+ let authorToken;
+
+ beforeEach(() => {
+ loadFixtures(issueListFixture);
+
+ authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
+ const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
+
+ const tokensContainer = document.querySelector('.tokens-container');
+ tokensContainer.appendChild(searchTermToken);
+ tokensContainer.appendChild(authorToken);
+ });
+
+ it('uses original value if present', () => {
+ const originalValue = 'original dance';
+ const valueContainer = authorToken.querySelector('.value-container');
+ valueContainer.dataset.originalValue = originalValue;
+
+ const searchQuery = DropdownUtils.getSearchQuery();
+
+ expect(searchQuery).toBe(' search term author:=original dance');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js
new file mode 100644
index 00000000000..ef87662a1ef
--- /dev/null
+++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js
@@ -0,0 +1,587 @@
+import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
+import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
+import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import '~/lib/utils/common_utils';
+import DropdownUtils from '~/filtered_search/dropdown_utils';
+import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
+import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
+import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
+import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
+
+describe('Filtered Search Manager', () => {
+ let input;
+ let manager;
+ let tokensContainer;
+ const page = 'issues';
+ const placeholder = 'Search or filter results...';
+
+ function dispatchBackspaceEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.keyCode = BACKSPACE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function dispatchDeleteEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.keyCode = DELETE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function dispatchAltBackspaceEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.altKey = true;
+ event.keyCode = BACKSPACE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function dispatchCtrlBackspaceEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.ctrlKey = true;
+ event.keyCode = BACKSPACE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function dispatchMetaBackspaceEvent(element, eventType) {
+ const event = new Event(eventType);
+ event.metaKey = true;
+ event.keyCode = BACKSPACE_KEY_CODE;
+ element.dispatchEvent(event);
+ }
+
+ function getVisualTokens() {
+ return tokensContainer.querySelectorAll('.js-visual-token');
+ }
+
+ beforeEach(() => {
+ setFixtures(`
+ <div class="filtered-search-box">
+ <form>
+ <ul class="tokens-container list-unstyled">
+ ${FilteredSearchSpecHelper.createInputHTML(placeholder)}
+ </ul>
+ <button class="clear-search" type="button">
+ <i class="fa fa-times"></i>
+ </button>
+ </form>
+ </div>
+ `);
+
+ jest.spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').mockImplementation();
+ });
+
+ const initializeManager = () => {
+ jest.spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').mockImplementation();
+ jest.spyOn(FilteredSearchManager.prototype, 'tokenChange').mockImplementation();
+ jest
+ .spyOn(FilteredSearchDropdownManager.prototype, 'updateDropdownOffset')
+ .mockImplementation();
+ jest.spyOn(gl.utils, 'getParameterByName').mockReturnValue(null);
+ jest.spyOn(FilteredSearchVisualTokens, 'unselectTokens');
+
+ input = document.querySelector('.filtered-search');
+ tokensContainer = document.querySelector('.tokens-container');
+ manager = new FilteredSearchManager({ page });
+ manager.setup();
+ };
+
+ afterEach(() => {
+ manager.cleanup();
+ });
+
+ describe('class constructor', () => {
+ const isLocalStorageAvailable = 'isLocalStorageAvailable';
+
+ beforeEach(() => {
+ jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(isLocalStorageAvailable);
+ jest.spyOn(RecentSearchesRoot.prototype, 'render').mockImplementation();
+ });
+
+ it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => {
+ manager = new FilteredSearchManager({ page });
+
+ expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
+ expect(manager.recentSearchesStore.state).toEqual(
+ expect.objectContaining({
+ isLocalStorageAvailable,
+ allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
+ }),
+ );
+ });
+ });
+
+ describe('setup', () => {
+ beforeEach(() => {
+ manager = new FilteredSearchManager({ page });
+ });
+
+ it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => {
+ jest
+ .spyOn(RecentSearchesService.prototype, 'fetch')
+ .mockImplementation(() => Promise.reject(new RecentSearchesServiceError()));
+ jest.spyOn(window, 'Flash').mockImplementation();
+
+ manager.setup();
+
+ expect(window.Flash).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('searchState', () => {
+ beforeEach(() => {
+ jest.spyOn(FilteredSearchManager.prototype, 'search').mockImplementation();
+ initializeManager();
+ });
+
+ it('should blur button', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ },
+ };
+ jest.spyOn(e.currentTarget, 'blur');
+ manager.searchState(e);
+
+ expect(e.currentTarget.blur).toHaveBeenCalled();
+ });
+
+ it('should not call search if there is no state', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ },
+ };
+
+ manager.searchState(e);
+
+ expect(FilteredSearchManager.prototype.search).not.toHaveBeenCalled();
+ });
+
+ it('should call search when there is state', () => {
+ const e = {
+ preventDefault: () => {},
+ currentTarget: {
+ blur: () => {},
+ dataset: {
+ state: 'opened',
+ },
+ },
+ };
+
+ manager.searchState(e);
+
+ expect(FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened');
+ });
+ });
+
+ describe('search', () => {
+ const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
+
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('should search with a single word', done => {
+ input.value = 'searchTerm';
+
+ visitUrl.mockImplementation(url => {
+ expect(url).toEqual(`${defaultParams}&search=searchTerm`);
+ done();
+ });
+
+ manager.search();
+ });
+
+ it('should search with multiple words', done => {
+ input.value = 'awesome search terms';
+
+ visitUrl.mockImplementation(url => {
+ expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`);
+ done();
+ });
+
+ manager.search();
+ });
+
+ it('should search with special characters', done => {
+ input.value = '~!@#$%^&*()_+{}:<>,.?/';
+
+ visitUrl.mockImplementation(url => {
+ expect(url).toEqual(
+ `${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`,
+ );
+ done();
+ });
+
+ manager.search();
+ });
+
+ it('removes duplicated tokens', done => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
+ ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
+ ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
+ `);
+
+ visitUrl.mockImplementation(url => {
+ expect(url).toEqual(`${defaultParams}&label_name[]=bug`);
+ done();
+ });
+
+ manager.search();
+ });
+ });
+
+ describe('handleInputPlaceholder', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('should render placeholder when there is no input', () => {
+ expect(input.placeholder).toEqual(placeholder);
+ });
+
+ it('should not render placeholder when there is input', () => {
+ input.value = 'test words';
+
+ const event = new Event('input');
+ input.dispatchEvent(event);
+
+ expect(input.placeholder).toEqual('');
+ });
+
+ it('should not render placeholder when there are tokens and no input', () => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+
+ const event = new Event('input');
+ input.dispatchEvent(event);
+
+ expect(input.placeholder).toEqual('');
+ });
+ });
+
+ describe('checkForBackspace', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ describe('tokens and no input', () => {
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+ });
+
+ it('removes last token', () => {
+ jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial');
+ dispatchBackspaceEvent(input, 'keyup');
+ dispatchBackspaceEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
+ });
+
+ it('sets the input', () => {
+ jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial');
+ dispatchDeleteEvent(input, 'keyup');
+ dispatchDeleteEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled();
+ expect(input.value).toEqual('~bug');
+ });
+ });
+
+ it('does not remove token or change input when there is existing input', () => {
+ jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial');
+ jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial');
+
+ input.value = 'text';
+ dispatchDeleteEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('text');
+ });
+
+ it('does not remove previous token on single backspace press', () => {
+ jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial');
+ jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial');
+
+ input.value = 't';
+ dispatchDeleteEvent(input, 'keyup');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('t');
+ });
+ });
+
+ describe('checkForAltOrCtrlBackspace', () => {
+ beforeEach(() => {
+ initializeManager();
+ jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial');
+ });
+
+ describe('tokens and no input', () => {
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+ });
+
+ it('removes last token via alt-backspace', () => {
+ dispatchAltBackspaceEvent(input, 'keydown');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
+ });
+
+ it('removes last token via ctrl-backspace', () => {
+ dispatchCtrlBackspaceEvent(input, 'keydown');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
+ });
+ });
+
+ describe('tokens and input', () => {
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+ });
+
+ it('does not remove token or change input via alt-backspace when there is existing input', () => {
+ input = manager.filteredSearchInput;
+ input.value = 'text';
+ dispatchAltBackspaceEvent(input, 'keydown');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('text');
+ });
+
+ it('does not remove token or change input via ctrl-backspace when there is existing input', () => {
+ input = manager.filteredSearchInput;
+ input.value = 'text';
+ dispatchCtrlBackspaceEvent(input, 'keydown');
+
+ expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
+ expect(input.value).toEqual('text');
+ });
+ });
+ });
+
+ describe('checkForMetaBackspace', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
+ );
+ });
+
+ it('removes all tokens and input', () => {
+ jest.spyOn(FilteredSearchManager.prototype, 'clearSearch');
+ dispatchMetaBackspaceEvent(input, 'keydown');
+
+ expect(manager.clearSearch).toHaveBeenCalled();
+ expect(manager.filteredSearchInput.value).toEqual('');
+ expect(DropdownUtils.getSearchQuery()).toEqual('');
+ });
+ });
+
+ describe('removeToken', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('removes token even when it is already selected', () => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
+ );
+
+ tokensContainer.querySelector('.js-visual-token .remove-token').click();
+
+ expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null);
+ });
+
+ describe('unselected token', () => {
+ beforeEach(() => {
+ jest.spyOn(FilteredSearchManager.prototype, 'removeSelectedToken');
+
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'),
+ );
+ tokensContainer.querySelector('.js-visual-token .remove-token').click();
+ });
+
+ it('removes token when remove button is selected', () => {
+ expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null);
+ });
+
+ it('calls removeSelectedToken', () => {
+ expect(manager.removeSelectedToken).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('removeSelectedTokenKeydown', () => {
+ beforeEach(() => {
+ initializeManager();
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
+ FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
+ );
+ });
+
+ it('removes selected token when the backspace key is pressed', () => {
+ expect(getVisualTokens().length).toEqual(1);
+
+ dispatchBackspaceEvent(document, 'keydown');
+
+ expect(getVisualTokens().length).toEqual(0);
+ });
+
+ it('removes selected token when the delete key is pressed', () => {
+ expect(getVisualTokens().length).toEqual(1);
+
+ dispatchDeleteEvent(document, 'keydown');
+
+ expect(getVisualTokens().length).toEqual(0);
+ });
+
+ it('updates the input placeholder after removal', () => {
+ manager.handleInputPlaceholder();
+
+ expect(input.placeholder).toEqual('');
+ expect(getVisualTokens().length).toEqual(1);
+
+ dispatchBackspaceEvent(document, 'keydown');
+
+ expect(input.placeholder).not.toEqual('');
+ expect(getVisualTokens().length).toEqual(0);
+ });
+
+ it('updates the clear button after removal', () => {
+ manager.toggleClearSearchButton();
+
+ const clearButton = document.querySelector('.clear-search');
+
+ expect(clearButton.classList.contains('hidden')).toEqual(false);
+ expect(getVisualTokens().length).toEqual(1);
+
+ dispatchBackspaceEvent(document, 'keydown');
+
+ expect(clearButton.classList.contains('hidden')).toEqual(true);
+ expect(getVisualTokens().length).toEqual(0);
+ });
+ });
+
+ describe('removeSelectedToken', () => {
+ beforeEach(() => {
+ jest.spyOn(FilteredSearchVisualTokens, 'removeSelectedToken');
+ jest.spyOn(FilteredSearchManager.prototype, 'handleInputPlaceholder');
+ jest.spyOn(FilteredSearchManager.prototype, 'toggleClearSearchButton');
+ initializeManager();
+ });
+
+ it('calls FilteredSearchVisualTokens.removeSelectedToken', () => {
+ manager.removeSelectedToken();
+
+ expect(FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled();
+ });
+
+ it('calls handleInputPlaceholder', () => {
+ manager.removeSelectedToken();
+
+ expect(manager.handleInputPlaceholder).toHaveBeenCalled();
+ });
+
+ it('calls toggleClearSearchButton', () => {
+ manager.removeSelectedToken();
+
+ expect(manager.toggleClearSearchButton).toHaveBeenCalled();
+ });
+
+ it('calls update dropdown offset', () => {
+ manager.removeSelectedToken();
+
+ expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled();
+ });
+ });
+
+ describe('Clearing search', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('Clicking the "x" clear button, clears the input', () => {
+ const inputValue = 'label:=~bug';
+ manager.filteredSearchInput.value = inputValue;
+ manager.filteredSearchInput.dispatchEvent(new Event('input'));
+
+ expect(DropdownUtils.getSearchQuery()).toEqual(inputValue);
+
+ manager.clearSearchButton.click();
+
+ expect(manager.filteredSearchInput.value).toEqual('');
+ expect(DropdownUtils.getSearchQuery()).toEqual('');
+ });
+ });
+
+ describe('toggleInputContainerFocus', () => {
+ beforeEach(() => {
+ initializeManager();
+ });
+
+ it('toggles on focus', () => {
+ input.focus();
+
+ expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(
+ true,
+ );
+ });
+
+ it('toggles on blur', () => {
+ input.blur();
+
+ expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(
+ false,
+ );
+ });
+ });
+
+ describe('getAllParams', () => {
+ let paramsArr;
+ beforeEach(() => {
+ paramsArr = ['key=value', 'otherkey=othervalue'];
+
+ initializeManager();
+ });
+
+ it('correctly modifies params when custom modifier is passed', () => {
+ const modifedParams = manager.getAllParams.call(
+ {
+ modifyUrlParams: params => params.reverse(),
+ },
+ [].concat(paramsArr),
+ );
+
+ expect(modifedParams[0]).toBe(paramsArr[1]);
+ });
+
+ it('does not modify params when no custom modifier is passed', () => {
+ const modifedParams = manager.getAllParams.call({}, paramsArr);
+
+ expect(modifedParams[1]).toBe(paramsArr[1]);
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js
new file mode 100644
index 00000000000..dec03e5ab93
--- /dev/null
+++ b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js
@@ -0,0 +1,152 @@
+import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
+
+describe('Filtered Search Tokenizer', () => {
+ const allowedKeys = IssuableFilteredSearchTokenKeys.getKeys();
+
+ describe('processTokens', () => {
+ it('returns for input containing only search value', () => {
+ const results = FilteredSearchTokenizer.processTokens('searchTerm', allowedKeys);
+
+ expect(results.searchToken).toBe('searchTerm');
+ expect(results.tokens.length).toBe(0);
+ expect(results.lastToken).toBe(results.searchToken);
+ });
+
+ it('returns for input containing only tokens', () => {
+ const results = FilteredSearchTokenizer.processTokens(
+ 'author:@root label:~"Very Important" milestone:%v1.0 assignee:none',
+ allowedKeys,
+ );
+
+ expect(results.searchToken).toBe('');
+ expect(results.tokens.length).toBe(4);
+ expect(results.tokens[3]).toBe(results.lastToken);
+
+ expect(results.tokens[0].key).toBe('author');
+ expect(results.tokens[0].value).toBe('root');
+ expect(results.tokens[0].symbol).toBe('@');
+
+ expect(results.tokens[1].key).toBe('label');
+ expect(results.tokens[1].value).toBe('"Very Important"');
+ expect(results.tokens[1].symbol).toBe('~');
+
+ expect(results.tokens[2].key).toBe('milestone');
+ expect(results.tokens[2].value).toBe('v1.0');
+ expect(results.tokens[2].symbol).toBe('%');
+
+ expect(results.tokens[3].key).toBe('assignee');
+ expect(results.tokens[3].value).toBe('none');
+ expect(results.tokens[3].symbol).toBe('');
+ });
+
+ it('returns for input starting with search value and ending with tokens', () => {
+ const results = FilteredSearchTokenizer.processTokens(
+ 'searchTerm anotherSearchTerm milestone:none',
+ allowedKeys,
+ );
+
+ expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
+ expect(results.tokens.length).toBe(1);
+ expect(results.tokens[0]).toBe(results.lastToken);
+ expect(results.tokens[0].key).toBe('milestone');
+ expect(results.tokens[0].value).toBe('none');
+ expect(results.tokens[0].symbol).toBe('');
+ });
+
+ it('returns for input starting with tokens and ending with search value', () => {
+ const results = FilteredSearchTokenizer.processTokens(
+ 'assignee:@user searchTerm',
+ allowedKeys,
+ );
+
+ expect(results.searchToken).toBe('searchTerm');
+ expect(results.tokens.length).toBe(1);
+ expect(results.tokens[0].key).toBe('assignee');
+ expect(results.tokens[0].value).toBe('user');
+ expect(results.tokens[0].symbol).toBe('@');
+ expect(results.lastToken).toBe(results.searchToken);
+ });
+
+ it('returns for input containing search value wrapped between tokens', () => {
+ const results = FilteredSearchTokenizer.processTokens(
+ 'author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none',
+ allowedKeys,
+ );
+
+ expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
+ expect(results.tokens.length).toBe(3);
+ expect(results.tokens[2]).toBe(results.lastToken);
+
+ expect(results.tokens[0].key).toBe('author');
+ expect(results.tokens[0].value).toBe('root');
+ expect(results.tokens[0].symbol).toBe('@');
+
+ expect(results.tokens[1].key).toBe('label');
+ expect(results.tokens[1].value).toBe('"Won\'t fix"');
+ expect(results.tokens[1].symbol).toBe('~');
+
+ expect(results.tokens[2].key).toBe('milestone');
+ expect(results.tokens[2].value).toBe('none');
+ expect(results.tokens[2].symbol).toBe('');
+ });
+
+ it('returns for input containing search value in between tokens', () => {
+ const results = FilteredSearchTokenizer.processTokens(
+ 'author:@root searchTerm assignee:none anotherSearchTerm label:~Doing',
+ allowedKeys,
+ );
+
+ expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
+ expect(results.tokens.length).toBe(3);
+ expect(results.tokens[2]).toBe(results.lastToken);
+
+ expect(results.tokens[0].key).toBe('author');
+ expect(results.tokens[0].value).toBe('root');
+ expect(results.tokens[0].symbol).toBe('@');
+
+ expect(results.tokens[1].key).toBe('assignee');
+ expect(results.tokens[1].value).toBe('none');
+ expect(results.tokens[1].symbol).toBe('');
+
+ expect(results.tokens[2].key).toBe('label');
+ expect(results.tokens[2].value).toBe('Doing');
+ expect(results.tokens[2].symbol).toBe('~');
+ });
+
+ it('returns search value for invalid tokens', () => {
+ const results = FilteredSearchTokenizer.processTokens('fake:token', allowedKeys);
+
+ expect(results.lastToken).toBe('fake:token');
+ expect(results.searchToken).toBe('fake:token');
+ expect(results.tokens.length).toEqual(0);
+ });
+
+ it('returns search value and token for mix of valid and invalid tokens', () => {
+ const results = FilteredSearchTokenizer.processTokens('label:real fake:token', allowedKeys);
+
+ expect(results.tokens.length).toEqual(1);
+ expect(results.tokens[0].key).toBe('label');
+ expect(results.tokens[0].value).toBe('real');
+ expect(results.tokens[0].symbol).toBe('');
+ expect(results.lastToken).toBe('fake:token');
+ expect(results.searchToken).toBe('fake:token');
+ });
+
+ it('returns search value for invalid symbols', () => {
+ const results = FilteredSearchTokenizer.processTokens('std::includes', allowedKeys);
+
+ expect(results.lastToken).toBe('std::includes');
+ expect(results.searchToken).toBe('std::includes');
+ });
+
+ it('removes duplicated values', () => {
+ const results = FilteredSearchTokenizer.processTokens('label:~foo label:~foo', allowedKeys);
+
+ expect(results.tokens.length).toBe(1);
+ expect(results.tokens[0].key).toBe('label');
+ expect(results.tokens[0].value).toBe('foo');
+ expect(results.tokens[0].symbol).toBe('~');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js
new file mode 100644
index 00000000000..c7be900ba2c
--- /dev/null
+++ b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js
@@ -0,0 +1,148 @@
+import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+
+describe('Issues Filtered Search Token Keys', () => {
+ describe('get', () => {
+ let tokenKeys;
+
+ beforeEach(() => {
+ tokenKeys = IssuableFilteredSearchTokenKeys.get();
+ });
+
+ it('should return tokenKeys', () => {
+ expect(tokenKeys).not.toBeNull();
+ });
+
+ it('should return tokenKeys as an array', () => {
+ expect(tokenKeys instanceof Array).toBe(true);
+ });
+
+ it('should always return the same array', () => {
+ const tokenKeys2 = IssuableFilteredSearchTokenKeys.get();
+
+ expect(tokenKeys).toEqual(tokenKeys2);
+ });
+
+ it('should return assignee as a string', () => {
+ const assignee = tokenKeys.find(tokenKey => tokenKey.key === 'assignee');
+
+ expect(assignee.type).toEqual('string');
+ });
+ });
+
+ describe('getKeys', () => {
+ it('should return keys', () => {
+ const getKeys = IssuableFilteredSearchTokenKeys.getKeys();
+ const keys = IssuableFilteredSearchTokenKeys.get().map(i => i.key);
+
+ keys.forEach((key, i) => {
+ expect(key).toEqual(getKeys[i]);
+ });
+ });
+ });
+
+ describe('getConditions', () => {
+ let conditions;
+
+ beforeEach(() => {
+ conditions = IssuableFilteredSearchTokenKeys.getConditions();
+ });
+
+ it('should return conditions', () => {
+ expect(conditions).not.toBeNull();
+ });
+
+ it('should return conditions as an array', () => {
+ expect(conditions instanceof Array).toBe(true);
+ });
+ });
+
+ describe('searchByKey', () => {
+ it('should return null when key not found', () => {
+ const tokenKey = IssuableFilteredSearchTokenKeys.searchByKey('notakey');
+
+ expect(tokenKey).toBeNull();
+ });
+
+ it('should return tokenKey when found by key', () => {
+ const tokenKeys = IssuableFilteredSearchTokenKeys.get();
+ const result = IssuableFilteredSearchTokenKeys.searchByKey(tokenKeys[0].key);
+
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchBySymbol', () => {
+ it('should return null when symbol not found', () => {
+ const tokenKey = IssuableFilteredSearchTokenKeys.searchBySymbol('notasymbol');
+
+ expect(tokenKey).toBeNull();
+ });
+
+ it('should return tokenKey when found by symbol', () => {
+ const tokenKeys = IssuableFilteredSearchTokenKeys.get();
+ const result = IssuableFilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol);
+
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchByKeyParam', () => {
+ it('should return null when key param not found', () => {
+ const tokenKey = IssuableFilteredSearchTokenKeys.searchByKeyParam('notakeyparam');
+
+ expect(tokenKey).toBeNull();
+ });
+
+ it('should return tokenKey when found by key param', () => {
+ const tokenKeys = IssuableFilteredSearchTokenKeys.get();
+ const result = IssuableFilteredSearchTokenKeys.searchByKeyParam(
+ `${tokenKeys[0].key}_${tokenKeys[0].param}`,
+ );
+
+ expect(result).toEqual(tokenKeys[0]);
+ });
+
+ it('should return alternative tokenKey when found by key param', () => {
+ const tokenKeys = IssuableFilteredSearchTokenKeys.getAlternatives();
+ const result = IssuableFilteredSearchTokenKeys.searchByKeyParam(
+ `${tokenKeys[0].key}_${tokenKeys[0].param}`,
+ );
+
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchByConditionUrl', () => {
+ it('should return null when condition url not found', () => {
+ const condition = IssuableFilteredSearchTokenKeys.searchByConditionUrl(null);
+
+ expect(condition).toBeNull();
+ });
+
+ it('should return condition when found by url', () => {
+ const conditions = IssuableFilteredSearchTokenKeys.getConditions();
+ const result = IssuableFilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url);
+
+ expect(result).toBe(conditions[0]);
+ });
+ });
+
+ describe('searchByConditionKeyValue', () => {
+ it('should return null when condition tokenKey and value not found', () => {
+ const condition = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(null, null);
+
+ expect(condition).toBeNull();
+ });
+
+ it('should return condition when found by tokenKey and value', () => {
+ const conditions = IssuableFilteredSearchTokenKeys.getConditions();
+ const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(
+ conditions[0].tokenKey,
+ conditions[0].operator,
+ conditions[0].value,
+ );
+
+ expect(result).toEqual(conditions[0]);
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/recent_searches_root_spec.js b/spec/frontend/filtered_search/recent_searches_root_spec.js
new file mode 100644
index 00000000000..281d406e013
--- /dev/null
+++ b/spec/frontend/filtered_search/recent_searches_root_spec.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
+
+jest.mock('vue');
+
+describe('RecentSearchesRoot', () => {
+ describe('render', () => {
+ let recentSearchesRoot;
+ let data;
+ let template;
+
+ beforeEach(() => {
+ recentSearchesRoot = {
+ store: {
+ state: 'state',
+ },
+ };
+
+ Vue.mockImplementation(options => {
+ ({ data, template } = options);
+ });
+
+ RecentSearchesRoot.prototype.render.call(recentSearchesRoot);
+ });
+
+ it('should instantiate Vue', () => {
+ expect(Vue).toHaveBeenCalled();
+ expect(data()).toBe(recentSearchesRoot.store.state);
+ expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js
new file mode 100644
index 00000000000..a89d38b7a20
--- /dev/null
+++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js
@@ -0,0 +1,161 @@
+import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
+import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+
+useLocalStorageSpy();
+
+describe('RecentSearchesService', () => {
+ let service;
+
+ beforeEach(() => {
+ service = new RecentSearchesService();
+ localStorage.removeItem(service.localStorageKey);
+ });
+
+ describe('fetch', () => {
+ beforeEach(() => {
+ jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true);
+ });
+
+ it('should default to empty array', done => {
+ const fetchItemsPromise = service.fetch();
+
+ fetchItemsPromise
+ .then(items => {
+ expect(items).toEqual([]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should reject when unable to parse', done => {
+ jest.spyOn(localStorage, 'getItem').mockReturnValue('fail');
+ const fetchItemsPromise = service.fetch();
+
+ fetchItemsPromise
+ .then(done.fail)
+ .catch(error => {
+ expect(error).toEqual(expect.any(SyntaxError));
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should reject when service is unavailable', done => {
+ RecentSearchesService.isAvailable.mockReturnValue(false);
+
+ service
+ .fetch()
+ .then(done.fail)
+ .catch(error => {
+ expect(error).toEqual(expect.any(Error));
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('should return items from localStorage', done => {
+ jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]');
+ const fetchItemsPromise = service.fetch();
+
+ fetchItemsPromise
+ .then(items => {
+ expect(items).toEqual(['foo', 'bar']);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ describe('if .isAvailable returns `false`', () => {
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.mockReturnValue(false);
+
+ jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {});
+ });
+
+ it('should not call .getItem', done => {
+ RecentSearchesService.prototype
+ .fetch()
+ .then(done.fail)
+ .catch(err => {
+ expect(err).toEqual(new RecentSearchesServiceError());
+ expect(localStorage.getItem).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('setRecentSearches', () => {
+ beforeEach(() => {
+ jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true);
+ });
+
+ it('should save things in localStorage', () => {
+ jest.spyOn(localStorage, 'setItem');
+ const items = ['foo', 'bar'];
+ service.save(items);
+
+ expect(localStorage.setItem).toHaveBeenCalledWith(expect.any(String), JSON.stringify(items));
+ });
+ });
+
+ describe('save', () => {
+ beforeEach(() => {
+ jest.spyOn(localStorage, 'setItem');
+ jest.spyOn(RecentSearchesService, 'isAvailable').mockImplementation(() => {});
+ });
+
+ describe('if .isAvailable returns `true`', () => {
+ const searchesString = 'searchesString';
+ const localStorageKey = 'localStorageKey';
+ const recentSearchesService = {
+ localStorageKey,
+ };
+
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.mockReturnValue(true);
+
+ jest.spyOn(JSON, 'stringify').mockReturnValue(searchesString);
+ });
+
+ it('should call .setItem', () => {
+ RecentSearchesService.prototype.save.call(recentSearchesService);
+
+ expect(localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString);
+ });
+ });
+
+ describe('if .isAvailable returns `false`', () => {
+ beforeEach(() => {
+ RecentSearchesService.isAvailable.mockReturnValue(false);
+ });
+
+ it('should not call .setItem', () => {
+ RecentSearchesService.prototype.save();
+
+ expect(localStorage.setItem).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('isAvailable', () => {
+ let isAvailable;
+
+ beforeEach(() => {
+ jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe');
+
+ isAvailable = RecentSearchesService.isAvailable();
+ });
+
+ it('should call .isLocalStorageAccessSafe', () => {
+ expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ });
+
+ it('should return a boolean', () => {
+ expect(typeof isAvailable).toBe('boolean');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js
new file mode 100644
index 00000000000..ea501423403
--- /dev/null
+++ b/spec/frontend/filtered_search/visual_token_value_spec.js
@@ -0,0 +1,389 @@
+import { escape } from 'lodash';
+import VisualTokenValue from '~/filtered_search/visual_token_value';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import UsersCache from '~/lib/utils/users_cache';
+import DropdownUtils from '~/filtered_search//dropdown_utils';
+import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
+
+describe('Filtered Search Visual Tokens', () => {
+ const findElements = tokenElement => {
+ const tokenNameElement = tokenElement.querySelector('.name');
+ const tokenValueContainer = tokenElement.querySelector('.value-container');
+ const tokenValueElement = tokenValueContainer.querySelector('.value');
+ const tokenOperatorElement = tokenElement.querySelector('.operator');
+ const tokenType = tokenNameElement.innerText.toLowerCase();
+ const tokenValue = tokenValueElement.innerText;
+ const tokenOperator = tokenOperatorElement.innerText;
+ const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
+ return { subject, tokenValueContainer, tokenValueElement };
+ };
+
+ let tokensContainer;
+ let authorToken;
+ let bugLabelToken;
+
+ beforeEach(() => {
+ setFixtures(`
+ <ul class="tokens-container">
+ ${FilteredSearchSpecHelper.createInputHTML()}
+ </ul>
+ `);
+ tokensContainer = document.querySelector('.tokens-container');
+
+ authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
+ bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
+ });
+
+ describe('updateUserTokenAppearance', () => {
+ let usersCacheSpy;
+
+ beforeEach(() => {
+ jest.spyOn(UsersCache, 'retrieve').mockImplementation(username => usersCacheSpy(username));
+ });
+
+ it('ignores error if UsersCache throws', done => {
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+ const dummyError = new Error('Earth rotated backwards');
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = username => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.reject(dummyError);
+ };
+
+ subject
+ .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(window.Flash.mock.calls.length).toBe(0);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does nothing if user cannot be found', done => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = username => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(undefined);
+ };
+
+ subject
+ .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueElement.innerText).toBe(tokenValue);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('replaces author token with avatar and display name', done => {
+ const dummyUser = {
+ name: 'Important Person',
+ avatar_url: 'https://host.invalid/mypics/avatar.png',
+ };
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = username => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(dummyUser);
+ };
+
+ subject
+ .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
+ expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
+ const avatar = tokenValueElement.querySelector('img.avatar');
+
+ expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url);
+ expect(avatar.getAttribute('alt')).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('escapes user name when creating token', done => {
+ const dummyUser = {
+ name: '<script>',
+ avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`,
+ };
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+ const tokenValue = tokenValueElement.innerText;
+ usersCacheSpy = username => {
+ expect(`@${username}`).toBe(tokenValue);
+ return Promise.resolve(dummyUser);
+ };
+
+ subject
+ .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
+ .then(() => {
+ expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
+ tokenValueElement.querySelector('.avatar').remove();
+
+ expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name));
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateLabelTokenColor', () => {
+ const jsonFixtureName = 'labels/project_labels.json';
+ const dummyEndpoint = '/dummy/endpoint';
+
+ preloadFixtures(jsonFixtureName);
+
+ let labelData;
+
+ beforeAll(() => {
+ labelData = getJSONFixture(jsonFixtureName);
+ });
+
+ const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
+ 'label',
+ '=',
+ '~doesnotexist',
+ );
+ const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
+ 'label',
+ '=',
+ '~"some space"',
+ );
+
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
+ ${bugLabelToken.outerHTML}
+ ${missingLabelToken.outerHTML}
+ ${spaceLabelToken.outerHTML}
+ `);
+
+ const filteredSearchInput = document.querySelector('.filtered-search');
+ filteredSearchInput.dataset.runnerTagsEndpoint = `${dummyEndpoint}/admin/runners/tag_list`;
+ filteredSearchInput.dataset.labelsEndpoint = `${dummyEndpoint}/-/labels`;
+ filteredSearchInput.dataset.milestonesEndpoint = `${dummyEndpoint}/-/milestones`;
+
+ AjaxCache.internalStorage = {};
+ AjaxCache.internalStorage[`${filteredSearchInput.dataset.labelsEndpoint}.json`] = labelData;
+ });
+
+ const parseColor = color => {
+ const dummyElement = document.createElement('div');
+ dummyElement.style.color = color;
+ return dummyElement.style.color;
+ };
+
+ const expectValueContainerStyle = (tokenValueContainer, label) => {
+ expect(tokenValueContainer.getAttribute('style')).not.toBe(null);
+ expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color));
+ expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color));
+ };
+
+ const findLabel = tokenValue =>
+ labelData.find(label => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`);
+
+ it('updates the color of a label token', done => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+
+ subject
+ .updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expectValueContainerStyle(tokenValueContainer, matchingLabel);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('updates the color of a label token with spaces', done => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+
+ subject
+ .updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expectValueContainerStyle(tokenValueContainer, matchingLabel);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does not change color of a missing label', done => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken);
+ const tokenValue = tokenValueElement.innerText;
+ const matchingLabel = findLabel(tokenValue);
+
+ expect(matchingLabel).toBe(undefined);
+
+ subject
+ .updateLabelTokenColor(tokenValueContainer, tokenValue)
+ .then(() => {
+ expect(tokenValueContainer.getAttribute('style')).toBe(null);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('setTokenStyle', () => {
+ let originalTextColor;
+
+ beforeEach(() => {
+ originalTextColor = bugLabelToken.style.color;
+ });
+
+ it('should set backgroundColor', () => {
+ const originalBackgroundColor = bugLabelToken.style.backgroundColor;
+ const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'blue', 'white');
+
+ expect(token.style.backgroundColor).toEqual('blue');
+ expect(token.style.backgroundColor).not.toEqual(originalBackgroundColor);
+ });
+
+ it('should set textColor', () => {
+ const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'white', 'black');
+
+ expect(token.style.color).toEqual('black');
+ expect(token.style.color).not.toEqual(originalTextColor);
+ });
+
+ it('should add inverted class when textColor is #FFFFFF', () => {
+ const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'black', '#FFFFFF');
+
+ expect(token.style.color).toEqual('rgb(255, 255, 255)');
+ expect(token.style.color).not.toEqual(originalTextColor);
+ expect(token.querySelector('.remove-token').classList.contains('inverted')).toEqual(true);
+ });
+ });
+
+ describe('render', () => {
+ const setupSpies = subject => {
+ jest.spyOn(subject, 'updateLabelTokenColor').mockImplementation(() => {});
+ const updateLabelTokenColorSpy = subject.updateLabelTokenColor;
+
+ jest.spyOn(subject, 'updateUserTokenAppearance').mockImplementation(() => {});
+ const updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance;
+
+ return { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy };
+ };
+
+ const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search');
+ const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken(
+ 'milestone',
+ 'upcoming',
+ );
+
+ beforeEach(() => {
+ tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
+ ${authorToken.outerHTML}
+ ${bugLabelToken.outerHTML}
+ ${keywordToken.outerHTML}
+ ${milestoneToken.outerHTML}
+ `);
+ });
+
+ it('renders a author token value element', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(1);
+ const expectedArgs = [tokenValueContainer, tokenValueElement];
+
+ expect(updateUserTokenAppearanceSpy.mock.calls[0]).toEqual(expectedArgs);
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ });
+
+ it('renders a label token value element', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+
+ const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(1);
+ const expectedArgs = [tokenValueContainer];
+
+ expect(updateLabelTokenColorSpy.mock.calls[0]).toEqual(expectedArgs);
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('renders a milestone token value element', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(milestoneToken);
+
+ const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update user token appearance for `none` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ subject.tokenValue = 'none';
+
+ const { updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update user token appearance for `None` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ subject.tokenValue = 'None';
+
+ const { updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update user token appearance for `any` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
+
+ subject.tokenValue = 'any';
+
+ const { updateUserTokenAppearanceSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update label token color for `None` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+
+ subject.tokenValue = 'None';
+
+ const { updateLabelTokenColorSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update label token color for `none` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+
+ subject.tokenValue = 'none';
+
+ const { updateLabelTokenColorSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ });
+
+ it('does not update label token color for `any` filter', () => {
+ const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
+
+ subject.tokenValue = 'any';
+
+ const { updateLabelTokenColorSpy } = setupSpies(subject);
+ subject.render(tokenValueContainer, tokenValueElement);
+
+ expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/fixtures/test_report.rb b/spec/frontend/fixtures/test_report.rb
index d26bba9b9d0..d0ecaf11994 100644
--- a/spec/frontend/fixtures/test_report.rb
+++ b/spec/frontend/fixtures/test_report.rb
@@ -15,7 +15,7 @@ describe Projects::PipelinesController, "(JavaScript fixtures)", type: :controll
before do
sign_in(user)
- stub_feature_flags(junit_pipeline_view: true)
+ stub_feature_flags(junit_pipeline_view: project)
end
it "pipelines/test_report.json" do
diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js
new file mode 100644
index 00000000000..fa7c1904339
--- /dev/null
+++ b/spec/frontend/flash_spec.js
@@ -0,0 +1,233 @@
+import flash, { createFlashEl, createAction, hideFlash, removeFlashClickListener } from '~/flash';
+
+describe('Flash', () => {
+ describe('createFlashEl', () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ });
+
+ afterEach(() => {
+ el.innerHTML = '';
+ });
+
+ it('creates flash element with type', () => {
+ el.innerHTML = createFlashEl('testing', 'alert');
+
+ expect(el.querySelector('.flash-alert')).not.toBeNull();
+ });
+
+ it('escapes text', () => {
+ el.innerHTML = createFlashEl('<script>alert("a");</script>', 'alert');
+
+ expect(el.querySelector('.flash-text').textContent.trim()).toBe(
+ '<script>alert("a");</script>',
+ );
+ });
+ });
+
+ describe('hideFlash', () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ el.className = 'js-testing';
+ });
+
+ it('sets transition style', () => {
+ hideFlash(el);
+
+ expect(el.style.transition).toBe('opacity 0.15s');
+ });
+
+ it('sets opacity style', () => {
+ hideFlash(el);
+
+ expect(el.style.opacity).toBe('0');
+ });
+
+ it('does not set styles when fadeTransition is false', () => {
+ hideFlash(el, false);
+
+ expect(el.style.opacity).toBe('');
+ expect(el.style.transition).toBeFalsy();
+ });
+
+ it('removes element after transitionend', () => {
+ document.body.appendChild(el);
+
+ hideFlash(el);
+ el.dispatchEvent(new Event('transitionend'));
+
+ expect(document.querySelector('.js-testing')).toBeNull();
+ });
+
+ it('calls event listener callback once', () => {
+ jest.spyOn(el, 'remove');
+ document.body.appendChild(el);
+
+ hideFlash(el);
+
+ el.dispatchEvent(new Event('transitionend'));
+ el.dispatchEvent(new Event('transitionend'));
+
+ expect(el.remove.mock.calls.length).toBe(1);
+ });
+ });
+
+ describe('createAction', () => {
+ let el;
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ });
+
+ it('creates link with href', () => {
+ el.innerHTML = createAction({
+ href: 'testing',
+ title: 'test',
+ });
+
+ expect(el.querySelector('.flash-action').href).toContain('testing');
+ });
+
+ it('uses hash as href when no href is present', () => {
+ el.innerHTML = createAction({
+ title: 'test',
+ });
+
+ expect(el.querySelector('.flash-action').href).toContain('#');
+ });
+
+ it('adds role when no href is present', () => {
+ el.innerHTML = createAction({
+ title: 'test',
+ });
+
+ expect(el.querySelector('.flash-action').getAttribute('role')).toBe('button');
+ });
+
+ it('escapes the title text', () => {
+ el.innerHTML = createAction({
+ title: '<script>alert("a")</script>',
+ });
+
+ expect(el.querySelector('.flash-action').textContent.trim()).toBe(
+ '<script>alert("a")</script>',
+ );
+ });
+ });
+
+ describe('createFlash', () => {
+ describe('no flash-container', () => {
+ it('does not add to the DOM', () => {
+ const flashEl = flash('testing');
+
+ expect(flashEl).toBeNull();
+
+ expect(document.querySelector('.flash-alert')).toBeNull();
+ });
+ });
+
+ describe('with flash-container', () => {
+ beforeEach(() => {
+ document.body.innerHTML += `
+ <div class="content-wrapper js-content-wrapper">
+ <div class="flash-container"></div>
+ </div>
+ `;
+ });
+
+ afterEach(() => {
+ document.querySelector('.js-content-wrapper').remove();
+ });
+
+ it('adds flash element into container', () => {
+ flash('test', 'alert', document, null, false, true);
+
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
+
+ expect(document.body.className).toContain('flash-shown');
+ });
+
+ it('adds flash into specified parent', () => {
+ flash('test', 'alert', document.querySelector('.content-wrapper'));
+
+ expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull();
+ });
+
+ it('adds container classes when inside content-wrapper', () => {
+ flash('test');
+
+ expect(document.querySelector('.flash-text').className).toBe('flash-text');
+ });
+
+ it('does not add container when outside of content-wrapper', () => {
+ document.querySelector('.content-wrapper').className = 'js-content-wrapper';
+ flash('test');
+
+ expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text');
+ });
+
+ it('removes element after clicking', () => {
+ flash('test', 'alert', document, null, false, true);
+
+ document.querySelector('.flash-alert .js-close-icon').click();
+
+ expect(document.querySelector('.flash-alert')).toBeNull();
+
+ expect(document.body.className).not.toContain('flash-shown');
+ });
+
+ describe('with actionConfig', () => {
+ it('adds action link', () => {
+ flash('test', 'alert', document, {
+ title: 'test',
+ });
+
+ expect(document.querySelector('.flash-action')).not.toBeNull();
+ });
+
+ it('calls actionConfig clickHandler on click', () => {
+ const actionConfig = {
+ title: 'test',
+ clickHandler: jest.fn(),
+ };
+
+ flash('test', 'alert', document, actionConfig);
+
+ document.querySelector('.flash-action').click();
+
+ expect(actionConfig.clickHandler).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('removeFlashClickListener', () => {
+ beforeEach(() => {
+ document.body.innerHTML += `
+ <div class="flash-container">
+ <div class="flash">
+ <div class="close-icon js-close-icon"></div>
+ </div>
+ </div>
+ `;
+ });
+
+ it('removes global flash on click', done => {
+ const flashEl = document.querySelector('.flash');
+
+ removeFlashClickListener(flashEl, false);
+
+ flashEl.querySelector('.js-close-icon').click();
+
+ setImmediate(() => {
+ expect(document.querySelector('.flash')).toBeNull();
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js
new file mode 100644
index 00000000000..7c54a48aa41
--- /dev/null
+++ b/spec/frontend/frequent_items/components/app_spec.js
@@ -0,0 +1,251 @@
+import MockAdapter from 'axios-mock-adapter';
+import Vue from 'vue';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import axios from '~/lib/utils/axios_utils';
+import appComponent from '~/frequent_items/components/app.vue';
+import eventHub from '~/frequent_items/event_hub';
+import store from '~/frequent_items/store';
+import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants';
+import { getTopFrequentItems } from '~/frequent_items/utils';
+import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+useLocalStorageSpy();
+
+let session;
+const createComponentWithStore = (namespace = 'projects') => {
+ session = currentSession[namespace];
+ gon.api_version = session.apiVersion;
+ const Component = Vue.extend(appComponent);
+
+ return mountComponentWithStore(Component, {
+ store,
+ props: {
+ namespace,
+ currentUserName: session.username,
+ currentItem: session.project || session.group,
+ },
+ });
+};
+
+describe('Frequent Items App Component', () => {
+ let vm;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ vm = createComponentWithStore();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('dropdownOpenHandler', () => {
+ it('should fetch frequent items when no search has been previously made on desktop', () => {
+ jest.spyOn(vm, 'fetchFrequentItems').mockImplementation(() => {});
+
+ vm.dropdownOpenHandler();
+
+ expect(vm.fetchFrequentItems).toHaveBeenCalledWith();
+ });
+ });
+
+ describe('logItemAccess', () => {
+ let storage;
+
+ beforeEach(() => {
+ storage = {};
+
+ localStorage.setItem.mockImplementation((storageKey, value) => {
+ storage[storageKey] = value;
+ });
+
+ localStorage.getItem.mockImplementation(storageKey => {
+ if (storage[storageKey]) {
+ return storage[storageKey];
+ }
+
+ return null;
+ });
+ });
+
+ it('should create a project store if it does not exist and adds a project', () => {
+ vm.logItemAccess(session.storageKey, session.project);
+
+ const projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects.length).toBe(1);
+ expect(projects[0].frequency).toBe(1);
+ expect(projects[0].lastAccessedOn).toBeDefined();
+ });
+
+ it('should prevent inserting same report multiple times into store', () => {
+ vm.logItemAccess(session.storageKey, session.project);
+ vm.logItemAccess(session.storageKey, session.project);
+
+ const projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects.length).toBe(1);
+ });
+
+ it('should increase frequency of report if it was logged multiple times over the course of an hour', () => {
+ let projects;
+ const newTimestamp = Date.now() + HOUR_IN_MS + 1;
+
+ vm.logItemAccess(session.storageKey, session.project);
+ projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects[0].frequency).toBe(1);
+
+ vm.logItemAccess(session.storageKey, {
+ ...session.project,
+ lastAccessedOn: newTimestamp,
+ });
+ projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects[0].frequency).toBe(2);
+ expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn);
+ });
+
+ it('should always update project metadata', () => {
+ let projects;
+ const oldProject = {
+ ...session.project,
+ };
+
+ const newProject = {
+ ...session.project,
+ name: 'New Name',
+ avatarUrl: 'new/avatar.png',
+ namespace: 'New / Namespace',
+ webUrl: 'http://localhost/new/web/url',
+ };
+
+ vm.logItemAccess(session.storageKey, oldProject);
+ projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects[0].name).toBe(oldProject.name);
+ expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl);
+ expect(projects[0].namespace).toBe(oldProject.namespace);
+ expect(projects[0].webUrl).toBe(oldProject.webUrl);
+
+ vm.logItemAccess(session.storageKey, newProject);
+ projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects[0].name).toBe(newProject.name);
+ expect(projects[0].avatarUrl).toBe(newProject.avatarUrl);
+ expect(projects[0].namespace).toBe(newProject.namespace);
+ expect(projects[0].webUrl).toBe(newProject.webUrl);
+ });
+
+ it('should not add more than 20 projects in store', () => {
+ for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) {
+ const project = {
+ ...session.project,
+ id,
+ };
+ vm.logItemAccess(session.storageKey, project);
+ }
+
+ const projects = JSON.parse(storage[session.storageKey]);
+
+ expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT);
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', done => {
+ jest.spyOn(eventHub, '$on').mockImplementation(() => {});
+
+ createComponentWithStore().$mount();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', done => {
+ jest.spyOn(eventHub, '$off').mockImplementation(() => {});
+
+ vm.$mount();
+ vm.$destroy();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render search input', () => {
+ expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
+ });
+
+ it('should render loading animation', done => {
+ vm.$store.dispatch('fetchSearchedItems');
+
+ Vue.nextTick(() => {
+ const loadingEl = vm.$el.querySelector('.loading-animation');
+
+ expect(loadingEl).toBeDefined();
+ expect(loadingEl.classList.contains('prepend-top-20')).toBe(true);
+ expect(loadingEl.querySelector('span').getAttribute('aria-label')).toBe('Loading projects');
+ done();
+ });
+ });
+
+ it('should render frequent projects list header', done => {
+ Vue.nextTick(() => {
+ const sectionHeaderEl = vm.$el.querySelector('.section-header');
+
+ expect(sectionHeaderEl).toBeDefined();
+ expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited');
+ done();
+ });
+ });
+
+ it('should render frequent projects list', done => {
+ const expectedResult = getTopFrequentItems(mockFrequentProjects);
+ localStorage.getItem.mockImplementation(() => JSON.stringify(mockFrequentProjects));
+
+ expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
+
+ vm.fetchFrequentItems();
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
+ expectedResult.length,
+ );
+ done();
+ });
+ });
+
+ it('should render searched projects list', done => {
+ mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects);
+
+ expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
+
+ vm.$store.dispatch('setSearchQuery', 'gitlab');
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+ })
+ .then(waitForPromises)
+ .then(() => {
+ expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
+ mockSearchedProjects.data.length,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/frequent_items/mock_data.js b/spec/frontend/frequent_items/mock_data.js
index 5cd4cddd877..8c3c66f67ff 100644
--- a/spec/frontend/frequent_items/mock_data.js
+++ b/spec/frontend/frequent_items/mock_data.js
@@ -1,5 +1,94 @@
import { TEST_HOST } from 'helpers/test_constants';
+export const currentSession = {
+ groups: {
+ username: 'root',
+ storageKey: 'root/frequent-groups',
+ apiVersion: 'v4',
+ group: {
+ id: 1,
+ name: 'dummy-group',
+ full_name: 'dummy-parent-group',
+ webUrl: `${TEST_HOST}/dummy-group`,
+ avatarUrl: null,
+ lastAccessedOn: Date.now(),
+ },
+ },
+ projects: {
+ username: 'root',
+ storageKey: 'root/frequent-projects',
+ apiVersion: 'v4',
+ project: {
+ id: 1,
+ name: 'dummy-project',
+ namespace: 'SampleGroup / Dummy-Project',
+ webUrl: `${TEST_HOST}/samplegroup/dummy-project`,
+ avatarUrl: null,
+ lastAccessedOn: Date.now(),
+ },
+ },
+};
+
+export const mockNamespace = 'projects';
+
+export const mockStorageKey = 'test-user/frequent-projects';
+
+export const mockGroup = {
+ id: 1,
+ name: 'Sub451',
+ namespace: 'Commit451 / Sub451',
+ webUrl: `${TEST_HOST}/Commit451/Sub451`,
+ avatarUrl: null,
+};
+
+export const mockRawGroup = {
+ id: 1,
+ name: 'Sub451',
+ full_name: 'Commit451 / Sub451',
+ web_url: `${TEST_HOST}/Commit451/Sub451`,
+ avatar_url: null,
+};
+
+export const mockFrequentGroups = [
+ {
+ id: 3,
+ name: 'Subgroup451',
+ full_name: 'Commit451 / Subgroup451',
+ webUrl: '/Commit451/Subgroup451',
+ avatarUrl: null,
+ frequency: 7,
+ lastAccessedOn: 1497979281815,
+ },
+ {
+ id: 1,
+ name: 'Commit451',
+ full_name: 'Commit451',
+ webUrl: '/Commit451',
+ avatarUrl: null,
+ frequency: 3,
+ lastAccessedOn: 1497979281815,
+ },
+];
+
+export const mockSearchedGroups = [mockRawGroup];
+export const mockProcessedSearchedGroups = [mockGroup];
+
+export const mockProject = {
+ id: 1,
+ name: 'GitLab Community Edition',
+ namespace: 'gitlab-org / gitlab-ce',
+ webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`,
+ avatarUrl: null,
+};
+
+export const mockRawProject = {
+ id: 1,
+ name: 'GitLab Community Edition',
+ name_with_namespace: 'gitlab-org / gitlab-ce',
+ web_url: `${TEST_HOST}/gitlab-org/gitlab-foss`,
+ avatar_url: null,
+};
+
export const mockFrequentProjects = [
{
id: 1,
@@ -48,10 +137,34 @@ export const mockFrequentProjects = [
},
];
-export const mockProject = {
- id: 1,
- name: 'GitLab Community Edition',
- namespace: 'gitlab-org / gitlab-ce',
- webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`,
- avatarUrl: null,
-};
+export const mockSearchedProjects = { data: [mockRawProject] };
+export const mockProcessedSearchedProjects = [mockProject];
+
+export const unsortedFrequentItems = [
+ { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
+ { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
+ { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
+ { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
+ { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
+ { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
+ { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
+ { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
+ { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
+];
+
+/**
+ * This const has a specific order which tests authenticity
+ * of `getTopFrequentItems` method so
+ * DO NOT change order of items in this const.
+ */
+export const sortedFrequentItems = [
+ { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
+ { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
+ { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
+ { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
+ { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
+ { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
+ { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
+ { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
+ { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
+];
diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js
new file mode 100644
index 00000000000..304098e85f1
--- /dev/null
+++ b/spec/frontend/frequent_items/store/actions_spec.js
@@ -0,0 +1,228 @@
+import testAction from 'helpers/vuex_action_helper';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import AccessorUtilities from '~/lib/utils/accessor';
+import * as actions from '~/frequent_items/store/actions';
+import * as types from '~/frequent_items/store/mutation_types';
+import state from '~/frequent_items/store/state';
+import {
+ mockNamespace,
+ mockStorageKey,
+ mockFrequentProjects,
+ mockSearchedProjects,
+} from '../mock_data';
+
+describe('Frequent Items Dropdown Store Actions', () => {
+ let mockedState;
+ let mock;
+
+ beforeEach(() => {
+ mockedState = state();
+ mock = new MockAdapter(axios);
+
+ mockedState.namespace = mockNamespace;
+ mockedState.storageKey = mockStorageKey;
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setNamespace', () => {
+ it('should set namespace', done => {
+ testAction(
+ actions.setNamespace,
+ mockNamespace,
+ mockedState,
+ [{ type: types.SET_NAMESPACE, payload: mockNamespace }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setStorageKey', () => {
+ it('should set storage key', done => {
+ testAction(
+ actions.setStorageKey,
+ mockStorageKey,
+ mockedState,
+ [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestFrequentItems', () => {
+ it('should request frequent items', done => {
+ testAction(
+ actions.requestFrequentItems,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_FREQUENT_ITEMS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFrequentItemsSuccess', () => {
+ it('should set frequent items', done => {
+ testAction(
+ actions.receiveFrequentItemsSuccess,
+ mockFrequentProjects,
+ mockedState,
+ [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFrequentItemsError', () => {
+ it('should set frequent items error state', done => {
+ testAction(
+ actions.receiveFrequentItemsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchFrequentItems', () => {
+ it('should dispatch `receiveFrequentItemsSuccess`', done => {
+ mockedState.namespace = mockNamespace;
+ mockedState.storageKey = mockStorageKey;
+
+ testAction(
+ actions.fetchFrequentItems,
+ null,
+ mockedState,
+ [],
+ [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }],
+ done,
+ );
+ });
+
+ it('should dispatch `receiveFrequentItemsError`', done => {
+ jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false);
+ mockedState.namespace = mockNamespace;
+ mockedState.storageKey = mockStorageKey;
+
+ testAction(
+ actions.fetchFrequentItems,
+ null,
+ mockedState,
+ [],
+ [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }],
+ done,
+ );
+ });
+ });
+
+ describe('requestSearchedItems', () => {
+ it('should request searched items', done => {
+ testAction(
+ actions.requestSearchedItems,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_SEARCHED_ITEMS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveSearchedItemsSuccess', () => {
+ it('should set searched items', done => {
+ testAction(
+ actions.receiveSearchedItemsSuccess,
+ mockSearchedProjects,
+ mockedState,
+ [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveSearchedItemsError', () => {
+ it('should set searched items error state', done => {
+ testAction(
+ actions.receiveSearchedItemsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchSearchedItems', () => {
+ beforeEach(() => {
+ gon.api_version = 'v4';
+ });
+
+ it('should dispatch `receiveSearchedItemsSuccess`', done => {
+ mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {});
+
+ testAction(
+ actions.fetchSearchedItems,
+ null,
+ mockedState,
+ [],
+ [
+ { type: 'requestSearchedItems' },
+ {
+ type: 'receiveSearchedItemsSuccess',
+ payload: { data: mockSearchedProjects, headers: {} },
+ },
+ ],
+ done,
+ );
+ });
+
+ it('should dispatch `receiveSearchedItemsError`', done => {
+ gon.api_version = 'v4';
+ mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500);
+
+ testAction(
+ actions.fetchSearchedItems,
+ null,
+ mockedState,
+ [],
+ [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }],
+ done,
+ );
+ });
+ });
+
+ describe('setSearchQuery', () => {
+ it('should commit query and dispatch `fetchSearchedItems` when query is present', done => {
+ testAction(
+ actions.setSearchQuery,
+ { query: 'test' },
+ mockedState,
+ [{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }],
+ [{ type: 'fetchSearchedItems', payload: { query: 'test' } }],
+ done,
+ );
+ });
+
+ it('should commit query and dispatch `fetchFrequentItems` when query is empty', done => {
+ testAction(
+ actions.setSearchQuery,
+ null,
+ mockedState,
+ [{ type: types.SET_SEARCH_QUERY, payload: null }],
+ [{ type: 'fetchFrequentItems' }],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/frequent_items/store/mutations_spec.js b/spec/frontend/frequent_items/store/mutations_spec.js
new file mode 100644
index 00000000000..d36964b2600
--- /dev/null
+++ b/spec/frontend/frequent_items/store/mutations_spec.js
@@ -0,0 +1,117 @@
+import state from '~/frequent_items/store/state';
+import mutations from '~/frequent_items/store/mutations';
+import * as types from '~/frequent_items/store/mutation_types';
+import {
+ mockNamespace,
+ mockStorageKey,
+ mockFrequentProjects,
+ mockSearchedProjects,
+ mockProcessedSearchedProjects,
+ mockSearchedGroups,
+ mockProcessedSearchedGroups,
+} from '../mock_data';
+
+describe('Frequent Items dropdown mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_NAMESPACE', () => {
+ it('should set namespace', () => {
+ mutations[types.SET_NAMESPACE](stateCopy, mockNamespace);
+
+ expect(stateCopy.namespace).toEqual(mockNamespace);
+ });
+ });
+
+ describe('SET_STORAGE_KEY', () => {
+ it('should set storage key', () => {
+ mutations[types.SET_STORAGE_KEY](stateCopy, mockStorageKey);
+
+ expect(stateCopy.storageKey).toEqual(mockStorageKey);
+ });
+ });
+
+ describe('SET_SEARCH_QUERY', () => {
+ it('should set search query', () => {
+ const searchQuery = 'gitlab-ce';
+
+ mutations[types.SET_SEARCH_QUERY](stateCopy, searchQuery);
+
+ expect(stateCopy.searchQuery).toEqual(searchQuery);
+ });
+ });
+
+ describe('REQUEST_FREQUENT_ITEMS', () => {
+ it('should set view states when requesting frequent items', () => {
+ mutations[types.REQUEST_FREQUENT_ITEMS](stateCopy);
+
+ expect(stateCopy.isLoadingItems).toEqual(true);
+ expect(stateCopy.hasSearchQuery).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_FREQUENT_ITEMS_SUCCESS', () => {
+ it('should set view states when receiving frequent items', () => {
+ mutations[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](stateCopy, mockFrequentProjects);
+
+ expect(stateCopy.items).toEqual(mockFrequentProjects);
+ expect(stateCopy.isLoadingItems).toEqual(false);
+ expect(stateCopy.hasSearchQuery).toEqual(false);
+ expect(stateCopy.isFetchFailed).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_FREQUENT_ITEMS_ERROR', () => {
+ it('should set items and view states when error occurs retrieving frequent items', () => {
+ mutations[types.RECEIVE_FREQUENT_ITEMS_ERROR](stateCopy);
+
+ expect(stateCopy.items).toEqual([]);
+ expect(stateCopy.isLoadingItems).toEqual(false);
+ expect(stateCopy.hasSearchQuery).toEqual(false);
+ expect(stateCopy.isFetchFailed).toEqual(true);
+ });
+ });
+
+ describe('REQUEST_SEARCHED_ITEMS', () => {
+ it('should set view states when requesting searched items', () => {
+ mutations[types.REQUEST_SEARCHED_ITEMS](stateCopy);
+
+ expect(stateCopy.isLoadingItems).toEqual(true);
+ expect(stateCopy.hasSearchQuery).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_SEARCHED_ITEMS_SUCCESS', () => {
+ it('should set items and view states when receiving searched items', () => {
+ mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedProjects);
+
+ expect(stateCopy.items).toEqual(mockProcessedSearchedProjects);
+ expect(stateCopy.isLoadingItems).toEqual(false);
+ expect(stateCopy.hasSearchQuery).toEqual(true);
+ expect(stateCopy.isFetchFailed).toEqual(false);
+ });
+
+ it('should also handle the different `full_name` key for namespace in groups payload', () => {
+ mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedGroups);
+
+ expect(stateCopy.items).toEqual(mockProcessedSearchedGroups);
+ expect(stateCopy.isLoadingItems).toEqual(false);
+ expect(stateCopy.hasSearchQuery).toEqual(true);
+ expect(stateCopy.isFetchFailed).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_SEARCHED_ITEMS_ERROR', () => {
+ it('should set view states when error occurs retrieving searched items', () => {
+ mutations[types.RECEIVE_SEARCHED_ITEMS_ERROR](stateCopy);
+
+ expect(stateCopy.items).toEqual([]);
+ expect(stateCopy.isLoadingItems).toEqual(false);
+ expect(stateCopy.hasSearchQuery).toEqual(true);
+ expect(stateCopy.isFetchFailed).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/frequent_items/utils_spec.js b/spec/frontend/frequent_items/utils_spec.js
new file mode 100644
index 00000000000..181dd9268dc
--- /dev/null
+++ b/spec/frontend/frequent_items/utils_spec.js
@@ -0,0 +1,130 @@
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import {
+ isMobile,
+ getTopFrequentItems,
+ updateExistingFrequentItem,
+ sanitizeItem,
+} from '~/frequent_items/utils';
+import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants';
+import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data';
+
+describe('Frequent Items utils spec', () => {
+ describe('isMobile', () => {
+ it('returns true when the screen is medium ', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
+
+ expect(isMobile()).toBe(true);
+ });
+
+ it('returns true when the screen is small ', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm');
+
+ expect(isMobile()).toBe(true);
+ });
+
+ it('returns true when the screen is extra-small ', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs');
+
+ expect(isMobile()).toBe(true);
+ });
+
+ it('returns false when the screen is larger than medium ', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg');
+
+ expect(isMobile()).toBe(false);
+ });
+ });
+
+ describe('getTopFrequentItems', () => {
+ it('returns empty array if no items provided', () => {
+ const result = getTopFrequentItems();
+
+ expect(result.length).toBe(0);
+ });
+
+ it('returns correct amount of items for mobile', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
+ const result = getTopFrequentItems(unsortedFrequentItems);
+
+ expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE);
+ });
+
+ it('returns correct amount of items for desktop', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
+ const result = getTopFrequentItems(unsortedFrequentItems);
+
+ expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP);
+ });
+
+ it('sorts frequent items in order of frequency and lastAccessedOn', () => {
+ jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
+ const result = getTopFrequentItems(unsortedFrequentItems);
+ const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
+ describe('updateExistingFrequentItem', () => {
+ let mockedProject;
+
+ beforeEach(() => {
+ mockedProject = {
+ ...mockProject,
+ frequency: 1,
+ lastAccessedOn: 1497979281815,
+ };
+ });
+
+ it('updates item if accessed over an hour ago', () => {
+ const newTimestamp = Date.now() + HOUR_IN_MS + 1;
+ const newItem = {
+ ...mockedProject,
+ lastAccessedOn: newTimestamp,
+ };
+ const result = updateExistingFrequentItem(mockedProject, newItem);
+
+ expect(result.frequency).toBe(mockedProject.frequency + 1);
+ });
+
+ it('does not update item if accessed within the hour', () => {
+ const newItem = {
+ ...mockedProject,
+ lastAccessedOn: mockedProject.lastAccessedOn + HOUR_IN_MS,
+ };
+ const result = updateExistingFrequentItem(mockedProject, newItem);
+
+ expect(result.frequency).toBe(mockedProject.frequency);
+ });
+ });
+
+ describe('sanitizeItem', () => {
+ it('strips HTML tags for name and namespace', () => {
+ const input = {
+ name: '<br><b>test</b>',
+ namespace: '<br>test',
+ id: 1,
+ };
+
+ expect(sanitizeItem(input)).toEqual({ name: 'test', namespace: 'test', id: 1 });
+ });
+
+ it("skips `name` key if it doesn't exist on the item", () => {
+ const input = {
+ namespace: '<br>test',
+ id: 1,
+ };
+
+ expect(sanitizeItem(input)).toEqual({ namespace: 'test', id: 1 });
+ });
+
+ it("skips `namespace` key if it doesn't exist on the item", () => {
+ const input = {
+ name: '<br><b>test</b>',
+ id: 1,
+ };
+
+ expect(sanitizeItem(input)).toEqual({ name: 'test', id: 1 });
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
new file mode 100644
index 00000000000..35eda21e047
--- /dev/null
+++ b/spec/frontend/groups/components/app_spec.js
@@ -0,0 +1,507 @@
+import '~/flash';
+import $ from 'jquery';
+import Vue from 'vue';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import appComponent from '~/groups/components/app.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import GroupsStore from '~/groups/store/groups_store';
+import GroupsService from '~/groups/service/groups_service';
+import * as urlUtilities from '~/lib/utils/url_utility';
+
+import {
+ mockEndpoint,
+ mockGroups,
+ mockSearchedGroups,
+ mockRawPageInfo,
+ mockParentGroupItem,
+ mockRawChildren,
+ mockChildren,
+ mockPageInfo,
+} from '../mock_data';
+
+const createComponent = (hideProjects = false) => {
+ const Component = Vue.extend(appComponent);
+ const store = new GroupsStore(false);
+ const service = new GroupsService(mockEndpoint);
+
+ store.state.pageInfo = mockPageInfo;
+
+ return new Component({
+ propsData: {
+ store,
+ service,
+ hideProjects,
+ },
+ });
+};
+
+describe('AppComponent', () => {
+ let vm;
+ let mock;
+ let getGroupsSpy;
+
+ beforeEach(() => {
+ mock = new AxiosMockAdapter(axios);
+ mock.onGet('/dashboard/groups.json').reply(200, mockGroups);
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ getGroupsSpy = jest.spyOn(vm.service, 'getGroups');
+ return vm.$nextTick();
+ });
+
+ describe('computed', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('groups', () => {
+ it('should return list of groups from store', () => {
+ jest.spyOn(vm.store, 'getGroups').mockImplementation(() => {});
+
+ const { groups } = vm;
+
+ expect(vm.store.getGroups).toHaveBeenCalled();
+ expect(groups).not.toBeDefined();
+ });
+ });
+
+ describe('pageInfo', () => {
+ it('should return pagination info from store', () => {
+ jest.spyOn(vm.store, 'getPaginationInfo').mockImplementation(() => {});
+
+ const { pageInfo } = vm;
+
+ expect(vm.store.getPaginationInfo).toHaveBeenCalled();
+ expect(pageInfo).not.toBeDefined();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('fetchGroups', () => {
+ it('should call `getGroups` with all the params provided', () => {
+ return vm
+ .fetchGroups({
+ parentId: 1,
+ page: 2,
+ filterGroupsBy: 'git',
+ sortBy: 'created_desc',
+ archived: true,
+ })
+ .then(() => {
+ expect(getGroupsSpy).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
+ });
+ });
+
+ it('should set headers to store for building pagination info when called with `updatePagination`', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, { headers: mockRawPageInfo });
+
+ jest.spyOn(vm, 'updatePagination').mockImplementation(() => {});
+
+ return vm.fetchGroups({ updatePagination: true }).then(() => {
+ expect(getGroupsSpy).toHaveBeenCalled();
+ expect(vm.updatePagination).toHaveBeenCalled();
+ });
+ });
+
+ it('should show flash error when request fails', () => {
+ mock.onGet('/dashboard/groups.json').reply(400);
+
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ return vm.fetchGroups({}).then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
+ });
+ });
+ });
+
+ describe('fetchAllGroups', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm, 'updateGroups');
+ });
+
+ it('should fetch default set of groups', () => {
+ jest.spyOn(vm, 'updatePagination');
+
+ const fetchPromise = vm.fetchAllGroups();
+
+ expect(vm.isLoading).toBe(true);
+
+ return fetchPromise.then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+
+ it('should fetch matching set of groups when app is loaded with search query', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, mockSearchedGroups);
+
+ const fetchPromise = vm.fetchAllGroups();
+
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: null,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: null,
+ });
+ return fetchPromise.then(() => {
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('fetchPage', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm, 'updateGroups');
+ });
+
+ it('should fetch groups for provided page details and update window state', () => {
+ jest.spyOn(urlUtilities, 'mergeUrlParams');
+ jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+
+ const fetchPagePromise = vm.fetchPage(2, null, null, true);
+
+ expect(vm.isLoading).toBe(true);
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: 2,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: true,
+ });
+
+ return fetchPagePromise.then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(urlUtilities.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, expect.any(String));
+ expect(window.history.replaceState).toHaveBeenCalledWith(
+ {
+ page: expect.any(String),
+ },
+ expect.any(String),
+ expect.any(String),
+ );
+
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('toggleChildren', () => {
+ let groupItem;
+
+ beforeEach(() => {
+ groupItem = { ...mockParentGroupItem };
+ groupItem.isOpen = false;
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should fetch children of given group and expand it if group is collapsed and children are not loaded', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, mockRawChildren);
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm.store, 'setGroupChildren').mockImplementation(() => {});
+
+ vm.toggleChildren(groupItem);
+
+ expect(groupItem.isChildrenLoading).toBe(true);
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ parentId: groupItem.id,
+ });
+ return waitForPromises().then(() => {
+ expect(vm.store.setGroupChildren).toHaveBeenCalled();
+ });
+ });
+
+ it('should skip network request while expanding group if children are already loaded', () => {
+ jest.spyOn(vm, 'fetchGroups');
+ groupItem.children = mockRawChildren;
+
+ vm.toggleChildren(groupItem);
+
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBe(true);
+ });
+
+ it('should collapse group if it is already expanded', () => {
+ jest.spyOn(vm, 'fetchGroups');
+ groupItem.isOpen = true;
+
+ vm.toggleChildren(groupItem);
+
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBe(false);
+ });
+
+ it('should set `isChildrenLoading` back to `false` if load request fails', () => {
+ mock.onGet('/dashboard/groups.json').reply(400);
+
+ vm.toggleChildren(groupItem);
+
+ expect(groupItem.isChildrenLoading).toBe(true);
+ return waitForPromises().then(() => {
+ expect(groupItem.isChildrenLoading).toBe(false);
+ });
+ });
+ });
+
+ describe('showLeaveGroupModal', () => {
+ it('caches candidate group (as props) which is to be left', () => {
+ const group = { ...mockParentGroupItem };
+
+ expect(vm.targetGroup).toBe(null);
+ expect(vm.targetParentGroup).toBe(null);
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.targetGroup).not.toBe(null);
+ expect(vm.targetParentGroup).not.toBe(null);
+ });
+
+ it('updates props which show modal confirmation dialog', () => {
+ const group = { ...mockParentGroupItem };
+
+ expect(vm.showModal).toBe(false);
+ expect(vm.groupLeaveConfirmationMessage).toBe('');
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.showModal).toBe(true);
+ expect(vm.groupLeaveConfirmationMessage).toBe(
+ `Are you sure you want to leave the "${group.fullName}" group?`,
+ );
+ });
+ });
+
+ describe('hideLeaveGroupModal', () => {
+ it('hides modal confirmation which is shown before leaving the group', () => {
+ const group = { ...mockParentGroupItem };
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.showModal).toBe(true);
+ vm.hideLeaveGroupModal();
+
+ expect(vm.showModal).toBe(false);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ let groupItem;
+ let childGroupItem;
+
+ beforeEach(() => {
+ groupItem = { ...mockParentGroupItem };
+ groupItem.children = mockChildren;
+ [childGroupItem] = groupItem.children;
+ groupItem.isChildrenLoading = false;
+ vm.targetGroup = childGroupItem;
+ vm.targetParentGroup = groupItem;
+ });
+
+ it('hides modal confirmation leave group and remove group item from tree', () => {
+ const notice = `You left the "${childGroupItem.fullName}" group.`;
+ jest.spyOn(vm.service, 'leaveGroup').mockResolvedValue({ data: { notice } });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+
+ vm.leaveGroup();
+
+ expect(vm.showModal).toBe(false);
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath);
+ return waitForPromises().then(() => {
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup);
+ expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
+ });
+ });
+
+ it('should show error flash message if request failed to leave group', () => {
+ const message = 'An error occurred. Please try again.';
+ jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 500 });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ vm.leaveGroup();
+
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ return waitForPromises().then(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(vm.targetGroup.isBeingRemoved).toBe(false);
+ });
+ });
+
+ it('should show appropriate error flash message if request forbids to leave group', () => {
+ const message = 'Failed to leave the group. Please make sure you are not the only owner.';
+ jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 403 });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ vm.leaveGroup(childGroupItem, groupItem);
+
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ return waitForPromises().then(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(vm.targetGroup.isBeingRemoved).toBe(false);
+ });
+ });
+ });
+
+ describe('updatePagination', () => {
+ it('should set pagination info to store from provided headers', () => {
+ jest.spyOn(vm.store, 'setPaginationInfo').mockImplementation(() => {});
+
+ vm.updatePagination(mockRawPageInfo);
+
+ expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
+ });
+ });
+
+ describe('updateGroups', () => {
+ it('should call setGroups on store if method was called directly', () => {
+ jest.spyOn(vm.store, 'setGroups').mockImplementation(() => {});
+
+ vm.updateGroups(mockGroups);
+
+ expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
+ jest.spyOn(vm.store, 'setSearchedGroups').mockImplementation(() => {});
+
+ vm.updateGroups(mockGroups, true);
+
+ expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should set `isSearchEmpty` prop based on groups count', () => {
+ vm.updateGroups(mockGroups);
+
+ expect(vm.isSearchEmpty).toBe(false);
+
+ vm.updateGroups([]);
+
+ expect(vm.isSearchEmpty).toBe(true);
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', () => {
+ jest.spyOn(eventHub, '$on').mockImplementation(() => {});
+
+ const newVm = createComponent();
+ newVm.$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function));
+ newVm.$destroy();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', () => {
+ const newVm = createComponent();
+ newVm.$mount();
+ return vm.$nextTick().then(() => {
+ expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search');
+ newVm.$destroy();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', () => {
+ const newVm = createComponent(true);
+ newVm.$mount();
+ return vm.$nextTick().then(() => {
+ expect(newVm.searchEmptyMessage).toBe('No groups matched your search');
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', () => {
+ jest.spyOn(eventHub, '$off').mockImplementation(() => {});
+
+ const newVm = createComponent();
+ newVm.$mount();
+ newVm.$destroy();
+
+ return vm.$nextTick().then(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', expect.any(Function));
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render loading icon', () => {
+ vm.isLoading = true;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+ expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups');
+ });
+ });
+
+ it('should render groups tree', () => {
+ vm.store.state.groups = [mockParentGroupItem];
+ vm.isLoading = false;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ });
+ });
+
+ it('renders modal confirmation dialog', () => {
+ vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?';
+ vm.showModal = true;
+ return vm.$nextTick().then(() => {
+ const modalDialogEl = vm.$el.querySelector('.modal');
+
+ expect(modalDialogEl).not.toBe(null);
+ expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
+ expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js
new file mode 100644
index 00000000000..a40fa9bece8
--- /dev/null
+++ b/spec/frontend/groups/components/group_folder_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import { mockGroups, mockParentGroupItem } from '../mock_data';
+
+const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
+ const Component = Vue.extend(groupFolderComponent);
+
+ return new Component({
+ propsData: {
+ groups,
+ parentGroup,
+ },
+ });
+};
+
+describe('GroupFolderComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ vm.$mount();
+
+ return Vue.nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('hasMoreChildren', () => {
+ it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
+ expect(vm.hasMoreChildren).toBeFalsy();
+ });
+ });
+
+ describe('moreChildrenStats', () => {
+ it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
+ expect(vm.moreChildrenStats).toBe('3 more items');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
+ });
+
+ it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
+ const parentGroup = { ...mockParentGroupItem };
+ parentGroup.childrenCount = 21;
+
+ const newVm = createComponent(mockGroups, parentGroup);
+ newVm.$mount();
+
+ expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
new file mode 100644
index 00000000000..7eb1c54ddb2
--- /dev/null
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -0,0 +1,215 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import eventHub from '~/groups/event_hub';
+import * as urlUtilities from '~/lib/utils/url_utility';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(groupItemComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('GroupItemComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-folder', groupFolderComponent);
+
+ vm = createComponent();
+
+ return Vue.nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('groupDomId', () => {
+ it('should return ID string suffixed with group ID', () => {
+ expect(vm.groupDomId).toBe('group-55');
+ });
+ });
+
+ describe('rowClass', () => {
+ it('should return map of classes based on group details', () => {
+ const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
+ const { rowClass } = vm;
+
+ expect(Object.keys(rowClass).length).toBe(classes.length);
+ Object.keys(rowClass).forEach(className => {
+ expect(classes.indexOf(className)).toBeGreaterThan(-1);
+ });
+ });
+ });
+
+ describe('hasChildren', () => {
+ it('should return boolean value representing if group has any children present', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.childrenCount = 5;
+ newVm = createComponent(group);
+
+ expect(newVm.hasChildren).toBeTruthy();
+ newVm.$destroy();
+
+ group.childrenCount = 0;
+ newVm = createComponent(group);
+
+ expect(newVm.hasChildren).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('hasAvatar', () => {
+ it('should return boolean value representing if group has any avatar present', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.avatarUrl = null;
+ newVm = createComponent(group);
+
+ expect(newVm.hasAvatar).toBeFalsy();
+ newVm.$destroy();
+
+ group.avatarUrl = '/uploads/group_avatar.png';
+ newVm = createComponent(group);
+
+ expect(newVm.hasAvatar).toBeTruthy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing if group item is of type `group` or not', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.type = 'group';
+ newVm = createComponent(group);
+
+ expect(newVm.isGroup).toBeTruthy();
+ newVm.$destroy();
+
+ group.type = 'project';
+ newVm = createComponent(group);
+
+ expect(newVm.isGroup).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onClickRowGroup', () => {
+ let event;
+
+ beforeEach(() => {
+ const classList = {
+ contains() {
+ return false;
+ },
+ };
+
+ event = {
+ target: {
+ classList,
+ parentElement: {
+ classList,
+ },
+ },
+ };
+ });
+
+ it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ vm.onClickRowGroup(event);
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
+ });
+
+ it('should navigate page to group homepage if group does not have any children present', () => {
+ jest.spyOn(urlUtilities, 'visitUrl').mockImplementation();
+ const group = { ...mockParentGroupItem };
+ group.childrenCount = 0;
+ const newVm = createComponent(group);
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ newVm.onClickRowGroup(event);
+
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ expect(urlUtilities.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
+ });
+ });
+ });
+
+ describe('template', () => {
+ let group = null;
+
+ describe('for a group pending deletion', () => {
+ beforeEach(() => {
+ group = { ...mockParentGroupItem, pendingRemoval: true };
+ vm = createComponent(group);
+ });
+
+ it('renders the group pending removal badge', () => {
+ const badgeEl = vm.$el.querySelector('.badge-warning');
+
+ expect(badgeEl).toBeDefined();
+ expect(badgeEl.innerHTML).toContain('pending removal');
+ });
+ });
+
+ describe('for a group not scheduled for deletion', () => {
+ beforeEach(() => {
+ group = { ...mockParentGroupItem, pendingRemoval: false };
+ vm = createComponent(group);
+ });
+
+ it('does not render the group pending removal badge', () => {
+ const groupTextContainer = vm.$el.querySelector('.group-text-container');
+
+ expect(groupTextContainer).not.toContain('pending removal');
+ });
+ });
+
+ it('should render component template correctly', () => {
+ const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+
+ expect(vm.$el.getAttribute('id')).toBe('group-55');
+ expect(vm.$el.classList.contains('group-row')).toBeTruthy();
+
+ expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
+
+ expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
+
+ expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
+
+ expect(vm.$el.querySelector('.title')).toBeDefined();
+ expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
+
+ expect(visibilityIconEl).not.toBe(null);
+ expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
+ expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
+
+ expect(vm.$el.querySelector('.access-type')).toBeDefined();
+ expect(vm.$el.querySelector('.description')).toBeDefined();
+
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
new file mode 100644
index 00000000000..6205400eb03
--- /dev/null
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import groupsComponent from '~/groups/components/groups.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import { mockGroups, mockPageInfo } from '../mock_data';
+
+const createComponent = (searchEmpty = false) => {
+ const Component = Vue.extend(groupsComponent);
+
+ return mountComponent(Component, {
+ groups: mockGroups,
+ pageInfo: mockPageInfo,
+ searchEmptyMessage: 'No matching results',
+ searchEmpty,
+ });
+};
+
+describe('GroupsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ return vm.$nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('change', () => {
+ it('should emit `fetchPage` event when page is changed via pagination', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+
+ vm.change(2);
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'fetchPage',
+ 2,
+ expect.any(Object),
+ expect.any(Object),
+ expect.any(Object),
+ );
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0);
+ });
+ });
+
+ it('should render empty search message when `searchEmpty` is `true`', () => {
+ vm.searchEmpty = true;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js
new file mode 100644
index 00000000000..c0dc1a816e6
--- /dev/null
+++ b/spec/frontend/groups/components/item_actions_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemActionsComponent from '~/groups/components/item_actions.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(itemActionsComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('ItemActionsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('onLeaveGroup', () => {
+ it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ vm.onLeaveGroup();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'showLeaveGroupModal',
+ vm.group,
+ vm.parentGroup,
+ );
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('controls')).toBeTruthy();
+ });
+
+ it('should render Edit Group button with correct attribute values', () => {
+ const group = { ...mockParentGroupItem };
+ group.canEdit = true;
+ const newVm = createComponent(group);
+
+ const editBtn = newVm.$el.querySelector('a.edit-group');
+
+ expect(editBtn).toBeDefined();
+ expect(editBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(editBtn.getAttribute('href')).toBe(group.editPath);
+ expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
+ expect(editBtn.dataset.originalTitle).toBe('Edit group');
+ expect(editBtn.querySelectorAll('svg use').length).not.toBe(0);
+ expect(editBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#settings');
+
+ newVm.$destroy();
+ });
+
+ it('should render Leave Group button with correct attribute values', () => {
+ const group = { ...mockParentGroupItem };
+ group.canLeave = true;
+ const newVm = createComponent(group);
+
+ const leaveBtn = newVm.$el.querySelector('a.leave-group');
+
+ expect(leaveBtn).toBeDefined();
+ expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
+ expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
+ expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
+ expect(leaveBtn.querySelectorAll('svg use').length).not.toBe(0);
+ expect(leaveBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#leave');
+
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js
new file mode 100644
index 00000000000..bfe27be9b51
--- /dev/null
+++ b/spec/frontend/groups/components/item_caret_spec.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemCaretComponent from '~/groups/components/item_caret.vue';
+
+const createComponent = (isGroupOpen = false) => {
+ const Component = Vue.extend(itemCaretComponent);
+
+ return mountComponent(Component, {
+ isGroupOpen,
+ });
+};
+
+describe('ItemCaretComponent', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ vm = createComponent();
+ expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('svg').length).toBe(1);
+ });
+
+ it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
+ vm = createComponent(true);
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down');
+ });
+
+ it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
+ vm = createComponent();
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right');
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js
new file mode 100644
index 00000000000..771643609ec
--- /dev/null
+++ b/spec/frontend/groups/components/item_stats_spec.js
@@ -0,0 +1,119 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemStatsComponent from '~/groups/components/item_stats.vue';
+import {
+ mockParentGroupItem,
+ ITEM_TYPE,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
+} from '../mock_data';
+
+const createComponent = (item = mockParentGroupItem) => {
+ const Component = Vue.extend(itemStatsComponent);
+
+ return mountComponent(Component, {
+ item,
+ });
+};
+
+describe('ItemStatsComponent', () => {
+ describe('computed', () => {
+ describe('visibilityIcon', () => {
+ it('should return icon class based on `item.visibility` value', () => {
+ Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('visibilityTooltip', () => {
+ it('should return tooltip string for Group based on `item.visibility` value', () => {
+ Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.GROUP };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+
+ it('should return tooltip string for Project based on `item.visibility` value', () => {
+ Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.PROJECT };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('isProject', () => {
+ it('should return boolean value representing whether `item.type` is Project or not', () => {
+ let item;
+ let vm;
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT };
+ vm = createComponent(item);
+
+ expect(vm.isProject).toBeTruthy();
+ vm.$destroy();
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP };
+ vm = createComponent(item);
+
+ expect(vm.isProject).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing whether `item.type` is Group or not', () => {
+ let item;
+ let vm;
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP };
+ vm = createComponent(item);
+
+ expect(vm.isGroup).toBeTruthy();
+ vm.$destroy();
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT };
+ vm = createComponent(item);
+
+ expect(vm.isGroup).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element correctly', () => {
+ const vm = createComponent();
+
+ expect(vm.$el.classList.contains('stats')).toBeTruthy();
+
+ vm.$destroy();
+ });
+
+ it('renders start count and last updated information for project item correctly', () => {
+ const item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT, starCount: 4 };
+ const vm = createComponent(item);
+
+ const projectStarIconEl = vm.$el.querySelector('.project-stars');
+
+ expect(projectStarIconEl).not.toBeNull();
+ expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
+ expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
+ expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0);
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js
new file mode 100644
index 00000000000..da6f145fa19
--- /dev/null
+++ b/spec/frontend/groups/components/item_stats_value_spec.js
@@ -0,0 +1,82 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemStatsValueComponent from '~/groups/components/item_stats_value.vue';
+
+const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => {
+ const Component = Vue.extend(itemStatsValueComponent);
+
+ return mountComponent(Component, {
+ title,
+ cssClass,
+ iconName,
+ tooltipPlacement,
+ value,
+ });
+};
+
+describe('ItemStatsValueComponent', () => {
+ describe('computed', () => {
+ let vm;
+ const itemConfig = {
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ };
+
+ describe('isValuePresent', () => {
+ it('returns true if non-empty `value` is present', () => {
+ vm = createComponent({ ...itemConfig, value: 10 });
+
+ expect(vm.isValuePresent).toBeTruthy();
+ });
+
+ it('returns false if empty `value` is present', () => {
+ vm = createComponent(itemConfig);
+
+ expect(vm.isValuePresent).toBeFalsy();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ beforeEach(() => {
+ vm = createComponent({
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ value: 10,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders component element correctly', () => {
+ expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0);
+ expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
+ });
+
+ it('renders element tooltip correctly', () => {
+ expect(vm.$el.dataset.originalTitle).toBe('Subgroups');
+ expect(vm.$el.dataset.placement).toBe('left');
+ });
+
+ it('renders element icon correctly', () => {
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder');
+ });
+
+ it('renders value count correctly', () => {
+ expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10');
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js
new file mode 100644
index 00000000000..251b5b5ff4c
--- /dev/null
+++ b/spec/frontend/groups/components/item_type_icon_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { ITEM_TYPE } from '../mock_data';
+
+const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
+ const Component = Vue.extend(itemTypeIconComponent);
+
+ return mountComponent(Component, {
+ itemType,
+ isGroupOpen,
+ });
+};
+
+describe('ItemTypeIconComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+
+ expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render folder open or close icon based `isGroupOpen` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.GROUP, true);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open');
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder');
+ vm.$destroy();
+ });
+
+ it('should render bookmark icon based on `isProject` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.PROJECT);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark');
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark');
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js
new file mode 100644
index 00000000000..380dda9f7b1
--- /dev/null
+++ b/spec/frontend/groups/mock_data.js
@@ -0,0 +1,398 @@
+export const mockEndpoint = '/dashboard/groups.json';
+
+export const ITEM_TYPE = {
+ PROJECT: 'project',
+ GROUP: 'group',
+};
+
+export const GROUP_VISIBILITY_TYPE = {
+ public: 'Public - The group and any public projects can be viewed without any authentication.',
+ internal: 'Internal - The group and any internal projects can be viewed by any logged in user.',
+ private: 'Private - The group and its projects can only be viewed by members.',
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ public: 'Public - The project can be accessed without any authentication.',
+ internal: 'Internal - The project can be accessed by any logged in user.',
+ private:
+ 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ public: 'earth',
+ internal: 'shield',
+ private: 'lock',
+};
+
+export const mockParentGroupItem = {
+ id: 55,
+ name: 'hardware',
+ description: '',
+ visibility: 'public',
+ fullName: 'platform / hardware',
+ relativePath: '/platform/hardware',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/edit',
+ childrenCount: 3,
+ leavePath: '/groups/platform/hardware/group_members/leave',
+ parentId: 54,
+ memberCount: '1',
+ projectCount: 1,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
+ updatedAt: '2017-04-09T18:40:39.101Z',
+};
+
+export const mockRawChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [],
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+];
+
+export const mockChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ fullName: 'platform / hardware / bsp',
+ relativePath: '/platform/hardware/bsp',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/bsp/edit',
+ childrenCount: 6,
+ leavePath: '/groups/platform/hardware/bsp/group_members/leave',
+ parentId: 55,
+ memberCount: '1',
+ projectCount: 4,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
+ updatedAt: '2017-04-09T18:40:39.101Z',
+ },
+];
+
+export const mockGroups = [
+ {
+ id: 75,
+ name: 'test-group',
+ description: '',
+ visibility: 'public',
+ full_name: 'test-group',
+ relative_path: '/test-group',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/test-group/edit',
+ children_count: 2,
+ leave_path: '/groups/test-group/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 67,
+ name: 'open-source',
+ description: '',
+ visibility: 'private',
+ full_name: 'open-source',
+ relative_path: '/open-source',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/open-source/edit',
+ children_count: 0,
+ leave_path: '/groups/open-source/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 54,
+ name: 'platform',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform',
+ relative_path: '/platform',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 5,
+ name: 'H5bp',
+ description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.',
+ visibility: 'public',
+ full_name: 'H5bp',
+ relative_path: '/h5bp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/h5bp/edit',
+ children_count: 1,
+ leave_path: '/groups/h5bp/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 4,
+ name: 'Twitter',
+ description: 'Deserunt hic nostrum placeat veniam.',
+ visibility: 'public',
+ full_name: 'Twitter',
+ relative_path: '/twitter',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/twitter/edit',
+ children_count: 2,
+ leave_path: '/groups/twitter/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 3,
+ name: 'Documentcloud',
+ description: 'Consequatur saepe totam ea pariatur maxime.',
+ visibility: 'public',
+ full_name: 'Documentcloud',
+ relative_path: '/documentcloud',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/documentcloud/edit',
+ children_count: 1,
+ leave_path: '/groups/documentcloud/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 2,
+ name: 'Gitlab Org',
+ description: 'Debitis ea quas aperiam velit doloremque ab.',
+ visibility: 'public',
+ full_name: 'Gitlab Org',
+ relative_path: '/gitlab-org',
+ can_edit: true,
+ type: 'group',
+ avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
+ permission: 'Owner',
+ edit_path: '/groups/gitlab-org/edit',
+ children_count: 4,
+ leave_path: '/groups/gitlab-org/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 4,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+];
+
+export const mockSearchedGroups = [
+ {
+ id: 55,
+ name: 'hardware',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware',
+ relative_path: '/platform/hardware',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/edit',
+ children_count: 3,
+ leave_path: '/groups/platform/hardware/group_members/leave',
+ parent_id: 54,
+ number_users_with_delimiter: '1',
+ project_count: 1,
+ subgroup_count: 2,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ children: [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ children: [
+ {
+ id: 60,
+ name: 'kernel',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel',
+ relative_path: '/platform/hardware/bsp/kernel',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave',
+ parent_id: 57,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ children: [
+ {
+ id: 61,
+ name: 'common',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common',
+ relative_path: '/platform/hardware/bsp/kernel/common',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/common/edit',
+ children_count: 2,
+ leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave',
+ parent_id: 60,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ children: [
+ {
+ id: 17,
+ name: 'v4.4',
+ description:
+ 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.4',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.4',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit',
+ star_count: 0,
+ updated_at: '2017-09-12T06:37:04.925Z',
+ },
+ {
+ id: 16,
+ name: 'v4.1',
+ description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.1',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.1',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit',
+ star_count: 0,
+ updated_at: '2017-04-09T18:41:03.112Z',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export const mockRawPageInfo = {
+ 'x-per-page': 10,
+ 'x-page': 10,
+ 'x-total': 10,
+ 'x-total-pages': 10,
+ 'x-next-page': 10,
+ 'x-prev-page': 10,
+};
+
+export const mockPageInfo = {
+ perPage: 10,
+ page: 10,
+ total: 10,
+ totalPages: 10,
+ nextPage: 10,
+ prevPage: 10,
+};
diff --git a/spec/frontend/groups/service/groups_service_spec.js b/spec/frontend/groups/service/groups_service_spec.js
new file mode 100644
index 00000000000..38a565eba01
--- /dev/null
+++ b/spec/frontend/groups/service/groups_service_spec.js
@@ -0,0 +1,42 @@
+import axios from '~/lib/utils/axios_utils';
+
+import GroupsService from '~/groups/service/groups_service';
+import { mockEndpoint, mockParentGroupItem } from '../mock_data';
+
+describe('GroupsService', () => {
+ let service;
+
+ beforeEach(() => {
+ service = new GroupsService(mockEndpoint);
+ });
+
+ describe('getGroups', () => {
+ it('should return promise for `GET` request on provided endpoint', () => {
+ jest.spyOn(axios, 'get').mockResolvedValue();
+ const params = {
+ page: 2,
+ filter: 'git',
+ sort: 'created_asc',
+ archived: true,
+ };
+
+ service.getGroups(55, 2, 'git', 'created_asc', true);
+
+ expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params: { parent_id: 55 } });
+
+ service.getGroups(null, 2, 'git', 'created_asc', true);
+
+ expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params });
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should return promise for `DELETE` request on provided endpoint', () => {
+ jest.spyOn(axios, 'delete').mockResolvedValue();
+
+ service.leaveGroup(mockParentGroupItem.leavePath);
+
+ expect(axios.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
+ });
+ });
+});
diff --git a/spec/frontend/groups/store/groups_store_spec.js b/spec/frontend/groups/store/groups_store_spec.js
new file mode 100644
index 00000000000..7d12f73d270
--- /dev/null
+++ b/spec/frontend/groups/store/groups_store_spec.js
@@ -0,0 +1,123 @@
+import GroupsStore from '~/groups/store/groups_store';
+import {
+ mockGroups,
+ mockSearchedGroups,
+ mockParentGroupItem,
+ mockRawChildren,
+ mockRawPageInfo,
+} from '../mock_data';
+
+describe('ProjectsStore', () => {
+ describe('constructor', () => {
+ it('should initialize default state', () => {
+ let store;
+
+ store = new GroupsStore();
+
+ expect(Object.keys(store.state).length).toBe(2);
+ expect(Array.isArray(store.state.groups)).toBeTruthy();
+ expect(Object.keys(store.state.pageInfo).length).toBe(0);
+ expect(store.hideProjects).not.toBeDefined();
+
+ store = new GroupsStore(true);
+
+ expect(store.hideProjects).toBeTruthy();
+ });
+ });
+
+ describe('setGroups', () => {
+ it('should set groups to state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setGroups(mockGroups);
+
+ expect(store.state.groups.length).toBe(mockGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ });
+ });
+
+ describe('setSearchedGroups', () => {
+ it('should set searched groups to state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setSearchedGroups(mockSearchedGroups);
+
+ expect(store.state.groups.length).toBe(mockSearchedGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName')).toBeGreaterThan(
+ -1,
+ );
+ });
+ });
+
+ describe('setGroupChildren', () => {
+ it('should set children to group item in state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setGroupChildren(mockParentGroupItem, mockRawChildren);
+
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(mockParentGroupItem.children.length).toBe(1);
+ expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(mockParentGroupItem.isOpen).toBeTruthy();
+ expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
+ });
+ });
+
+ describe('setPaginationInfo', () => {
+ it('should parse and set pagination info in state', () => {
+ const store = new GroupsStore();
+
+ store.setPaginationInfo(mockRawPageInfo);
+
+ expect(store.state.pageInfo.perPage).toBe(10);
+ expect(store.state.pageInfo.page).toBe(10);
+ expect(store.state.pageInfo.total).toBe(10);
+ expect(store.state.pageInfo.totalPages).toBe(10);
+ expect(store.state.pageInfo.nextPage).toBe(10);
+ expect(store.state.pageInfo.previousPage).toBe(10);
+ });
+ });
+
+ describe('formatGroupItem', () => {
+ it('should parse group item object and return updated object', () => {
+ let store;
+ let updatedGroupItem;
+
+ store = new GroupsStore();
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+
+ expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
+ expect(updatedGroupItem.isChildrenLoading).toBe(false);
+ expect(updatedGroupItem.isBeingRemoved).toBe(false);
+
+ store = new GroupsStore(true);
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+
+ expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
+ });
+ });
+
+ describe('removeGroup', () => {
+ it('should remove children from group item in state', () => {
+ const store = new GroupsStore();
+ const rawParentGroup = { ...mockGroups[0] };
+ const rawChildGroup = { ...mockGroups[1] };
+
+ store.setGroups([rawParentGroup]);
+ store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
+ const childItem = store.state.groups[0].children[0];
+
+ store.removeGroup(childItem, store.state.groups[0]);
+
+ expect(store.state.groups[0].children.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 0a74799283a..6d2d7976196 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -60,8 +60,8 @@ describe('Header', () => {
beforeEach(() => {
setFixtures(`
<li class="js-nav-user-dropdown">
- <a class="js-buy-ci-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy CI minutes
- </a>
+ <a class="js-buy-ci-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy CI minutes</a>
+ <a class="js-upgrade-plan-link" data-track-event="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a>
</li>`);
trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn);
@@ -77,8 +77,16 @@ describe('Header', () => {
it('sends a tracking event when the dropdown is opened and contains Buy CI minutes link', () => {
$('.js-nav-user-dropdown').trigger('shown.bs.dropdown');
- expect(trackingSpy).toHaveBeenCalledTimes(1);
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'show_buy_ci_minutes', {
+ expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_buy_ci_minutes', {
+ label: 'free',
+ property: 'user_dropdown',
+ });
+ });
+
+ it('sends a tracking event when the dropdown is opened and contains Upgrade link', () => {
+ $('.js-nav-user-dropdown').trigger('shown.bs.dropdown');
+
+ expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_upgrade_link', {
label: 'free',
property: 'user_dropdown',
});
diff --git a/spec/frontend/helpers/class_spec_helper.js b/spec/frontend/helpers/class_spec_helper.js
index 7a60d33b471..b26f087f0c5 100644
--- a/spec/frontend/helpers/class_spec_helper.js
+++ b/spec/frontend/helpers/class_spec_helper.js
@@ -1,3 +1,4 @@
+// eslint-disable-next-line jest/no-export
export default class ClassSpecHelper {
static itShouldBeAStaticMethod(base, method) {
return it('should be a static method', () => {
diff --git a/spec/frontend/helpers/event_hub_factory_spec.js b/spec/frontend/helpers/event_hub_factory_spec.js
new file mode 100644
index 00000000000..dcfec6b836a
--- /dev/null
+++ b/spec/frontend/helpers/event_hub_factory_spec.js
@@ -0,0 +1,94 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+describe('event bus factory', () => {
+ let eventBus;
+
+ beforeEach(() => {
+ eventBus = createEventHub();
+ });
+
+ afterEach(() => {
+ eventBus = null;
+ });
+
+ describe('underlying module', () => {
+ let mitt;
+
+ beforeEach(() => {
+ jest.resetModules();
+ jest.mock('mitt');
+
+ // eslint-disable-next-line global-require
+ mitt = require('mitt');
+ mitt.mockReturnValue(() => ({}));
+
+ const createEventHubActual = jest.requireActual('~/helpers/event_hub_factory').default;
+ eventBus = createEventHubActual();
+ });
+
+ it('creates an emitter', () => {
+ expect(mitt).toHaveBeenCalled();
+ });
+ });
+
+ describe('instance', () => {
+ it.each`
+ method
+ ${'on'}
+ ${'once'}
+ ${'off'}
+ ${'emit'}
+ `('binds $$method to $method ', ({ method }) => {
+ expect(typeof eventBus[method]).toBe('function');
+ expect(eventBus[method]).toBe(eventBus[`$${method}`]);
+ });
+ });
+
+ describe('once', () => {
+ const event = 'foobar';
+ let handler;
+
+ beforeEach(() => {
+ jest.spyOn(eventBus, 'on');
+ jest.spyOn(eventBus, 'off');
+ handler = jest.fn();
+ eventBus.once(event, handler);
+ });
+
+ it('calls on internally', () => {
+ expect(eventBus.on).toHaveBeenCalled();
+ });
+
+ it('calls handler when event is emitted', () => {
+ eventBus.emit(event);
+ expect(handler).toHaveBeenCalled();
+ });
+
+ it('calls off when event is emitted', () => {
+ eventBus.emit(event);
+ expect(eventBus.off).toHaveBeenCalled();
+ });
+
+ it('calls the handler only once when event is emitted multiple times', () => {
+ eventBus.emit(event);
+ eventBus.emit(event);
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ describe('when the handler thows an error', () => {
+ beforeEach(() => {
+ handler = jest.fn().mockImplementation(() => {
+ throw new Error();
+ });
+ eventBus.once(event, handler);
+ });
+
+ it('calls off when event is emitted', () => {
+ expect(() => {
+ eventBus.emit(event);
+ }).toThrow();
+ expect(eventBus.off).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/helpers/filtered_search_spec_helper.js b/spec/frontend/helpers/filtered_search_spec_helper.js
new file mode 100644
index 00000000000..ceb7982bbc3
--- /dev/null
+++ b/spec/frontend/helpers/filtered_search_spec_helper.js
@@ -0,0 +1,69 @@
+export default class FilteredSearchSpecHelper {
+ static createFilterVisualTokenHTML(name, operator, value, isSelected) {
+ return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected)
+ .outerHTML;
+ }
+
+ static createFilterVisualToken(name, operator, value, isSelected = false) {
+ const li = document.createElement('li');
+ li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
+
+ li.innerHTML = `
+ <div class="selectable ${isSelected ? 'selected' : ''}" role="button">
+ <div class="name">${name}</div>
+ <div class="operator">${operator}</div>
+ <div class="value-container">
+ <div class="value">${value}</div>
+ <div class="remove-token" role="button">
+ <i class="fa fa-close"></i>
+ </div>
+ </div>
+ </div>
+ `;
+
+ return li;
+ }
+
+ static createNameFilterVisualTokenHTML(name) {
+ return `
+ <li class="js-visual-token filtered-search-token">
+ <div class="name">${name}</div>
+ </li>
+ `;
+ }
+
+ static createNameOperatorFilterVisualTokenHTML(name, operator) {
+ return `
+ <li class="js-visual-token filtered-search-token">
+ <div class="name">${name}</div>
+ <div class="operator">${operator}</div>
+ </li>
+ `;
+ }
+
+ static createSearchVisualToken(name) {
+ const li = document.createElement('li');
+ li.classList.add('js-visual-token', 'filtered-search-term');
+ li.innerHTML = `<div class="name">${name}</div>`;
+ return li;
+ }
+
+ static createSearchVisualTokenHTML(name) {
+ return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML;
+ }
+
+ static createInputHTML(placeholder = '', value = '') {
+ return `
+ <li class="input-token">
+ <input type='text' class='filtered-search' placeholder='${placeholder}' value='${value}'/>
+ </li>
+ `;
+ }
+
+ static createTokensContainerHTML(html, inputPlaceholder) {
+ return `
+ ${html}
+ ${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)}
+ `;
+ }
+}
diff --git a/spec/frontend/helpers/fixtures.js b/spec/frontend/helpers/fixtures.js
index 778196843db..a89ceab3f8e 100644
--- a/spec/frontend/helpers/fixtures.js
+++ b/spec/frontend/helpers/fixtures.js
@@ -23,11 +23,12 @@ Did you run bin/rake frontend:fixtures?`,
export const getJSONFixture = relativePath => JSON.parse(getFixture(relativePath));
export const resetHTMLFixture = () => {
- document.body.textContent = '';
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
};
export const setHTMLFixture = (htmlContent, resetHook = afterEach) => {
- document.body.outerHTML = htmlContent;
+ document.body.innerHTML = htmlContent;
resetHook(resetHTMLFixture);
};
diff --git a/spec/frontend/helpers/set_window_location_helper.js b/spec/frontend/helpers/set_window_location_helper.js
new file mode 100644
index 00000000000..a94e73762c9
--- /dev/null
+++ b/spec/frontend/helpers/set_window_location_helper.js
@@ -0,0 +1,40 @@
+/**
+ * setWindowLocation allows for setting `window.location`
+ * (doing so directly is causing an error in jsdom)
+ *
+ * Example usage:
+ * assert(window.location.hash === undefined);
+ * setWindowLocation('http://example.com#foo')
+ * assert(window.location.hash === '#foo');
+ *
+ * More information:
+ * https://github.com/facebook/jest/issues/890
+ *
+ * @param url
+ */
+export default function setWindowLocation(url) {
+ const parsedUrl = new URL(url);
+
+ const newLocationValue = [
+ 'hash',
+ 'host',
+ 'hostname',
+ 'href',
+ 'origin',
+ 'pathname',
+ 'port',
+ 'protocol',
+ 'search',
+ ].reduce(
+ (location, prop) => ({
+ ...location,
+ [prop]: parsedUrl[prop],
+ }),
+ {},
+ );
+
+ Object.defineProperty(window, 'location', {
+ value: newLocationValue,
+ writable: true,
+ });
+}
diff --git a/spec/frontend/helpers/set_window_location_helper_spec.js b/spec/frontend/helpers/set_window_location_helper_spec.js
new file mode 100644
index 00000000000..2a2c024c824
--- /dev/null
+++ b/spec/frontend/helpers/set_window_location_helper_spec.js
@@ -0,0 +1,40 @@
+import setWindowLocation from './set_window_location_helper';
+
+describe('setWindowLocation', () => {
+ const originalLocation = window.location;
+
+ afterEach(() => {
+ window.location = originalLocation;
+ });
+
+ it.each`
+ url | property | value
+ ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'}
+ ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'}
+ ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'}
+ ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'}
+ ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'}
+ ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'}
+ ${'https://gitlab.com'} | ${'protocol'} | ${'https:'}
+ ${'http://gitlab.com#foo'} | ${'protocol'} | ${'http:'}
+ ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'}
+ ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'}
+ `(
+ 'sets "window.location.$property" to be "$value" when called with: "$url"',
+ ({ url, property, value }) => {
+ expect(window.location).toBe(originalLocation);
+
+ setWindowLocation(url);
+
+ expect(window.location[property]).toBe(value);
+ },
+ );
+
+ it.each([null, 1, undefined, false, '', 'gitlab.com'])(
+ 'throws an error when called with an invalid url: "%s"',
+ invalidUrl => {
+ expect(() => setWindowLocation(invalidUrl)).toThrow(new TypeError('Invalid URL'));
+ expect(window.location).toBe(originalLocation);
+ },
+ );
+});
diff --git a/spec/frontend/helpers/vue_mount_component_helper.js b/spec/frontend/helpers/vue_mount_component_helper.js
index 6848c95d95d..615ff69a01c 100644
--- a/spec/frontend/helpers/vue_mount_component_helper.js
+++ b/spec/frontend/helpers/vue_mount_component_helper.js
@@ -1,22 +1,38 @@
import Vue from 'vue';
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
const mountComponent = (Component, props = {}, el = null) =>
new Component({
propsData: props,
}).$mount(el);
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
export const createComponentWithStore = (Component, store, propsData = {}) =>
new Component({
store,
propsData,
});
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
export const mountComponentWithStore = (Component, { el, props, store }) =>
new Component({
store,
propsData: props || {},
}).$mount(el);
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
export const mountComponentWithSlots = (Component, { props, slots }) => {
const component = new Component({
propsData: props || {},
@@ -30,9 +46,18 @@ export const mountComponentWithSlots = (Component, { props, slots }) => {
/**
* Mount a component with the given render method.
*
+ * -----------------------------
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ * -----------------------------
+ *
* This helps with inserting slots that need to be compiled.
*/
export const mountComponentWithRender = (render, el = null) =>
mountComponent(Vue.extend({ render }), {}, el);
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
export default mountComponent;
diff --git a/spec/frontend/helpers/web_worker_mock.js b/spec/frontend/helpers/web_worker_mock.js
new file mode 100644
index 00000000000..2b4a391e1d2
--- /dev/null
+++ b/spec/frontend/helpers/web_worker_mock.js
@@ -0,0 +1,10 @@
+/* eslint-disable class-methods-use-this */
+export default class WebWorkerMock {
+ addEventListener() {}
+
+ removeEventListener() {}
+
+ terminate() {}
+
+ postMessage() {}
+}
diff --git a/spec/frontend/ide/components/activity_bar_spec.js b/spec/frontend/ide/components/activity_bar_spec.js
new file mode 100644
index 00000000000..8b3853d4535
--- /dev/null
+++ b/spec/frontend/ide/components/activity_bar_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import { leftSidebarViews } from '~/ide/constants';
+import ActivityBar from '~/ide/components/activity_bar.vue';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore } from '../helpers';
+
+describe('IDE activity bar', () => {
+ const Component = Vue.extend(ActivityBar);
+ let vm;
+
+ beforeEach(() => {
+ Vue.set(store.state.projects, 'abcproject', {
+ web_url: 'testing',
+ });
+ Vue.set(store.state, 'currentProjectId', 'abcproject');
+
+ vm = createComponentWithStore(Component, store);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('updateActivityBarView', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'updateActivityBarView').mockImplementation(() => {});
+
+ vm.$mount();
+ });
+
+ it('calls updateActivityBarView with edit value on click', () => {
+ vm.$el.querySelector('.js-ide-edit-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.edit.name);
+ });
+
+ it('calls updateActivityBarView with commit value on click', () => {
+ vm.$el.querySelector('.js-ide-commit-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.commit.name);
+ });
+
+ it('calls updateActivityBarView with review value on click', () => {
+ vm.$el.querySelector('.js-ide-review-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.review.name);
+ });
+ });
+
+ describe('active item', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ it('sets edit item active', () => {
+ expect(vm.$el.querySelector('.js-ide-edit-mode').classList).toContain('active');
+ });
+
+ it('sets commit item active', done => {
+ vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.js-ide-commit-mode').classList).toContain('active');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
index a25aba61516..ff780939026 100644
--- a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
@@ -7,27 +7,32 @@ import { file } from '../../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
+const TEST_FILE_PATH = 'test/file/path';
+
describe('IDE commit editor header', () => {
let wrapper;
- let f;
let store;
- const findDiscardModal = () => wrapper.find({ ref: 'discardModal' });
- const findDiscardButton = () => wrapper.find({ ref: 'discardButton' });
-
- beforeEach(() => {
- f = file('file');
- store = createStore();
-
+ const createComponent = (fileProps = {}) => {
wrapper = mount(EditorHeader, {
store,
localVue,
propsData: {
- activeFile: f,
+ activeFile: {
+ ...file(TEST_FILE_PATH),
+ staged: true,
+ ...fileProps,
+ },
},
});
+ };
- jest.spyOn(wrapper.vm, 'discardChanges').mockImplementation();
+ const findDiscardModal = () => wrapper.find({ ref: 'discardModal' });
+ const findDiscardButton = () => wrapper.find({ ref: 'discardButton' });
+
+ beforeEach(() => {
+ store = createStore();
+ jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
@@ -35,29 +40,38 @@ describe('IDE commit editor header', () => {
wrapper = null;
});
- it('renders button to discard', () => {
- expect(wrapper.vm.$el.querySelectorAll('.btn')).toHaveLength(1);
+ it.each`
+ fileProps | shouldExist
+ ${{ staged: false, changed: false }} | ${false}
+ ${{ staged: true, changed: false }} | ${true}
+ ${{ staged: false, changed: true }} | ${true}
+ ${{ staged: true, changed: true }} | ${true}
+ `('with $fileProps, show discard button is $shouldExist', ({ fileProps, shouldExist }) => {
+ createComponent(fileProps);
+
+ expect(findDiscardButton().exists()).toBe(shouldExist);
});
describe('discard button', () => {
- let modal;
-
beforeEach(() => {
- modal = findDiscardModal();
+ createComponent();
+ const modal = findDiscardModal();
jest.spyOn(modal.vm, 'show');
findDiscardButton().trigger('click');
});
it('opens a dialog confirming discard', () => {
- expect(modal.vm.show).toHaveBeenCalled();
+ expect(findDiscardModal().vm.show).toHaveBeenCalled();
});
it('calls discardFileChanges if dialog result is confirmed', () => {
- modal.vm.$emit('ok');
+ expect(store.dispatch).not.toHaveBeenCalled();
+
+ findDiscardModal().vm.$emit('ok');
- expect(wrapper.vm.discardChanges).toHaveBeenCalledWith(f.path);
+ expect(store.dispatch).toHaveBeenCalledWith('discardFileChanges', TEST_FILE_PATH);
});
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js
index dfde69ab2df..129180bb46e 100644
--- a/spec/frontend/ide/components/commit_sidebar/form_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import waitForPromises from 'helpers/wait_for_promises';
import { projectData } from 'jest/ide/mock_data';
import store from '~/ide/stores';
import CommitForm from '~/ide/components/commit_sidebar/form.vue';
@@ -31,10 +30,10 @@ describe('IDE commit form', () => {
});
describe('compact', () => {
- beforeEach(done => {
+ beforeEach(() => {
vm.isCompact = true;
- vm.$nextTick(done);
+ return vm.$nextTick();
});
it('renders commit button in compact mode', () => {
@@ -46,95 +45,84 @@ describe('IDE commit form', () => {
expect(vm.$el.querySelector('form')).toBeNull();
});
- it('renders overview text', done => {
+ it('renders overview text', () => {
vm.$store.state.stagedFiles.push('test');
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.$el.querySelector('p').textContent).toContain('1 changed file');
- done();
});
});
- it('shows form when clicking commit button', done => {
+ it('shows form when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.$el.querySelector('form')).not.toBeNull();
-
- done();
});
});
- it('toggles activity bar view when clicking commit button', done => {
+ it('toggles activity bar view when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name);
-
- done();
});
});
- it('collapses if lastCommitMsg is set to empty and current view is not commit view', done => {
+ it('collapses if lastCommitMsg is set to empty and current view is not commit view', () => {
store.state.lastCommitMsg = 'abc';
store.state.currentActivityView = leftSidebarViews.edit.name;
- vm.$nextTick(() => {
- // if commit message is set, form is uncollapsed
- expect(vm.isCompact).toBe(false);
+ return vm
+ .$nextTick()
+ .then(() => {
+ // if commit message is set, form is uncollapsed
+ expect(vm.isCompact).toBe(false);
- store.state.lastCommitMsg = '';
+ store.state.lastCommitMsg = '';
- vm.$nextTick(() => {
+ return vm.$nextTick();
+ })
+ .then(() => {
// collapsed when set to empty
expect(vm.isCompact).toBe(true);
-
- done();
});
- });
});
});
describe('full', () => {
- beforeEach(done => {
+ beforeEach(() => {
vm.isCompact = false;
- vm.$nextTick(done);
+ return vm.$nextTick();
});
- it('updates commitMessage in store on input', done => {
+ it('updates commitMessage in store on input', () => {
const textarea = vm.$el.querySelector('textarea');
textarea.value = 'testing commit message';
textarea.dispatchEvent(new Event('input'));
- waitForPromises()
- .then(() => {
- expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
- })
- .then(done)
- .catch(done.fail);
+ return vm.$nextTick().then(() => {
+ expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
+ });
});
- it('updating currentActivityView not to commit view sets compact mode', done => {
+ it('updating currentActivityView not to commit view sets compact mode', () => {
store.state.currentActivityView = 'a';
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.isCompact).toBe(true);
-
- done();
});
});
- it('always opens itself in full view current activity view is not commit view when clicking commit button', done => {
+ it('always opens itself in full view current activity view is not commit view when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name);
expect(vm.isCompact).toBe(false);
-
- done();
});
});
@@ -143,41 +131,54 @@ describe('IDE commit form', () => {
expect(vm.$el.querySelector('.btn-default').textContent).toContain('Collapse');
});
- it('resets commitMessage when clicking discard button', done => {
+ it('resets commitMessage when clicking discard button', () => {
vm.$store.state.commit.commitMessage = 'testing commit message';
- waitForPromises()
+ return vm
+ .$nextTick()
.then(() => {
vm.$el.querySelector('.btn-default').click();
})
- .then(Vue.nextTick)
+ .then(() => vm.$nextTick())
.then(() => {
expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message');
- })
- .then(done)
- .catch(done.fail);
+ });
});
});
describe('when submitting', () => {
beforeEach(() => {
- jest.spyOn(vm, 'commitChanges').mockImplementation(() => {});
+ jest.spyOn(vm, 'commitChanges');
+
vm.$store.state.stagedFiles.push('test');
+ vm.$store.state.commit.commitMessage = 'testing commit message';
});
- it('calls commitChanges', done => {
- vm.$store.state.commit.commitMessage = 'testing commit message';
+ it('calls commitChanges', () => {
+ vm.commitChanges.mockResolvedValue({ success: true });
+
+ return vm.$nextTick().then(() => {
+ vm.$el.querySelector('.btn-success').click();
+
+ expect(vm.commitChanges).toHaveBeenCalled();
+ });
+ });
+
+ it('opens new branch modal if commitChanges throws an error', () => {
+ vm.commitChanges.mockRejectedValue({ success: false });
- waitForPromises()
+ jest.spyOn(vm.$refs.createBranchModal, 'show').mockImplementation();
+
+ return vm
+ .$nextTick()
.then(() => {
vm.$el.querySelector('.btn-success').click();
+
+ return vm.$nextTick();
})
- .then(Vue.nextTick)
.then(() => {
- expect(vm.commitChanges).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ expect(vm.$refs.createBranchModal.show).toHaveBeenCalled();
+ });
});
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js
index ee209487665..2b5664ffc4e 100644
--- a/spec/frontend/ide/components/commit_sidebar/list_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js
@@ -21,8 +21,6 @@ describe('Multi-file editor commit sidebar list', () => {
keyPrefix: 'staged',
});
- vm.$store.state.rightPanelCollapsed = false;
-
vm.$mount();
});
diff --git a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
new file mode 100644
index 00000000000..ac80ba58056
--- /dev/null
+++ b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
@@ -0,0 +1,134 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { resetStore } from 'jest/ide/helpers';
+import store from '~/ide/stores';
+import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue';
+
+describe('IDE commit sidebar radio group', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '2';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('uses label if present', () => {
+ expect(vm.$el.textContent).toContain('test');
+ });
+
+ it('uses slot if label is not present', done => {
+ vm.$destroy();
+
+ vm = new Vue({
+ components: {
+ radioGroup,
+ },
+ store,
+ render: createElement =>
+ createElement('radio-group', { props: { value: '1' } }, 'Testing slot'),
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(() => {
+ expect(vm.$el.textContent).toContain('Testing slot');
+
+ done();
+ });
+ });
+
+ it('updates store when changing radio button', done => {
+ vm.$el.querySelector('input').dispatchEvent(new Event('change'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.commitAction).toBe('1');
+
+ done();
+ });
+ });
+
+ describe('with input', () => {
+ beforeEach(done => {
+ vm.$destroy();
+
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '1';
+ store.state.commit.newBranchName = 'test-123';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ showInput: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders input box when commitAction matches value', () => {
+ expect(vm.$el.querySelector('.form-control')).not.toBeNull();
+ });
+
+ it('hides input when commitAction doesnt match value', done => {
+ store.state.commit.commitAction = '2';
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.form-control')).toBeNull();
+ done();
+ });
+ });
+
+ it('updates branch name in store on input', done => {
+ const input = vm.$el.querySelector('.form-control');
+ input.value = 'testing-123';
+ input.dispatchEvent(new Event('input'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.newBranchName).toBe('testing-123');
+
+ done();
+ });
+ });
+
+ it('renders newBranchName if present', () => {
+ const input = vm.$el.querySelector('.form-control');
+
+ expect(input.value).toBe('test-123');
+ });
+ });
+
+ describe('tooltipTitle', () => {
+ it('returns title when disabled', () => {
+ vm.title = 'test title';
+ vm.disabled = true;
+
+ expect(vm.tooltipTitle).toBe('test title');
+ });
+
+ it('returns blank when not disabled', () => {
+ vm.title = 'test title';
+
+ expect(vm.tooltipTitle).not.toBe('test title');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js
new file mode 100644
index 00000000000..e78bacadebb
--- /dev/null
+++ b/spec/frontend/ide/components/file_row_extra_spec.js
@@ -0,0 +1,170 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import FileRowExtra from '~/ide/components/file_row_extra.vue';
+import { file, resetStore } from '../helpers';
+
+describe('IDE extra file row component', () => {
+ let Component;
+ let vm;
+ let unstagedFilesCount = 0;
+ let stagedFilesCount = 0;
+ let changesCount = 0;
+
+ beforeAll(() => {
+ Component = Vue.extend(FileRowExtra);
+ });
+
+ beforeEach(() => {
+ vm = createComponentWithStore(Component, createStore(), {
+ file: {
+ ...file('test'),
+ },
+ dropdownOpen: false,
+ });
+
+ jest.spyOn(vm, 'getUnstagedFilesCountForPath', 'get').mockReturnValue(() => unstagedFilesCount);
+ jest.spyOn(vm, 'getStagedFilesCountForPath', 'get').mockReturnValue(() => stagedFilesCount);
+ jest.spyOn(vm, 'getChangesInFolder', 'get').mockReturnValue(() => changesCount);
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ resetStore(vm.$store);
+
+ stagedFilesCount = 0;
+ unstagedFilesCount = 0;
+ changesCount = 0;
+ });
+
+ describe('folderChangesTooltip', () => {
+ it('returns undefined when changes count is 0', () => {
+ changesCount = 0;
+
+ expect(vm.folderChangesTooltip).toBe(undefined);
+ });
+
+ [{ input: 1, output: '1 changed file' }, { input: 2, output: '2 changed files' }].forEach(
+ ({ input, output }) => {
+ it('returns changed files count if changes count is not 0', () => {
+ changesCount = input;
+
+ expect(vm.folderChangesTooltip).toBe(output);
+ });
+ },
+ );
+ });
+
+ describe('show tree changes count', () => {
+ it('does not show for blobs', () => {
+ vm.file.type = 'blob';
+
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+ });
+
+ it('does not show when changes count is 0', () => {
+ vm.file.type = 'tree';
+
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+ });
+
+ it('does not show when tree is open', done => {
+ vm.file.type = 'tree';
+ vm.file.opened = true;
+ changesCount = 1;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows for trees with changes', done => {
+ vm.file.type = 'tree';
+ vm.file.opened = false;
+ changesCount = 1;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-tree-changes')).not.toBe(null);
+
+ done();
+ });
+ });
+ });
+
+ describe('changes file icon', () => {
+ it('hides when file is not changed', () => {
+ expect(vm.$el.querySelector('.file-changed-icon')).toBe(null);
+ });
+
+ it('shows when file is changed', done => {
+ vm.file.changed = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is staged', done => {
+ vm.file.staged = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is a tempFile', done => {
+ vm.file.tempFile = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is renamed', done => {
+ vm.file.prevPath = 'original-file';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('hides when file is renamed', done => {
+ vm.file.prevPath = 'original-file';
+ vm.file.type = 'tree';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).toBe(null);
+
+ done();
+ });
+ });
+ });
+
+ describe('merge request icon', () => {
+ it('hides when not a merge request change', () => {
+ expect(vm.$el.querySelector('.ic-git-merge')).toBe(null);
+ });
+
+ it('shows when a merge request change', done => {
+ vm.file.mrChange = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ic-git-merge')).not.toBe(null);
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/file_templates/bar_spec.js b/spec/frontend/ide/components/file_templates/bar_spec.js
new file mode 100644
index 00000000000..21dbe18a223
--- /dev/null
+++ b/spec/frontend/ide/components/file_templates/bar_spec.js
@@ -0,0 +1,117 @@
+import Vue from 'vue';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import Bar from '~/ide/components/file_templates/bar.vue';
+import { resetStore, file } from '../../helpers';
+
+describe('IDE file templates bar component', () => {
+ let Component;
+ let vm;
+
+ beforeAll(() => {
+ Component = Vue.extend(Bar);
+ });
+
+ beforeEach(() => {
+ const store = createStore();
+
+ store.state.openFiles.push({
+ ...file('file'),
+ opened: true,
+ active: true,
+ });
+
+ vm = mountComponentWithStore(Component, { store });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ resetStore(vm.$store);
+ });
+
+ describe('template type dropdown', () => {
+ it('renders dropdown component', () => {
+ expect(vm.$el.querySelector('.dropdown').textContent).toContain('Choose a type');
+ });
+
+ it('calls setSelectedTemplateType when clicking item', () => {
+ jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation();
+
+ vm.$el.querySelector('.dropdown-content button').click();
+
+ expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
+ name: '.gitlab-ci.yml',
+ key: 'gitlab_ci_ymls',
+ });
+ });
+ });
+
+ describe('template dropdown', () => {
+ beforeEach(done => {
+ vm.$store.state.fileTemplates.templates = [
+ {
+ name: 'test',
+ },
+ ];
+ vm.$store.state.fileTemplates.selectedTemplateType = {
+ name: '.gitlab-ci.yml',
+ key: 'gitlab_ci_ymls',
+ };
+
+ vm.$nextTick(done);
+ });
+
+ it('renders dropdown component', () => {
+ expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template');
+ });
+
+ it('calls fetchTemplate on click', () => {
+ jest.spyOn(vm, 'fetchTemplate').mockImplementation();
+
+ vm.$el
+ .querySelectorAll('.dropdown-content')[1]
+ .querySelector('button')
+ .click();
+
+ expect(vm.fetchTemplate).toHaveBeenCalledWith({
+ name: 'test',
+ });
+ });
+ });
+
+ it('shows undo button if updateSuccess is true', done => {
+ vm.$store.state.fileTemplates.updateSuccess = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.btn-default').style.display).not.toBe('none');
+
+ done();
+ });
+ });
+
+ it('calls undoFileTemplate when clicking undo button', () => {
+ jest.spyOn(vm, 'undoFileTemplate').mockImplementation();
+
+ vm.$el.querySelector('.btn-default').click();
+
+ expect(vm.undoFileTemplate).toHaveBeenCalled();
+ });
+
+ it('calls setSelectedTemplateType if activeFile name matches a template', done => {
+ const fileName = '.gitlab-ci.yml';
+
+ jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation(() => {});
+ vm.$store.state.openFiles[0].name = fileName;
+
+ vm.setInitialType();
+
+ vm.$nextTick(() => {
+ expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
+ name: fileName,
+ key: 'gitlab_ci_ymls',
+ });
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js
new file mode 100644
index 00000000000..b56957e1f6d
--- /dev/null
+++ b/spec/frontend/ide/components/ide_review_spec.js
@@ -0,0 +1,73 @@
+import Vue from 'vue';
+import IdeReview from '~/ide/components/ide_review.vue';
+import { createStore } from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IDE review mode', () => {
+ const Component = Vue.extend(IdeReview);
+ let vm;
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+
+ describe('merge request', () => {
+ beforeEach(() => {
+ store.state.currentMergeRequestId = '1';
+ store.state.projects.abcproject.mergeRequests['1'] = {
+ iid: 123,
+ web_url: 'testing123',
+ };
+
+ return vm.$nextTick();
+ });
+
+ it('renders edit dropdown', () => {
+ expect(vm.$el.querySelector('.btn')).not.toBe(null);
+ });
+
+ it('renders merge request link & IID', () => {
+ store.state.viewer = 'mrdiff';
+
+ return vm.$nextTick(() => {
+ const link = vm.$el.querySelector('.ide-review-sub-header');
+
+ expect(link.querySelector('a').getAttribute('href')).toBe('testing123');
+ expect(trimText(link.textContent)).toBe('Merge request (!123)');
+ });
+ });
+
+ it('changes text to latest changes when viewer is not mrdiff', () => {
+ store.state.viewer = 'diff';
+
+ return vm.$nextTick(() => {
+ expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe(
+ 'Latest changes',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
new file mode 100644
index 00000000000..65cad2e7eb0
--- /dev/null
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -0,0 +1,57 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import ideSidebar from '~/ide/components/ide_side_bar.vue';
+import { leftSidebarViews } from '~/ide/constants';
+import { resetStore } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IdeSidebar', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ideSidebar);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.projects.abcproject = projectData;
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a sidebar', () => {
+ expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
+ });
+
+ it('renders loading icon component', done => {
+ vm.$store.state.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+
+ done();
+ });
+ });
+
+ describe('activityBarComponent', () => {
+ it('renders tree component', () => {
+ expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull();
+ });
+
+ it('renders commit component', done => {
+ vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js
new file mode 100644
index 00000000000..78a280e6304
--- /dev/null
+++ b/spec/frontend/ide/components/ide_spec.js
@@ -0,0 +1,125 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import ide from '~/ide/components/ide.vue';
+import { file, resetStore } from '../helpers';
+import { projectData } from '../mock_data';
+
+function bootstrap(projData) {
+ const Component = Vue.extend(ide);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [],
+ loading: false,
+ });
+
+ return createComponentWithStore(Component, store, {
+ emptyStateSvgPath: 'svg',
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ });
+}
+
+describe('ide component, empty repo', () => {
+ let vm;
+
+ beforeEach(() => {
+ const emptyProjData = { ...projectData, empty_repo: true, branches: {} };
+ vm = bootstrap(emptyProjData);
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders "New file" button in empty repo', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).not.toBeNull();
+ done();
+ });
+ });
+});
+
+describe('ide component, non-empty repo', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = bootstrap(projectData);
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('shows error message when set', done => {
+ expect(vm.$el.querySelector('.gl-alert')).toBe(null);
+
+ vm.$store.state.errorMessage = {
+ text: 'error',
+ };
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.gl-alert')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ describe('onBeforeUnload', () => {
+ it('returns undefined when no staged files or changed files', () => {
+ expect(vm.onBeforeUnload()).toBe(undefined);
+ });
+
+ it('returns warning text when their are changed files', () => {
+ vm.$store.state.changedFiles.push(file());
+
+ expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
+ });
+
+ it('returns warning text when their are staged files', () => {
+ vm.$store.state.stagedFiles.push(file());
+
+ expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
+ });
+
+ it('updates event object', () => {
+ const event = {};
+ vm.$store.state.stagedFiles.push(file());
+
+ vm.onBeforeUnload(event);
+
+ expect(event.returnValue).toBe('Are you sure you want to lose unsaved changes?');
+ });
+ });
+
+ describe('non-existent branch', () => {
+ it('does not render "New file" button for non-existent branch when repo is not empty', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
+ done();
+ });
+ });
+ });
+
+ describe('branch with files', () => {
+ beforeEach(() => {
+ store.state.trees['abcproject/master'].tree = [file()];
+ });
+
+ it('does not render "New file" button', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js
new file mode 100644
index 00000000000..bc8144f544c
--- /dev/null
+++ b/spec/frontend/ide/components/ide_status_bar_spec.js
@@ -0,0 +1,127 @@
+import Vue from 'vue';
+import _ from 'lodash';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { TEST_HOST } from '../../helpers/test_constants';
+import { createStore } from '~/ide/stores';
+import IdeStatusBar from '~/ide/components/ide_status_bar.vue';
+import { rightSidebarViews } from '~/ide/constants';
+import { projectData } from '../mock_data';
+
+const TEST_PROJECT_ID = 'abcproject';
+const TEST_MERGE_REQUEST_ID = '9001';
+const TEST_MERGE_REQUEST_URL = `${TEST_HOST}merge-requests/${TEST_MERGE_REQUEST_ID}`;
+
+describe('ideStatusBar', () => {
+ let store;
+ let vm;
+
+ const createComponent = () => {
+ vm = createComponentWithStore(Vue.extend(IdeStatusBar), store).$mount();
+ };
+ const findMRStatus = () => vm.$el.querySelector('.js-ide-status-mr');
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.currentProjectId = TEST_PROJECT_ID;
+ store.state.projects[TEST_PROJECT_ID] = _.clone(projectData);
+ store.state.currentBranchId = 'master';
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('triggers a setInterval', () => {
+ expect(vm.intervalId).not.toBe(null);
+ });
+
+ it('renders the statusbar', () => {
+ expect(vm.$el.className).toBe('ide-status-bar');
+ });
+
+ describe('commitAgeUpdate', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'commitAgeUpdate').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ it('gets called every second', () => {
+ expect(vm.commitAgeUpdate).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1000);
+
+ expect(vm.commitAgeUpdate.mock.calls.length).toEqual(1);
+
+ jest.advanceTimersByTime(1000);
+
+ expect(vm.commitAgeUpdate.mock.calls.length).toEqual(2);
+ });
+ });
+
+ describe('getCommitPath', () => {
+ it('returns the path to the commit details', () => {
+ expect(vm.getCommitPath('abc123de')).toBe('/commit/abc123de');
+ });
+ });
+
+ describe('pipeline status', () => {
+ it('opens right sidebar on clicking icon', done => {
+ jest.spyOn(vm, 'openRightPane').mockImplementation(() => {});
+ Vue.set(vm.$store.state.pipelines, 'latestPipeline', {
+ details: {
+ status: {
+ text: 'success',
+ details_path: 'test',
+ icon: 'status_success',
+ },
+ },
+ commit: {
+ author_gravatar_url: 'www',
+ },
+ });
+
+ vm.$nextTick()
+ .then(() => {
+ vm.$el.querySelector('.ide-status-pipeline button').click();
+
+ expect(vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ it('does not show merge request status', () => {
+ expect(findMRStatus()).toBe(null);
+ });
+ });
+
+ describe('with merge request in store', () => {
+ beforeEach(() => {
+ store.state.projects[TEST_PROJECT_ID].mergeRequests = {
+ [TEST_MERGE_REQUEST_ID]: {
+ web_url: TEST_MERGE_REQUEST_URL,
+ references: {
+ short: `!${TEST_MERGE_REQUEST_ID}`,
+ },
+ },
+ };
+ store.state.currentMergeRequestId = TEST_MERGE_REQUEST_ID;
+
+ createComponent();
+ });
+
+ it('shows merge request status', () => {
+ expect(findMRStatus().textContent.trim()).toEqual(`Merge request !${TEST_MERGE_REQUEST_ID}`);
+ expect(findMRStatus().querySelector('a').href).toEqual(TEST_MERGE_REQUEST_URL);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js
new file mode 100644
index 00000000000..30f11db3153
--- /dev/null
+++ b/spec/frontend/ide/components/ide_tree_list_spec.js
@@ -0,0 +1,77 @@
+import Vue from 'vue';
+import IdeTreeList from '~/ide/components/ide_tree_list.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IDE tree list', () => {
+ const Component = Vue.extend(IdeTreeList);
+ const normalBranchTree = [file('fileName')];
+ const emptyBranchTree = [];
+ let vm;
+
+ const bootstrapWithTree = (tree = normalBranchTree) => {
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree,
+ loading: false,
+ });
+
+ vm = createComponentWithStore(Component, store, {
+ viewerType: 'edit',
+ });
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('normal branch', () => {
+ beforeEach(() => {
+ bootstrapWithTree();
+
+ jest.spyOn(vm, 'updateViewer');
+
+ vm.$mount();
+ });
+
+ it('updates viewer on mount', () => {
+ expect(vm.updateViewer).toHaveBeenCalledWith('edit');
+ });
+
+ it('renders loading indicator', done => {
+ store.state.trees['abcproject/master'].loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+
+ done();
+ });
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+ });
+
+ describe('empty-branch state', () => {
+ beforeEach(() => {
+ bootstrapWithTree(emptyBranchTree);
+
+ jest.spyOn(vm, 'updateViewer');
+
+ vm.$mount();
+ });
+
+ it('does not load files if the branch is empty', () => {
+ expect(vm.$el.textContent).not.toContain('fileName');
+ expect(vm.$el.textContent).toContain('No files');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js
new file mode 100644
index 00000000000..01f007f09c3
--- /dev/null
+++ b/spec/frontend/ide/components/ide_tree_spec.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import IdeTree from '~/ide/components/ide_tree.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IdeRepoTree', () => {
+ let vm;
+
+ beforeEach(() => {
+ const IdeRepoTree = Vue.extend(IdeTree);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(IdeRepoTree, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+});
diff --git a/spec/frontend/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js
new file mode 100644
index 00000000000..babae00d2f7
--- /dev/null
+++ b/spec/frontend/ide/components/jobs/detail/description_spec.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import Description from '~/ide/components/jobs/detail/description.vue';
+import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { jobs } from '../../../mock_data';
+
+describe('IDE job description', () => {
+ const Component = Vue.extend(Description);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job: jobs[0],
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders job details', () => {
+ expect(vm.$el.textContent).toContain('#1');
+ expect(vm.$el.textContent).toContain('test');
+ });
+
+ it('renders CI icon', () => {
+ expect(vm.$el.querySelector('.ci-status-icon .ic-status_success_borderless')).not.toBe(null);
+ });
+});
diff --git a/spec/frontend/ide/components/jobs/item_spec.js b/spec/frontend/ide/components/jobs/item_spec.js
new file mode 100644
index 00000000000..2f97d39e98e
--- /dev/null
+++ b/spec/frontend/ide/components/jobs/item_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import JobItem from '~/ide/components/jobs/item.vue';
+import mountComponent from '../../../helpers/vue_mount_component_helper';
+import { jobs } from '../../mock_data';
+
+describe('IDE jobs item', () => {
+ const Component = Vue.extend(JobItem);
+ const job = jobs[0];
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders job details', () => {
+ expect(vm.$el.textContent).toContain(job.name);
+ expect(vm.$el.textContent).toContain(`#${job.id}`);
+ });
+
+ it('renders CI icon', () => {
+ expect(vm.$el.querySelector('.ic-status_success_borderless')).not.toBe(null);
+ });
+
+ it('does not render view logs button if not started', done => {
+ vm.job.started = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.btn')).toBe(null);
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js
new file mode 100644
index 00000000000..6a2451ad263
--- /dev/null
+++ b/spec/frontend/ide/components/merge_requests/item_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import router from '~/ide/ide_router';
+import Item from '~/ide/components/merge_requests/item.vue';
+import mountCompontent from '../../../helpers/vue_mount_component_helper';
+
+describe('IDE merge request item', () => {
+ const Component = Vue.extend(Item);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountCompontent(Component, {
+ item: {
+ iid: 1,
+ projectPathWithNamespace: 'gitlab-org/gitlab-ce',
+ title: 'Merge request title',
+ },
+ currentId: '1',
+ currentProjectId: 'gitlab-org/gitlab-ce',
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders merge requests data', () => {
+ expect(vm.$el.textContent).toContain('Merge request title');
+ expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1');
+ });
+
+ it('renders link with href', () => {
+ const expectedHref = router.resolve(
+ `/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`,
+ ).href;
+
+ expect(vm.$el.tagName.toLowerCase()).toBe('a');
+ expect(vm.$el).toHaveAttr('href', expectedHref);
+ });
+
+ it('renders icon if ID matches currentId', () => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
+ });
+
+ it('does not render icon if ID does not match currentId', done => {
+ vm.currentId = '2';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
+
+ done();
+ });
+ });
+
+ it('does not render icon if project ID does not match', done => {
+ vm.currentProjectId = 'test/test';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js
new file mode 100644
index 00000000000..2aa3992a6d8
--- /dev/null
+++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import { trimText } from 'helpers/text_helper';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue';
+import { createStore } from '~/ide/stores';
+
+describe('NavDropdown', () => {
+ const TEST_BRANCH_ID = 'lorem-ipsum-dolar';
+ const TEST_MR_ID = '12345';
+ let store;
+ let vm;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const createComponent = (props = {}) => {
+ vm = mountComponentWithStore(Vue.extend(NavDropdownButton), { props, store });
+ vm.$mount();
+ };
+
+ const findIcon = name => vm.$el.querySelector(`.ic-${name}`);
+ const findMRIcon = () => findIcon('merge-request');
+ const findBranchIcon = () => findIcon('branch');
+
+ describe('normal', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty placeholders, if state is falsey', () => {
+ expect(trimText(vm.$el.textContent)).toEqual('- -');
+ });
+
+ it('renders branch name, if state has currentBranchId', done => {
+ vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders mr id, if state has currentMergeRequestId', done => {
+ vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders branch and mr, if state has both', done => {
+ vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+ vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows icons', () => {
+ expect(findBranchIcon()).toBeTruthy();
+ expect(findMRIcon()).toBeTruthy();
+ });
+ });
+
+ describe('with showMergeRequests false', () => {
+ beforeEach(() => {
+ createComponent({ showMergeRequests: false });
+ });
+
+ it('shows single empty placeholder, if state is falsey', () => {
+ expect(trimText(vm.$el.textContent)).toEqual('-');
+ });
+
+ it('shows only branch icon', () => {
+ expect(findBranchIcon()).toBeTruthy();
+ expect(findMRIcon()).toBe(null);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/nav_dropdown_spec.js b/spec/frontend/ide/components/nav_dropdown_spec.js
new file mode 100644
index 00000000000..ce123d925c8
--- /dev/null
+++ b/spec/frontend/ide/components/nav_dropdown_spec.js
@@ -0,0 +1,102 @@
+import $ from 'jquery';
+import { mount } from '@vue/test-utils';
+import { createStore } from '~/ide/stores';
+import NavDropdown from '~/ide/components/nav_dropdown.vue';
+import { PERMISSION_READ_MR } from '~/ide/constants';
+
+const TEST_PROJECT_ID = 'lorem-ipsum';
+
+describe('IDE NavDropdown', () => {
+ let store;
+ let wrapper;
+
+ beforeEach(() => {
+ store = createStore();
+ Object.assign(store.state, {
+ currentProjectId: TEST_PROJECT_ID,
+ currentBranchId: 'master',
+ projects: {
+ [TEST_PROJECT_ID]: {
+ userPermissions: {
+ [PERMISSION_READ_MR]: true,
+ },
+ branches: {
+ master: { id: 'master' },
+ },
+ },
+ },
+ });
+ jest.spyOn(store, 'dispatch').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const createComponent = () => {
+ wrapper = mount(NavDropdown, {
+ store,
+ });
+ };
+
+ const findIcon = name => wrapper.find(`.ic-${name}`);
+ const findMRIcon = () => findIcon('merge-request');
+ const findNavForm = () => wrapper.find('.ide-nav-form');
+ const showDropdown = () => {
+ $(wrapper.vm.$el).trigger('show.bs.dropdown');
+ };
+ const hideDropdown = () => {
+ $(wrapper.vm.$el).trigger('hide.bs.dropdown');
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders nothing initially', () => {
+ expect(findNavForm().exists()).toBe(false);
+ });
+
+ it('renders nav form when show.bs.dropdown', done => {
+ showDropdown();
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(findNavForm().exists()).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('destroys nav form when closed', done => {
+ showDropdown();
+ hideDropdown();
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(findNavForm().exists()).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders merge request icon', () => {
+ expect(findMRIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('when user cannot read merge requests', () => {
+ beforeEach(() => {
+ store.state.projects[TEST_PROJECT_ID].userPermissions = {};
+
+ createComponent();
+ });
+
+ it('does not render merge requests', () => {
+ expect(findMRIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/button_spec.js b/spec/frontend/ide/components/new_dropdown/button_spec.js
new file mode 100644
index 00000000000..3c611b7de8f
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/button_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import Button from '~/ide/components/new_dropdown/button.vue';
+
+describe('IDE new entry dropdown button component', () => {
+ let Component;
+ let vm;
+
+ beforeAll(() => {
+ Component = Vue.extend(Button);
+ });
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ label: 'Testing',
+ icon: 'doc-new',
+ });
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders button with label', () => {
+ expect(vm.$el.textContent).toContain('Testing');
+ });
+
+ it('renders icon', () => {
+ expect(vm.$el.querySelector('.ic-doc-new')).not.toBe(null);
+ });
+
+ it('emits click event', () => {
+ vm.$el.click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('click');
+ });
+
+ it('hides label if showLabel is false', done => {
+ vm.showLabel = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.textContent).not.toContain('Testing');
+
+ done();
+ });
+ });
+
+ describe('tooltipTitle', () => {
+ it('returns empty string when showLabel is true', () => {
+ expect(vm.tooltipTitle).toBe('');
+ });
+
+ it('returns label', done => {
+ vm.showLabel = false;
+
+ vm.$nextTick(() => {
+ expect(vm.tooltipTitle).toBe('Testing');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/index_spec.js b/spec/frontend/ide/components/new_dropdown/index_spec.js
new file mode 100644
index 00000000000..00781c16609
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/index_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import newDropdown from '~/ide/components/new_dropdown/index.vue';
+import { resetStore } from '../../helpers';
+
+describe('new dropdown component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const component = Vue.extend(newDropdown);
+
+ vm = createComponentWithStore(component, store, {
+ branch: 'master',
+ path: '',
+ mouseOver: false,
+ type: 'tree',
+ });
+
+ vm.$store.state.currentProjectId = 'abcproject';
+ vm.$store.state.path = '';
+ vm.$store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+
+ vm.$mount();
+
+ jest.spyOn(vm.$refs.newModal, 'open').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders new file, upload and new directory links', () => {
+ const buttons = vm.$el.querySelectorAll('.dropdown-menu button');
+
+ expect(buttons[0].textContent.trim()).toBe('New file');
+ expect(buttons[1].textContent.trim()).toBe('Upload file');
+ expect(buttons[2].textContent.trim()).toBe('New directory');
+ });
+
+ describe('createNewItem', () => {
+ it('opens modal for a blob when new file is clicked', () => {
+ vm.$el.querySelectorAll('.dropdown-menu button')[0].click();
+
+ expect(vm.$refs.newModal.open).toHaveBeenCalledWith('blob', '');
+ });
+
+ it('opens modal for a tree when new directory is clicked', () => {
+ vm.$el.querySelectorAll('.dropdown-menu button')[2].click();
+
+ expect(vm.$refs.newModal.open).toHaveBeenCalledWith('tree', '');
+ });
+ });
+
+ describe('isOpen', () => {
+ it('scrolls dropdown into view', done => {
+ jest.spyOn(vm.$refs.dropdownMenu, 'scrollIntoView').mockImplementation(() => {});
+
+ vm.isOpen = true;
+
+ setImmediate(() => {
+ expect(vm.$refs.dropdownMenu.scrollIntoView).toHaveBeenCalledWith({
+ block: 'nearest',
+ });
+
+ done();
+ });
+ });
+ });
+
+ describe('delete entry', () => {
+ it('calls delete action', () => {
+ jest.spyOn(vm, 'deleteEntry').mockImplementation(() => {});
+
+ vm.$el.querySelectorAll('.dropdown-menu button')[4].click();
+
+ expect(vm.deleteEntry).toHaveBeenCalledWith('');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js
new file mode 100644
index 00000000000..62a59a76bf4
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js
@@ -0,0 +1,175 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import modal from '~/ide/components/new_dropdown/modal.vue';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
+
+describe('new file modal component', () => {
+ const Component = Vue.extend(modal);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe.each`
+ entryType | modalTitle | btnTitle | showsFileTemplates
+ ${'tree'} | ${'Create new directory'} | ${'Create directory'} | ${false}
+ ${'blob'} | ${'Create new file'} | ${'Create file'} | ${true}
+ `('$entryType', ({ entryType, modalTitle, btnTitle, showsFileTemplates }) => {
+ beforeEach(done => {
+ const store = createStore();
+
+ vm = createComponentWithStore(Component, store).$mount();
+ vm.open(entryType);
+ vm.name = 'testing';
+
+ vm.$nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.close();
+ });
+
+ it(`sets modal title as ${entryType}`, () => {
+ expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
+ });
+
+ it(`sets button label as ${entryType}`, () => {
+ expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle);
+ });
+
+ it(`sets form label as ${entryType}`, () => {
+ expect(document.querySelector('.label-bold').textContent.trim()).toBe('Name');
+ });
+
+ it(`shows file templates: ${showsFileTemplates}`, () => {
+ const templateFilesEl = document.querySelector('.file-templates');
+ expect(Boolean(templateFilesEl)).toBe(showsFileTemplates);
+ });
+ });
+
+ describe('rename entry', () => {
+ beforeEach(() => {
+ const store = createStore();
+ store.state.entries = {
+ 'test-path': {
+ name: 'test',
+ type: 'blob',
+ path: 'test-path',
+ },
+ };
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ it.each`
+ entryType | modalTitle | btnTitle
+ ${'tree'} | ${'Rename folder'} | ${'Rename folder'}
+ ${'blob'} | ${'Rename file'} | ${'Rename file'}
+ `(
+ 'renders title and button for renaming $entryType',
+ ({ entryType, modalTitle, btnTitle }, done) => {
+ vm.$store.state.entries['test-path'].type = entryType;
+ vm.open('rename', 'test-path');
+
+ vm.$nextTick(() => {
+ expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
+ expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle);
+
+ done();
+ });
+ },
+ );
+
+ describe('entryName', () => {
+ it('returns entries name', () => {
+ vm.open('rename', 'test-path');
+
+ expect(vm.entryName).toBe('test-path');
+ });
+
+ it('does not reset entryName to its old value if empty', () => {
+ vm.entryName = 'hello';
+ vm.entryName = '';
+
+ expect(vm.entryName).toBe('');
+ });
+ });
+
+ describe('open', () => {
+ it('sets entryName to path provided if modalType is rename', () => {
+ vm.open('rename', 'test-path');
+
+ expect(vm.entryName).toBe('test-path');
+ });
+
+ it("appends '/' to the path if modalType isn't rename", () => {
+ vm.open('blob', 'test-path');
+
+ expect(vm.entryName).toBe('test-path/');
+ });
+
+ it('leaves entryName blank if no path is provided', () => {
+ vm.open('blob');
+
+ expect(vm.entryName).toBe('');
+ });
+ });
+ });
+
+ describe('submitForm', () => {
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.entries = {
+ 'test-path/test': {
+ name: 'test',
+ deleted: false,
+ },
+ };
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ it('throws an error when target entry exists', () => {
+ vm.open('rename', 'test-path/test');
+
+ expect(createFlash).not.toHaveBeenCalled();
+
+ vm.submitForm();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ 'The name "test-path/test" is already taken in this directory.',
+ 'alert',
+ expect.anything(),
+ null,
+ false,
+ true,
+ );
+ });
+
+ it('does not throw error when target entry does not exist', () => {
+ jest.spyOn(vm, 'renameEntry').mockImplementation();
+
+ vm.open('rename', 'test-path/test');
+ vm.entryName = 'test-path/test2';
+ vm.submitForm();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+
+ it('removes leading/trailing found in the new name', () => {
+ vm.open('rename', 'test-path/test');
+
+ vm.entryName = 'test-path /test';
+
+ vm.submitForm();
+
+ expect(vm.entryName).toBe('test-path/test');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js
new file mode 100644
index 00000000000..a418fdeb572
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import createComponent from 'helpers/vue_mount_component_helper';
+import upload from '~/ide/components/new_dropdown/upload.vue';
+
+describe('new dropdown upload', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(upload);
+
+ vm = createComponent(Component, {
+ path: '',
+ });
+
+ vm.entryName = 'testing';
+
+ jest.spyOn(vm, '$emit');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('openFile', () => {
+ it('calls for each file', () => {
+ const files = ['test', 'test2', 'test3'];
+
+ jest.spyOn(vm, 'readFile').mockImplementation(() => {});
+ jest.spyOn(vm.$refs.fileUpload, 'files', 'get').mockReturnValue(files);
+
+ vm.openFile();
+
+ expect(vm.readFile.mock.calls.length).toBe(3);
+
+ files.forEach((file, i) => {
+ expect(vm.readFile.mock.calls[i]).toEqual([file]);
+ });
+ });
+ });
+
+ describe('readFile', () => {
+ beforeEach(() => {
+ jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {});
+ });
+
+ it('calls readAsDataURL for all files', () => {
+ const file = {
+ type: 'images/png',
+ };
+
+ vm.readFile(file);
+
+ expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
+ });
+ });
+
+ describe('createFile', () => {
+ const textTarget = {
+ result: 'base64,cGxhaW4gdGV4dA==',
+ };
+ const binaryTarget = {
+ result: 'base64,w4I=',
+ };
+ const textFile = new File(['plain text'], 'textFile');
+
+ const binaryFile = {
+ name: 'binaryFile',
+ type: 'image/png',
+ };
+
+ beforeEach(() => {
+ jest.spyOn(FileReader.prototype, 'readAsText');
+ });
+
+ it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', done => {
+ const waitForCreate = new Promise(resolve => vm.$on('create', resolve));
+
+ vm.createFile(textTarget, textFile);
+
+ expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile);
+
+ waitForCreate
+ .then(() => {
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: textFile.name,
+ type: 'blob',
+ content: 'plain text',
+ base64: false,
+ binary: false,
+ rawPath: '',
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('splits content on base64 if binary', () => {
+ vm.createFile(binaryTarget, binaryFile);
+
+ expect(FileReader.prototype.readAsText).not.toHaveBeenCalledWith(textFile);
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: binaryFile.name,
+ type: 'blob',
+ content: binaryTarget.result.split('base64,')[1],
+ base64: true,
+ binary: true,
+ rawPath: binaryTarget.result,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
index 11e672b6685..d909a5e478e 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -7,10 +7,15 @@ import JobsList from '~/ide/components/jobs/list.vue';
import Tab from '~/vue_shared/components/tabs/tab.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { pipelines } from '../../../../javascripts/ide/mock_data';
+import IDEServices from '~/ide/services';
const localVue = createLocalVue();
localVue.use(Vuex);
+jest.mock('~/ide/services', () => ({
+ pingUsage: jest.fn(),
+}));
+
describe('IDE pipelines list', () => {
let wrapper;
@@ -25,14 +30,18 @@ describe('IDE pipelines list', () => {
};
const fetchLatestPipelineMock = jest.fn();
+ const pingUsageMock = jest.fn();
const failedStagesGetterMock = jest.fn().mockReturnValue([]);
+ const fakeProjectPath = 'alpha/beta';
const createComponent = (state = {}) => {
const { pipelines: pipelinesState, ...restOfState } = state;
const { defaultPipelines, ...defaultRestOfState } = defaultState;
const fakeStore = new Vuex.Store({
- getters: { currentProject: () => ({ web_url: 'some/url ' }) },
+ getters: {
+ currentProject: () => ({ web_url: 'some/url ', path_with_namespace: fakeProjectPath }),
+ },
state: {
...defaultRestOfState,
...restOfState,
@@ -46,6 +55,7 @@ describe('IDE pipelines list', () => {
},
actions: {
fetchLatestPipeline: fetchLatestPipelineMock,
+ pingUsage: pingUsageMock,
},
getters: {
jobsCount: () => 1,
@@ -77,6 +87,11 @@ describe('IDE pipelines list', () => {
expect(fetchLatestPipelineMock).toHaveBeenCalled();
});
+ it('pings pipeline usage', () => {
+ createComponent();
+ expect(IDEServices.pingUsage).toHaveBeenCalledWith(fakeProjectPath);
+ });
+
describe('when loading', () => {
let defaultPipelinesLoadingState;
beforeAll(() => {
diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js
index 0cde6fb6060..7b2025f5e9f 100644
--- a/spec/frontend/ide/components/preview/clientside_spec.js
+++ b/spec/frontend/ide/components/preview/clientside_spec.js
@@ -70,14 +70,6 @@ describe('IDE clientside preview', () => {
});
};
- beforeAll(() => {
- jest.useFakeTimers();
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
-
afterEach(() => {
wrapper.destroy();
});
diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js
index 5ea03eb1593..237be018807 100644
--- a/spec/frontend/ide/components/repo_commit_section_spec.js
+++ b/spec/frontend/ide/components/repo_commit_section_spec.js
@@ -36,7 +36,6 @@ describe('RepoCommitSection', () => {
}),
);
- store.state.rightPanelCollapsed = false;
store.state.currentBranch = 'master';
store.state.changedFiles = [];
store.state.stagedFiles = [{ ...files[0] }, { ...files[1] }];
diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js
new file mode 100644
index 00000000000..82ea73ffbb1
--- /dev/null
+++ b/spec/frontend/ide/components/repo_tab_spec.js
@@ -0,0 +1,185 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoTab from '~/ide/components/repo_tab.vue';
+import router from '~/ide/ide_router';
+import { file, resetStore } from '../helpers';
+
+describe('RepoTab', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const RepoTab = Vue.extend(repoTab);
+
+ return new RepoTab({
+ store,
+ propsData,
+ }).$mount();
+ }
+
+ beforeEach(() => {
+ jest.spyOn(router, 'push').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a close link and a name link', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+ vm.$store.state.openFiles.push(vm.tab);
+ const close = vm.$el.querySelector('.multi-file-tab-close');
+ const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`);
+
+ expect(close.innerHTML).toContain('#close');
+ expect(name.textContent.trim()).toEqual(vm.tab.name);
+ });
+
+ it('does not call openPendingTab when tab is active', done => {
+ vm = createComponent({
+ tab: {
+ ...file(),
+ pending: true,
+ active: true,
+ },
+ });
+
+ jest.spyOn(vm, 'openPendingTab').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ vm.$nextTick(() => {
+ expect(vm.openPendingTab).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('fires clickFile when the link is clicked', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ jest.spyOn(vm, 'clickFile').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('calls closeFile when clicking close button', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ jest.spyOn(vm, 'closeFile').mockImplementation(() => {});
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ expect(vm.closeFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('changes icon on hover', done => {
+ const tab = file();
+ tab.changed = true;
+ vm = createComponent({
+ tab,
+ });
+
+ vm.$el.dispatchEvent(new Event('mouseover'));
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.file-modified')).toBeNull();
+
+ vm.$el.dispatchEvent(new Event('mouseout'));
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.$el.querySelector('.file-modified')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ describe('locked file', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file('locked file');
+ f.file_lock = {
+ user: {
+ name: 'testuser',
+ updated_at: new Date(),
+ },
+ };
+
+ vm = createComponent({
+ tab: f,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders lock icon', () => {
+ expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
+ });
+
+ it('renders a tooltip', () => {
+ expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain(
+ 'Locked by testuser',
+ );
+ });
+ });
+
+ describe('methods', () => {
+ describe('closeTab', () => {
+ it('closes tab if file has changed', done => {
+ const tab = file();
+ tab.changed = true;
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.changedFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+ expect(vm.$store.state.changedFiles.length).toBe(1);
+
+ done();
+ });
+ });
+
+ it('closes tab when clicking close btn', done => {
+ const tab = file('lose');
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/repo_tabs_spec.js b/spec/frontend/ide/components/repo_tabs_spec.js
new file mode 100644
index 00000000000..583f71e6121
--- /dev/null
+++ b/spec/frontend/ide/components/repo_tabs_spec.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import repoTabs from '~/ide/components/repo_tabs.vue';
+import createComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('RepoTabs', () => {
+ const openedFiles = [file('open1'), file('open2')];
+ const RepoTabs = Vue.extend(repoTabs);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a list of tabs', done => {
+ vm = createComponent(RepoTabs, {
+ files: openedFiles,
+ viewer: 'editor',
+ hasChanges: false,
+ activeFile: file('activeFile'),
+ hasMergeRequest: false,
+ });
+ openedFiles[0].active = true;
+
+ vm.$nextTick(() => {
+ const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')];
+
+ expect(tabs.length).toEqual(2);
+ expect(tabs[0].parentNode.classList.contains('active')).toEqual(true);
+ expect(tabs[1].parentNode.classList.contains('active')).toEqual(false);
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/shared/tokened_input_spec.js b/spec/frontend/ide/components/shared/tokened_input_spec.js
new file mode 100644
index 00000000000..e687216bd06
--- /dev/null
+++ b/spec/frontend/ide/components/shared/tokened_input_spec.js
@@ -0,0 +1,133 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import TokenedInput from '~/ide/components/shared/tokened_input.vue';
+
+const TEST_PLACEHOLDER = 'Searching in test';
+const TEST_TOKENS = [
+ { label: 'lorem', id: 1 },
+ { label: 'ipsum', id: 2 },
+ { label: 'dolar', id: 3 },
+];
+const TEST_VALUE = 'lorem';
+
+function getTokenElements(vm) {
+ return Array.from(vm.$el.querySelectorAll('.filtered-search-token button'));
+}
+
+function createBackspaceEvent() {
+ const e = new Event('keyup');
+ e.keyCode = 8;
+ e.which = e.keyCode;
+ e.altKey = false;
+ e.ctrlKey = true;
+ e.shiftKey = false;
+ e.metaKey = false;
+ return e;
+}
+
+describe('IDE shared/TokenedInput', () => {
+ const Component = Vue.extend(TokenedInput);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ tokens: TEST_TOKENS,
+ placeholder: TEST_PLACEHOLDER,
+ value: TEST_VALUE,
+ });
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders tokens', () => {
+ const renderedTokens = getTokenElements(vm).map(x => x.textContent.trim());
+
+ expect(renderedTokens).toEqual(TEST_TOKENS.map(x => x.label));
+ });
+
+ it('renders input', () => {
+ expect(vm.$refs.input).toBeTruthy();
+ expect(vm.$refs.input).toHaveValue(TEST_VALUE);
+ });
+
+ it('renders placeholder, when tokens are empty', done => {
+ vm.tokens = [];
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('triggers "removeToken" on token click', () => {
+ getTokenElements(vm)[0].click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]);
+ });
+
+ it('when input triggers backspace event, it calls "onBackspace"', () => {
+ jest.spyOn(vm, 'onBackspace').mockImplementation(() => {});
+
+ vm.$refs.input.dispatchEvent(createBackspaceEvent());
+ vm.$refs.input.dispatchEvent(createBackspaceEvent());
+
+ expect(vm.onBackspace).toHaveBeenCalledTimes(2);
+ });
+
+ it('triggers "removeToken" on backspaces when value is empty', () => {
+ vm.value = '';
+
+ vm.onBackspace();
+
+ expect(vm.$emit).not.toHaveBeenCalled();
+ expect(vm.backspaceCount).toEqual(1);
+
+ vm.onBackspace();
+
+ expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]);
+ expect(vm.backspaceCount).toEqual(0);
+ });
+
+ it('does not trigger "removeToken" on backspaces when value is not empty', () => {
+ vm.onBackspace();
+ vm.onBackspace();
+
+ expect(vm.backspaceCount).toEqual(0);
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('does not trigger "removeToken" on backspaces when tokens are empty', () => {
+ vm.tokens = [];
+
+ vm.onBackspace();
+ vm.onBackspace();
+
+ expect(vm.backspaceCount).toEqual(0);
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('triggers "focus" on input focus', () => {
+ vm.$refs.input.dispatchEvent(new Event('focus'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('focus');
+ });
+
+ it('triggers "blur" on input blur', () => {
+ vm.$refs.input.dispatchEvent(new Event('blur'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('blur');
+ });
+
+ it('triggers "input" with value on input change', () => {
+ vm.$refs.input.value = 'something-else';
+ vm.$refs.input.dispatchEvent(new Event('input'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else');
+ });
+});
diff --git a/spec/frontend/ide/lib/common/model_manager_spec.js b/spec/frontend/ide/lib/common/model_manager_spec.js
new file mode 100644
index 00000000000..08e4ab0f113
--- /dev/null
+++ b/spec/frontend/ide/lib/common/model_manager_spec.js
@@ -0,0 +1,126 @@
+import eventHub from '~/ide/eventhub';
+import ModelManager from '~/ide/lib/common/model_manager';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model manager', () => {
+ let instance;
+
+ beforeEach(() => {
+ instance = new ModelManager();
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ });
+
+ describe('addModel', () => {
+ it('caches model', () => {
+ instance.addModel(file());
+
+ expect(instance.models.size).toBe(1);
+ });
+
+ it('caches model by file path', () => {
+ const f = file('path-name');
+ instance.addModel(f);
+
+ expect(instance.models.keys().next().value).toBe(f.key);
+ });
+
+ it('adds model into disposable', () => {
+ jest.spyOn(instance.disposable, 'add');
+
+ instance.addModel(file());
+
+ expect(instance.disposable.add).toHaveBeenCalled();
+ });
+
+ it('returns cached model', () => {
+ jest.spyOn(instance.models, 'get');
+
+ instance.addModel(file());
+ instance.addModel(file());
+
+ expect(instance.models.get).toHaveBeenCalled();
+ });
+
+ it('adds eventHub listener', () => {
+ const f = file();
+ jest.spyOn(eventHub, '$on');
+
+ instance.addModel(f);
+
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.key}`,
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('hasCachedModel', () => {
+ it('returns false when no models exist', () => {
+ expect(instance.hasCachedModel('path')).toBeFalsy();
+ });
+
+ it('returns true when model exists', () => {
+ const f = file('path-name');
+
+ instance.addModel(f);
+
+ expect(instance.hasCachedModel(f.key)).toBeTruthy();
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns cached model', () => {
+ instance.addModel(file('path-name'));
+
+ expect(instance.getModel('path-name')).not.toBeNull();
+ });
+ });
+
+ describe('removeCachedModel', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file();
+
+ instance.addModel(f);
+ });
+
+ it('clears cached model', () => {
+ instance.removeCachedModel(f);
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ jest.spyOn(eventHub, '$off');
+
+ instance.removeCachedModel(f);
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.key}`,
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached models', () => {
+ instance.addModel(file());
+
+ instance.dispose();
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('calls disposable dispose', () => {
+ jest.spyOn(instance.disposable, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/common/model_spec.js b/spec/frontend/ide/lib/common/model_spec.js
new file mode 100644
index 00000000000..2ef2f0da6da
--- /dev/null
+++ b/spec/frontend/ide/lib/common/model_spec.js
@@ -0,0 +1,137 @@
+import eventHub from '~/ide/eventhub';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model', () => {
+ let model;
+
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$on');
+
+ const f = file('path');
+ f.mrChange = { diff: 'ABC' };
+ f.baseRaw = 'test';
+ model = new Model(f);
+ });
+
+ afterEach(() => {
+ model.dispose();
+ });
+
+ it('creates original model & base model & new model', () => {
+ expect(model.originalModel).not.toBeNull();
+ expect(model.model).not.toBeNull();
+ expect(model.baseModel).not.toBeNull();
+
+ expect(model.originalModel.uri.path).toBe('original/path--path');
+ expect(model.model.uri.path).toBe('path--path');
+ expect(model.baseModel.uri.path).toBe('target/path--path');
+ });
+
+ it('creates model with head file to compare against', () => {
+ const f = file('path');
+ model.dispose();
+
+ model = new Model(f, {
+ ...f,
+ content: '123 testing',
+ });
+
+ expect(model.head).not.toBeNull();
+ expect(model.getOriginalModel().getValue()).toBe('123 testing');
+ });
+
+ it('adds eventHub listener', () => {
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.key}`,
+ expect.anything(),
+ );
+ });
+
+ describe('path', () => {
+ it('returns file path', () => {
+ expect(model.path).toBe(model.file.key);
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns model', () => {
+ expect(model.getModel()).toBe(model.model);
+ });
+ });
+
+ describe('getOriginalModel', () => {
+ it('returns original model', () => {
+ expect(model.getOriginalModel()).toBe(model.originalModel);
+ });
+ });
+
+ describe('getBaseModel', () => {
+ it('returns base model', () => {
+ expect(model.getBaseModel()).toBe(model.baseModel);
+ });
+ });
+
+ describe('setValue', () => {
+ it('updates models value', () => {
+ model.setValue('testing 123');
+
+ expect(model.getModel().getValue()).toBe('testing 123');
+ });
+ });
+
+ describe('onChange', () => {
+ it('calls callback on change', done => {
+ const spy = jest.fn();
+ model.onChange(spy);
+
+ model.getModel().setValue('123');
+
+ setImmediate(() => {
+ expect(spy).toHaveBeenCalledWith(model, expect.anything());
+ done();
+ });
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ jest.spyOn(model.disposable, 'dispose');
+
+ model.dispose();
+
+ expect(model.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('clears events', () => {
+ model.onChange(() => {});
+
+ expect(model.events.size).toBe(1);
+
+ model.dispose();
+
+ expect(model.events.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ jest.spyOn(eventHub, '$off');
+
+ model.dispose();
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.key}`,
+ expect.anything(),
+ );
+ });
+
+ it('calls onDispose callback', () => {
+ const disposeSpy = jest.fn();
+
+ model.onDispose(disposeSpy);
+
+ model.dispose();
+
+ expect(disposeSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/decorations/controller_spec.js b/spec/frontend/ide/lib/decorations/controller_spec.js
new file mode 100644
index 00000000000..4556fc9d646
--- /dev/null
+++ b/spec/frontend/ide/lib/decorations/controller_spec.js
@@ -0,0 +1,143 @@
+import Editor from '~/ide/lib/editor';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library decorations controller', () => {
+ let editorInstance;
+ let controller;
+ let model;
+
+ beforeEach(() => {
+ editorInstance = Editor.create();
+ editorInstance.createInstance(document.createElement('div'));
+
+ controller = new DecorationsController(editorInstance);
+ model = new Model(file('path'));
+ });
+
+ afterEach(() => {
+ model.dispose();
+ editorInstance.dispose();
+ controller.dispose();
+ });
+
+ describe('getAllDecorationsForModel', () => {
+ it('returns empty array when no decorations exist for model', () => {
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations).toEqual([]);
+ });
+
+ it('returns decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
+ });
+ });
+
+ describe('addDecorations', () => {
+ it('caches decorations in a new map', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('does not create new cache model', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ expect(controller.decorations.keys().next().value).toBe('gitlab:path--path');
+ });
+
+ it('calls decorate method', () => {
+ jest.spyOn(controller, 'decorate').mockImplementation(() => {});
+
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorate).toHaveBeenCalled();
+ });
+ });
+
+ describe('decorate', () => {
+ it('sets decorations on editor instance', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockImplementation(() => {});
+
+ controller.decorate(model);
+
+ expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []);
+ });
+
+ it('caches decorations', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockReturnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockReturnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.keys().next().value).toBe('gitlab:path--path');
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached decorations', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ controller.dispose();
+
+ expect(controller.decorations.size).toBe(0);
+ });
+
+ it('clears cached editorDecorations', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ controller.dispose();
+
+ expect(controller.editorDecorations.size).toBe(0);
+ });
+ });
+
+ describe('hasDecorations', () => {
+ it('returns true when decorations are cached', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.hasDecorations(model)).toBe(true);
+ });
+
+ it('returns false when no model decorations exist', () => {
+ expect(controller.hasDecorations(model)).toBe(false);
+ });
+ });
+
+ describe('removeDecorations', () => {
+ beforeEach(() => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+ controller.decorate(model);
+ });
+
+ it('removes cached decorations', () => {
+ expect(controller.decorations.size).not.toBe(0);
+ expect(controller.editorDecorations.size).not.toBe(0);
+
+ controller.removeDecorations(model);
+
+ expect(controller.decorations.size).toBe(0);
+ expect(controller.editorDecorations.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/diff/controller_spec.js b/spec/frontend/ide/lib/diff/controller_spec.js
new file mode 100644
index 00000000000..0b33a4c6ad6
--- /dev/null
+++ b/spec/frontend/ide/lib/diff/controller_spec.js
@@ -0,0 +1,215 @@
+import { Range } from 'monaco-editor';
+import Editor from '~/ide/lib/editor';
+import ModelManager from '~/ide/lib/common/model_manager';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/ide/lib/diff/controller';
+import { computeDiff } from '~/ide/lib/diff/diff';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library dirty diff controller', () => {
+ let editorInstance;
+ let controller;
+ let modelManager;
+ let decorationsController;
+ let model;
+
+ beforeEach(() => {
+ editorInstance = Editor.create();
+ editorInstance.createInstance(document.createElement('div'));
+
+ modelManager = new ModelManager();
+ decorationsController = new DecorationsController(editorInstance);
+
+ model = modelManager.addModel(file('path'));
+
+ controller = new DirtyDiffController(modelManager, decorationsController);
+ });
+
+ afterEach(() => {
+ controller.dispose();
+ model.dispose();
+ decorationsController.dispose();
+ editorInstance.dispose();
+ });
+
+ describe('getDiffChangeType', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDiffChangeType(change)).toBe(type);
+ });
+ });
+ });
+
+ describe('getDecorator', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns with linesDecorationsClassName for ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDecorator(change).options.linesDecorationsClassName).toBe(
+ `dirty-diff dirty-diff-${type}`,
+ );
+ });
+
+ it('returns with line numbers', () => {
+ const change = {
+ lineNumber: 1,
+ endLineNumber: 2,
+ [type]: true,
+ };
+
+ const { range } = getDecorator(change);
+
+ expect(range.startLineNumber).toBe(1);
+ expect(range.endLineNumber).toBe(2);
+ expect(range.startColumn).toBe(1);
+ expect(range.endColumn).toBe(1);
+ });
+ });
+ });
+
+ describe('attachModel', () => {
+ it('adds change event callback', () => {
+ jest.spyOn(model, 'onChange').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ expect(model.onChange).toHaveBeenCalled();
+ });
+
+ it('adds dispose event callback', () => {
+ jest.spyOn(model, 'onDispose').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ expect(model.onDispose).toHaveBeenCalled();
+ });
+
+ it('calls throttledComputeDiff on change', () => {
+ jest.spyOn(controller, 'throttledComputeDiff').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ model.getModel().setValue('123');
+
+ expect(controller.throttledComputeDiff).toHaveBeenCalled();
+ });
+
+ it('caches model', () => {
+ controller.attachModel(model);
+
+ expect(controller.models.has(model.url)).toBe(true);
+ });
+ });
+
+ describe('computeDiff', () => {
+ it('posts to worker', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'postMessage').mockImplementation(() => {});
+
+ controller.computeDiff(model);
+
+ expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
+ path: model.path,
+ originalContent: '',
+ newContent: '',
+ });
+ });
+ });
+
+ describe('reDecorate', () => {
+ it('calls computeDiff when no decorations are cached', () => {
+ jest.spyOn(controller, 'computeDiff').mockImplementation(() => {});
+
+ controller.reDecorate(model);
+
+ expect(controller.computeDiff).toHaveBeenCalledWith(model);
+ });
+
+ it('calls decorate when decorations are cached', () => {
+ jest.spyOn(controller.decorationsController, 'decorate').mockImplementation(() => {});
+
+ controller.decorationsController.decorations.set(model.url, 'test');
+
+ controller.reDecorate(model);
+
+ expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model);
+ });
+ });
+
+ describe('decorate', () => {
+ it('adds decorations into decorations controller', () => {
+ jest.spyOn(controller.decorationsController, 'addDecorations').mockImplementation(() => {});
+
+ controller.decorate({ data: { changes: [], path: model.path } });
+
+ expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(
+ model,
+ 'dirtyDiff',
+ expect.anything(),
+ );
+ });
+
+ it('adds decorations into editor', () => {
+ const spy = jest.spyOn(controller.decorationsController.editor.instance, 'deltaDecorations');
+
+ controller.decorate({
+ data: { changes: computeDiff('123', '1234'), path: model.path },
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ [],
+ [
+ {
+ range: new Range(1, 1, 1, 1),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
+ },
+ },
+ ],
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ jest.spyOn(controller.disposable, 'dispose');
+
+ controller.dispose();
+
+ expect(controller.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('terminates worker', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'terminate');
+
+ controller.dispose();
+
+ expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
+ });
+
+ it('removes worker event listener', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'removeEventListener');
+
+ controller.dispose();
+
+ expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith(
+ 'message',
+ expect.anything(),
+ );
+ });
+
+ it('clears cached models', () => {
+ controller.attachModel(model);
+
+ model.dispose();
+
+ expect(controller.models.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js
new file mode 100644
index 00000000000..36d4c3c26ee
--- /dev/null
+++ b/spec/frontend/ide/lib/editor_spec.js
@@ -0,0 +1,302 @@
+import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
+import Editor from '~/ide/lib/editor';
+import { defaultEditorOptions } from '~/ide/lib/editor_options';
+import { file } from '../helpers';
+
+describe('Multi-file editor library', () => {
+ let instance;
+ let el;
+ let holder;
+
+ const setNodeOffsetWidth = val => {
+ Object.defineProperty(instance.instance.getDomNode(), 'offsetWidth', {
+ get() {
+ return val;
+ },
+ });
+ };
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ holder = document.createElement('div');
+ el.appendChild(holder);
+
+ document.body.appendChild(el);
+
+ instance = Editor.create();
+ });
+
+ afterEach(() => {
+ instance.modelManager.dispose();
+ instance.dispose();
+ Editor.editorInstance = null;
+
+ el.remove();
+ });
+
+ it('creates instance of editor', () => {
+ expect(Editor.editorInstance).not.toBeNull();
+ });
+
+ it('creates instance returns cached instance', () => {
+ expect(Editor.create()).toEqual(instance);
+ });
+
+ describe('createInstance', () => {
+ it('creates editor instance', () => {
+ jest.spyOn(monacoEditor, 'create');
+
+ instance.createInstance(holder);
+
+ expect(monacoEditor.create).toHaveBeenCalled();
+ });
+
+ it('creates dirty diff controller', () => {
+ instance.createInstance(holder);
+
+ expect(instance.dirtyDiffController).not.toBeNull();
+ });
+
+ it('creates model manager', () => {
+ instance.createInstance(holder);
+
+ expect(instance.modelManager).not.toBeNull();
+ });
+ });
+
+ describe('createDiffInstance', () => {
+ it('creates editor instance', () => {
+ jest.spyOn(monacoEditor, 'createDiffEditor');
+
+ instance.createDiffInstance(holder);
+
+ expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, {
+ ...defaultEditorOptions,
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ renderSideBySide: false,
+ readOnly: true,
+ renderLineHighlight: 'all',
+ hideCursorInOverviewRuler: false,
+ });
+ });
+ });
+
+ describe('createModel', () => {
+ it('calls model manager addModel', () => {
+ jest.spyOn(instance.modelManager, 'addModel').mockImplementation(() => {});
+
+ instance.createModel('FILE');
+
+ expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE', null);
+ });
+ });
+
+ describe('attachModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createInstance(document.createElement('div'));
+
+ model = instance.createModel(file());
+ });
+
+ it('sets the current model on the instance', () => {
+ instance.attachModel(model);
+
+ expect(instance.currentModel).toBe(model);
+ });
+
+ it('attaches the model to the current instance', () => {
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
+ });
+
+ it('sets original & modified when diff editor', () => {
+ jest.spyOn(instance.instance, 'getEditorType').mockReturnValue('vs.editor.IDiffEditor');
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+ });
+
+ it('attaches the model to the dirty diff controller', () => {
+ jest.spyOn(instance.dirtyDiffController, 'attachModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model);
+ });
+
+ it('re-decorates with the dirty diff controller', () => {
+ jest.spyOn(instance.dirtyDiffController, 'reDecorate').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model);
+ });
+ });
+
+ describe('attachMergeRequestModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createDiffInstance(document.createElement('div'));
+
+ const f = file();
+ f.mrChanges = { diff: 'ABC' };
+ f.baseRaw = 'testing';
+
+ model = instance.createModel(f);
+ });
+
+ it('sets original & modified', () => {
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachMergeRequestModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+ });
+ });
+
+ describe('clearEditor', () => {
+ it('resets the editor model', () => {
+ instance.createInstance(document.createElement('div'));
+
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.clearEditor();
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('languages', () => {
+ it('registers custom languages defined with Monaco', () => {
+ expect(monacoLanguages.getLanguages()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'vue',
+ }),
+ ]),
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposble dispose method', () => {
+ jest.spyOn(instance.disposable, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('resets instance', () => {
+ instance.createInstance(document.createElement('div'));
+
+ expect(instance.instance).not.toBeNull();
+
+ instance.dispose();
+
+ expect(instance.instance).toBeNull();
+ });
+
+ it('does not dispose modelManager', () => {
+ jest.spyOn(instance.modelManager, 'dispose').mockImplementation(() => {});
+
+ instance.dispose();
+
+ expect(instance.modelManager.dispose).not.toHaveBeenCalled();
+ });
+
+ it('does not dispose decorationsController', () => {
+ jest.spyOn(instance.decorationsController, 'dispose').mockImplementation(() => {});
+
+ instance.dispose();
+
+ expect(instance.decorationsController.dispose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('updateDiffView', () => {
+ describe('edit mode', () => {
+ it('does not update options', () => {
+ instance.createInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions').mockImplementation(() => {});
+
+ instance.updateDiffView();
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('diff mode', () => {
+ beforeEach(() => {
+ instance.createDiffInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions');
+ });
+
+ it('sets renderSideBySide to false if el is less than 700 pixels', () => {
+ setNodeOffsetWidth(600);
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
+ renderSideBySide: false,
+ });
+ });
+
+ it('sets renderSideBySide to false if el is more than 700 pixels', () => {
+ setNodeOffsetWidth(800);
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
+ renderSideBySide: true,
+ });
+ });
+ });
+ });
+
+ describe('isDiffEditorType', () => {
+ it('returns true when diff editor', () => {
+ instance.createDiffInstance(holder);
+
+ expect(instance.isDiffEditorType).toBe(true);
+ });
+
+ it('returns false when not diff editor', () => {
+ instance.createInstance(holder);
+
+ expect(instance.isDiffEditorType).toBe(false);
+ });
+ });
+
+ it('sets quickSuggestions to false when language is markdown', () => {
+ instance.createInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions');
+
+ const model = instance.createModel({
+ ...file(),
+ key: 'index.md',
+ path: 'index.md',
+ });
+
+ instance.attachModel(model);
+
+ expect(instance.instance.updateOptions).toHaveBeenCalledWith({
+ readOnly: false,
+ quickSuggestions: false,
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/languages/vue_spec.js b/spec/frontend/ide/lib/languages/vue_spec.js
new file mode 100644
index 00000000000..3d8784c1436
--- /dev/null
+++ b/spec/frontend/ide/lib/languages/vue_spec.js
@@ -0,0 +1,92 @@
+import { editor } from 'monaco-editor';
+import { registerLanguages } from '~/ide/utils';
+import vue from '~/ide/lib/languages/vue';
+
+// This file only tests syntax specific to vue. This does not test existing syntaxes
+// of html, javascript, css and handlebars, which vue files extend.
+describe('tokenization for .vue files', () => {
+ beforeEach(() => {
+ registerLanguages(vue);
+ });
+
+ test.each([
+ [
+ '<div v-if="something">content</div>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 4, type: '' },
+ { language: 'vue', offset: 5, type: 'variable' },
+ { language: 'vue', offset: 21, type: 'delimiter.html' },
+ { language: 'vue', offset: 22, type: '' },
+ { language: 'vue', offset: 29, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 34, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<input :placeholder="placeholder">',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 6, type: '' },
+ { language: 'vue', offset: 7, type: 'variable' },
+ { language: 'vue', offset: 33, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<gl-modal @ok="submitForm()"></gl-modal>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 3, type: 'attribute.name' },
+ { language: 'vue', offset: 9, type: '' },
+ { language: 'vue', offset: 10, type: 'variable' },
+ { language: 'vue', offset: 28, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 33, type: 'attribute.name' },
+ { language: 'vue', offset: 39, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<a v-on:click.stop="doSomething">...</a>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 2, type: '' },
+ { language: 'vue', offset: 3, type: 'variable' },
+ { language: 'vue', offset: 32, type: 'delimiter.html' },
+ { language: 'vue', offset: 33, type: '' },
+ { language: 'vue', offset: 36, type: 'delimiter.html' },
+ { language: 'vue', offset: 38, type: 'tag.html' },
+ { language: 'vue', offset: 39, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<a @[event]="doSomething">...</a>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 2, type: '' },
+ { language: 'vue', offset: 3, type: 'variable' },
+ { language: 'vue', offset: 25, type: 'delimiter.html' },
+ { language: 'vue', offset: 26, type: '' },
+ { language: 'vue', offset: 29, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 32, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ ])('%s', (string, tokens) => {
+ expect(editor.tokenize(string, 'vue')).toEqual(tokens);
+ });
+});
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index 658ad37d7f2..3cb6e064aa2 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -221,4 +221,67 @@ describe('IDE services', () => {
});
});
});
+
+ describe('getFiles', () => {
+ let mock;
+ let relativeUrlRoot;
+ const TEST_RELATIVE_URL_ROOT = 'blah-blah';
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'get');
+ relativeUrlRoot = gon.relative_url_root;
+ gon.relative_url_root = TEST_RELATIVE_URL_ROOT;
+
+ mock = new MockAdapter(axios);
+
+ mock
+ .onGet(`${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`)
+ .reply(200, [TEST_FILE_PATH]);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ gon.relative_url_root = relativeUrlRoot;
+ });
+
+ it('initates the api call based on the passed path and commit hash', () => {
+ return services.getFiles(TEST_PROJECT_ID, TEST_COMMIT_SHA).then(({ data }) => {
+ expect(axios.get).toHaveBeenCalledWith(
+ `${gon.relative_url_root}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`,
+ expect.any(Object),
+ );
+ expect(data).toEqual([TEST_FILE_PATH]);
+ });
+ });
+ });
+
+ describe('pingUsage', () => {
+ let mock;
+ let relativeUrlRoot;
+ const TEST_RELATIVE_URL_ROOT = 'blah-blah';
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'post');
+ relativeUrlRoot = gon.relative_url_root;
+ gon.relative_url_root = TEST_RELATIVE_URL_ROOT;
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ gon.relative_url_root = relativeUrlRoot;
+ });
+
+ it('posts to usage endpoint', () => {
+ const TEST_PROJECT_PATH = 'foo/bar';
+ const axiosURL = `${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_PATH}/usage_ping/web_ide_pipelines_count`;
+
+ mock.onPost(axiosURL).reply(200);
+
+ return services.pingUsage(TEST_PROJECT_PATH).then(() => {
+ expect(axios.post).toHaveBeenCalledWith(axiosURL);
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js
index 5d0fe35a10e..2eca9acb8d8 100644
--- a/spec/frontend/ide/stores/mutations_spec.js
+++ b/spec/frontend/ide/stores/mutations_spec.js
@@ -55,30 +55,6 @@ describe('Multi-file store mutations', () => {
});
});
- describe('SET_LEFT_PANEL_COLLAPSED', () => {
- it('sets left panel collapsed', () => {
- mutations.SET_LEFT_PANEL_COLLAPSED(localState, true);
-
- expect(localState.leftPanelCollapsed).toBeTruthy();
-
- mutations.SET_LEFT_PANEL_COLLAPSED(localState, false);
-
- expect(localState.leftPanelCollapsed).toBeFalsy();
- });
- });
-
- describe('SET_RIGHT_PANEL_COLLAPSED', () => {
- it('sets right panel collapsed', () => {
- mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true);
-
- expect(localState.rightPanelCollapsed).toBeTruthy();
-
- mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false);
-
- expect(localState.rightPanelCollapsed).toBeFalsy();
- });
- });
-
describe('CLEAR_STAGED_CHANGES', () => {
it('clears stagedFiles array', () => {
localState.stagedFiles.push('a');
@@ -339,23 +315,6 @@ describe('Multi-file store mutations', () => {
});
});
- describe('OPEN_NEW_ENTRY_MODAL', () => {
- it('sets entryModal', () => {
- localState.entries.testPath = file();
-
- mutations.OPEN_NEW_ENTRY_MODAL(localState, {
- type: 'test',
- path: 'testPath',
- });
-
- expect(localState.entryModal).toEqual({
- type: 'test',
- path: 'testPath',
- entry: localState.entries.testPath,
- });
- });
- });
-
describe('RENAME_ENTRY', () => {
beforeEach(() => {
localState.trees = {
diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js
index 90f2644de62..b87f6c1f05a 100644
--- a/spec/frontend/ide/stores/utils_spec.js
+++ b/spec/frontend/ide/stores/utils_spec.js
@@ -685,4 +685,75 @@ describe('Multi-file store utils', () => {
});
});
});
+
+ describe('extractMarkdownImagesFromEntries', () => {
+ let mdFile;
+ let entries;
+
+ beforeEach(() => {
+ const img = { content: '/base64/encoded/image+' };
+ mdFile = { path: 'path/to/some/directory/myfile.md' };
+ entries = {
+ // invalid (or lack of) extensions are also supported as long as there's
+ // a real image inside and can go into an <img> tag's `src` and the browser
+ // can render it
+ img,
+ 'img.js': img,
+ 'img.png': img,
+ 'img.with.many.dots.png': img,
+ 'path/to/img.gif': img,
+ 'path/to/some/img.jpg': img,
+ 'path/to/some/img 1/img.png': img,
+ 'path/to/some/directory/img.png': img,
+ 'path/to/some/directory/img 1.png': img,
+ };
+ });
+
+ it.each`
+ markdownBefore | ext | imgAlt | imgTitle
+ ${'* ![img](/img)'} | ${'jpeg'} | ${'img'} | ${undefined}
+ ${'* ![img](/img.js)'} | ${'js'} | ${'img'} | ${undefined}
+ ${'* ![img](img.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](./img.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![with spaces](../img 1/img.png)'} | ${'png'} | ${'with spaces'} | ${undefined}
+ ${'* ![img](../../img.gif " title ")'} | ${'gif'} | ${'img'} | ${' title '}
+ ${'* ![img](../img.jpg)'} | ${'jpg'} | ${'img'} | ${undefined}
+ ${'* ![img](/img.png "title")'} | ${'png'} | ${'img'} | ${'title'}
+ ${'* ![img](/img.with.many.dots.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](img 1.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](img.png "title here")'} | ${'png'} | ${'img'} | ${'title here'}
+ `(
+ 'correctly transforms markdown with uncommitted images: $markdownBefore',
+ ({ markdownBefore, ext, imgAlt, imgTitle }) => {
+ mdFile.content = markdownBefore;
+
+ expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
+ content: '* {{gl_md_img_1}}',
+ images: {
+ '{{gl_md_img_1}}': {
+ src: `data:image/${ext};base64,/base64/encoded/image+`,
+ alt: imgAlt,
+ title: imgTitle,
+ },
+ },
+ });
+ },
+ );
+
+ it.each`
+ markdown
+ ${'* ![img](i.png)'}
+ ${'* ![img](img.png invalid title)'}
+ ${'* ![img](img.png "incorrect" "markdown")'}
+ ${'* ![img](https://gitlab.com/logo.png)'}
+ ${'* ![img](https://gitlab.com/some/deep/nested/path/logo.png)'}
+ `("doesn't touch invalid or non-existant images in markdown: $markdown", ({ markdown }) => {
+ mdFile.content = markdown;
+
+ expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
+ content: markdown,
+ images: {},
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js
index 44eae7eacbe..ea975500e8d 100644
--- a/spec/frontend/ide/utils_spec.js
+++ b/spec/frontend/ide/utils_spec.js
@@ -1,6 +1,7 @@
import { commitItemIconMap } from '~/ide/constants';
-import { getCommitIconMap, isTextFile } from '~/ide/utils';
+import { getCommitIconMap, isTextFile, registerLanguages, trimPathComponents } from '~/ide/utils';
import { decorateData } from '~/ide/stores/utils';
+import { languages } from 'monaco-editor';
describe('WebIDE utils', () => {
describe('isTextFile', () => {
@@ -102,4 +103,93 @@ describe('WebIDE utils', () => {
expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified);
});
});
+
+ describe('trimPathComponents', () => {
+ it.each`
+ input | output
+ ${'example path '} | ${'example path'}
+ ${'p/somefile '} | ${'p/somefile'}
+ ${'p /somefile '} | ${'p/somefile'}
+ ${'p/ somefile '} | ${'p/somefile'}
+ ${' p/somefile '} | ${'p/somefile'}
+ ${'p/somefile .md'} | ${'p/somefile .md'}
+ ${'path / to / some/file.doc '} | ${'path/to/some/file.doc'}
+ `('trims all path components in path: "$input"', ({ input, output }) => {
+ expect(trimPathComponents(input)).toEqual(output);
+ });
+ });
+
+ describe('registerLanguages', () => {
+ let langs;
+
+ beforeEach(() => {
+ langs = [
+ {
+ id: 'html',
+ extensions: ['.html'],
+ conf: { comments: { blockComment: ['<!--', '-->'] } },
+ language: { tokenizer: {} },
+ },
+ {
+ id: 'css',
+ extensions: ['.css'],
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ language: { tokenizer: {} },
+ },
+ {
+ id: 'js',
+ extensions: ['.js'],
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ language: { tokenizer: {} },
+ },
+ ];
+
+ jest.spyOn(languages, 'register').mockImplementation(() => {});
+ jest.spyOn(languages, 'setMonarchTokensProvider').mockImplementation(() => {});
+ jest.spyOn(languages, 'setLanguageConfiguration').mockImplementation(() => {});
+ });
+
+ it('registers all the passed languages with Monaco', () => {
+ registerLanguages(...langs);
+
+ expect(languages.register.mock.calls).toEqual([
+ [
+ {
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ extensions: ['.css'],
+ id: 'css',
+ language: { tokenizer: {} },
+ },
+ ],
+ [
+ {
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ extensions: ['.js'],
+ id: 'js',
+ language: { tokenizer: {} },
+ },
+ ],
+ [
+ {
+ conf: { comments: { blockComment: ['<!--', '-->'] } },
+ extensions: ['.html'],
+ id: 'html',
+ language: { tokenizer: {} },
+ },
+ ],
+ ]);
+
+ expect(languages.setMonarchTokensProvider.mock.calls).toEqual([
+ ['css', { tokenizer: {} }],
+ ['js', { tokenizer: {} }],
+ ['html', { tokenizer: {} }],
+ ]);
+
+ expect(languages.setLanguageConfiguration.mock.calls).toEqual([
+ ['css', { comments: { blockComment: ['/*', '*/'] } }],
+ ['js', { comments: { blockComment: ['/*', '*/'] } }],
+ ['html', { comments: { blockComment: ['<!--', '-->'] } }],
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/image_diff/helpers/badge_helper_spec.js b/spec/frontend/image_diff/helpers/badge_helper_spec.js
new file mode 100644
index 00000000000..c970ccc535d
--- /dev/null
+++ b/spec/frontend/image_diff/helpers/badge_helper_spec.js
@@ -0,0 +1,130 @@
+import * as badgeHelper from '~/image_diff/helpers/badge_helper';
+import * as mockData from '../mock_data';
+
+describe('badge helper', () => {
+ const { coordinate, noteId, badgeText, badgeNumber } = mockData;
+ let containerEl;
+ let buttonEl;
+
+ beforeEach(() => {
+ containerEl = document.createElement('div');
+ });
+
+ describe('createImageBadge', () => {
+ beforeEach(() => {
+ buttonEl = badgeHelper.createImageBadge(noteId, coordinate);
+ });
+
+ it('should create button', () => {
+ expect(buttonEl.tagName).toEqual('BUTTON');
+ expect(buttonEl.getAttribute('type')).toEqual('button');
+ });
+
+ it('should set disabled attribute', () => {
+ expect(buttonEl.hasAttribute('disabled')).toEqual(true);
+ });
+
+ it('should set noteId', () => {
+ expect(buttonEl.dataset.noteId).toEqual(noteId);
+ });
+
+ it('should set coordinate', () => {
+ expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+
+ describe('classNames', () => {
+ it('should set .js-image-badge by default', () => {
+ expect(buttonEl.className).toEqual('js-image-badge');
+ });
+
+ it('should add additional class names if parameter is passed', () => {
+ const classNames = ['first-class', 'second-class'];
+ buttonEl = badgeHelper.createImageBadge(noteId, coordinate, classNames);
+
+ expect(buttonEl.className).toEqual(classNames.concat('js-image-badge').join(' '));
+ });
+ });
+ });
+
+ describe('addImageBadge', () => {
+ beforeEach(() => {
+ badgeHelper.addImageBadge(containerEl, {
+ coordinate,
+ badgeText,
+ noteId,
+ });
+ buttonEl = containerEl.querySelector('button');
+ });
+
+ it('should appends button to container', () => {
+ expect(buttonEl).toBeDefined();
+ });
+
+ it('should add badge classes', () => {
+ expect(buttonEl.className).toContain('badge badge-pill');
+ });
+
+ it('should set the badge text', () => {
+ expect(buttonEl.textContent).toEqual(badgeText);
+ });
+
+ it('should set the button coordinates', () => {
+ expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+
+ it('should set the button noteId', () => {
+ expect(buttonEl.dataset.noteId).toEqual(noteId);
+ });
+ });
+
+ describe('addImageCommentBadge', () => {
+ beforeEach(() => {
+ badgeHelper.addImageCommentBadge(containerEl, {
+ coordinate,
+ noteId,
+ });
+ buttonEl = containerEl.querySelector('button');
+ });
+
+ it('should append icon button to container', () => {
+ expect(buttonEl).toBeDefined();
+ });
+
+ it('should create icon comment button', () => {
+ const iconEl = buttonEl.querySelector('svg');
+
+ expect(iconEl).toBeDefined();
+ });
+ });
+
+ describe('addAvatarBadge', () => {
+ let avatarBadgeEl;
+
+ beforeEach(() => {
+ containerEl.innerHTML = `
+ <div id="${noteId}">
+ <div class="badge hidden">
+ </div>
+ </div>
+ `;
+
+ badgeHelper.addAvatarBadge(containerEl, {
+ detail: {
+ noteId,
+ badgeNumber,
+ },
+ });
+ avatarBadgeEl = containerEl.querySelector(`#${noteId} .badge`);
+ });
+
+ it('should update badge number', () => {
+ expect(avatarBadgeEl.textContent).toEqual(badgeNumber.toString());
+ });
+
+ it('should remove hidden class', () => {
+ expect(avatarBadgeEl.classList.contains('hidden')).toEqual(false);
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js
new file mode 100644
index 00000000000..395bb7de362
--- /dev/null
+++ b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js
@@ -0,0 +1,144 @@
+import * as commentIndicatorHelper from '~/image_diff/helpers/comment_indicator_helper';
+import * as mockData from '../mock_data';
+
+describe('commentIndicatorHelper', () => {
+ const { coordinate } = mockData;
+ let containerEl;
+
+ beforeEach(() => {
+ containerEl = document.createElement('div');
+ });
+
+ describe('addCommentIndicator', () => {
+ let buttonEl;
+
+ beforeEach(() => {
+ commentIndicatorHelper.addCommentIndicator(containerEl, coordinate);
+ buttonEl = containerEl.querySelector('button');
+ });
+
+ it('should append button to container', () => {
+ expect(buttonEl).toBeDefined();
+ });
+
+ describe('button', () => {
+ it('should set coordinate', () => {
+ expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+
+ it('should contain image-comment-dark svg', () => {
+ const svgEl = buttonEl.querySelector('svg');
+
+ expect(svgEl).toBeDefined();
+
+ const svgLink = svgEl.querySelector('use').getAttribute('xlink:href');
+
+ expect(svgLink.indexOf('image-comment-dark')).not.toBe(-1);
+ });
+ });
+ });
+
+ describe('removeCommentIndicator', () => {
+ it('should return removed false if there is no comment-indicator', () => {
+ const result = commentIndicatorHelper.removeCommentIndicator(containerEl);
+
+ expect(result.removed).toEqual(false);
+ });
+
+ describe('has comment indicator', () => {
+ let result;
+
+ beforeEach(() => {
+ containerEl.innerHTML = `
+ <div class="comment-indicator" style="left:${coordinate.x}px; top: ${coordinate.y}px;">
+ <img src="${gl.TEST_HOST}/image.png">
+ </div>
+ `;
+ result = commentIndicatorHelper.removeCommentIndicator(containerEl);
+ });
+
+ it('should remove comment indicator', () => {
+ expect(containerEl.querySelector('.comment-indicator')).toBeNull();
+ });
+
+ it('should return removed true', () => {
+ expect(result.removed).toEqual(true);
+ });
+
+ it('should return indicator meta', () => {
+ expect(result.x).toEqual(coordinate.x);
+ expect(result.y).toEqual(coordinate.y);
+ expect(result.image).toBeDefined();
+ expect(result.image.width).toBeDefined();
+ expect(result.image.height).toBeDefined();
+ });
+ });
+ });
+
+ describe('showCommentIndicator', () => {
+ describe('commentIndicator exists', () => {
+ beforeEach(() => {
+ containerEl.innerHTML = `
+ <button class="comment-indicator"></button>
+ `;
+ commentIndicatorHelper.showCommentIndicator(containerEl, coordinate);
+ });
+
+ it('should set commentIndicator coordinates', () => {
+ const commentIndicatorEl = containerEl.querySelector('.comment-indicator');
+
+ expect(commentIndicatorEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(commentIndicatorEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+ });
+
+ describe('commentIndicator does not exist', () => {
+ beforeEach(() => {
+ commentIndicatorHelper.showCommentIndicator(containerEl, coordinate);
+ });
+
+ it('should addCommentIndicator', () => {
+ const buttonEl = containerEl.querySelector('.comment-indicator');
+
+ expect(buttonEl).toBeDefined();
+ expect(buttonEl.style.left).toEqual(`${coordinate.x}px`);
+ expect(buttonEl.style.top).toEqual(`${coordinate.y}px`);
+ });
+ });
+ });
+
+ describe('commentIndicatorOnClick', () => {
+ let event;
+ let textAreaEl;
+
+ beforeEach(() => {
+ containerEl.innerHTML = `
+ <div class="diff-viewer">
+ <button></button>
+ <div class="note-container">
+ <textarea class="note-textarea"></textarea>
+ </div>
+ </div>
+ `;
+ textAreaEl = containerEl.querySelector('textarea');
+
+ event = {
+ stopPropagation: () => {},
+ currentTarget: containerEl.querySelector('button'),
+ };
+
+ jest.spyOn(event, 'stopPropagation').mockImplementation(() => {});
+ jest.spyOn(textAreaEl, 'focus').mockImplementation(() => {});
+ commentIndicatorHelper.commentIndicatorOnClick(event);
+ });
+
+ it('should stopPropagation', () => {
+ expect(event.stopPropagation).toHaveBeenCalled();
+ });
+
+ it('should focus textAreaEl', () => {
+ expect(textAreaEl.focus).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/helpers/dom_helper_spec.js b/spec/frontend/image_diff/helpers/dom_helper_spec.js
new file mode 100644
index 00000000000..9357d626bbe
--- /dev/null
+++ b/spec/frontend/image_diff/helpers/dom_helper_spec.js
@@ -0,0 +1,120 @@
+import * as domHelper from '~/image_diff/helpers/dom_helper';
+import * as mockData from '../mock_data';
+
+describe('domHelper', () => {
+ const { imageMeta, badgeNumber } = mockData;
+
+ describe('setPositionDataAttribute', () => {
+ let containerEl;
+ let attributeAfterCall;
+ const position = {
+ myProperty: 'myProperty',
+ };
+
+ beforeEach(() => {
+ containerEl = document.createElement('div');
+ containerEl.dataset.position = JSON.stringify(position);
+ domHelper.setPositionDataAttribute(containerEl, imageMeta);
+ attributeAfterCall = JSON.parse(containerEl.dataset.position);
+ });
+
+ it('should set x, y, width, height', () => {
+ expect(attributeAfterCall.x).toEqual(imageMeta.x);
+ expect(attributeAfterCall.y).toEqual(imageMeta.y);
+ expect(attributeAfterCall.width).toEqual(imageMeta.width);
+ expect(attributeAfterCall.height).toEqual(imageMeta.height);
+ });
+
+ it('should not override other properties', () => {
+ expect(attributeAfterCall.myProperty).toEqual('myProperty');
+ });
+ });
+
+ describe('updateDiscussionAvatarBadgeNumber', () => {
+ let discussionEl;
+
+ beforeEach(() => {
+ discussionEl = document.createElement('div');
+ discussionEl.innerHTML = `
+ <a href="#" class="image-diff-avatar-link">
+ <div class="badge"></div>
+ </a>
+ `;
+ domHelper.updateDiscussionAvatarBadgeNumber(discussionEl, badgeNumber);
+ });
+
+ it('should update avatar badge number', () => {
+ expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString());
+ });
+ });
+
+ describe('updateDiscussionBadgeNumber', () => {
+ let discussionEl;
+
+ beforeEach(() => {
+ discussionEl = document.createElement('div');
+ discussionEl.innerHTML = `
+ <div class="badge"></div>
+ `;
+ domHelper.updateDiscussionBadgeNumber(discussionEl, badgeNumber);
+ });
+
+ it('should update discussion badge number', () => {
+ expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString());
+ });
+ });
+
+ describe('toggleCollapsed', () => {
+ let element;
+ let discussionNotesEl;
+
+ beforeEach(() => {
+ element = document.createElement('div');
+ element.innerHTML = `
+ <div class="discussion-notes">
+ <button></button>
+ <form class="discussion-form"></form>
+ </div>
+ `;
+ discussionNotesEl = element.querySelector('.discussion-notes');
+ });
+
+ describe('not collapsed', () => {
+ beforeEach(() => {
+ domHelper.toggleCollapsed({
+ currentTarget: element.querySelector('button'),
+ });
+ });
+
+ it('should add collapsed class', () => {
+ expect(discussionNotesEl.classList.contains('collapsed')).toEqual(true);
+ });
+
+ it('should force formEl to display none', () => {
+ const formEl = element.querySelector('.discussion-form');
+
+ expect(formEl.style.display).toEqual('none');
+ });
+ });
+
+ describe('collapsed', () => {
+ beforeEach(() => {
+ discussionNotesEl.classList.add('collapsed');
+
+ domHelper.toggleCollapsed({
+ currentTarget: element.querySelector('button'),
+ });
+ });
+
+ it('should remove collapsed class', () => {
+ expect(discussionNotesEl.classList.contains('collapsed')).toEqual(false);
+ });
+
+ it('should force formEl to display block', () => {
+ const formEl = element.querySelector('.discussion-form');
+
+ expect(formEl.style.display).toEqual('block');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/helpers/utils_helper_spec.js b/spec/frontend/image_diff/helpers/utils_helper_spec.js
new file mode 100644
index 00000000000..3b6378be883
--- /dev/null
+++ b/spec/frontend/image_diff/helpers/utils_helper_spec.js
@@ -0,0 +1,152 @@
+import * as utilsHelper from '~/image_diff/helpers/utils_helper';
+import ImageBadge from '~/image_diff/image_badge';
+import * as mockData from '../mock_data';
+
+describe('utilsHelper', () => {
+ const { noteId, discussionId, image, imageProperties, imageMeta } = mockData;
+
+ describe('resizeCoordinatesToImageElement', () => {
+ let result;
+
+ beforeEach(() => {
+ result = utilsHelper.resizeCoordinatesToImageElement(image, imageMeta);
+ });
+
+ it('should return x based on widthRatio', () => {
+ expect(result.x).toEqual(imageMeta.x * 0.5);
+ });
+
+ it('should return y based on heightRatio', () => {
+ expect(result.y).toEqual(imageMeta.y * 0.5);
+ });
+
+ it('should return image width', () => {
+ expect(result.width).toEqual(image.width);
+ });
+
+ it('should return image height', () => {
+ expect(result.height).toEqual(image.height);
+ });
+ });
+
+ describe('generateBadgeFromDiscussionDOM', () => {
+ let discussionEl;
+ let result;
+
+ beforeEach(() => {
+ const imageFrameEl = document.createElement('div');
+ imageFrameEl.innerHTML = `
+ <img src="${gl.TEST_HOST}/image.png">
+ `;
+ discussionEl = document.createElement('div');
+ discussionEl.dataset.discussionId = discussionId;
+ discussionEl.innerHTML = `
+ <div class="note" id="${noteId}"></div>
+ `;
+ discussionEl.dataset.position = JSON.stringify(imageMeta);
+ result = utilsHelper.generateBadgeFromDiscussionDOM(imageFrameEl, discussionEl);
+ });
+
+ it('should return actual image properties', () => {
+ const { actual } = result;
+
+ expect(actual.x).toEqual(imageMeta.x);
+ expect(actual.y).toEqual(imageMeta.y);
+ expect(actual.width).toEqual(imageMeta.width);
+ expect(actual.height).toEqual(imageMeta.height);
+ });
+
+ it('should return browser image properties', () => {
+ const { browser } = result;
+
+ expect(browser.x).toBeDefined();
+ expect(browser.y).toBeDefined();
+ expect(browser.width).toBeDefined();
+ expect(browser.height).toBeDefined();
+ });
+
+ it('should return instance of ImageBadge', () => {
+ expect(result instanceof ImageBadge).toEqual(true);
+ });
+
+ it('should return noteId', () => {
+ expect(result.noteId).toEqual(noteId);
+ });
+
+ it('should return discussionId', () => {
+ expect(result.discussionId).toEqual(discussionId);
+ });
+ });
+
+ describe('getTargetSelection', () => {
+ let containerEl;
+
+ beforeEach(() => {
+ containerEl = {
+ querySelector: () => imageProperties,
+ };
+ });
+
+ function generateEvent(offsetX, offsetY) {
+ return {
+ currentTarget: containerEl,
+ offsetX,
+ offsetY,
+ };
+ }
+
+ it('should return browser properties', () => {
+ const event = generateEvent(25, 25);
+ const result = utilsHelper.getTargetSelection(event);
+
+ const { browser } = result;
+
+ expect(browser.x).toEqual(event.offsetX);
+ expect(browser.y).toEqual(event.offsetY);
+ expect(browser.width).toEqual(imageProperties.width);
+ expect(browser.height).toEqual(imageProperties.height);
+ });
+
+ it('should return resized actual image properties', () => {
+ const event = generateEvent(50, 50);
+ const result = utilsHelper.getTargetSelection(event);
+
+ const { actual } = result;
+
+ expect(actual.x).toEqual(100);
+ expect(actual.y).toEqual(100);
+ expect(actual.width).toEqual(imageProperties.naturalWidth);
+ expect(actual.height).toEqual(imageProperties.naturalHeight);
+ });
+
+ describe('normalize coordinates', () => {
+ it('should return x = 0 if x < 0', () => {
+ const event = generateEvent(-5, 50);
+ const result = utilsHelper.getTargetSelection(event);
+
+ expect(result.browser.x).toEqual(0);
+ });
+
+ it('should return x = width if x > width', () => {
+ const event = generateEvent(1000, 50);
+ const result = utilsHelper.getTargetSelection(event);
+
+ expect(result.browser.x).toEqual(imageProperties.width);
+ });
+
+ it('should return y = 0 if y < 0', () => {
+ const event = generateEvent(50, -10);
+ const result = utilsHelper.getTargetSelection(event);
+
+ expect(result.browser.y).toEqual(0);
+ });
+
+ it('should return y = height if y > height', () => {
+ const event = generateEvent(50, 1000);
+ const result = utilsHelper.getTargetSelection(event);
+
+ expect(result.browser.y).toEqual(imageProperties.height);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/image_badge_spec.js b/spec/frontend/image_diff/image_badge_spec.js
new file mode 100644
index 00000000000..a11b50ead47
--- /dev/null
+++ b/spec/frontend/image_diff/image_badge_spec.js
@@ -0,0 +1,84 @@
+import ImageBadge from '~/image_diff/image_badge';
+import imageDiffHelper from '~/image_diff/helpers/index';
+import * as mockData from './mock_data';
+
+describe('ImageBadge', () => {
+ const { noteId, discussionId, imageMeta } = mockData;
+ const options = {
+ noteId,
+ discussionId,
+ };
+
+ it('should save actual property', () => {
+ const imageBadge = new ImageBadge({ ...options, actual: imageMeta });
+
+ const { actual } = imageBadge;
+
+ expect(actual.x).toEqual(imageMeta.x);
+ expect(actual.y).toEqual(imageMeta.y);
+ expect(actual.width).toEqual(imageMeta.width);
+ expect(actual.height).toEqual(imageMeta.height);
+ });
+
+ it('should save browser property', () => {
+ const imageBadge = new ImageBadge({ ...options, browser: imageMeta });
+
+ const { browser } = imageBadge;
+
+ expect(browser.x).toEqual(imageMeta.x);
+ expect(browser.y).toEqual(imageMeta.y);
+ expect(browser.width).toEqual(imageMeta.width);
+ expect(browser.height).toEqual(imageMeta.height);
+ });
+
+ it('should save noteId', () => {
+ const imageBadge = new ImageBadge(options);
+
+ expect(imageBadge.noteId).toEqual(noteId);
+ });
+
+ it('should save discussionId', () => {
+ const imageBadge = new ImageBadge(options);
+
+ expect(imageBadge.discussionId).toEqual(discussionId);
+ });
+
+ describe('default values', () => {
+ let imageBadge;
+
+ beforeEach(() => {
+ imageBadge = new ImageBadge(options);
+ });
+
+ it('should return defaultimageMeta if actual property is not provided', () => {
+ const { actual } = imageBadge;
+
+ expect(actual.x).toEqual(0);
+ expect(actual.y).toEqual(0);
+ expect(actual.width).toEqual(0);
+ expect(actual.height).toEqual(0);
+ });
+
+ it('should return defaultimageMeta if browser property is not provided', () => {
+ const { browser } = imageBadge;
+
+ expect(browser.x).toEqual(0);
+ expect(browser.y).toEqual(0);
+ expect(browser.width).toEqual(0);
+ expect(browser.height).toEqual(0);
+ });
+ });
+
+ describe('imageEl property is provided and not browser property', () => {
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(true);
+ });
+
+ it('should generate browser property', () => {
+ const imageBadge = new ImageBadge({ ...options, imageEl: document.createElement('img') });
+
+ expect(imageDiffHelper.resizeCoordinatesToImageElement).toHaveBeenCalled();
+ expect(imageBadge.browser).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/image_diff_spec.js b/spec/frontend/image_diff/image_diff_spec.js
new file mode 100644
index 00000000000..c15718b5106
--- /dev/null
+++ b/spec/frontend/image_diff/image_diff_spec.js
@@ -0,0 +1,361 @@
+import ImageDiff from '~/image_diff/image_diff';
+import * as imageUtility from '~/lib/utils/image_utility';
+import imageDiffHelper from '~/image_diff/helpers/index';
+import * as mockData from './mock_data';
+
+describe('ImageDiff', () => {
+ let element;
+ let imageDiff;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id="element">
+ <div class="diff-file">
+ <div class="js-image-frame">
+ <img src="${gl.TEST_HOST}/image.png">
+ <div class="comment-indicator"></div>
+ <div id="badge-1" class="badge">1</div>
+ <div id="badge-2" class="badge">2</div>
+ <div id="badge-3" class="badge">3</div>
+ </div>
+ <div class="note-container">
+ <div class="discussion-notes">
+ <div class="js-diff-notes-toggle"></div>
+ <div class="notes"></div>
+ </div>
+ <div class="discussion-notes">
+ <div class="js-diff-notes-toggle"></div>
+ <div class="notes"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ `);
+ element = document.getElementById('element');
+ });
+
+ describe('constructor', () => {
+ beforeEach(() => {
+ imageDiff = new ImageDiff(element, {
+ canCreateNote: true,
+ renderCommentBadge: true,
+ });
+ });
+
+ it('should set el', () => {
+ expect(imageDiff.el).toEqual(element);
+ });
+
+ it('should set canCreateNote', () => {
+ expect(imageDiff.canCreateNote).toEqual(true);
+ });
+
+ it('should set renderCommentBadge', () => {
+ expect(imageDiff.renderCommentBadge).toEqual(true);
+ });
+
+ it('should set $noteContainer', () => {
+ expect(imageDiff.$noteContainer[0]).toEqual(element.querySelector('.note-container'));
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ imageDiff = new ImageDiff(element);
+ });
+
+ it('should set canCreateNote as false', () => {
+ expect(imageDiff.canCreateNote).toEqual(false);
+ });
+
+ it('should set renderCommentBadge as false', () => {
+ expect(imageDiff.renderCommentBadge).toEqual(false);
+ });
+ });
+ });
+
+ describe('init', () => {
+ beforeEach(() => {
+ jest.spyOn(ImageDiff.prototype, 'bindEvents').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.init();
+ });
+
+ it('should set imageFrameEl', () => {
+ expect(imageDiff.imageFrameEl).toEqual(element.querySelector('.diff-file .js-image-frame'));
+ });
+
+ it('should set imageEl', () => {
+ expect(imageDiff.imageEl).toEqual(element.querySelector('.diff-file .js-image-frame img'));
+ });
+
+ it('should call bindEvents', () => {
+ expect(imageDiff.bindEvents).toHaveBeenCalled();
+ });
+ });
+
+ describe('bindEvents', () => {
+ let imageEl;
+
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'toggleCollapsed').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'commentIndicatorOnClick').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'removeCommentIndicator').mockImplementation(() => {});
+ jest.spyOn(ImageDiff.prototype, 'imageClicked').mockImplementation(() => {});
+ jest.spyOn(ImageDiff.prototype, 'addBadge').mockImplementation(() => {});
+ jest.spyOn(ImageDiff.prototype, 'removeBadge').mockImplementation(() => {});
+ jest.spyOn(ImageDiff.prototype, 'renderBadges').mockImplementation(() => {});
+ imageEl = element.querySelector('.diff-file .js-image-frame img');
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false);
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageEl = imageEl;
+ imageDiff.bindEvents();
+ });
+
+ it('should register click event delegation to js-diff-notes-toggle', () => {
+ element.querySelector('.js-diff-notes-toggle').click();
+
+ expect(imageDiffHelper.toggleCollapsed).toHaveBeenCalled();
+ });
+
+ it('should register click event delegation to comment-indicator', () => {
+ element.querySelector('.comment-indicator').click();
+
+ expect(imageDiffHelper.commentIndicatorOnClick).toHaveBeenCalled();
+ });
+ });
+
+ describe('image not loaded', () => {
+ beforeEach(() => {
+ jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false);
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageEl = imageEl;
+ imageDiff.bindEvents();
+ });
+
+ it('should registers load eventListener', () => {
+ const loadEvent = new Event('load');
+ imageEl.dispatchEvent(loadEvent);
+
+ expect(imageDiff.renderBadges).toHaveBeenCalled();
+ });
+ });
+
+ describe('canCreateNote', () => {
+ beforeEach(() => {
+ jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false);
+ imageDiff = new ImageDiff(element, {
+ canCreateNote: true,
+ });
+ imageDiff.imageEl = imageEl;
+ imageDiff.bindEvents();
+ });
+
+ it('should register click.imageDiff event', () => {
+ const event = new CustomEvent('click.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiff.imageClicked).toHaveBeenCalled();
+ });
+
+ it('should register blur.imageDiff event', () => {
+ const event = new CustomEvent('blur.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled();
+ });
+
+ it('should register addBadge.imageDiff event', () => {
+ const event = new CustomEvent('addBadge.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiff.addBadge).toHaveBeenCalled();
+ });
+
+ it('should register removeBadge.imageDiff event', () => {
+ const event = new CustomEvent('removeBadge.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiff.removeBadge).toHaveBeenCalled();
+ });
+ });
+
+ describe('canCreateNote is false', () => {
+ beforeEach(() => {
+ jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false);
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageEl = imageEl;
+ imageDiff.bindEvents();
+ });
+
+ it('should not register click.imageDiff event', () => {
+ const event = new CustomEvent('click.imageDiff');
+ element.dispatchEvent(event);
+
+ expect(imageDiff.imageClicked).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('imageClicked', () => {
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'getTargetSelection').mockReturnValue({
+ actual: {},
+ browser: {},
+ });
+ jest.spyOn(imageDiffHelper, 'setPositionDataAttribute').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageClicked({
+ detail: {
+ currentTarget: {},
+ },
+ });
+ });
+
+ it('should call getTargetSelection', () => {
+ expect(imageDiffHelper.getTargetSelection).toHaveBeenCalled();
+ });
+
+ it('should call setPositionDataAttribute', () => {
+ expect(imageDiffHelper.setPositionDataAttribute).toHaveBeenCalled();
+ });
+
+ it('should call showCommentIndicator', () => {
+ expect(imageDiffHelper.showCommentIndicator).toHaveBeenCalled();
+ });
+ });
+
+ describe('renderBadges', () => {
+ beforeEach(() => {
+ jest.spyOn(ImageDiff.prototype, 'renderBadge').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.renderBadges();
+ });
+
+ it('should call renderBadge for each discussionEl', () => {
+ const discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes');
+
+ expect(imageDiff.renderBadge.mock.calls.length).toEqual(discussionEls.length);
+ });
+ });
+
+ describe('renderBadge', () => {
+ let discussionEls;
+
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'addImageBadge').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'addImageCommentBadge').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'generateBadgeFromDiscussionDOM').mockReturnValue({
+ browser: {},
+ noteId: 'noteId',
+ });
+ discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes');
+ imageDiff = new ImageDiff(element);
+ imageDiff.renderBadge(discussionEls[0], 0);
+ });
+
+ it('should populate imageBadges', () => {
+ expect(imageDiff.imageBadges.length).toEqual(1);
+ });
+
+ describe('renderCommentBadge', () => {
+ beforeEach(() => {
+ imageDiff.renderCommentBadge = true;
+ imageDiff.renderBadge(discussionEls[0], 0);
+ });
+
+ it('should call addImageCommentBadge', () => {
+ expect(imageDiffHelper.addImageCommentBadge).toHaveBeenCalled();
+ });
+ });
+
+ describe('renderCommentBadge is false', () => {
+ it('should call addImageBadge', () => {
+ expect(imageDiffHelper.addImageBadge).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('addBadge', () => {
+ beforeEach(() => {
+ jest.spyOn(imageDiffHelper, 'addImageBadge').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'addAvatarBadge').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame');
+ imageDiff.addBadge({
+ detail: {
+ x: 0,
+ y: 1,
+ width: 25,
+ height: 50,
+ noteId: 'noteId',
+ discussionId: 'discussionId',
+ },
+ });
+ });
+
+ it('should add imageBadge to imageBadges', () => {
+ expect(imageDiff.imageBadges.length).toEqual(1);
+ });
+
+ it('should call addImageBadge', () => {
+ expect(imageDiffHelper.addImageBadge).toHaveBeenCalled();
+ });
+
+ it('should call addAvatarBadge', () => {
+ expect(imageDiffHelper.addAvatarBadge).toHaveBeenCalled();
+ });
+
+ it('should call updateDiscussionBadgeNumber', () => {
+ expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled();
+ });
+ });
+
+ describe('removeBadge', () => {
+ beforeEach(() => {
+ const { imageMeta } = mockData;
+
+ jest.spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').mockImplementation(() => {});
+ jest.spyOn(imageDiffHelper, 'updateDiscussionAvatarBadgeNumber').mockImplementation(() => {});
+ imageDiff = new ImageDiff(element);
+ imageDiff.imageBadges = [imageMeta, imageMeta, imageMeta];
+ imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame');
+ imageDiff.removeBadge({
+ detail: {
+ badgeNumber: 2,
+ },
+ });
+ });
+
+ describe('cascade badge count', () => {
+ it('should update next imageBadgeEl value', () => {
+ const imageBadgeEls = imageDiff.imageFrameEl.querySelectorAll('.badge');
+
+ expect(imageBadgeEls[0].textContent).toEqual('1');
+ expect(imageBadgeEls[1].textContent).toEqual('2');
+ expect(imageBadgeEls.length).toEqual(2);
+ });
+
+ it('should call updateDiscussionBadgeNumber', () => {
+ expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled();
+ });
+
+ it('should call updateDiscussionAvatarBadgeNumber', () => {
+ expect(imageDiffHelper.updateDiscussionAvatarBadgeNumber).toHaveBeenCalled();
+ });
+ });
+
+ it('should remove badge from imageBadges', () => {
+ expect(imageDiff.imageBadges.length).toEqual(2);
+ });
+
+ it('should remove imageBadgeEl', () => {
+ expect(imageDiff.imageFrameEl.querySelector('#badge-2')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/mock_data.js b/spec/frontend/image_diff/mock_data.js
new file mode 100644
index 00000000000..a0d1732dd0a
--- /dev/null
+++ b/spec/frontend/image_diff/mock_data.js
@@ -0,0 +1,28 @@
+export const noteId = 'noteId';
+export const discussionId = 'discussionId';
+export const badgeText = 'badgeText';
+export const badgeNumber = 5;
+
+export const coordinate = {
+ x: 100,
+ y: 100,
+};
+
+export const image = {
+ width: 100,
+ height: 100,
+};
+
+export const imageProperties = {
+ width: image.width,
+ height: image.height,
+ naturalWidth: image.width * 2,
+ naturalHeight: image.height * 2,
+};
+
+export const imageMeta = {
+ x: coordinate.x,
+ y: coordinate.y,
+ width: imageProperties.naturalWidth,
+ height: imageProperties.naturalHeight,
+};
diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js
new file mode 100644
index 00000000000..f2a7b7f8406
--- /dev/null
+++ b/spec/frontend/image_diff/replaced_image_diff_spec.js
@@ -0,0 +1,356 @@
+import ReplacedImageDiff from '~/image_diff/replaced_image_diff';
+import ImageDiff from '~/image_diff/image_diff';
+import { viewTypes } from '~/image_diff/view_types';
+import imageDiffHelper from '~/image_diff/helpers/index';
+
+describe('ReplacedImageDiff', () => {
+ let element;
+ let replacedImageDiff;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id="element">
+ <div class="two-up">
+ <div class="js-image-frame">
+ <img src="${gl.TEST_HOST}/image.png">
+ </div>
+ </div>
+ <div class="swipe">
+ <div class="js-image-frame">
+ <img src="${gl.TEST_HOST}/image.png">
+ </div>
+ </div>
+ <div class="onion-skin">
+ <div class="js-image-frame">
+ <img src="${gl.TEST_HOST}/image.png">
+ </div>
+ </div>
+ <div class="view-modes-menu">
+ <div class="two-up">2-up</div>
+ <div class="swipe">Swipe</div>
+ <div class="onion-skin">Onion skin</div>
+ </div>
+ </div>
+ `);
+ element = document.getElementById('element');
+ });
+
+ function setupImageFrameEls() {
+ replacedImageDiff.imageFrameEls = [];
+ replacedImageDiff.imageFrameEls[viewTypes.TWO_UP] = element.querySelector(
+ '.two-up .js-image-frame',
+ );
+ replacedImageDiff.imageFrameEls[viewTypes.SWIPE] = element.querySelector(
+ '.swipe .js-image-frame',
+ );
+ replacedImageDiff.imageFrameEls[viewTypes.ONION_SKIN] = element.querySelector(
+ '.onion-skin .js-image-frame',
+ );
+ }
+
+ function setupViewModesEls() {
+ replacedImageDiff.viewModesEls = [];
+ replacedImageDiff.viewModesEls[viewTypes.TWO_UP] = element.querySelector(
+ '.view-modes-menu .two-up',
+ );
+ replacedImageDiff.viewModesEls[viewTypes.SWIPE] = element.querySelector(
+ '.view-modes-menu .swipe',
+ );
+ replacedImageDiff.viewModesEls[viewTypes.ONION_SKIN] = element.querySelector(
+ '.view-modes-menu .onion-skin',
+ );
+ }
+
+ function setupImageEls() {
+ replacedImageDiff.imageEls = [];
+ replacedImageDiff.imageEls[viewTypes.TWO_UP] = element.querySelector('.two-up img');
+ replacedImageDiff.imageEls[viewTypes.SWIPE] = element.querySelector('.swipe img');
+ replacedImageDiff.imageEls[viewTypes.ONION_SKIN] = element.querySelector('.onion-skin img');
+ }
+
+ it('should extend ImageDiff', () => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+
+ expect(replacedImageDiff instanceof ImageDiff).toEqual(true);
+ });
+
+ describe('init', () => {
+ beforeEach(() => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'bindEvents').mockImplementation(() => {});
+ jest.spyOn(ReplacedImageDiff.prototype, 'generateImageEls').mockImplementation(() => {});
+
+ replacedImageDiff = new ReplacedImageDiff(element);
+ replacedImageDiff.init();
+ });
+
+ it('should set imageFrameEls', () => {
+ const { imageFrameEls } = replacedImageDiff;
+
+ expect(imageFrameEls).toBeDefined();
+ expect(imageFrameEls[viewTypes.TWO_UP]).toEqual(
+ element.querySelector('.two-up .js-image-frame'),
+ );
+
+ expect(imageFrameEls[viewTypes.SWIPE]).toEqual(
+ element.querySelector('.swipe .js-image-frame'),
+ );
+
+ expect(imageFrameEls[viewTypes.ONION_SKIN]).toEqual(
+ element.querySelector('.onion-skin .js-image-frame'),
+ );
+ });
+
+ it('should set viewModesEls', () => {
+ const { viewModesEls } = replacedImageDiff;
+
+ expect(viewModesEls).toBeDefined();
+ expect(viewModesEls[viewTypes.TWO_UP]).toEqual(
+ element.querySelector('.view-modes-menu .two-up'),
+ );
+
+ expect(viewModesEls[viewTypes.SWIPE]).toEqual(
+ element.querySelector('.view-modes-menu .swipe'),
+ );
+
+ expect(viewModesEls[viewTypes.ONION_SKIN]).toEqual(
+ element.querySelector('.view-modes-menu .onion-skin'),
+ );
+ });
+
+ it('should generateImageEls', () => {
+ expect(ReplacedImageDiff.prototype.generateImageEls).toHaveBeenCalled();
+ });
+
+ it('should bindEvents', () => {
+ expect(ReplacedImageDiff.prototype.bindEvents).toHaveBeenCalled();
+ });
+
+ describe('currentView', () => {
+ it('should set currentView', () => {
+ replacedImageDiff.init(viewTypes.ONION_SKIN);
+
+ expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN);
+ });
+
+ it('should default to viewTypes.TWO_UP', () => {
+ expect(replacedImageDiff.currentView).toEqual(viewTypes.TWO_UP);
+ });
+ });
+ });
+
+ describe('generateImageEls', () => {
+ beforeEach(() => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'bindEvents').mockImplementation(() => {});
+
+ replacedImageDiff = new ReplacedImageDiff(element, {
+ canCreateNote: false,
+ renderCommentBadge: false,
+ });
+
+ setupImageFrameEls();
+ });
+
+ it('should set imageEls', () => {
+ replacedImageDiff.generateImageEls();
+ const { imageEls } = replacedImageDiff;
+
+ expect(imageEls).toBeDefined();
+ expect(imageEls[viewTypes.TWO_UP]).toEqual(element.querySelector('.two-up img'));
+ expect(imageEls[viewTypes.SWIPE]).toEqual(element.querySelector('.swipe img'));
+ expect(imageEls[viewTypes.ONION_SKIN]).toEqual(element.querySelector('.onion-skin img'));
+ });
+ });
+
+ describe('bindEvents', () => {
+ beforeEach(() => {
+ jest.spyOn(ImageDiff.prototype, 'bindEvents').mockImplementation(() => {});
+ replacedImageDiff = new ReplacedImageDiff(element);
+
+ setupViewModesEls();
+ });
+
+ it('should call super.bindEvents', () => {
+ replacedImageDiff.bindEvents();
+
+ expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled();
+ });
+
+ it('should register click eventlistener to 2-up view mode', done => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => {
+ expect(viewMode).toEqual(viewTypes.TWO_UP);
+ done();
+ });
+
+ replacedImageDiff.bindEvents();
+ replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click();
+ });
+
+ it('should register click eventlistener to swipe view mode', done => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => {
+ expect(viewMode).toEqual(viewTypes.SWIPE);
+ done();
+ });
+
+ replacedImageDiff.bindEvents();
+ replacedImageDiff.viewModesEls[viewTypes.SWIPE].click();
+ });
+
+ it('should register click eventlistener to onion skin view mode', done => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => {
+ expect(viewMode).toEqual(viewTypes.SWIPE);
+ done();
+ });
+
+ replacedImageDiff.bindEvents();
+ replacedImageDiff.viewModesEls[viewTypes.SWIPE].click();
+ });
+ });
+
+ describe('getters', () => {
+ describe('imageEl', () => {
+ beforeEach(() => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+ replacedImageDiff.currentView = viewTypes.TWO_UP;
+ setupImageEls();
+ });
+
+ it('should return imageEl based on currentView', () => {
+ expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.two-up img'));
+
+ replacedImageDiff.currentView = viewTypes.SWIPE;
+
+ expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.swipe img'));
+ });
+ });
+
+ describe('imageFrameEl', () => {
+ beforeEach(() => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+ replacedImageDiff.currentView = viewTypes.TWO_UP;
+ setupImageFrameEls();
+ });
+
+ it('should return imageFrameEl based on currentView', () => {
+ expect(replacedImageDiff.imageFrameEl).toEqual(
+ element.querySelector('.two-up .js-image-frame'),
+ );
+
+ replacedImageDiff.currentView = viewTypes.ONION_SKIN;
+
+ expect(replacedImageDiff.imageFrameEl).toEqual(
+ element.querySelector('.onion-skin .js-image-frame'),
+ );
+ });
+ });
+ });
+
+ describe('changeView', () => {
+ beforeEach(() => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+ jest.spyOn(imageDiffHelper, 'removeCommentIndicator').mockReturnValue({
+ removed: false,
+ });
+ setupImageFrameEls();
+ });
+
+ describe('invalid viewType', () => {
+ beforeEach(() => {
+ replacedImageDiff.changeView('some-view-name');
+ });
+
+ it('should not call removeCommentIndicator', () => {
+ expect(imageDiffHelper.removeCommentIndicator).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('valid viewType', () => {
+ beforeEach(() => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'renderNewView').mockImplementation(() => {});
+ replacedImageDiff.changeView(viewTypes.ONION_SKIN);
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ it('should call removeCommentIndicator', () => {
+ expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled();
+ });
+
+ it('should update currentView to newView', () => {
+ expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN);
+ });
+
+ it('should clear imageBadges', () => {
+ expect(replacedImageDiff.imageBadges.length).toEqual(0);
+ });
+
+ it('should call renderNewView', () => {
+ jest.advanceTimersByTime(251);
+
+ expect(replacedImageDiff.renderNewView).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('renderNewView', () => {
+ beforeEach(() => {
+ replacedImageDiff = new ReplacedImageDiff(element);
+ });
+
+ it('should call renderBadges', () => {
+ jest.spyOn(ReplacedImageDiff.prototype, 'renderBadges').mockImplementation(() => {});
+
+ replacedImageDiff.renderNewView({
+ removed: false,
+ });
+
+ expect(replacedImageDiff.renderBadges).toHaveBeenCalled();
+ });
+
+ describe('removeIndicator', () => {
+ const indicator = {
+ removed: true,
+ x: 0,
+ y: 1,
+ image: {
+ width: 50,
+ height: 100,
+ },
+ };
+
+ beforeEach(() => {
+ setupImageEls();
+ setupImageFrameEls();
+ });
+
+ it('should pass showCommentIndicator normalized indicator values', done => {
+ jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {});
+ jest
+ .spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement')
+ .mockImplementation((imageEl, meta) => {
+ expect(meta.x).toEqual(indicator.x);
+ expect(meta.y).toEqual(indicator.y);
+ expect(meta.width).toEqual(indicator.image.width);
+ expect(meta.height).toEqual(indicator.image.height);
+ done();
+ });
+ replacedImageDiff.renderNewView(indicator);
+ });
+
+ it('should call showCommentIndicator', done => {
+ const normalized = {
+ normalized: true,
+ };
+ jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(normalized);
+ jest
+ .spyOn(imageDiffHelper, 'showCommentIndicator')
+ .mockImplementation((imageFrameEl, normalizedIndicator) => {
+ expect(normalizedIndicator).toEqual(normalized);
+ done();
+ });
+ replacedImageDiff.renderNewView(indicator);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js
index 8f60823ee72..9491b52c888 100644
--- a/spec/frontend/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_projects/components/import_projects_table_spec.js
@@ -17,11 +17,12 @@ describe('ImportProjectsTable', () => {
};
function initStore() {
- const stubbedActions = Object.assign({}, actions, {
+ const stubbedActions = {
+ ...actions,
fetchJobs: jest.fn(),
fetchRepos: jest.fn(actions.requestRepos),
fetchImport: jest.fn(actions.requestImport),
- });
+ };
const store = new Vuex.Store({
state: state(),
diff --git a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js
index 8efd526e360..8be645c496f 100644
--- a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js
+++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js
@@ -18,9 +18,7 @@ describe('ProviderRepoTableRow', () => {
};
function initStore() {
- const stubbedActions = Object.assign({}, actions, {
- fetchImport,
- });
+ const stubbedActions = { ...actions, fetchImport };
const store = new Vuex.Store({
state: state(),
diff --git a/spec/frontend/integrations/edit/components/active_toggle_spec.js b/spec/frontend/integrations/edit/components/active_toggle_spec.js
index 8a11c200c15..5469b45f708 100644
--- a/spec/frontend/integrations/edit/components/active_toggle_spec.js
+++ b/spec/frontend/integrations/edit/components/active_toggle_spec.js
@@ -9,17 +9,19 @@ describe('ActiveToggle', () => {
const defaultProps = {
initialActivated: true,
- disabled: false,
};
const createComponent = props => {
wrapper = mount(ActiveToggle, {
- propsData: Object.assign({}, defaultProps, props),
+ propsData: { ...defaultProps, ...props },
});
};
afterEach(() => {
- if (wrapper) wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
const findGlToggle = () => wrapper.find(GlToggle);
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
new file mode 100644
index 00000000000..c93f63b11d0
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -0,0 +1,99 @@
+import { shallowMount } from '@vue/test-utils';
+import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
+import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
+import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
+import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
+
+describe('IntegrationForm', () => {
+ let wrapper;
+
+ const defaultProps = {
+ activeToggleProps: {
+ initialActivated: true,
+ },
+ showActive: true,
+ triggerFieldsProps: {
+ initialTriggerCommit: false,
+ initialTriggerMergeRequest: false,
+ initialEnableComments: false,
+ },
+ type: '',
+ };
+
+ const createComponent = props => {
+ wrapper = shallowMount(IntegrationForm, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ ActiveToggle,
+ JiraTriggerFields,
+ },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findActiveToggle = () => wrapper.find(ActiveToggle);
+ const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields);
+ const findTriggerFields = () => wrapper.find(TriggerFields);
+
+ describe('template', () => {
+ describe('showActive is true', () => {
+ it('renders ActiveToggle', () => {
+ createComponent();
+
+ expect(findActiveToggle().exists()).toBe(true);
+ });
+ });
+
+ describe('showActive is false', () => {
+ it('does not render ActiveToggle', () => {
+ createComponent({
+ showActive: false,
+ });
+
+ expect(findActiveToggle().exists()).toBe(false);
+ });
+ });
+
+ describe('type is "slack"', () => {
+ it('does not render JiraTriggerFields', () => {
+ createComponent({
+ type: 'slack',
+ });
+
+ expect(findJiraTriggerFields().exists()).toBe(false);
+ });
+ });
+
+ describe('type is "jira"', () => {
+ it('renders JiraTriggerFields', () => {
+ createComponent({
+ type: 'jira',
+ });
+
+ expect(findJiraTriggerFields().exists()).toBe(true);
+ });
+ });
+
+ describe('triggerEvents is present', () => {
+ it('renders TriggerFields', () => {
+ const events = [{ title: 'push' }];
+ const type = 'slack';
+
+ createComponent({
+ triggerEvents: events,
+ type,
+ });
+
+ expect(findTriggerFields().exists()).toBe(true);
+ expect(findTriggerFields().props('events')).toBe(events);
+ expect(findTriggerFields().props('type')).toBe(type);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
new file mode 100644
index 00000000000..e4c2a0be6a3
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
@@ -0,0 +1,97 @@
+import { mount } from '@vue/test-utils';
+import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
+import { GlFormCheckbox } from '@gitlab/ui';
+
+describe('JiraTriggerFields', () => {
+ let wrapper;
+
+ const defaultProps = {
+ initialTriggerCommit: false,
+ initialTriggerMergeRequest: false,
+ initialEnableComments: false,
+ };
+
+ const createComponent = props => {
+ wrapper = mount(JiraTriggerFields, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findCommentSettings = () => wrapper.find('[data-testid="comment-settings"]');
+ const findCommentDetail = () => wrapper.find('[data-testid="comment-detail"]');
+ const findCommentSettingsCheckbox = () => findCommentSettings().find(GlFormCheckbox);
+
+ describe('template', () => {
+ describe('initialTriggerCommit and initialTriggerMergeRequest are false', () => {
+ it('does not show comment settings', () => {
+ createComponent();
+
+ expect(findCommentSettings().isVisible()).toBe(false);
+ expect(findCommentDetail().isVisible()).toBe(false);
+ });
+ });
+
+ describe('initialTriggerCommit is true', () => {
+ beforeEach(() => {
+ createComponent({
+ initialTriggerCommit: true,
+ });
+ });
+
+ it('shows comment settings', () => {
+ expect(findCommentSettings().isVisible()).toBe(true);
+ expect(findCommentDetail().isVisible()).toBe(false);
+ });
+
+ // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1,
+ // browsers don't include unchecked boxes in form submissions.
+ it('includes comment settings as false even if unchecked', () => {
+ expect(
+ findCommentSettings()
+ .find('input[name="service[comment_on_event_enabled]"]')
+ .exists(),
+ ).toBe(true);
+ });
+
+ describe('on enable comments', () => {
+ it('shows comment detail', () => {
+ findCommentSettingsCheckbox().vm.$emit('input', true);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCommentDetail().isVisible()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('initialTriggerMergeRequest is true', () => {
+ it('shows comment settings', () => {
+ createComponent({
+ initialTriggerMergeRequest: true,
+ });
+
+ expect(findCommentSettings().isVisible()).toBe(true);
+ expect(findCommentDetail().isVisible()).toBe(false);
+ });
+ });
+
+ describe('initialTriggerCommit is true, initialEnableComments is true', () => {
+ it('shows comment settings and comment detail', () => {
+ createComponent({
+ initialTriggerCommit: true,
+ initialEnableComments: true,
+ });
+
+ expect(findCommentSettings().isVisible()).toBe(true);
+ expect(findCommentDetail().isVisible()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
new file mode 100644
index 00000000000..337876c6d16
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
@@ -0,0 +1,136 @@
+import { mount } from '@vue/test-utils';
+import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
+import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
+
+describe('TriggerFields', () => {
+ let wrapper;
+
+ const defaultProps = {
+ type: 'slack',
+ };
+
+ const createComponent = props => {
+ wrapper = mount(TriggerFields, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findAllGlFormCheckboxes = () => wrapper.findAll(GlFormCheckbox);
+ const findAllGlFormInputs = () => wrapper.findAll(GlFormInput);
+
+ describe('template', () => {
+ it('renders a label with text "Trigger"', () => {
+ createComponent();
+
+ const triggerLabel = wrapper.find('[data-testid="trigger-fields-group"]').find('label');
+ expect(triggerLabel.exists()).toBe(true);
+ expect(triggerLabel.text()).toBe('Trigger');
+ });
+
+ describe('events without field property', () => {
+ const events = [
+ {
+ title: 'push',
+ name: 'push_event',
+ description: 'Event on push',
+ value: true,
+ },
+ {
+ title: 'merge_request',
+ name: 'merge_requests_event',
+ description: 'Event on merge_request',
+ value: false,
+ },
+ ];
+
+ beforeEach(() => {
+ createComponent({
+ events,
+ });
+ });
+
+ it('does not render GlFormInput for each event', () => {
+ expect(findAllGlFormInputs().exists()).toBe(false);
+ });
+
+ it('renders GlFormInput with description for each event', () => {
+ const groups = wrapper.find('#trigger-fields').findAll(GlFormGroup);
+
+ expect(groups).toHaveLength(2);
+ groups.wrappers.forEach((group, index) => {
+ expect(group.find('small').text()).toBe(events[index].description);
+ });
+ });
+
+ it('renders GlFormCheckbox for each event', () => {
+ const checkboxes = findAllGlFormCheckboxes();
+ const expectedResults = [
+ { labelText: 'Push', inputName: 'service[push_event]' },
+ { labelText: 'Merge Request', inputName: 'service[merge_requests_event]' },
+ ];
+ expect(checkboxes).toHaveLength(2);
+
+ checkboxes.wrappers.forEach((checkbox, index) => {
+ expect(checkbox.find('label').text()).toBe(expectedResults[index].labelText);
+ expect(checkbox.find('input').attributes('name')).toBe(expectedResults[index].inputName);
+ expect(checkbox.vm.$attrs.checked).toBe(events[index].value);
+ });
+ });
+ });
+
+ describe('events with field property', () => {
+ const events = [
+ {
+ field: {
+ name: 'push_channel',
+ value: '',
+ },
+ },
+ {
+ field: {
+ name: 'merge_request_channel',
+ value: 'gitlab-development',
+ },
+ },
+ ];
+
+ beforeEach(() => {
+ createComponent({
+ events,
+ });
+ });
+
+ it('renders GlFormCheckbox for each event', () => {
+ expect(findAllGlFormCheckboxes()).toHaveLength(2);
+ });
+
+ it('renders GlFormInput for each event', () => {
+ const fields = findAllGlFormInputs();
+ const expectedResults = [
+ {
+ name: 'service[push_channel]',
+ placeholder: 'Slack channels (e.g. general, development)',
+ },
+ {
+ name: 'service[merge_request_channel]',
+ placeholder: 'Slack channels (e.g. general, development)',
+ },
+ ];
+
+ expect(fields).toHaveLength(2);
+
+ fields.wrappers.forEach((field, index) => {
+ expect(field.attributes()).toMatchObject(expectedResults[index]);
+ expect(field.vm.$attrs.value).toBe(events[index].field.value);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js
new file mode 100644
index 00000000000..c117a37ff2f
--- /dev/null
+++ b/spec/frontend/integrations/integration_settings_form_spec.js
@@ -0,0 +1,268 @@
+import $ from 'jquery';
+import MockAdaptor from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import IntegrationSettingsForm from '~/integrations/integration_settings_form';
+
+describe('IntegrationSettingsForm', () => {
+ const FIXTURE = 'services/edit_service.html';
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ });
+
+ describe('contructor', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ jest.spyOn(integrationSettingsForm, 'init').mockImplementation(() => {});
+ });
+
+ it('should initialize form element refs on class object', () => {
+ // Form Reference
+ expect(integrationSettingsForm.$form).toBeDefined();
+ expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM');
+ expect(integrationSettingsForm.formActive).toBeDefined();
+
+ // Form Child Elements
+ expect(integrationSettingsForm.$submitBtn).toBeDefined();
+ expect(integrationSettingsForm.$submitBtnLoader).toBeDefined();
+ expect(integrationSettingsForm.$submitBtnLabel).toBeDefined();
+ });
+
+ it('should initialize form metadata on class object', () => {
+ expect(integrationSettingsForm.testEndPoint).toBeDefined();
+ expect(integrationSettingsForm.canTestService).toBeDefined();
+ });
+ });
+
+ describe('toggleServiceState', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ });
+
+ it('should remove `novalidate` attribute to form when called with `true`', () => {
+ integrationSettingsForm.formActive = true;
+ integrationSettingsForm.toggleServiceState();
+
+ expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined();
+ });
+
+ it('should set `novalidate` attribute to form when called with `false`', () => {
+ integrationSettingsForm.formActive = false;
+ integrationSettingsForm.toggleServiceState();
+
+ expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined();
+ });
+ });
+
+ describe('toggleSubmitBtnLabel', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ });
+
+ it('should set Save button label to "Test settings and save changes" when serviceActive & canTestService are `true`', () => {
+ integrationSettingsForm.canTestService = true;
+ integrationSettingsForm.formActive = true;
+
+ integrationSettingsForm.toggleSubmitBtnLabel();
+
+ expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual(
+ 'Test settings and save changes',
+ );
+ });
+
+ it('should set Save button label to "Save changes" when either serviceActive or canTestService (or both) is `false`', () => {
+ integrationSettingsForm.canTestService = false;
+ integrationSettingsForm.formActive = false;
+
+ integrationSettingsForm.toggleSubmitBtnLabel();
+
+ expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
+
+ integrationSettingsForm.formActive = true;
+
+ integrationSettingsForm.toggleSubmitBtnLabel();
+
+ expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
+
+ integrationSettingsForm.canTestService = true;
+ integrationSettingsForm.formActive = false;
+
+ integrationSettingsForm.toggleSubmitBtnLabel();
+
+ expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes');
+ });
+ });
+
+ describe('toggleSubmitBtnState', () => {
+ let integrationSettingsForm;
+
+ beforeEach(() => {
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ });
+
+ it('should disable Save button and show loader animation when called with `true`', () => {
+ integrationSettingsForm.toggleSubmitBtnState(true);
+
+ expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeTruthy();
+ expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeFalsy();
+ });
+
+ it('should enable Save button and hide loader animation when called with `false`', () => {
+ integrationSettingsForm.toggleSubmitBtnState(false);
+
+ expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeFalsy();
+ expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeTruthy();
+ });
+ });
+
+ describe('testSettings', () => {
+ let integrationSettingsForm;
+ let formData;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdaptor(axios);
+
+ jest.spyOn(axios, 'put');
+
+ integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ // eslint-disable-next-line no-jquery/no-serialize
+ formData = integrationSettingsForm.$form.serialize();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should make an ajax request with provided `formData`', () => {
+ return integrationSettingsForm.testSettings(formData).then(() => {
+ expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData);
+ });
+ });
+
+ it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => {
+ const errorMessage = 'Test failed.';
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: true,
+ message: errorMessage,
+ service_response: 'some error',
+ test_failed: true,
+ });
+
+ return integrationSettingsForm.testSettings(formData).then(() => {
+ const $flashContainer = $('.flash-container');
+
+ expect(
+ $flashContainer
+ .find('.flash-text')
+ .text()
+ .trim(),
+ ).toEqual('Test failed. some error');
+
+ expect($flashContainer.find('.flash-action')).toBeDefined();
+ expect(
+ $flashContainer
+ .find('.flash-action')
+ .text()
+ .trim(),
+ ).toEqual('Save anyway');
+ });
+ });
+
+ it('should not show error Flash with `Save anyway` action if ajax request responds with error in validation', () => {
+ const errorMessage = 'Validations failed.';
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: true,
+ message: errorMessage,
+ service_response: 'some error',
+ test_failed: false,
+ });
+
+ return integrationSettingsForm.testSettings(formData).then(() => {
+ const $flashContainer = $('.flash-container');
+
+ expect(
+ $flashContainer
+ .find('.flash-text')
+ .text()
+ .trim(),
+ ).toEqual('Validations failed. some error');
+
+ expect($flashContainer.find('.flash-action')).toBeDefined();
+ expect(
+ $flashContainer
+ .find('.flash-action')
+ .text()
+ .trim(),
+ ).toEqual('');
+ });
+ });
+
+ it('should submit form if ajax request responds without any error in test', () => {
+ jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
+
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: false,
+ });
+
+ return integrationSettingsForm.testSettings(formData).then(() => {
+ expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
+ });
+ });
+
+ it('should submit form when clicked on `Save anyway` action of error Flash', () => {
+ jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
+
+ const errorMessage = 'Test failed.';
+ mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
+ error: true,
+ message: errorMessage,
+ test_failed: true,
+ });
+
+ return integrationSettingsForm
+ .testSettings(formData)
+ .then(() => {
+ const $flashAction = $('.flash-container .flash-action');
+
+ expect($flashAction).toBeDefined();
+
+ $flashAction.get(0).click();
+ })
+ .then(() => {
+ expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
+ });
+ });
+
+ it('should show error Flash if ajax request failed', () => {
+ const errorMessage = 'Something went wrong on our end.';
+
+ mock.onPut(integrationSettingsForm.testEndPoint).networkError();
+
+ return integrationSettingsForm.testSettings(formData).then(() => {
+ expect(
+ $('.flash-container .flash-text')
+ .text()
+ .trim(),
+ ).toEqual(errorMessage);
+ });
+ });
+
+ it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => {
+ mock.onPut(integrationSettingsForm.testEndPoint).networkError();
+
+ jest.spyOn(integrationSettingsForm, 'toggleSubmitBtnState').mockImplementation(() => {});
+
+ return integrationSettingsForm.testSettings(formData).then(() => {
+ expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuable_spec.js b/spec/frontend/issuable_spec.js
new file mode 100644
index 00000000000..63c1fda2fb4
--- /dev/null
+++ b/spec/frontend/issuable_spec.js
@@ -0,0 +1,64 @@
+import $ from 'jquery';
+import MockAdaptor from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import IssuableIndex from '~/issuable_index';
+import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
+
+describe('Issuable', () => {
+ describe('initBulkUpdate', () => {
+ it('should not set bulkUpdateSidebar', () => {
+ new IssuableIndex('issue_'); // eslint-disable-line no-new
+
+ expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeNull();
+ });
+
+ it('should set bulkUpdateSidebar', () => {
+ const element = document.createElement('div');
+ element.classList.add('issues-bulk-update');
+ document.body.appendChild(element);
+
+ new IssuableIndex('issue_'); // eslint-disable-line no-new
+
+ expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeDefined();
+ });
+ });
+
+ describe('resetIncomingEmailToken', () => {
+ let mock;
+
+ beforeEach(() => {
+ const element = document.createElement('a');
+ element.classList.add('incoming-email-token-reset');
+ element.setAttribute('href', 'foo');
+ document.body.appendChild(element);
+
+ const input = document.createElement('input');
+ input.setAttribute('id', 'issuable_email');
+ document.body.appendChild(input);
+
+ new IssuableIndex('issue_'); // eslint-disable-line no-new
+
+ mock = new MockAdaptor(axios);
+
+ mock.onPut('foo').reply(200, {
+ new_address: 'testing123',
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should send request to reset email token', done => {
+ jest.spyOn(axios, 'put');
+ document.querySelector('.incoming-email-token-reset').click();
+
+ setImmediate(() => {
+ expect(axios.put).toHaveBeenCalledWith('foo');
+ expect($('#issuable_email').val()).toBe('testing123');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js b/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js
new file mode 100644
index 00000000000..899010bdb0f
--- /dev/null
+++ b/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js
@@ -0,0 +1,121 @@
+import { GlAlert, GlLabel } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import IssuableListRootApp from '~/issuables_list/components/issuable_list_root_app.vue';
+
+describe('IssuableListRootApp', () => {
+ const issuesPath = 'gitlab-org/gitlab-test/-/issues';
+ const label = {
+ color: '#333',
+ title: 'jira-import::MTG-3',
+ };
+ let wrapper;
+
+ const findAlert = () => wrapper.find(GlAlert);
+
+ const findAlertLabel = () => wrapper.find(GlAlert).find(GlLabel);
+
+ const mountComponent = ({
+ isFinishedAlertShowing = false,
+ isInProgressAlertShowing = false,
+ isInProgress = false,
+ isFinished = false,
+ } = {}) =>
+ shallowMount(IssuableListRootApp, {
+ propsData: {
+ canEdit: true,
+ isJiraConfigured: true,
+ issuesPath,
+ projectPath: 'gitlab-org/gitlab-test',
+ },
+ data() {
+ return {
+ isFinishedAlertShowing,
+ isInProgressAlertShowing,
+ jiraImport: {
+ isInProgress,
+ isFinished,
+ label,
+ },
+ };
+ },
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when Jira import is not in progress', () => {
+ it('does not show an alert', () => {
+ wrapper = mountComponent();
+
+ expect(wrapper.contains(GlAlert)).toBe(false);
+ });
+ });
+
+ describe('when Jira import is in progress', () => {
+ it('shows an alert that tells the user a Jira import is in progress', () => {
+ wrapper = mountComponent({
+ isInProgressAlertShowing: true,
+ isInProgress: true,
+ });
+
+ expect(findAlert().text()).toBe(
+ 'Import in progress. Refresh page to see newly added issues.',
+ );
+ });
+ });
+
+ describe('when Jira import has finished', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ isFinishedAlertShowing: true,
+ isFinished: true,
+ });
+ });
+
+ describe('shows an alert', () => {
+ it('tells the user the Jira import has finished', () => {
+ expect(findAlert().text()).toBe('Issues successfully imported with the label');
+ });
+
+ it('contains the label title associated with the Jira import', () => {
+ const alertLabelTitle = findAlertLabel().props('title');
+
+ expect(alertLabelTitle).toBe(label.title);
+ });
+
+ it('contains the correct label color', () => {
+ const alertLabelTitle = findAlertLabel().props('backgroundColor');
+
+ expect(alertLabelTitle).toBe(label.color);
+ });
+
+ it('contains a link within the label', () => {
+ const alertLabelTarget = findAlertLabel().props('target');
+
+ expect(alertLabelTarget).toBe(
+ `${issuesPath}?label_name[]=${encodeURIComponent(label.title)}`,
+ );
+ });
+ });
+ });
+
+ describe('alert message', () => {
+ it('is hidden when dismissed', () => {
+ wrapper = mountComponent({
+ isInProgressAlertShowing: true,
+ isInProgress: true,
+ });
+
+ expect(wrapper.contains(GlAlert)).toBe(true);
+
+ findAlert().vm.$emit('dismiss');
+
+ return Vue.nextTick(() => {
+ expect(wrapper.contains(GlAlert)).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
new file mode 100644
index 00000000000..a59d6d35ded
--- /dev/null
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -0,0 +1,497 @@
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import '~/behaviors/markdown/render_gfm';
+import issuableApp from '~/issue_show/components/app.vue';
+import eventHub from '~/issue_show/event_hub';
+import { initialRequest, secondRequest } from '../mock_data';
+
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
+jest.mock('~/lib/utils/url_utility');
+jest.mock('~/issue_show/event_hub');
+
+const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
+
+describe('Issuable output', () => {
+ let mock;
+ let realtimeRequestCount = 0;
+ let vm;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div>
+ <title>Title</title>
+ <div class="detail-page-description content-block">
+ <details open>
+ <summary>One</summary>
+ </details>
+ <details>
+ <summary>Two</summary>
+ </details>
+ </div>
+ <div class="flash-container"></div>
+ <span id="task_status"></span>
+ </div>
+ `);
+
+ const IssuableDescriptionComponent = Vue.extend(issuableApp);
+
+ mock = new MockAdapter(axios);
+ mock
+ .onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes')
+ .reply(() => {
+ const res = Promise.resolve([200, REALTIME_REQUEST_STACK[realtimeRequestCount]]);
+ realtimeRequestCount += 1;
+ return res;
+ });
+
+ vm = new IssuableDescriptionComponent({
+ propsData: {
+ canUpdate: true,
+ canDestroy: true,
+ endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
+ updateEndpoint: TEST_HOST,
+ issuableRef: '#1',
+ initialTitleHtml: '',
+ initialTitleText: '',
+ initialDescriptionHtml: 'test',
+ initialDescriptionText: 'test',
+ lockVersion: 1,
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectNamespace: '/',
+ projectPath: '/',
+ issuableTemplateNamesPath: '/issuable-templates-path',
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ realtimeRequestCount = 0;
+
+ vm.poll.stop();
+ vm.$destroy();
+ });
+
+ it('should render a title/description/edited and update title/description/edited on update', () => {
+ let editedText;
+ return axios
+ .waitForAll()
+ .then(() => {
+ editedText = vm.$el.querySelector('.edited-text');
+ })
+ .then(() => {
+ expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
+ expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
+ expect(vm.$el.querySelector('.md').innerHTML).toContain('<p>this is a description!</p>');
+ expect(vm.$el.querySelector('.js-task-list-field').value).toContain(
+ 'this is a description',
+ );
+
+ expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
+ expect(vm.state.lock_version).toEqual(1);
+ })
+ .then(() => {
+ vm.poll.makeRequest();
+ return axios.waitForAll();
+ })
+ .then(() => {
+ expect(document.querySelector('title').innerText).toContain('2 (#1)');
+ expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
+ expect(vm.$el.querySelector('.md').innerHTML).toContain('<p>42</p>');
+ expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
+ expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
+ expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(
+ /Edited[\s\S]+?by Other User/,
+ );
+
+ expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
+ expect(vm.state.lock_version).toEqual(2);
+ });
+ });
+
+ it('shows actions if permissions are correct', () => {
+ vm.showForm = true;
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.btn')).not.toBeNull();
+ });
+ });
+
+ it('does not show actions if permissions are incorrect', () => {
+ vm.showForm = true;
+ vm.canUpdate = false;
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.btn')).toBeNull();
+ });
+ });
+
+ it('does not update formState if form is already open', () => {
+ vm.updateAndShowForm();
+
+ vm.state.titleText = 'testing 123';
+
+ vm.updateAndShowForm();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.store.formState.title).not.toBe('testing 123');
+ });
+ });
+
+ it('opens reCAPTCHA modal if update rejected as spam', () => {
+ let modal;
+
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: {
+ recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
+ },
+ });
+
+ vm.canUpdate = true;
+ vm.showForm = true;
+
+ return vm
+ .$nextTick()
+ .then(() => {
+ vm.$refs.recaptchaModal.scriptSrc = '//scriptsrc';
+ return vm.updateIssuable();
+ })
+ .then(() => {
+ modal = vm.$el.querySelector('.js-recaptcha-modal');
+
+ expect(modal.style.display).not.toEqual('none');
+ expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html');
+ expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
+ })
+ .then(() => {
+ modal.querySelector('.close').click();
+ return vm.$nextTick();
+ })
+ .then(() => {
+ expect(modal.style.display).toEqual('none');
+ expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
+ });
+ });
+
+ describe('updateIssuable', () => {
+ it('fetches new data after update', () => {
+ const updateStoreSpy = jest.spyOn(vm, 'updateStoreState');
+ const getDataSpy = jest.spyOn(vm.service, 'getData');
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: { web_url: window.location.pathname },
+ });
+
+ return vm.updateIssuable().then(() => {
+ expect(updateStoreSpy).toHaveBeenCalled();
+ expect(getDataSpy).toHaveBeenCalled();
+ });
+ });
+
+ it('correctly updates issuable data', () => {
+ const spy = jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: { web_url: window.location.pathname },
+ });
+
+ return vm.updateIssuable().then(() => {
+ expect(spy).toHaveBeenCalledWith(vm.formState);
+ expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
+ });
+ });
+
+ it('does not redirect if issue has not moved', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: {
+ web_url: window.location.pathname,
+ confidential: vm.isConfidential,
+ },
+ });
+
+ return vm.updateIssuable().then(() => {
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not redirect if issue has not moved and user has switched tabs', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: {
+ web_url: '',
+ confidential: vm.isConfidential,
+ },
+ });
+
+ return vm.updateIssuable().then(() => {
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+ });
+
+ it('redirects if returned web_url has changed', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockResolvedValue({
+ data: {
+ web_url: '/testing-issue-move',
+ confidential: vm.isConfidential,
+ },
+ });
+
+ vm.updateIssuable();
+
+ return vm.updateIssuable().then(() => {
+ expect(visitUrl).toHaveBeenCalledWith('/testing-issue-move');
+ });
+ });
+
+ describe('shows dialog when issue has unsaved changed', () => {
+ it('confirms on title change', () => {
+ vm.showForm = true;
+ vm.state.titleText = 'title has changed';
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ return vm.$nextTick().then(() => {
+ expect(e.returnValue).not.toBeNull();
+ });
+ });
+
+ it('confirms on description change', () => {
+ vm.showForm = true;
+ vm.state.descriptionText = 'description has changed';
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ return vm.$nextTick().then(() => {
+ expect(e.returnValue).not.toBeNull();
+ });
+ });
+
+ it('does nothing when nothing has changed', () => {
+ const e = { returnValue: null };
+ vm.handleBeforeUnloadEvent(e);
+ return vm.$nextTick().then(() => {
+ expect(e.returnValue).toBeNull();
+ });
+ });
+ });
+
+ describe('error when updating', () => {
+ it('closes form on error', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockRejectedValue();
+ return vm.updateIssuable().then(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating issue`,
+ );
+ });
+ });
+
+ it('returns the correct error message for issuableType', () => {
+ jest.spyOn(vm.service, 'updateIssuable').mockRejectedValue();
+ vm.issuableType = 'merge request';
+
+ return vm
+ .$nextTick()
+ .then(vm.updateIssuable)
+ .then(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating merge request`,
+ );
+ });
+ });
+
+ it('shows error message from backend if exists', () => {
+ const msg = 'Custom error message from backend';
+ jest
+ .spyOn(vm.service, 'updateIssuable')
+ .mockRejectedValue({ response: { data: { errors: [msg] } } });
+
+ return vm.updateIssuable().then(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `${vm.defaultErrorMessage}. ${msg}`,
+ );
+ });
+ });
+ });
+ });
+
+ describe('deleteIssuable', () => {
+ it('changes URL when deleted', () => {
+ jest.spyOn(vm.service, 'deleteIssuable').mockResolvedValue({
+ data: {
+ web_url: '/test',
+ },
+ });
+
+ return vm.deleteIssuable().then(() => {
+ expect(visitUrl).toHaveBeenCalledWith('/test');
+ });
+ });
+
+ it('stops polling when deleting', () => {
+ const spy = jest.spyOn(vm.poll, 'stop');
+ jest.spyOn(vm.service, 'deleteIssuable').mockResolvedValue({
+ data: {
+ web_url: '/test',
+ },
+ });
+
+ return vm.deleteIssuable().then(() => {
+ expect(spy).toHaveBeenCalledWith();
+ });
+ });
+
+ it('closes form on error', () => {
+ jest.spyOn(vm.service, 'deleteIssuable').mockRejectedValue();
+
+ return vm.deleteIssuable().then(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Error deleting issue',
+ );
+ });
+ });
+ });
+
+ describe('updateAndShowForm', () => {
+ it('shows locked warning if form is open & data is different', () => {
+ return vm
+ .$nextTick()
+ .then(() => {
+ vm.updateAndShowForm();
+
+ vm.poll.makeRequest();
+
+ return new Promise(resolve => {
+ vm.$watch('formState.lockedWarningVisible', value => {
+ if (value) resolve();
+ });
+ });
+ })
+ .then(() => {
+ expect(vm.formState.lockedWarningVisible).toEqual(true);
+ expect(vm.formState.lock_version).toEqual(1);
+ expect(vm.$el.querySelector('.alert')).not.toBeNull();
+ });
+ });
+ });
+
+ describe('requestTemplatesAndShowForm', () => {
+ let formSpy;
+
+ beforeEach(() => {
+ formSpy = jest.spyOn(vm, 'updateAndShowForm');
+ });
+
+ it('shows the form if template names request is successful', () => {
+ const mockData = [{ name: 'Bug' }];
+ mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData]));
+
+ return vm.requestTemplatesAndShowForm().then(() => {
+ expect(formSpy).toHaveBeenCalledWith(mockData);
+ });
+ });
+
+ it('shows the form if template names request failed', () => {
+ mock
+ .onGet('/issuable-templates-path')
+ .reply(() => Promise.reject(new Error('something went wrong')));
+
+ return vm.requestTemplatesAndShowForm().then(() => {
+ expect(document.querySelector('.flash-container .flash-text').textContent).toContain(
+ 'Error updating issue',
+ );
+
+ expect(formSpy).toHaveBeenCalledWith();
+ });
+ });
+ });
+
+ describe('show inline edit button', () => {
+ it('should not render by default', () => {
+ expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
+ });
+
+ it('should render if showInlineEditButton', () => {
+ vm.showInlineEditButton = true;
+
+ expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
+ });
+ });
+
+ describe('updateStoreState', () => {
+ it('should make a request and update the state of the store', () => {
+ const data = { foo: 1 };
+ const getDataSpy = jest.spyOn(vm.service, 'getData').mockResolvedValue({ data });
+ const updateStateSpy = jest.spyOn(vm.store, 'updateState').mockImplementation(jest.fn);
+
+ return vm.updateStoreState().then(() => {
+ expect(getDataSpy).toHaveBeenCalled();
+ expect(updateStateSpy).toHaveBeenCalledWith(data);
+ });
+ });
+
+ it('should show error message if store update fails', () => {
+ jest.spyOn(vm.service, 'getData').mockRejectedValue();
+ vm.issuableType = 'merge request';
+
+ return vm.updateStoreState().then(() => {
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ `Error updating ${vm.issuableType}`,
+ );
+ });
+ });
+ });
+
+ describe('issueChanged', () => {
+ beforeEach(() => {
+ vm.store.formState.title = '';
+ vm.store.formState.description = '';
+ vm.initialDescriptionText = '';
+ vm.initialTitleText = '';
+ });
+
+ it('returns true when title is changed', () => {
+ vm.store.formState.title = 'RandomText';
+
+ expect(vm.issueChanged).toBe(true);
+ });
+
+ it('returns false when title is empty null', () => {
+ vm.store.formState.title = null;
+
+ expect(vm.issueChanged).toBe(false);
+ });
+
+ it('returns false when `initialTitleText` is null and `formState.title` is empty string', () => {
+ vm.store.formState.title = '';
+ vm.initialTitleText = null;
+
+ expect(vm.issueChanged).toBe(false);
+ });
+
+ it('returns true when description is changed', () => {
+ vm.store.formState.description = 'RandomText';
+
+ expect(vm.issueChanged).toBe(true);
+ });
+
+ it('returns false when description is empty null', () => {
+ vm.store.formState.title = null;
+
+ expect(vm.issueChanged).toBe(false);
+ });
+
+ it('returns false when `initialDescriptionText` is null and `formState.description` is empty string', () => {
+ vm.store.formState.description = '';
+ vm.initialDescriptionText = null;
+
+ expect(vm.issueChanged).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js
new file mode 100644
index 00000000000..0053475dd13
--- /dev/null
+++ b/spec/frontend/issue_show/components/description_spec.js
@@ -0,0 +1,188 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import '~/behaviors/markdown/render_gfm';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+import Description from '~/issue_show/components/description.vue';
+import TaskList from '~/task_list';
+
+jest.mock('~/task_list');
+
+describe('Description component', () => {
+ let vm;
+ let DescriptionComponent;
+ const props = {
+ canUpdate: true,
+ descriptionHtml: 'test',
+ descriptionText: 'test',
+ updatedAt: new Date().toString(),
+ taskStatus: '',
+ updateUrl: TEST_HOST,
+ };
+
+ beforeEach(() => {
+ DescriptionComponent = Vue.extend(Description);
+
+ if (!document.querySelector('.issuable-meta')) {
+ const metaData = document.createElement('div');
+ metaData.classList.add('issuable-meta');
+ metaData.innerHTML =
+ '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>';
+
+ document.body.appendChild(metaData);
+ }
+
+ vm = mountComponent(DescriptionComponent, props);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ afterAll(() => {
+ $('.issuable-meta .flash-container').remove();
+ });
+
+ it('animates description changes', () => {
+ vm.descriptionHtml = 'changed';
+
+ return vm
+ .$nextTick()
+ .then(() => {
+ expect(
+ vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'),
+ ).toBeTruthy();
+ jest.runAllTimers();
+ return vm.$nextTick();
+ })
+ .then(() => {
+ expect(
+ vm.$el.querySelector('.md').classList.contains('issue-realtime-trigger-pulse'),
+ ).toBeTruthy();
+ });
+ });
+
+ it('opens reCAPTCHA dialog if update rejected as spam', () => {
+ let modal;
+ const recaptchaChild = vm.$children.find(
+ // eslint-disable-next-line no-underscore-dangle
+ child => child.$options._componentTag === 'recaptcha-modal',
+ );
+
+ recaptchaChild.scriptSrc = '//scriptsrc';
+
+ vm.taskListUpdateSuccess({
+ recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
+ });
+
+ return vm
+ .$nextTick()
+ .then(() => {
+ modal = vm.$el.querySelector('.js-recaptcha-modal');
+
+ expect(modal.style.display).not.toEqual('none');
+ expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html');
+ expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
+ })
+ .then(() => modal.querySelector('.close').click())
+ .then(() => vm.$nextTick())
+ .then(() => {
+ expect(modal.style.display).toEqual('none');
+ expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
+ });
+ });
+
+ it('applies syntax highlighting and math when description changed', () => {
+ const vmSpy = jest.spyOn(vm, 'renderGFM');
+ const prototypeSpy = jest.spyOn($.prototype, 'renderGFM');
+ vm.descriptionHtml = 'changed';
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$refs['gfm-content']).toBeDefined();
+ expect(vmSpy).toHaveBeenCalled();
+ expect(prototypeSpy).toHaveBeenCalled();
+ expect($.prototype.renderGFM).toHaveBeenCalled();
+ });
+ });
+
+ it('sets data-update-url', () => {
+ expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(TEST_HOST);
+ });
+
+ describe('TaskList', () => {
+ beforeEach(() => {
+ vm.$destroy();
+ TaskList.mockClear();
+ vm = mountComponent(DescriptionComponent, { ...props, issuableType: 'issuableType' });
+ });
+
+ it('re-inits the TaskList when description changed', () => {
+ vm.descriptionHtml = 'changed';
+
+ expect(TaskList).toHaveBeenCalled();
+ });
+
+ it('does not re-init the TaskList when canUpdate is false', () => {
+ vm.canUpdate = false;
+ vm.descriptionHtml = 'changed';
+
+ expect(TaskList).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls with issuableType dataType', () => {
+ vm.descriptionHtml = 'changed';
+
+ expect(TaskList).toHaveBeenCalledWith({
+ dataType: 'issuableType',
+ fieldName: 'description',
+ selector: '.detail-page-description',
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ lockVersion: 0,
+ });
+ });
+ });
+
+ describe('taskStatus', () => {
+ it('adds full taskStatus', () => {
+ vm.taskStatus = '1 of 1';
+
+ return vm.$nextTick().then(() => {
+ expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(
+ '1 of 1',
+ );
+ });
+ });
+
+ it('adds short taskStatus', () => {
+ vm.taskStatus = '1 of 1';
+
+ return vm.$nextTick().then(() => {
+ expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe(
+ '1/1 task',
+ );
+ });
+ });
+
+ it('clears task status text when no tasks are present', () => {
+ vm.taskStatus = '0 of 0';
+
+ return vm.$nextTick().then(() => {
+ expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe('');
+ });
+ });
+ });
+
+ describe('taskListUpdateError', () => {
+ it('should create flash notification and emit an event to parent', () => {
+ const msg =
+ 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.';
+ const spy = jest.spyOn(vm, '$emit');
+
+ vm.taskListUpdateError();
+
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
+ expect(spy).toHaveBeenCalledWith('taskListUpdateFailed');
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/components/edited_spec.js b/spec/frontend/issue_show/components/edited_spec.js
new file mode 100644
index 00000000000..a1683f060c0
--- /dev/null
+++ b/spec/frontend/issue_show/components/edited_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import edited from '~/issue_show/components/edited.vue';
+
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
+describe('edited', () => {
+ const EditedComponent = Vue.extend(edited);
+
+ it('should render an edited at+by string', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+
+ it('if no updatedAt is provided, no time element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/);
+ expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeFalsy();
+ });
+
+ it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/);
+ expect(editedComponent.$el.querySelector('.author-link')).toBeFalsy();
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+});
diff --git a/spec/frontend/issue_show/components/fields/description_template_spec.js b/spec/frontend/issue_show/components/fields/description_template_spec.js
new file mode 100644
index 00000000000..9ebab31f1ad
--- /dev/null
+++ b/spec/frontend/issue_show/components/fields/description_template_spec.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import descriptionTemplate from '~/issue_show/components/fields/description_template.vue';
+
+describe('Issue description template component', () => {
+ let vm;
+ let formState;
+
+ beforeEach(() => {
+ const Component = Vue.extend(descriptionTemplate);
+ formState = {
+ description: 'test',
+ };
+
+ vm = new Component({
+ propsData: {
+ formState,
+ issuableTemplates: [{ name: 'test' }],
+ projectPath: '/',
+ projectNamespace: '/',
+ },
+ }).$mount();
+ });
+
+ it('renders templates as JSON array in data attribute', () => {
+ expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe(
+ '[{"name":"test"}]',
+ );
+ });
+
+ it('updates formState when changing template', () => {
+ vm.issuableTemplate.editor.setValue('test new template');
+
+ expect(formState.description).toBe('test new template');
+ });
+
+ it('returns formState description with editor getValue', () => {
+ formState.description = 'testing new template';
+
+ expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template');
+ });
+});
diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js
new file mode 100644
index 00000000000..b06a3a89d3b
--- /dev/null
+++ b/spec/frontend/issue_show/components/form_spec.js
@@ -0,0 +1,99 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import formComponent from '~/issue_show/components/form.vue';
+import Autosave from '~/autosave';
+import eventHub from '~/issue_show/event_hub';
+
+jest.mock('~/autosave');
+
+describe('Inline edit form component', () => {
+ let vm;
+ const defaultProps = {
+ canDestroy: true,
+ formState: {
+ title: 'b',
+ description: 'a',
+ lockedWarningVisible: false,
+ },
+ issuableType: 'issue',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectPath: '/',
+ projectNamespace: '/',
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const createComponent = props => {
+ const Component = Vue.extend(formComponent);
+
+ vm = mountComponent(Component, {
+ ...defaultProps,
+ ...props,
+ });
+ };
+
+ it('does not render template selector if no templates exist', () => {
+ createComponent();
+
+ expect(vm.$el.querySelector('.js-issuable-selector-wrap')).toBeNull();
+ });
+
+ it('renders template selector when templates exists', () => {
+ createComponent({ issuableTemplates: ['test'] });
+
+ expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull();
+ });
+
+ it('hides locked warning by default', () => {
+ createComponent();
+
+ expect(vm.$el.querySelector('.alert')).toBeNull();
+ });
+
+ it('shows locked warning if formState is different', () => {
+ createComponent({ formState: { ...defaultProps.formState, lockedWarningVisible: true } });
+
+ expect(vm.$el.querySelector('.alert')).not.toBeNull();
+ });
+
+ it('hides locked warning when currently saving', () => {
+ createComponent({
+ formState: { ...defaultProps.formState, updateLoading: true, lockedWarningVisible: true },
+ });
+
+ expect(vm.$el.querySelector('.alert')).toBeNull();
+ });
+
+ describe('autosave', () => {
+ let spy;
+
+ beforeEach(() => {
+ spy = jest.spyOn(Autosave.prototype, 'reset');
+ });
+
+ it('initialized Autosave on mount', () => {
+ createComponent();
+
+ expect(Autosave).toHaveBeenCalledTimes(2);
+ });
+
+ it('calls reset on autosave when eventHub emits appropriate events', () => {
+ createComponent();
+
+ eventHub.$emit('close.form');
+
+ expect(spy).toHaveBeenCalledTimes(2);
+
+ eventHub.$emit('delete.issuable');
+
+ expect(spy).toHaveBeenCalledTimes(4);
+
+ eventHub.$emit('update.issuable');
+
+ expect(spy).toHaveBeenCalledTimes(6);
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/components/title_spec.js b/spec/frontend/issue_show/components/title_spec.js
new file mode 100644
index 00000000000..c274048fdd5
--- /dev/null
+++ b/spec/frontend/issue_show/components/title_spec.js
@@ -0,0 +1,95 @@
+import Vue from 'vue';
+import Store from '~/issue_show/stores';
+import titleComponent from '~/issue_show/components/title.vue';
+import eventHub from '~/issue_show/event_hub';
+
+describe('Title component', () => {
+ let vm;
+ beforeEach(() => {
+ setFixtures(`<title />`);
+
+ const Component = Vue.extend(titleComponent);
+ const store = new Store({
+ titleHtml: '',
+ descriptionHtml: '',
+ issuableRef: '',
+ });
+ vm = new Component({
+ propsData: {
+ issuableRef: '#1',
+ titleHtml: 'Testing <img />',
+ titleText: 'Testing',
+ showForm: false,
+ formState: store.formState,
+ },
+ }).$mount();
+ });
+
+ it('renders title HTML', () => {
+ expect(vm.$el.querySelector('.title').innerHTML.trim()).toBe('Testing <img>');
+ });
+
+ it('updates page title when changing titleHtml', () => {
+ const spy = jest.spyOn(vm, 'setPageTitle');
+ vm.titleHtml = 'test';
+
+ return vm.$nextTick().then(() => {
+ expect(spy).toHaveBeenCalled();
+ });
+ });
+
+ it('animates title changes', () => {
+ vm.titleHtml = 'test';
+ return vm
+ .$nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-pre-pulse');
+ jest.runAllTimers();
+ return vm.$nextTick();
+ })
+ .then(() => {
+ expect(vm.$el.querySelector('.title').classList).toContain('issue-realtime-trigger-pulse');
+ });
+ });
+
+ it('updates page title after changing title', () => {
+ vm.titleHtml = 'changed';
+ vm.titleText = 'changed';
+
+ return vm.$nextTick().then(() => {
+ expect(document.querySelector('title').textContent.trim()).toContain('changed');
+ });
+ });
+
+ describe('inline edit button', () => {
+ it('should not show by default', () => {
+ expect(vm.$el.querySelector('.btn-edit')).toBeNull();
+ });
+
+ it('should not show if canUpdate is false', () => {
+ vm.showInlineEditButton = true;
+ vm.canUpdate = false;
+
+ expect(vm.$el.querySelector('.btn-edit')).toBeNull();
+ });
+
+ it('should show if showInlineEditButton and canUpdate', () => {
+ vm.showInlineEditButton = true;
+ vm.canUpdate = true;
+
+ expect(vm.$el.querySelector('.btn-edit')).toBeDefined();
+ });
+
+ it('should trigger open.form event when clicked', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ vm.showInlineEditButton = true;
+ vm.canUpdate = true;
+
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.btn-edit').click();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('open.form');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jira_import/components/jira_import_app_spec.js b/spec/frontend/jira_import/components/jira_import_app_spec.js
index ce32559d5c9..0040e71c192 100644
--- a/spec/frontend/jira_import/components/jira_import_app_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_app_spec.js
@@ -1,5 +1,5 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import JiraImportApp from '~/jira_import/components/jira_import_app.vue';
import JiraImportForm from '~/jira_import/components/jira_import_form.vue';
@@ -11,12 +11,16 @@ import { IMPORT_STATE } from '~/jira_import/utils';
const mountComponent = ({
isJiraConfigured = true,
errorMessage = '',
- showAlert = true,
+ selectedProject = 'MTG',
+ showAlert = false,
status = IMPORT_STATE.NONE,
loading = false,
mutate = jest.fn(() => Promise.resolve()),
-} = {}) =>
- shallowMount(JiraImportApp, {
+ mountType,
+} = {}) => {
+ const mountFunction = mountType === 'mount' ? mount : shallowMount;
+
+ return mountFunction(JiraImportApp, {
propsData: {
isJiraConfigured,
inProgressIllustration: 'in-progress-illustration.svg',
@@ -26,6 +30,7 @@ const mountComponent = ({
['My Second Jira Project', 'MSJP'],
['Migrate to GitLab', 'MTG'],
],
+ jiraIntegrationPath: 'gitlab-org/gitlab-test/-/services/jira/edit',
projectPath: 'gitlab-org/gitlab-test',
setupIllustration: 'setup-illustration.svg',
},
@@ -33,15 +38,32 @@ const mountComponent = ({
return {
errorMessage,
showAlert,
+ selectedProject,
jiraImportDetails: {
status,
- import: {
- jiraProjectKey: 'MTG',
- scheduledAt: '2020-04-08T12:17:25+00:00',
- scheduledBy: {
- name: 'Jane Doe',
+ imports: [
+ {
+ jiraProjectKey: 'MTG',
+ scheduledAt: '2020-04-08T10:11:12+00:00',
+ scheduledBy: {
+ name: 'John Doe',
+ },
},
- },
+ {
+ jiraProjectKey: 'MSJP',
+ scheduledAt: '2020-04-09T13:14:15+00:00',
+ scheduledBy: {
+ name: 'Jimmy Doe',
+ },
+ },
+ {
+ jiraProjectKey: 'MTG',
+ scheduledAt: '2020-04-09T16:17:18+00:00',
+ scheduledBy: {
+ name: 'Jane Doe',
+ },
+ },
+ ],
},
};
},
@@ -52,6 +74,7 @@ const mountComponent = ({
},
},
});
+};
describe('JiraImportApp', () => {
let wrapper;
@@ -159,6 +182,64 @@ describe('JiraImportApp', () => {
});
});
+ describe('import in progress screen', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({ status: IMPORT_STATE.SCHEDULED });
+ });
+
+ it('shows the illustration', () => {
+ expect(getProgressComponent().props('illustration')).toBe('in-progress-illustration.svg');
+ });
+
+ it('shows the name of the most recent import initiator', () => {
+ expect(getProgressComponent().props('importInitiator')).toBe('Jane Doe');
+ });
+
+ it('shows the name of the most recent imported project', () => {
+ expect(getProgressComponent().props('importProject')).toBe('MTG');
+ });
+
+ it('shows the time of the most recent import', () => {
+ expect(getProgressComponent().props('importTime')).toBe('2020-04-09T16:17:18+00:00');
+ });
+
+ it('has the path to the issues page', () => {
+ expect(getProgressComponent().props('issuesPath')).toBe('gitlab-org/gitlab-test/-/issues');
+ });
+ });
+
+ describe('jira import form screen', () => {
+ describe('when selected project has been imported before', () => {
+ it('shows jira-import::MTG-3 label since project MTG has been imported 2 time before', () => {
+ wrapper = mountComponent();
+
+ expect(getFormComponent().props('importLabel')).toBe('jira-import::MTG-3');
+ });
+
+ it('shows warning alert to explain project MTG has been imported 2 times before', () => {
+ wrapper = mountComponent({ mountType: 'mount' });
+
+ expect(getAlert().text()).toBe(
+ 'You have imported from this project 2 times before. Each new import will create duplicate issues.',
+ );
+ });
+ });
+
+ describe('when selected project has not been imported before', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({ selectedProject: 'MJP' });
+ });
+
+ it('shows jira-import::MJP-1 label since project MJP has not been imported before', () => {
+ expect(getFormComponent().props('importLabel')).toBe('jira-import::MJP-1');
+ });
+
+ it('does not show warning alert since project MJP has not been imported before', () => {
+ expect(getAlert().exists()).toBe(false);
+ });
+ });
+ });
+
describe('initiating a Jira import', () => {
it('calls the mutation with the expected arguments', () => {
const mutate = jest.fn(() => Promise.resolve());
@@ -200,6 +281,7 @@ describe('JiraImportApp', () => {
wrapper = mountComponent({
errorMessage: 'There was an error importing the Jira project.',
showAlert: true,
+ selectedProject: null,
});
expect(getAlert().exists()).toBe(true);
diff --git a/spec/frontend/jira_import/components/jira_import_form_spec.js b/spec/frontend/jira_import/components/jira_import_form_spec.js
index 0987eb11693..dea94e7bf1f 100644
--- a/spec/frontend/jira_import/components/jira_import_form_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_form_spec.js
@@ -2,11 +2,15 @@ import { GlAvatar, GlButton, GlFormSelect, GlLabel } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import JiraImportForm from '~/jira_import/components/jira_import_form.vue';
+const importLabel = 'jira-import::MTG-1';
+const value = 'MTG';
+
const mountComponent = ({ mountType } = {}) => {
const mountFunction = mountType === 'mount' ? mount : shallowMount;
return mountFunction(JiraImportForm, {
propsData: {
+ importLabel,
issuesPath: 'gitlab-org/gitlab-test/-/issues',
jiraProjects: [
{
@@ -22,6 +26,7 @@ const mountComponent = ({ mountType } = {}) => {
value: 'MTG',
},
],
+ value,
},
});
};
@@ -29,6 +34,8 @@ const mountComponent = ({ mountType } = {}) => {
describe('JiraImportForm', () => {
let wrapper;
+ const getSelectDropdown = () => wrapper.find(GlFormSelect);
+
const getCancelButton = () => wrapper.findAll(GlButton).at(1);
afterEach(() => {
@@ -40,7 +47,7 @@ describe('JiraImportForm', () => {
it('is shown', () => {
wrapper = mountComponent();
- expect(wrapper.find(GlFormSelect).exists()).toBe(true);
+ expect(wrapper.contains(GlFormSelect)).toBe(true);
});
it('contains a list of Jira projects to select from', () => {
@@ -48,8 +55,7 @@ describe('JiraImportForm', () => {
const optionItems = ['My Jira Project', 'My Second Jira Project', 'Migrate to GitLab'];
- wrapper
- .find(GlFormSelect)
+ getSelectDropdown()
.findAll('option')
.wrappers.forEach((optionEl, index) => {
expect(optionEl.text()).toBe(optionItems[index]);
@@ -63,7 +69,7 @@ describe('JiraImportForm', () => {
});
it('shows a label which will be applied to imported Jira projects', () => {
- expect(wrapper.find(GlLabel).attributes('title')).toBe('jira-import::KEY-1');
+ expect(wrapper.find(GlLabel).props('title')).toBe(importLabel);
});
it('shows information to the user', () => {
@@ -77,7 +83,7 @@ describe('JiraImportForm', () => {
});
it('shows an avatar for the Reporter', () => {
- expect(wrapper.find(GlAvatar).exists()).toBe(true);
+ expect(wrapper.contains(GlAvatar)).toBe(true);
});
it('shows jira.issue.description.content for the Description', () => {
@@ -111,16 +117,19 @@ describe('JiraImportForm', () => {
});
});
- it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => {
- const selectedOption = 'MTG';
+ it('emits an "input" event when the input select value changes', () => {
+ wrapper = mountComponent({ mountType: 'mount' });
+
+ getSelectDropdown().vm.$emit('change', value);
+ expect(wrapper.emitted('input')[0]).toEqual([value]);
+ });
+
+ it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => {
wrapper = mountComponent();
- wrapper.setData({
- selectedOption,
- });
wrapper.find('form').trigger('submit');
- expect(wrapper.emitted('initiateJiraImport')[0]).toEqual([selectedOption]);
+ expect(wrapper.emitted('initiateJiraImport')[0]).toEqual([value]);
});
});
diff --git a/spec/frontend/jira_import/components/jira_import_progress_spec.js b/spec/frontend/jira_import/components/jira_import_progress_spec.js
index 9a6fc3b5925..3ccf14554e1 100644
--- a/spec/frontend/jira_import/components/jira_import_progress_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_progress_spec.js
@@ -2,10 +2,14 @@ import { GlEmptyState } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import JiraImportProgress from '~/jira_import/components/jira_import_progress.vue';
+const illustration = 'illustration.svg';
+const importProject = 'JIRAPROJECT';
+const issuesPath = 'gitlab-org/gitlab-test/-/issues';
+
describe('JiraImportProgress', () => {
let wrapper;
- const getGlEmptyStateAttribute = attribute => wrapper.find(GlEmptyState).attributes(attribute);
+ const getGlEmptyStateProp = attribute => wrapper.find(GlEmptyState).props(attribute);
const getParagraphText = () => wrapper.find('p').text();
@@ -13,11 +17,11 @@ describe('JiraImportProgress', () => {
const mountFunction = mountType === 'shallowMount' ? shallowMount : mount;
return mountFunction(JiraImportProgress, {
propsData: {
- illustration: 'illustration.svg',
+ illustration,
importInitiator: 'Jane Doe',
- importProject: 'JIRAPROJECT',
+ importProject,
importTime: '2020-04-08T12:17:25+00:00',
- issuesPath: 'gitlab-org/gitlab-test/-/issues',
+ issuesPath,
},
});
};
@@ -33,20 +37,21 @@ describe('JiraImportProgress', () => {
});
it('contains illustration', () => {
- expect(getGlEmptyStateAttribute('svgpath')).toBe('illustration.svg');
+ expect(getGlEmptyStateProp('svgPath')).toBe(illustration);
});
it('contains a title', () => {
const title = 'Import in progress';
- expect(getGlEmptyStateAttribute('title')).toBe(title);
+ expect(getGlEmptyStateProp('title')).toBe(title);
});
it('contains button text', () => {
- expect(getGlEmptyStateAttribute('primarybuttontext')).toBe('View issues');
+ expect(getGlEmptyStateProp('primaryButtonText')).toBe('View issues');
});
it('contains button url', () => {
- expect(getGlEmptyStateAttribute('primarybuttonlink')).toBe('gitlab-org/gitlab-test/-/issues');
+ const expected = `${issuesPath}?search=${importProject}`;
+ expect(getGlEmptyStateProp('primaryButtonLink')).toBe(expected);
});
});
diff --git a/spec/frontend/jira_import/components/jira_import_setup_spec.js b/spec/frontend/jira_import/components/jira_import_setup_spec.js
index 834c14b512e..aa94dc4f503 100644
--- a/spec/frontend/jira_import/components/jira_import_setup_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_setup_spec.js
@@ -2,15 +2,19 @@ import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import JiraImportSetup from '~/jira_import/components/jira_import_setup.vue';
+const illustration = 'illustration.svg';
+const jiraIntegrationPath = 'gitlab-org/gitlab-test/-/services/jira/edit';
+
describe('JiraImportSetup', () => {
let wrapper;
- const getGlEmptyStateAttribute = attribute => wrapper.find(GlEmptyState).attributes(attribute);
+ const getGlEmptyStateProp = attribute => wrapper.find(GlEmptyState).props(attribute);
beforeEach(() => {
wrapper = shallowMount(JiraImportSetup, {
propsData: {
- illustration: 'illustration.svg',
+ illustration,
+ jiraIntegrationPath,
},
});
});
@@ -21,15 +25,19 @@ describe('JiraImportSetup', () => {
});
it('contains illustration', () => {
- expect(getGlEmptyStateAttribute('svgpath')).toBe('illustration.svg');
+ expect(getGlEmptyStateProp('svgPath')).toBe(illustration);
});
it('contains a description', () => {
const description = 'You will first need to set up Jira Integration to use this feature.';
- expect(getGlEmptyStateAttribute('description')).toBe(description);
+ expect(getGlEmptyStateProp('description')).toBe(description);
});
it('contains button text', () => {
- expect(getGlEmptyStateAttribute('primarybuttontext')).toBe('Set up Jira Integration');
+ expect(getGlEmptyStateProp('primaryButtonText')).toBe('Set up Jira Integration');
+ });
+
+ it('contains button link', () => {
+ expect(getGlEmptyStateProp('primaryButtonLink')).toBe(jiraIntegrationPath);
});
});
diff --git a/spec/frontend/jira_import/utils_spec.js b/spec/frontend/jira_import/utils_spec.js
index a14db104229..0b1edd6550a 100644
--- a/spec/frontend/jira_import/utils_spec.js
+++ b/spec/frontend/jira_import/utils_spec.js
@@ -1,27 +1,62 @@
-import { IMPORT_STATE, isInProgress } from '~/jira_import/utils';
+import {
+ calculateJiraImportLabel,
+ IMPORT_STATE,
+ isFinished,
+ isInProgress,
+} from '~/jira_import/utils';
describe('isInProgress', () => {
- it('returns true when state is IMPORT_STATE.SCHEDULED', () => {
- expect(isInProgress(IMPORT_STATE.SCHEDULED)).toBe(true);
+ it.each`
+ state | result
+ ${IMPORT_STATE.SCHEDULED} | ${true}
+ ${IMPORT_STATE.STARTED} | ${true}
+ ${IMPORT_STATE.FAILED} | ${false}
+ ${IMPORT_STATE.FINISHED} | ${false}
+ ${IMPORT_STATE.NONE} | ${false}
+ ${undefined} | ${false}
+ `('returns $result when state is $state', ({ state, result }) => {
+ expect(isInProgress(state)).toBe(result);
});
+});
- it('returns true when state is IMPORT_STATE.STARTED', () => {
- expect(isInProgress(IMPORT_STATE.STARTED)).toBe(true);
+describe('isFinished', () => {
+ it.each`
+ state | result
+ ${IMPORT_STATE.SCHEDULED} | ${false}
+ ${IMPORT_STATE.STARTED} | ${false}
+ ${IMPORT_STATE.FAILED} | ${false}
+ ${IMPORT_STATE.FINISHED} | ${true}
+ ${IMPORT_STATE.NONE} | ${false}
+ ${undefined} | ${false}
+ `('returns $result when state is $state', ({ state, result }) => {
+ expect(isFinished(state)).toBe(result);
});
+});
- it('returns false when state is IMPORT_STATE.FAILED', () => {
- expect(isInProgress(IMPORT_STATE.FAILED)).toBe(false);
- });
+describe('calculateJiraImportLabel', () => {
+ const jiraImports = [
+ { jiraProjectKey: 'MTG' },
+ { jiraProjectKey: 'MJP' },
+ { jiraProjectKey: 'MTG' },
+ { jiraProjectKey: 'MSJP' },
+ { jiraProjectKey: 'MTG' },
+ ];
- it('returns false when state is IMPORT_STATE.FINISHED', () => {
- expect(isInProgress(IMPORT_STATE.FINISHED)).toBe(false);
- });
+ const labels = [
+ { color: '#111', title: 'jira-import::MTG-1' },
+ { color: '#222', title: 'jira-import::MTG-2' },
+ { color: '#333', title: 'jira-import::MTG-3' },
+ ];
+
+ it('returns a label with the Jira project key and correct import count in the title', () => {
+ const label = calculateJiraImportLabel(jiraImports, labels);
- it('returns false when state is IMPORT_STATE.NONE', () => {
- expect(isInProgress(IMPORT_STATE.NONE)).toBe(false);
+ expect(label.title).toBe('jira-import::MTG-3');
});
- it('returns false when state is undefined', () => {
- expect(isInProgress()).toBe(false);
+ it('returns a label with the correct color', () => {
+ const label = calculateJiraImportLabel(jiraImports, labels);
+
+ expect(label.color).toBe('#333');
});
});
diff --git a/spec/frontend/jobs/components/artifacts_block_spec.js b/spec/frontend/jobs/components/artifacts_block_spec.js
new file mode 100644
index 00000000000..9cb56737f3e
--- /dev/null
+++ b/spec/frontend/jobs/components/artifacts_block_spec.js
@@ -0,0 +1,119 @@
+import Vue from 'vue';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import component from '~/jobs/components/artifacts_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+
+describe('Artifacts block', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const expireAt = '2018-08-14T09:38:49.157Z';
+ const timeago = getTimeago();
+ const formattedDate = timeago.format(expireAt);
+
+ const expiredArtifact = {
+ expire_at: expireAt,
+ expired: true,
+ };
+
+ const nonExpiredArtifact = {
+ download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download',
+ browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse',
+ keep_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/keep',
+ expire_at: expireAt,
+ expired: false,
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('with expired artifacts', () => {
+ it('renders expired artifact date and info', () => {
+ vm = mountComponent(Component, {
+ artifact: expiredArtifact,
+ });
+
+ expect(vm.$el.querySelector('.js-artifacts-removed')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-artifacts-will-be-removed')).toBeNull();
+ expect(trimText(vm.$el.querySelector('.js-artifacts-removed').textContent)).toEqual(
+ `The artifacts were removed ${formattedDate}`,
+ );
+ });
+ });
+
+ describe('with artifacts that will expire', () => {
+ it('renders will expire artifact date and info', () => {
+ vm = mountComponent(Component, {
+ artifact: nonExpiredArtifact,
+ });
+
+ expect(vm.$el.querySelector('.js-artifacts-removed')).toBeNull();
+ expect(vm.$el.querySelector('.js-artifacts-will-be-removed')).not.toBeNull();
+ expect(trimText(vm.$el.querySelector('.js-artifacts-will-be-removed').textContent)).toEqual(
+ `The artifacts will be removed ${formattedDate}`,
+ );
+ });
+ });
+
+ describe('with keep path', () => {
+ it('renders the keep button', () => {
+ vm = mountComponent(Component, {
+ artifact: nonExpiredArtifact,
+ });
+
+ expect(vm.$el.querySelector('.js-keep-artifacts')).not.toBeNull();
+ });
+ });
+
+ describe('without keep path', () => {
+ it('does not render the keep button', () => {
+ vm = mountComponent(Component, {
+ artifact: expiredArtifact,
+ });
+
+ expect(vm.$el.querySelector('.js-keep-artifacts')).toBeNull();
+ });
+ });
+
+ describe('with download path', () => {
+ it('renders the download button', () => {
+ vm = mountComponent(Component, {
+ artifact: nonExpiredArtifact,
+ });
+
+ expect(vm.$el.querySelector('.js-download-artifacts')).not.toBeNull();
+ });
+ });
+
+ describe('without download path', () => {
+ it('does not render the keep button', () => {
+ vm = mountComponent(Component, {
+ artifact: expiredArtifact,
+ });
+
+ expect(vm.$el.querySelector('.js-download-artifacts')).toBeNull();
+ });
+ });
+
+ describe('with browse path', () => {
+ it('does not render the browse button', () => {
+ vm = mountComponent(Component, {
+ artifact: nonExpiredArtifact,
+ });
+
+ expect(vm.$el.querySelector('.js-browse-artifacts')).not.toBeNull();
+ });
+ });
+
+ describe('without browse path', () => {
+ it('does not render the browse button', () => {
+ vm = mountComponent(Component, {
+ artifact: expiredArtifact,
+ });
+
+ expect(vm.$el.querySelector('.js-browse-artifacts')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/commit_block_spec.js b/spec/frontend/jobs/components/commit_block_spec.js
new file mode 100644
index 00000000000..4e2d0053831
--- /dev/null
+++ b/spec/frontend/jobs/components/commit_block_spec.js
@@ -0,0 +1,89 @@
+import Vue from 'vue';
+import component from '~/jobs/components/commit_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Commit block', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const props = {
+ commit: {
+ short_id: '1f0fb84f',
+ id: '1f0fb84fb6770d74d97eee58118fd3909cd4f48c',
+ commit_path: 'commit/1f0fb84fb6770d74d97eee58118fd3909cd4f48c',
+ title: 'Update README.md',
+ },
+ mergeRequest: {
+ iid: '!21244',
+ path: 'merge_requests/21244',
+ },
+ isLastBlock: true,
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('pipeline short sha', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...props,
+ });
+ });
+
+ it('renders pipeline short sha link', () => {
+ expect(vm.$el.querySelector('.js-commit-sha').getAttribute('href')).toEqual(
+ props.commit.commit_path,
+ );
+
+ expect(vm.$el.querySelector('.js-commit-sha').textContent.trim()).toEqual(
+ props.commit.short_id,
+ );
+ });
+
+ it('renders clipboard button', () => {
+ expect(vm.$el.querySelector('button').getAttribute('data-clipboard-text')).toEqual(
+ props.commit.id,
+ );
+ });
+ });
+
+ describe('with merge request', () => {
+ it('renders merge request link and reference', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ });
+
+ expect(vm.$el.querySelector('.js-link-commit').getAttribute('href')).toEqual(
+ props.mergeRequest.path,
+ );
+
+ expect(vm.$el.querySelector('.js-link-commit').textContent.trim()).toEqual(
+ `!${props.mergeRequest.iid}`,
+ );
+ });
+ });
+
+ describe('without merge request', () => {
+ it('does not render merge request', () => {
+ const copyProps = { ...props };
+ delete copyProps.mergeRequest;
+
+ vm = mountComponent(Component, {
+ ...copyProps,
+ });
+
+ expect(vm.$el.querySelector('.js-link-commit')).toBeNull();
+ });
+ });
+
+ describe('git commit title', () => {
+ it('renders git commit title', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ });
+
+ expect(vm.$el.textContent).toContain(props.commit.title);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/empty_state_spec.js b/spec/frontend/jobs/components/empty_state_spec.js
new file mode 100644
index 00000000000..c6eac4e27b3
--- /dev/null
+++ b/spec/frontend/jobs/components/empty_state_spec.js
@@ -0,0 +1,141 @@
+import Vue from 'vue';
+import component from '~/jobs/components/empty_state.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Empty State', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const props = {
+ illustrationPath: 'illustrations/pending_job_empty.svg',
+ illustrationSizeClass: 'svg-430',
+ title: 'This job has not started yet',
+ playable: false,
+ variablesSettingsUrl: '',
+ };
+
+ const content = 'This job is in pending state and is waiting to be picked by a runner';
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('renders image and title', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ });
+ });
+
+ it('renders img with provided path and size', () => {
+ expect(vm.$el.querySelector('img').getAttribute('src')).toEqual(props.illustrationPath);
+ expect(vm.$el.querySelector('.svg-content').classList).toContain(props.illustrationSizeClass);
+ });
+
+ it('renders provided title', () => {
+ expect(vm.$el.querySelector('.js-job-empty-state-title').textContent.trim()).toEqual(
+ props.title,
+ );
+ });
+ });
+
+ describe('with content', () => {
+ it('renders content', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ });
+
+ expect(vm.$el.querySelector('.js-job-empty-state-content').textContent.trim()).toEqual(
+ content,
+ );
+ });
+ });
+
+ describe('without content', () => {
+ it('does not render content', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ });
+
+ expect(vm.$el.querySelector('.js-job-empty-state-content')).toBeNull();
+ });
+ });
+
+ describe('with action', () => {
+ it('renders action', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ action: {
+ path: 'runner',
+ button_title: 'Check runner',
+ method: 'post',
+ },
+ });
+
+ expect(vm.$el.querySelector('.js-job-empty-state-action').getAttribute('href')).toEqual(
+ 'runner',
+ );
+ });
+ });
+
+ describe('without action', () => {
+ it('does not render action', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ action: null,
+ });
+
+ expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull();
+ });
+ });
+
+ describe('without playbale action', () => {
+ it('does not render manual variables form', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ });
+
+ expect(vm.$el.querySelector('.js-manual-vars-form')).toBeNull();
+ });
+ });
+
+ describe('with playbale action and not scheduled job', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ playable: true,
+ scheduled: false,
+ action: {
+ path: 'runner',
+ button_title: 'Check runner',
+ method: 'post',
+ },
+ });
+ });
+
+ it('renders manual variables form', () => {
+ expect(vm.$el.querySelector('.js-manual-vars-form')).not.toBeNull();
+ });
+
+ it('does not render the empty state action', () => {
+ expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull();
+ });
+ });
+
+ describe('with playbale action and scheduled job', () => {
+ it('does not render manual variables form', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ });
+
+ expect(vm.$el.querySelector('.js-manual-vars-form')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/environments_block_spec.js b/spec/frontend/jobs/components/environments_block_spec.js
new file mode 100644
index 00000000000..4f2359e83b6
--- /dev/null
+++ b/spec/frontend/jobs/components/environments_block_spec.js
@@ -0,0 +1,261 @@
+import Vue from 'vue';
+import component from '~/jobs/components/environments_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const TEST_CLUSTER_NAME = 'test_cluster';
+const TEST_CLUSTER_PATH = 'path/to/test_cluster';
+const TEST_KUBERNETES_NAMESPACE = 'this-is-a-kubernetes-namespace';
+
+describe('Environments block', () => {
+ const Component = Vue.extend(component);
+ let vm;
+ const status = {
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ };
+
+ const environment = {
+ environment_path: '/environment',
+ name: 'environment',
+ };
+
+ const lastDeployment = { iid: 'deployment', deployable: { build_path: 'bar' } };
+
+ const createEnvironmentWithLastDeployment = () => ({
+ ...environment,
+ last_deployment: { ...lastDeployment },
+ });
+
+ const createDeploymentWithCluster = () => ({ name: TEST_CLUSTER_NAME, path: TEST_CLUSTER_PATH });
+
+ const createDeploymentWithClusterAndKubernetesNamespace = () => ({
+ name: TEST_CLUSTER_NAME,
+ path: TEST_CLUSTER_PATH,
+ kubernetes_namespace: TEST_KUBERNETES_NAMESPACE,
+ });
+
+ const createComponent = (deploymentStatus = {}, deploymentCluster = {}) => {
+ vm = mountComponent(Component, {
+ deploymentStatus,
+ deploymentCluster,
+ iconStatus: status,
+ });
+ };
+
+ const findText = () => vm.$el.textContent.trim();
+ const findJobDeploymentLink = () => vm.$el.querySelector('.js-job-deployment-link');
+ const findEnvironmentLink = () => vm.$el.querySelector('.js-environment-link');
+ const findClusterLink = () => vm.$el.querySelector('.js-job-cluster-link');
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('with last deployment', () => {
+ it('renders info for most recent deployment', () => {
+ createComponent({
+ status: 'last',
+ environment,
+ });
+
+ expect(findText()).toEqual('This job is deployed to environment.');
+ });
+
+ describe('when there is a cluster', () => {
+ it('renders info with cluster', () => {
+ createComponent(
+ {
+ status: 'last',
+ environment: createEnvironmentWithLastDeployment(),
+ },
+ createDeploymentWithCluster(),
+ );
+
+ expect(findText()).toEqual(
+ `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME}.`,
+ );
+ });
+
+ describe('when there is a kubernetes namespace', () => {
+ it('renders info with cluster', () => {
+ createComponent(
+ {
+ status: 'last',
+ environment: createEnvironmentWithLastDeployment(),
+ },
+ createDeploymentWithClusterAndKubernetesNamespace(),
+ );
+
+ expect(findText()).toEqual(
+ `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME} and namespace ${TEST_KUBERNETES_NAMESPACE}.`,
+ );
+ });
+ });
+ });
+ });
+
+ describe('with out of date deployment', () => {
+ describe('with last deployment', () => {
+ it('renders info for out date and most recent', () => {
+ createComponent({
+ status: 'out_of_date',
+ environment: createEnvironmentWithLastDeployment(),
+ });
+
+ expect(findText()).toEqual(
+ 'This job is an out-of-date deployment to environment. View the most recent deployment.',
+ );
+
+ expect(findJobDeploymentLink().getAttribute('href')).toEqual('bar');
+ });
+
+ describe('when there is a cluster', () => {
+ it('renders info with cluster', () => {
+ createComponent(
+ {
+ status: 'out_of_date',
+ environment: createEnvironmentWithLastDeployment(),
+ },
+ createDeploymentWithCluster(),
+ );
+
+ expect(findText()).toEqual(
+ `This job is an out-of-date deployment to environment using cluster ${TEST_CLUSTER_NAME}. View the most recent deployment.`,
+ );
+ });
+
+ describe('when there is a kubernetes namespace', () => {
+ it('renders info with cluster', () => {
+ createComponent(
+ {
+ status: 'out_of_date',
+ environment: createEnvironmentWithLastDeployment(),
+ },
+ createDeploymentWithClusterAndKubernetesNamespace(),
+ );
+
+ expect(findText()).toEqual(
+ `This job is an out-of-date deployment to environment using cluster ${TEST_CLUSTER_NAME} and namespace ${TEST_KUBERNETES_NAMESPACE}. View the most recent deployment.`,
+ );
+ });
+ });
+ });
+ });
+
+ describe('without last deployment', () => {
+ it('renders info about out of date deployment', () => {
+ createComponent({
+ status: 'out_of_date',
+ environment,
+ });
+
+ expect(findText()).toEqual('This job is an out-of-date deployment to environment.');
+ });
+ });
+ });
+
+ describe('with failed deployment', () => {
+ it('renders info about failed deployment', () => {
+ createComponent({
+ status: 'failed',
+ environment,
+ });
+
+ expect(findText()).toEqual('The deployment of this job to environment did not succeed.');
+ });
+ });
+
+ describe('creating deployment', () => {
+ describe('with last deployment', () => {
+ it('renders info about creating deployment and overriding latest deployment', () => {
+ createComponent({
+ status: 'creating',
+ environment: createEnvironmentWithLastDeployment(),
+ });
+
+ expect(findText()).toEqual(
+ 'This job is creating a deployment to environment. This will overwrite the latest deployment.',
+ );
+
+ expect(findJobDeploymentLink().getAttribute('href')).toEqual('bar');
+ expect(findEnvironmentLink().getAttribute('href')).toEqual(environment.environment_path);
+ expect(findClusterLink()).toBeNull();
+ });
+ });
+
+ describe('without last deployment', () => {
+ it('renders info about deployment being created', () => {
+ createComponent({
+ status: 'creating',
+ environment,
+ });
+
+ expect(findText()).toEqual('This job is creating a deployment to environment.');
+ });
+
+ describe('when there is a cluster', () => {
+ it('inclues information about the cluster', () => {
+ createComponent(
+ {
+ status: 'creating',
+ environment,
+ },
+ createDeploymentWithCluster(),
+ );
+
+ expect(findText()).toEqual(
+ `This job is creating a deployment to environment using cluster ${TEST_CLUSTER_NAME}.`,
+ );
+ });
+ });
+ });
+
+ describe('without environment', () => {
+ it('does not render environment link', () => {
+ createComponent({
+ status: 'creating',
+ environment: null,
+ });
+
+ expect(findEnvironmentLink()).toBeNull();
+ });
+ });
+ });
+
+ describe('with a cluster', () => {
+ it('renders the cluster link', () => {
+ createComponent(
+ {
+ status: 'last',
+ environment: createEnvironmentWithLastDeployment(),
+ },
+ createDeploymentWithCluster(),
+ );
+
+ expect(findText()).toEqual(
+ `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME}.`,
+ );
+
+ expect(findClusterLink().getAttribute('href')).toEqual(TEST_CLUSTER_PATH);
+ });
+
+ describe('when the cluster is missing the path', () => {
+ it('renders the name without a link', () => {
+ createComponent(
+ {
+ status: 'last',
+ environment: createEnvironmentWithLastDeployment(),
+ },
+ { name: 'the-cluster' },
+ );
+
+ expect(findText()).toContain('using cluster the-cluster.');
+
+ expect(findClusterLink()).toBeNull();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/job_container_item_spec.js b/spec/frontend/jobs/components/job_container_item_spec.js
new file mode 100644
index 00000000000..9019504d22d
--- /dev/null
+++ b/spec/frontend/jobs/components/job_container_item_spec.js
@@ -0,0 +1,101 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import JobContainerItem from '~/jobs/components/job_container_item.vue';
+import job from '../mock_data';
+
+describe('JobContainerItem', () => {
+ const delayedJobFixture = getJSONFixture('jobs/delayed.json');
+ const Component = Vue.extend(JobContainerItem);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const sharedTests = () => {
+ it('displays a status icon', () => {
+ expect(vm.$el).toHaveSpriteIcon(job.status.icon);
+ });
+
+ it('displays the job name', () => {
+ expect(vm.$el.innerText).toContain(job.name);
+ });
+
+ it('displays a link to the job', () => {
+ const link = vm.$el.querySelector('.js-job-link');
+
+ expect(link.href).toBe(job.status.details_path);
+ });
+ };
+
+ describe('when a job is not active and not retied', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job,
+ isActive: false,
+ });
+ });
+
+ sharedTests();
+ });
+
+ describe('when a job is active', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job,
+ isActive: true,
+ });
+ });
+
+ sharedTests();
+
+ it('displays an arrow', () => {
+ expect(vm.$el).toHaveSpriteIcon('arrow-right');
+ });
+ });
+
+ describe('when a job is retried', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job: {
+ ...job,
+ retried: true,
+ },
+ isActive: false,
+ });
+ });
+
+ sharedTests();
+
+ it('displays an icon', () => {
+ expect(vm.$el).toHaveSpriteIcon('retry');
+ });
+ });
+
+ describe('for delayed job', () => {
+ beforeEach(() => {
+ const remainingMilliseconds = 1337000;
+ jest
+ .spyOn(Date, 'now')
+ .mockImplementation(
+ () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingMilliseconds,
+ );
+ });
+
+ it('displays remaining time in tooltip', done => {
+ vm = mountComponent(Component, {
+ job: delayedJobFixture,
+ isActive: false,
+ });
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.js-job-link').getAttribute('data-original-title')).toEqual(
+ 'delayed job - delayed manual action (00:22:17)',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/job_log_spec.js b/spec/frontend/jobs/components/job_log_spec.js
new file mode 100644
index 00000000000..2bb1e0af3a2
--- /dev/null
+++ b/spec/frontend/jobs/components/job_log_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import component from '~/jobs/components/job_log.vue';
+import createStore from '~/jobs/store';
+import { resetStore } from '../store/helpers';
+
+describe('Job Log', () => {
+ const Component = Vue.extend(component);
+ let store;
+ let vm;
+
+ const trace =
+ '<span>Running with gitlab-runner 12.1.0 (de7731dd)<br/></span><span> on docker-auto-scale-com d5ae8d25<br/></span><div class="append-right-8" data-timestamp="1565502765" data-section="prepare-executor" role="button"></div><span class="section section-header js-s-prepare-executor">Using Docker executor with image ruby:2.6 ...<br/></span>';
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ vm.$destroy();
+ });
+
+ it('renders provided trace', () => {
+ vm = mountComponentWithStore(Component, {
+ props: {
+ trace,
+ isComplete: true,
+ },
+ store,
+ });
+
+ expect(vm.$el.querySelector('code').textContent).toContain(
+ 'Running with gitlab-runner 12.1.0 (de7731dd)',
+ );
+ });
+
+ describe('while receiving trace', () => {
+ it('renders animation', () => {
+ vm = mountComponentWithStore(Component, {
+ props: {
+ trace,
+ isComplete: false,
+ },
+ store,
+ });
+
+ expect(vm.$el.querySelector('.js-log-animation')).not.toBeNull();
+ });
+ });
+
+ describe('when build trace has finishes', () => {
+ it('does not render animation', () => {
+ vm = mountComponentWithStore(Component, {
+ props: {
+ trace,
+ isComplete: true,
+ },
+ store,
+ });
+
+ expect(vm.$el.querySelector('.js-log-animation')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/jobs_container_spec.js b/spec/frontend/jobs/components/jobs_container_spec.js
new file mode 100644
index 00000000000..119b18b7557
--- /dev/null
+++ b/spec/frontend/jobs/components/jobs_container_spec.js
@@ -0,0 +1,131 @@
+import Vue from 'vue';
+import component from '~/jobs/components/jobs_container.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Jobs List block', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const retried = {
+ status: {
+ details_path: '/gitlab-org/gitlab-foss/pipelines/28029444',
+ group: 'success',
+ has_details: true,
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ },
+ id: 233432756,
+ tooltip: 'build - passed',
+ retried: true,
+ };
+
+ const active = {
+ name: 'test',
+ status: {
+ details_path: '/gitlab-org/gitlab-foss/pipelines/28029444',
+ group: 'success',
+ has_details: true,
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ },
+ id: 2322756,
+ tooltip: 'build - passed',
+ active: true,
+ };
+
+ const job = {
+ name: 'build',
+ status: {
+ details_path: '/gitlab-org/gitlab-foss/pipelines/28029444',
+ group: 'success',
+ has_details: true,
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ },
+ id: 232153,
+ tooltip: 'build - passed',
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders list of jobs', () => {
+ vm = mountComponent(Component, {
+ jobs: [job, retried, active],
+ jobId: 12313,
+ });
+
+ expect(vm.$el.querySelectorAll('a').length).toEqual(3);
+ });
+
+ it('renders arrow right when job id matches `jobId`', () => {
+ vm = mountComponent(Component, {
+ jobs: [active],
+ jobId: active.id,
+ });
+
+ expect(vm.$el.querySelector('a .js-arrow-right')).not.toBeNull();
+ });
+
+ it('does not render arrow right when job is not active', () => {
+ vm = mountComponent(Component, {
+ jobs: [job],
+ jobId: active.id,
+ });
+
+ expect(vm.$el.querySelector('a .js-arrow-right')).toBeNull();
+ });
+
+ it('renders job name when present', () => {
+ vm = mountComponent(Component, {
+ jobs: [job],
+ jobId: active.id,
+ });
+
+ expect(vm.$el.querySelector('a').textContent.trim()).toContain(job.name);
+ expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(job.id);
+ });
+
+ it('renders job id when job name is not available', () => {
+ vm = mountComponent(Component, {
+ jobs: [retried],
+ jobId: active.id,
+ });
+
+ expect(vm.$el.querySelector('a').textContent.trim()).toContain(retried.id);
+ });
+
+ it('links to the job page', () => {
+ vm = mountComponent(Component, {
+ jobs: [job],
+ jobId: active.id,
+ });
+
+ expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(job.status.details_path);
+ });
+
+ it('renders retry icon when job was retried', () => {
+ vm = mountComponent(Component, {
+ jobs: [retried],
+ jobId: active.id,
+ });
+
+ expect(vm.$el.querySelector('.js-retry-icon')).not.toBeNull();
+ });
+
+ it('does not render retry icon when job was not retried', () => {
+ vm = mountComponent(Component, {
+ jobs: [job],
+ jobId: active.id,
+ });
+
+ expect(vm.$el.querySelector('.js-retry-icon')).toBeNull();
+ });
+});
diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js
index f2e202674ee..5ce69221dab 100644
--- a/spec/frontend/jobs/components/log/line_header_spec.js
+++ b/spec/frontend/jobs/components/log/line_header_spec.js
@@ -86,7 +86,7 @@ describe('Job Log Header Line', () => {
describe('with duration', () => {
beforeEach(() => {
- createComponent(Object.assign({}, data, { duration: '00:10' }));
+ createComponent({ ...data, duration: '00:10' });
});
it('renders the duration badge', () => {
diff --git a/spec/frontend/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js
new file mode 100644
index 00000000000..82fd73ef033
--- /dev/null
+++ b/spec/frontend/jobs/components/manual_variables_form_spec.js
@@ -0,0 +1,103 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlDeprecatedButton } from '@gitlab/ui';
+import Form from '~/jobs/components/manual_variables_form.vue';
+
+const localVue = createLocalVue();
+
+describe('Manual Variables Form', () => {
+ let wrapper;
+
+ const requiredProps = {
+ action: {
+ path: '/play',
+ method: 'post',
+ button_title: 'Trigger this manual action',
+ },
+ variablesSettingsUrl: '/settings',
+ };
+
+ const factory = (props = {}) => {
+ wrapper = shallowMount(localVue.extend(Form), {
+ propsData: props,
+ localVue,
+ });
+ };
+
+ beforeEach(() => {
+ factory(requiredProps);
+ });
+
+ afterEach(done => {
+ // The component has a `nextTick` callback after some events so we need
+ // to wait for those to finish before destroying.
+ setImmediate(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ done();
+ });
+ });
+
+ it('renders empty form with correct placeholders', () => {
+ expect(wrapper.find({ ref: 'inputKey' }).attributes('placeholder')).toBe('Input variable key');
+ expect(wrapper.find({ ref: 'inputSecretValue' }).attributes('placeholder')).toBe(
+ 'Input variable value',
+ );
+ });
+
+ it('renders help text with provided link', () => {
+ expect(wrapper.find('p').text()).toBe(
+ 'Specify variable values to be used in this run. The values specified in CI/CD settings will be used as default',
+ );
+
+ expect(wrapper.find('a').attributes('href')).toBe(requiredProps.variablesSettingsUrl);
+ });
+
+ describe('when adding a new variable', () => {
+ it('creates a new variable when user types a new key and resets the form', done => {
+ wrapper.vm
+ .$nextTick()
+ .then(() => wrapper.find({ ref: 'inputKey' }).setValue('new key'))
+ .then(() => {
+ expect(wrapper.vm.variables.length).toBe(1);
+ expect(wrapper.vm.variables[0].key).toBe('new key');
+ expect(wrapper.find({ ref: 'inputKey' }).attributes('value')).toBe(undefined);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('creates a new variable when user types a new value and resets the form', done => {
+ wrapper.vm
+ .$nextTick()
+ .then(() => wrapper.find({ ref: 'inputSecretValue' }).setValue('new value'))
+ .then(() => {
+ expect(wrapper.vm.variables.length).toBe(1);
+ expect(wrapper.vm.variables[0].secret_value).toBe('new value');
+ expect(wrapper.find({ ref: 'inputSecretValue' }).attributes('value')).toBe(undefined);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('when deleting a variable', () => {
+ beforeEach(done => {
+ wrapper.vm.variables = [
+ {
+ key: 'new key',
+ secret_value: 'value',
+ id: '1',
+ },
+ ];
+
+ wrapper.vm.$nextTick(done);
+ });
+
+ it('removes the variable row', () => {
+ wrapper.find(GlDeprecatedButton).vm.$emit('click');
+
+ expect(wrapper.vm.variables.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js
new file mode 100644
index 00000000000..0c8e2dc3aef
--- /dev/null
+++ b/spec/frontend/jobs/components/sidebar_spec.js
@@ -0,0 +1,166 @@
+import Vue from 'vue';
+import sidebarDetailsBlock from '~/jobs/components/sidebar.vue';
+import createStore from '~/jobs/store';
+import job, { jobsInStage } from '../mock_data';
+import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+
+describe('Sidebar details block', () => {
+ const SidebarComponent = Vue.extend(sidebarDetailsBlock);
+ let vm;
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when there is no retry path retry', () => {
+ it('should not render a retry button', () => {
+ const copy = { ...job };
+ delete copy.retry_path;
+
+ store.dispatch('receiveJobSuccess', copy);
+ vm = mountComponentWithStore(SidebarComponent, {
+ store,
+ });
+
+ expect(vm.$el.querySelector('.js-retry-button')).toBeNull();
+ });
+ });
+
+ describe('without terminal path', () => {
+ it('does not render terminal link', () => {
+ store.dispatch('receiveJobSuccess', job);
+ vm = mountComponentWithStore(SidebarComponent, { store });
+
+ expect(vm.$el.querySelector('.js-terminal-link')).toBeNull();
+ });
+ });
+
+ describe('with terminal path', () => {
+ it('renders terminal link', () => {
+ store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
+ vm = mountComponentWithStore(SidebarComponent, {
+ store,
+ });
+
+ expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull();
+ });
+ });
+
+ beforeEach(() => {
+ store.dispatch('receiveJobSuccess', job);
+ vm = mountComponentWithStore(SidebarComponent, { store });
+ });
+
+ describe('actions', () => {
+ it('should render link to new issue', () => {
+ expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(
+ job.new_issue_path,
+ );
+
+ expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
+ });
+
+ it('should render link to retry job', () => {
+ expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path);
+ });
+
+ it('should render link to cancel job', () => {
+ expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
+ });
+ });
+
+ describe('information', () => {
+ it('should render job duration', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-duration').textContent)).toEqual(
+ 'Duration: 6 seconds',
+ );
+ });
+
+ it('should render erased date', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-erased').textContent)).toEqual(
+ 'Erased: 3 weeks ago',
+ );
+ });
+
+ it('should render finished date', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-finished').textContent)).toEqual(
+ 'Finished: 3 weeks ago',
+ );
+ });
+
+ it('should render queued date', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-queued').textContent)).toEqual(
+ 'Queued: 9 seconds',
+ );
+ });
+
+ it('should render runner ID', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual(
+ 'Runner: local ci runner (#1)',
+ );
+ });
+
+ it('should render timeout information', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual(
+ 'Timeout: 1m 40s (from runner)',
+ );
+ });
+
+ it('should render coverage', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual(
+ 'Coverage: 20%',
+ );
+ });
+
+ it('should render tags', () => {
+ expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag');
+ });
+ });
+
+ describe('stages dropdown', () => {
+ beforeEach(() => {
+ store.dispatch('receiveJobSuccess', job);
+ });
+
+ describe('with stages', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(SidebarComponent, { store });
+ });
+
+ it('renders value provided as selectedStage as selected', () => {
+ expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual(
+ vm.selectedStage,
+ );
+ });
+ });
+
+ describe('without jobs for stages', () => {
+ beforeEach(() => {
+ store.dispatch('receiveJobSuccess', job);
+ vm = mountComponentWithStore(SidebarComponent, { store });
+ });
+
+ it('does not render job container', () => {
+ expect(vm.$el.querySelector('.js-jobs-container')).toBeNull();
+ });
+ });
+
+ describe('with jobs for stages', () => {
+ beforeEach(() => {
+ store.dispatch('receiveJobSuccess', job);
+ store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
+ vm = mountComponentWithStore(SidebarComponent, { store });
+ });
+
+ it('renders list of jobs', () => {
+ expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/stages_dropdown_spec.js b/spec/frontend/jobs/components/stages_dropdown_spec.js
new file mode 100644
index 00000000000..e8fa6094c25
--- /dev/null
+++ b/spec/frontend/jobs/components/stages_dropdown_spec.js
@@ -0,0 +1,163 @@
+import Vue from 'vue';
+import { trimText } from 'helpers/text_helper';
+import component from '~/jobs/components/stages_dropdown.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Stages Dropdown', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const mockPipelineData = {
+ id: 28029444,
+ details: {
+ status: {
+ details_path: '/gitlab-org/gitlab-foss/pipelines/28029444',
+ group: 'success',
+ has_details: true,
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ },
+ },
+ path: 'pipeline/28029444',
+ flags: {
+ merge_request_pipeline: true,
+ detached_merge_request_pipeline: false,
+ },
+ merge_request: {
+ iid: 1234,
+ path: '/root/detached-merge-request-pipelines/-/merge_requests/1',
+ title: 'Update README.md',
+ source_branch: 'feature-1234',
+ source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234',
+ target_branch: 'master',
+ target_branch_path: '/root/detached-merge-request-pipelines/branches/master',
+ },
+ ref: {
+ name: 'test-branch',
+ },
+ };
+
+ describe('without a merge request pipeline', () => {
+ let pipeline;
+
+ beforeEach(() => {
+ pipeline = JSON.parse(JSON.stringify(mockPipelineData));
+ delete pipeline.merge_request;
+ delete pipeline.flags.merge_request_pipeline;
+ delete pipeline.flags.detached_merge_request_pipeline;
+
+ vm = mountComponent(Component, {
+ pipeline,
+ stages: [{ name: 'build' }, { name: 'test' }],
+ selectedStage: 'deploy',
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders pipeline status', () => {
+ expect(vm.$el.querySelector('.js-ci-status-icon-success')).not.toBeNull();
+ });
+
+ it('renders pipeline link', () => {
+ expect(vm.$el.querySelector('.js-pipeline-path').getAttribute('href')).toEqual(
+ 'pipeline/28029444',
+ );
+ });
+
+ it('renders dropdown with stages', () => {
+ expect(vm.$el.querySelector('.dropdown .js-stage-item').textContent).toContain('build');
+ });
+
+ it('rendes selected stage', () => {
+ expect(vm.$el.querySelector('.dropdown .js-selected-stage').textContent).toContain('deploy');
+ });
+
+ it(`renders the pipeline info text like "Pipeline #123 for source_branch"`, () => {
+ const expected = `Pipeline #${pipeline.id} for ${pipeline.ref.name}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
+
+ expect(actual).toBe(expected);
+ });
+ });
+
+ describe('with an "attached" merge request pipeline', () => {
+ let pipeline;
+
+ beforeEach(() => {
+ pipeline = JSON.parse(JSON.stringify(mockPipelineData));
+ pipeline.flags.merge_request_pipeline = true;
+ pipeline.flags.detached_merge_request_pipeline = false;
+
+ vm = mountComponent(Component, {
+ pipeline,
+ stages: [],
+ selectedStage: 'deploy',
+ });
+ });
+
+ it(`renders the pipeline info text like "Pipeline #123 for !456 with source_branch into target_branch"`, () => {
+ const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch} into ${pipeline.merge_request.target_branch}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
+
+ expect(actual).toBe(expected);
+ });
+
+ it(`renders the correct merge request link`, () => {
+ const actual = vm.$el.querySelector('.js-mr-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.path);
+ });
+
+ it(`renders the correct source branch link`, () => {
+ const actual = vm.$el.querySelector('.js-source-branch-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.source_branch_path);
+ });
+
+ it(`renders the correct target branch link`, () => {
+ const actual = vm.$el.querySelector('.js-target-branch-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.target_branch_path);
+ });
+ });
+
+ describe('with a detached merge request pipeline', () => {
+ let pipeline;
+
+ beforeEach(() => {
+ pipeline = JSON.parse(JSON.stringify(mockPipelineData));
+ pipeline.flags.merge_request_pipeline = false;
+ pipeline.flags.detached_merge_request_pipeline = true;
+
+ vm = mountComponent(Component, {
+ pipeline,
+ stages: [],
+ selectedStage: 'deploy',
+ });
+ });
+
+ it(`renders the pipeline info like "Pipeline #123 for !456 with source_branch"`, () => {
+ const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText);
+
+ expect(actual).toBe(expected);
+ });
+
+ it(`renders the correct merge request link`, () => {
+ const actual = vm.$el.querySelector('.js-mr-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.path);
+ });
+
+ it(`renders the correct source branch link`, () => {
+ const actual = vm.$el.querySelector('.js-source-branch-link').href;
+
+ expect(actual).toContain(pipeline.merge_request.source_branch_path);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/trigger_block_spec.js b/spec/frontend/jobs/components/trigger_block_spec.js
new file mode 100644
index 00000000000..448197b82c0
--- /dev/null
+++ b/spec/frontend/jobs/components/trigger_block_spec.js
@@ -0,0 +1,100 @@
+import Vue from 'vue';
+import component from '~/jobs/components/trigger_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Trigger block', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('with short token', () => {
+ it('renders short token', () => {
+ vm = mountComponent(Component, {
+ trigger: {
+ short_token: '0a666b2',
+ },
+ });
+
+ expect(vm.$el.querySelector('.js-short-token').textContent).toContain('0a666b2');
+ });
+ });
+
+ describe('without short token', () => {
+ it('does not render short token', () => {
+ vm = mountComponent(Component, { trigger: {} });
+
+ expect(vm.$el.querySelector('.js-short-token')).toBeNull();
+ });
+ });
+
+ describe('with variables', () => {
+ describe('hide/reveal variables', () => {
+ it('should toggle variables on click', done => {
+ vm = mountComponent(Component, {
+ trigger: {
+ short_token: 'bd7e',
+ variables: [
+ { key: 'UPLOAD_TO_GCS', value: 'false', public: false },
+ { key: 'UPLOAD_TO_S3', value: 'true', public: false },
+ ],
+ },
+ });
+
+ vm.$el.querySelector('.js-reveal-variables').click();
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.js-build-variables')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-reveal-variables').textContent.trim()).toEqual(
+ 'Hide values',
+ );
+
+ expect(vm.$el.querySelector('.js-build-variables').textContent).toContain(
+ 'UPLOAD_TO_GCS',
+ );
+
+ expect(vm.$el.querySelector('.js-build-variables').textContent).toContain('false');
+ expect(vm.$el.querySelector('.js-build-variables').textContent).toContain(
+ 'UPLOAD_TO_S3',
+ );
+
+ expect(vm.$el.querySelector('.js-build-variables').textContent).toContain('true');
+
+ vm.$el.querySelector('.js-reveal-variables').click();
+ })
+ .then(vm.$nextTick)
+ .then(() => {
+ expect(vm.$el.querySelector('.js-reveal-variables').textContent.trim()).toEqual(
+ 'Reveal values',
+ );
+
+ expect(vm.$el.querySelector('.js-build-variables').textContent).toContain(
+ 'UPLOAD_TO_GCS',
+ );
+
+ expect(vm.$el.querySelector('.js-build-value').textContent).toContain('••••••');
+
+ expect(vm.$el.querySelector('.js-build-variables').textContent).toContain(
+ 'UPLOAD_TO_S3',
+ );
+
+ expect(vm.$el.querySelector('.js-build-value').textContent).toContain('••••••');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('without variables', () => {
+ it('does not render variables', () => {
+ vm = mountComponent(Component, { trigger: {} });
+
+ expect(vm.$el.querySelector('.js-reveal-variables')).toBeNull();
+ expect(vm.$el.querySelector('.js-build-variables')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js b/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js
new file mode 100644
index 00000000000..68fcb321214
--- /dev/null
+++ b/spec/frontend/jobs/components/unmet_prerequisites_block_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import component from '~/jobs/components/unmet_prerequisites_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Unmet Prerequisites Block Job component', () => {
+ const Component = Vue.extend(component);
+ let vm;
+ const helpPath = '/user/project/clusters/index.html#troubleshooting-failed-deployment-jobs';
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ hasNoRunnersForProject: true,
+ helpPath,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders an alert with the correct message', () => {
+ const container = vm.$el.querySelector('.js-failed-unmet-prerequisites');
+ const alertMessage =
+ 'This job failed because the necessary resources were not successfully created.';
+
+ expect(container).not.toBeNull();
+ expect(container.innerHTML).toContain(alertMessage);
+ });
+
+ it('renders link to help page', () => {
+ const helpLink = vm.$el.querySelector('.js-help-path');
+
+ expect(helpLink).not.toBeNull();
+ expect(helpLink.innerHTML).toContain('More information');
+ expect(helpLink.getAttribute('href')).toEqual(helpPath);
+ });
+});
diff --git a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js
new file mode 100644
index 00000000000..2f7a6030650
--- /dev/null
+++ b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js
@@ -0,0 +1,79 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+
+describe('DelayedJobMixin', () => {
+ const delayedJobFixture = getJSONFixture('jobs/delayed.json');
+ const dummyComponent = Vue.extend({
+ mixins: [delayedJobMixin],
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ render(createElement) {
+ return createElement('div', this.remainingTime);
+ },
+ });
+
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ jest.clearAllTimers();
+ });
+
+ describe('if job is empty object', () => {
+ beforeEach(() => {
+ vm = mountComponent(dummyComponent, {
+ job: {},
+ });
+ });
+
+ it('sets remaining time to 00:00:00', () => {
+ expect(vm.$el.innerText).toBe('00:00:00');
+ });
+
+ describe('after mounting', () => {
+ beforeEach(() => vm.$nextTick());
+
+ it('does not update remaining time', () => {
+ expect(vm.$el.innerText).toBe('00:00:00');
+ });
+ });
+ });
+
+ describe('if job is delayed job', () => {
+ let remainingTimeInMilliseconds = 42000;
+
+ beforeEach(() => {
+ jest
+ .spyOn(Date, 'now')
+ .mockImplementation(
+ () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds,
+ );
+
+ vm = mountComponent(dummyComponent, {
+ job: delayedJobFixture,
+ });
+ });
+
+ describe('after mounting', () => {
+ beforeEach(() => vm.$nextTick());
+
+ it('sets remaining time', () => {
+ expect(vm.$el.innerText).toBe('00:00:42');
+ });
+
+ it('updates remaining time', () => {
+ remainingTimeInMilliseconds = 41000;
+ jest.advanceTimersByTime(1000);
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.innerText).toBe('00:00:41');
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js
new file mode 100644
index 00000000000..91bd5521f70
--- /dev/null
+++ b/spec/frontend/jobs/store/actions_spec.js
@@ -0,0 +1,512 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from '../../helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import {
+ setJobEndpoint,
+ setTraceOptions,
+ clearEtagPoll,
+ stopPolling,
+ requestJob,
+ fetchJob,
+ receiveJobSuccess,
+ receiveJobError,
+ scrollTop,
+ scrollBottom,
+ requestTrace,
+ fetchTrace,
+ startPollingTrace,
+ stopPollingTrace,
+ receiveTraceSuccess,
+ receiveTraceError,
+ toggleCollapsibleLine,
+ requestJobsForStage,
+ fetchJobsForStage,
+ receiveJobsForStageSuccess,
+ receiveJobsForStageError,
+ hideSidebar,
+ showSidebar,
+ toggleSidebar,
+} from '~/jobs/store/actions';
+import state from '~/jobs/store/state';
+import * as types from '~/jobs/store/mutation_types';
+
+describe('Job State actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setJobEndpoint', () => {
+ it('should commit SET_JOB_ENDPOINT mutation', done => {
+ testAction(
+ setJobEndpoint,
+ 'job/872324.json',
+ mockedState,
+ [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setTraceOptions', () => {
+ it('should commit SET_TRACE_OPTIONS mutation', done => {
+ testAction(
+ setTraceOptions,
+ { pagePath: 'job/872324/trace.json' },
+ mockedState,
+ [{ type: types.SET_TRACE_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('hideSidebar', () => {
+ it('should commit HIDE_SIDEBAR mutation', done => {
+ testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], [], done);
+ });
+ });
+
+ describe('showSidebar', () => {
+ it('should commit HIDE_SIDEBAR mutation', done => {
+ testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], [], done);
+ });
+ });
+
+ describe('toggleSidebar', () => {
+ describe('when isSidebarOpen is true', () => {
+ it('should dispatch hideSidebar', done => {
+ testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }], done);
+ });
+ });
+
+ describe('when isSidebarOpen is false', () => {
+ it('should dispatch showSidebar', done => {
+ mockedState.isSidebarOpen = false;
+
+ testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }], done);
+ });
+ });
+ });
+
+ describe('requestJob', () => {
+ it('should commit REQUEST_JOB mutation', done => {
+ testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], [], done);
+ });
+ });
+
+ describe('fetchJob', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.jobEndpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches requestJob and receiveJobSuccess ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 121212, name: 'karma' });
+
+ testAction(
+ fetchJob,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestJob',
+ },
+ {
+ payload: { id: 121212, name: 'karma' },
+ type: 'receiveJobSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
+ });
+
+ it('dispatches requestJob and receiveJobError ', done => {
+ testAction(
+ fetchJob,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestJob',
+ },
+ {
+ type: 'receiveJobError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveJobSuccess', () => {
+ it('should commit RECEIVE_JOB_SUCCESS mutation', done => {
+ testAction(
+ receiveJobSuccess,
+ { id: 121232132 },
+ mockedState,
+ [{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveJobError', () => {
+ it('should commit RECEIVE_JOB_ERROR mutation', done => {
+ testAction(receiveJobError, null, mockedState, [{ type: types.RECEIVE_JOB_ERROR }], [], done);
+ });
+ });
+
+ describe('scrollTop', () => {
+ it('should dispatch toggleScrollButtons action', done => {
+ testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done);
+ });
+ });
+
+ describe('scrollBottom', () => {
+ it('should dispatch toggleScrollButtons action', done => {
+ testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done);
+ });
+ });
+
+ describe('requestTrace', () => {
+ it('should commit REQUEST_TRACE mutation', done => {
+ testAction(requestTrace, null, mockedState, [{ type: types.REQUEST_TRACE }], [], done);
+ });
+ });
+
+ describe('fetchTrace', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.traceEndpoint = `${TEST_HOST}/endpoint`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches requestTrace, receiveTraceSuccess and stopPollingTrace when job is complete', done => {
+ mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, {
+ html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
+ complete: true,
+ });
+
+ testAction(
+ fetchTrace,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'toggleScrollisInBottom',
+ payload: true,
+ },
+ {
+ payload: {
+ html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
+ complete: true,
+ },
+ type: 'receiveTraceSuccess',
+ },
+ {
+ type: 'stopPollingTrace',
+ },
+ ],
+ done,
+ );
+ });
+
+ describe('when job is incomplete', () => {
+ let tracePayload;
+
+ beforeEach(() => {
+ tracePayload = {
+ html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
+ complete: false,
+ };
+
+ mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, tracePayload);
+ });
+
+ it('dispatches startPollingTrace', done => {
+ testAction(
+ fetchTrace,
+ null,
+ mockedState,
+ [],
+ [
+ { type: 'toggleScrollisInBottom', payload: true },
+ { type: 'receiveTraceSuccess', payload: tracePayload },
+ { type: 'startPollingTrace' },
+ ],
+ done,
+ );
+ });
+
+ it('does not dispatch startPollingTrace when timeout is non-empty', done => {
+ mockedState.traceTimeout = 1;
+
+ testAction(
+ fetchTrace,
+ null,
+ mockedState,
+ [],
+ [
+ { type: 'toggleScrollisInBottom', payload: true },
+ { type: 'receiveTraceSuccess', payload: tracePayload },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500);
+ });
+
+ it('dispatches requestTrace and receiveTraceError ', done => {
+ testAction(
+ fetchTrace,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'receiveTraceError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('startPollingTrace', () => {
+ let dispatch;
+ let commit;
+
+ beforeEach(() => {
+ dispatch = jest.fn();
+ commit = jest.fn();
+
+ startPollingTrace({ dispatch, commit });
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ it('should save the timeout id but not call fetchTrace', () => {
+ expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, expect.any(Number));
+ expect(commit.mock.calls[0][1]).toBeGreaterThan(0);
+
+ expect(dispatch).not.toHaveBeenCalledWith('fetchTrace');
+ });
+
+ describe('after timeout has passed', () => {
+ beforeEach(() => {
+ jest.advanceTimersByTime(4000);
+ });
+
+ it('should clear the timeout id and fetchTrace', () => {
+ expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, 0);
+ expect(dispatch).toHaveBeenCalledWith('fetchTrace');
+ });
+ });
+ });
+
+ describe('stopPollingTrace', () => {
+ let origTimeout;
+
+ beforeEach(() => {
+ // Can't use spyOn(window, 'clearTimeout') because this caused unrelated specs to timeout
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23838#note_280277727
+ origTimeout = window.clearTimeout;
+ window.clearTimeout = jest.fn();
+ });
+
+ afterEach(() => {
+ window.clearTimeout = origTimeout;
+ });
+
+ it('should commit STOP_POLLING_TRACE mutation ', done => {
+ const traceTimeout = 7;
+
+ testAction(
+ stopPollingTrace,
+ null,
+ { ...mockedState, traceTimeout },
+ [{ type: types.SET_TRACE_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_TRACE }],
+ [],
+ )
+ .then(() => {
+ expect(window.clearTimeout).toHaveBeenCalledWith(traceTimeout);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('receiveTraceSuccess', () => {
+ it('should commit RECEIVE_TRACE_SUCCESS mutation ', done => {
+ testAction(
+ receiveTraceSuccess,
+ 'hello world',
+ mockedState,
+ [{ type: types.RECEIVE_TRACE_SUCCESS, payload: 'hello world' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveTraceError', () => {
+ it('should commit stop polling trace', done => {
+ testAction(receiveTraceError, null, mockedState, [], [{ type: 'stopPollingTrace' }], done);
+ });
+ });
+
+ describe('toggleCollapsibleLine', () => {
+ it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', done => {
+ testAction(
+ toggleCollapsibleLine,
+ { isClosed: true },
+ mockedState,
+ [{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestJobsForStage', () => {
+ it('should commit REQUEST_JOBS_FOR_STAGE mutation ', done => {
+ testAction(
+ requestJobsForStage,
+ { name: 'deploy' },
+ mockedState,
+ [{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchJobsForStage', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', done => {
+ mock
+ .onGet(`${TEST_HOST}/jobs.json`)
+ .replyOnce(200, { latest_statuses: [{ id: 121212, name: 'build' }], retried: [] });
+
+ testAction(
+ fetchJobsForStage,
+ { dropdown_path: `${TEST_HOST}/jobs.json` },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestJobsForStage',
+ payload: { dropdown_path: `${TEST_HOST}/jobs.json` },
+ },
+ {
+ payload: [{ id: 121212, name: 'build' }],
+ type: 'receiveJobsForStageSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/jobs.json`).reply(500);
+ });
+
+ it('dispatches requestJobsForStage and receiveJobsForStageError', done => {
+ testAction(
+ fetchJobsForStage,
+ { dropdown_path: `${TEST_HOST}/jobs.json` },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestJobsForStage',
+ payload: { dropdown_path: `${TEST_HOST}/jobs.json` },
+ },
+ {
+ type: 'receiveJobsForStageError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveJobsForStageSuccess', () => {
+ it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', done => {
+ testAction(
+ receiveJobsForStageSuccess,
+ [{ id: 121212, name: 'karma' }],
+ mockedState,
+ [{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveJobsForStageError', () => {
+ it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', done => {
+ testAction(
+ receiveJobsForStageError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/jobs/store/helpers.js b/spec/frontend/jobs/store/helpers.js
new file mode 100644
index 00000000000..81a769b4a6e
--- /dev/null
+++ b/spec/frontend/jobs/store/helpers.js
@@ -0,0 +1,6 @@
+import state from '~/jobs/store/state';
+
+// eslint-disable-next-line import/prefer-default-export
+export const resetStore = store => {
+ store.replaceState(state());
+};
diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js
index d77690ffac0..3557d3b94b6 100644
--- a/spec/frontend/jobs/store/mutations_spec.js
+++ b/spec/frontend/jobs/store/mutations_spec.js
@@ -59,7 +59,7 @@ describe('Jobs Store Mutations', () => {
describe('when traceSize is bigger than the total size', () => {
it('sets isTraceSizeVisible to false', () => {
- const copy = Object.assign({}, stateCopy, { traceSize: 5118460, size: 2321312 });
+ const copy = { ...stateCopy, traceSize: 5118460, size: 2321312 };
mutations[types.RECEIVE_TRACE_SUCCESS](copy, { total: 511846 });
diff --git a/spec/frontend/labels_select_spec.js b/spec/frontend/labels_select_spec.js
index 5f48bad4970..8b08eb9e124 100644
--- a/spec/frontend/labels_select_spec.js
+++ b/spec/frontend/labels_select_spec.js
@@ -45,7 +45,6 @@ describe('LabelsSelect', () => {
labels: mockLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
- scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
@@ -71,10 +70,6 @@ describe('LabelsSelect', () => {
it('generated label item has a gl-label-text class', () => {
expect($labelEl.find('span').hasClass('gl-label-text')).toEqual(true);
});
-
- it('generated label item template does not have gl-label-icon class', () => {
- expect($labelEl.find('.gl-label-icon')).toHaveLength(0);
- });
});
describe('when scoped label is present', () => {
@@ -87,7 +82,6 @@ describe('LabelsSelect', () => {
labels: mockScopedLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
- scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
@@ -106,14 +100,6 @@ describe('LabelsSelect', () => {
expect($labelEl.find('a').attr('data-html')).toBe('true');
});
- it('generated label item template has question icon', () => {
- expect($labelEl.find('i.fa-question-circle')).toHaveLength(1);
- });
-
- it('generated label item template has gl-label-icon class', () => {
- expect($labelEl.find('.gl-label-icon')).toHaveLength(1);
- });
-
it('generated label item template has correct label styles', () => {
expect($labelEl.find('span.gl-label-text').attr('style')).toBe(
`background-color: ${label.color}; color: ${label.text_color};`,
@@ -141,7 +127,6 @@ describe('LabelsSelect', () => {
labels: mockScopedLabels2,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
- scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
diff --git a/spec/frontend/landing_spec.js b/spec/frontend/landing_spec.js
new file mode 100644
index 00000000000..448d8ee2e81
--- /dev/null
+++ b/spec/frontend/landing_spec.js
@@ -0,0 +1,184 @@
+import Cookies from 'js-cookie';
+import Landing from '~/landing';
+
+describe('Landing', () => {
+ const test = {};
+
+ describe('class constructor', () => {
+ beforeEach(() => {
+ test.landingElement = {};
+ test.dismissButton = {};
+ test.cookieName = 'cookie_name';
+
+ test.landing = new Landing(test.landingElement, test.dismissButton, test.cookieName);
+ });
+
+ it('should set .landing', () => {
+ expect(test.landing.landingElement).toBe(test.landingElement);
+ });
+
+ it('should set .cookieName', () => {
+ expect(test.landing.cookieName).toBe(test.cookieName);
+ });
+
+ it('should set .dismissButton', () => {
+ expect(test.landing.dismissButton).toBe(test.dismissButton);
+ });
+
+ it('should set .eventWrapper', () => {
+ expect(test.landing.eventWrapper).toEqual({});
+ });
+ });
+
+ describe('toggle', () => {
+ beforeEach(() => {
+ test.isDismissed = false;
+ test.landingElement = {
+ classList: {
+ toggle: jest.fn(),
+ },
+ };
+ test.landing = {
+ isDismissed: () => {},
+ addEvents: () => {},
+ landingElement: test.landingElement,
+ };
+
+ jest.spyOn(test.landing, 'isDismissed').mockReturnValue(test.isDismissed);
+ jest.spyOn(test.landing, 'addEvents').mockImplementation(() => {});
+
+ Landing.prototype.toggle.call(test.landing);
+ });
+
+ it('should call .isDismissed', () => {
+ expect(test.landing.isDismissed).toHaveBeenCalled();
+ });
+
+ it('should call .classList.toggle', () => {
+ expect(test.landingElement.classList.toggle).toHaveBeenCalledWith('hidden', test.isDismissed);
+ });
+
+ it('should call .addEvents', () => {
+ expect(test.landing.addEvents).toHaveBeenCalled();
+ });
+
+ describe('if isDismissed is true', () => {
+ beforeEach(() => {
+ test.isDismissed = true;
+ test.landingElement = {
+ classList: {
+ toggle: jest.fn(),
+ },
+ };
+ test.landing = {
+ isDismissed: () => {},
+ addEvents: () => {},
+ landingElement: test.landingElement,
+ };
+
+ jest.spyOn(test.landing, 'isDismissed').mockReturnValue(test.isDismissed);
+ jest.spyOn(test.landing, 'addEvents').mockImplementation(() => {});
+
+ test.landing.isDismissed.mockClear();
+
+ Landing.prototype.toggle.call(test.landing);
+ });
+
+ it('should not call .addEvents', () => {
+ expect(test.landing.addEvents).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('addEvents', () => {
+ beforeEach(() => {
+ test.dismissButton = {
+ addEventListener: jest.fn(),
+ };
+ test.eventWrapper = {};
+ test.landing = {
+ eventWrapper: test.eventWrapper,
+ dismissButton: test.dismissButton,
+ dismissLanding: () => {},
+ };
+
+ Landing.prototype.addEvents.call(test.landing);
+ });
+
+ it('should set .eventWrapper.dismissLanding', () => {
+ expect(test.eventWrapper.dismissLanding).toEqual(expect.any(Function));
+ });
+
+ it('should call .addEventListener', () => {
+ expect(test.dismissButton.addEventListener).toHaveBeenCalledWith(
+ 'click',
+ test.eventWrapper.dismissLanding,
+ );
+ });
+ });
+
+ describe('removeEvents', () => {
+ beforeEach(() => {
+ test.dismissButton = {
+ removeEventListener: jest.fn(),
+ };
+ test.eventWrapper = { dismissLanding: () => {} };
+ test.landing = {
+ eventWrapper: test.eventWrapper,
+ dismissButton: test.dismissButton,
+ };
+
+ Landing.prototype.removeEvents.call(test.landing);
+ });
+
+ it('should call .removeEventListener', () => {
+ expect(test.dismissButton.removeEventListener).toHaveBeenCalledWith(
+ 'click',
+ test.eventWrapper.dismissLanding,
+ );
+ });
+ });
+
+ describe('dismissLanding', () => {
+ beforeEach(() => {
+ test.landingElement = {
+ classList: {
+ add: jest.fn(),
+ },
+ };
+ test.cookieName = 'cookie_name';
+ test.landing = { landingElement: test.landingElement, cookieName: test.cookieName };
+
+ jest.spyOn(Cookies, 'set').mockImplementation(() => {});
+
+ Landing.prototype.dismissLanding.call(test.landing);
+ });
+
+ it('should call .classList.add', () => {
+ expect(test.landingElement.classList.add).toHaveBeenCalledWith('hidden');
+ });
+
+ it('should call Cookies.set', () => {
+ expect(Cookies.set).toHaveBeenCalledWith(test.cookieName, 'true', { expires: 365 });
+ });
+ });
+
+ describe('isDismissed', () => {
+ beforeEach(() => {
+ test.cookieName = 'cookie_name';
+ test.landing = { cookieName: test.cookieName };
+
+ jest.spyOn(Cookies, 'get').mockReturnValue('true');
+
+ test.isDismissed = Landing.prototype.isDismissed.call(test.landing);
+ });
+
+ it('should call Cookies.get', () => {
+ expect(Cookies.get).toHaveBeenCalledWith(test.cookieName);
+ });
+
+ it('should return a boolean', () => {
+ expect(typeof test.isDismissed).toEqual('boolean');
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/axios_utils_spec.js b/spec/frontend/lib/utils/axios_utils_spec.js
index d5c39567f06..1585a38ae86 100644
--- a/spec/frontend/lib/utils/axios_utils_spec.js
+++ b/spec/frontend/lib/utils/axios_utils_spec.js
@@ -11,6 +11,7 @@ describe('axios_utils', () => {
mock = new AxiosMockAdapter(axios);
mock.onAny('/ok').reply(200);
mock.onAny('/err').reply(500);
+ // eslint-disable-next-line jest/no-standalone-expect
expect(axios.countActiveRequests()).toBe(0);
});
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index 1edfda30fec..c8dc90c9ace 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -503,7 +503,7 @@ describe('common_utils', () => {
beforeEach(() => {
window.gon = window.gon || {};
- beforeGon = Object.assign({}, window.gon);
+ beforeGon = { ...window.gon };
window.gon.sprite_icons = 'icons.svg';
});
diff --git a/spec/frontend/lib/utils/csrf_token_spec.js b/spec/frontend/lib/utils/csrf_token_spec.js
new file mode 100644
index 00000000000..1b98ef126e9
--- /dev/null
+++ b/spec/frontend/lib/utils/csrf_token_spec.js
@@ -0,0 +1,57 @@
+import csrf from '~/lib/utils/csrf';
+import { setHTMLFixture } from 'helpers/fixtures';
+
+describe('csrf', () => {
+ let testContext;
+
+ beforeEach(() => {
+ testContext = {};
+ });
+
+ beforeEach(() => {
+ testContext.tokenKey = 'X-CSRF-Token';
+ testContext.token =
+ 'pH1cvjnP9grx2oKlhWEDvUZnJ8x2eXsIs1qzyHkF3DugSG5yTxR76CWeEZRhML2D1IeVB7NEW0t5l/axE4iJpQ==';
+ });
+
+ it('returns the correct headerKey', () => {
+ expect(csrf.headerKey).toBe(testContext.tokenKey);
+ });
+
+ describe('when csrf token is in the DOM', () => {
+ beforeEach(() => {
+ setHTMLFixture(`
+ <meta name="csrf-token" content="${testContext.token}">
+ `);
+
+ csrf.init();
+ });
+
+ it('returns the csrf token', () => {
+ expect(csrf.token).toBe(testContext.token);
+ });
+
+ it('returns the csrf headers object', () => {
+ expect(csrf.headers[testContext.tokenKey]).toBe(testContext.token);
+ });
+ });
+
+ describe('when csrf token is not in the DOM', () => {
+ beforeEach(() => {
+ setHTMLFixture(`
+ <meta name="some-other-token">
+ `);
+
+ csrf.init();
+ });
+
+ it('returns null for token', () => {
+ expect(csrf.token).toBeNull();
+ });
+
+ it('returns empty object for headers', () => {
+ expect(typeof csrf.headers).toBe('object');
+ expect(Object.keys(csrf.headers).length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/downloader_spec.js b/spec/frontend/lib/utils/downloader_spec.js
new file mode 100644
index 00000000000..c14cba3a62b
--- /dev/null
+++ b/spec/frontend/lib/utils/downloader_spec.js
@@ -0,0 +1,40 @@
+import downloader from '~/lib/utils/downloader';
+
+describe('Downloader', () => {
+ let a;
+
+ beforeEach(() => {
+ a = { click: jest.fn() };
+ jest.spyOn(document, 'createElement').mockImplementation(() => a);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when inline file content is provided', () => {
+ const fileData = 'inline content';
+ const fileName = 'test.csv';
+
+ it('uses the data urls to download the file', () => {
+ downloader({ fileName, fileData });
+ expect(document.createElement).toHaveBeenCalledWith('a');
+ expect(a.download).toBe(fileName);
+ expect(a.href).toBe(`data:text/plain;base64,${fileData}`);
+ expect(a.click).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when an endpoint is provided', () => {
+ const url = 'https://gitlab.com/test.csv';
+ const fileName = 'test.csv';
+
+ it('uses the endpoint to download the file', () => {
+ downloader({ fileName, url });
+ expect(document.createElement).toHaveBeenCalledWith('a');
+ expect(a.download).toBe(fileName);
+ expect(a.href).toBe(url);
+ expect(a.click).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js
new file mode 100644
index 00000000000..88172f38894
--- /dev/null
+++ b/spec/frontend/lib/utils/navigation_utility_spec.js
@@ -0,0 +1,23 @@
+import findAndFollowLink from '~/lib/utils/navigation_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility');
+
+describe('findAndFollowLink', () => {
+ it('visits a link when the selector exists', () => {
+ const href = '/some/path';
+
+ setFixtures(`<a class="my-shortcut" href="${href}">link</a>`);
+
+ findAndFollowLink('.my-shortcut');
+
+ expect(visitUrl).toHaveBeenCalledWith(href);
+ });
+
+ it('does not throw an exception when the selector does not exist', () => {
+ // this should not throw an exception
+ findAndFollowLink('.this-selector-does-not-exist');
+
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js
new file mode 100644
index 00000000000..5ee9738ebf3
--- /dev/null
+++ b/spec/frontend/lib/utils/poll_spec.js
@@ -0,0 +1,225 @@
+import Poll from '~/lib/utils/poll';
+import { successCodes } from '~/lib/utils/http_status';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('Poll', () => {
+ let callbacks;
+ let service;
+
+ function setup() {
+ return new Poll({
+ resource: service,
+ method: 'fetch',
+ successCallback: callbacks.success,
+ errorCallback: callbacks.error,
+ notificationCallback: callbacks.notification,
+ }).makeRequest();
+ }
+
+ const mockServiceCall = (response, shouldFail = false) => {
+ const value = {
+ ...response,
+ header: response.header || {},
+ };
+
+ if (shouldFail) {
+ service.fetch.mockRejectedValue(value);
+ } else {
+ service.fetch.mockResolvedValue(value);
+ }
+ };
+
+ const waitForAllCallsToFinish = (waitForCount, successCallback) => {
+ if (!waitForCount) {
+ return Promise.resolve().then(successCallback());
+ }
+
+ jest.runOnlyPendingTimers();
+
+ return waitForPromises().then(() => waitForAllCallsToFinish(waitForCount - 1, successCallback));
+ };
+
+ beforeEach(() => {
+ service = {
+ fetch: jest.fn(),
+ };
+ callbacks = {
+ success: jest.fn(),
+ error: jest.fn(),
+ notification: jest.fn(),
+ };
+ });
+
+ it('calls the success callback when no header for interval is provided', done => {
+ mockServiceCall({ status: 200 });
+ setup();
+
+ waitForAllCallsToFinish(1, () => {
+ expect(callbacks.success).toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls the error callback when the http request returns an error', done => {
+ mockServiceCall({ status: 500 }, true);
+ setup();
+
+ waitForAllCallsToFinish(1, () => {
+ expect(callbacks.success).not.toHaveBeenCalled();
+ expect(callbacks.error).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('skips the error callback when request is aborted', done => {
+ mockServiceCall({ status: 0 }, true);
+ setup();
+
+ waitForAllCallsToFinish(1, () => {
+ expect(callbacks.success).not.toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+ expect(callbacks.notification).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('should call the success callback when the interval header is -1', done => {
+ mockServiceCall({ status: 200, headers: { 'poll-interval': -1 } });
+ setup()
+ .then(() => {
+ expect(callbacks.success).toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ describe('for 2xx status code', () => {
+ successCodes.forEach(httpCode => {
+ it(`starts polling when http status is ${httpCode} and interval header is provided`, done => {
+ mockServiceCall({ status: httpCode, headers: { 'poll-interval': 1 } });
+
+ const Polling = new Poll({
+ resource: service,
+ method: 'fetch',
+ data: { page: 1 },
+ successCallback: callbacks.success,
+ errorCallback: callbacks.error,
+ });
+
+ Polling.makeRequest();
+
+ waitForAllCallsToFinish(2, () => {
+ Polling.stop();
+
+ expect(service.fetch.mock.calls).toHaveLength(2);
+ expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
+ expect(callbacks.success).toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+ });
+
+ describe('stop', () => {
+ it('stops polling when method is called', done => {
+ mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } });
+
+ const Polling = new Poll({
+ resource: service,
+ method: 'fetch',
+ data: { page: 1 },
+ successCallback: () => {
+ Polling.stop();
+ },
+ errorCallback: callbacks.error,
+ });
+
+ jest.spyOn(Polling, 'stop');
+
+ Polling.makeRequest();
+
+ waitForAllCallsToFinish(1, () => {
+ expect(service.fetch.mock.calls).toHaveLength(1);
+ expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
+ expect(Polling.stop).toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+
+ describe('enable', () => {
+ it('should enable polling upon a response', done => {
+ mockServiceCall({ status: 200 });
+ const Polling = new Poll({
+ resource: service,
+ method: 'fetch',
+ data: { page: 1 },
+ successCallback: () => {},
+ });
+
+ Polling.enable({
+ data: { page: 4 },
+ response: { status: 200, headers: { 'poll-interval': 1 } },
+ });
+
+ waitForAllCallsToFinish(1, () => {
+ Polling.stop();
+
+ expect(service.fetch.mock.calls).toHaveLength(1);
+ expect(service.fetch).toHaveBeenCalledWith({ page: 4 });
+ expect(Polling.options.data).toEqual({ page: 4 });
+ done();
+ });
+ });
+ });
+
+ describe('restart', () => {
+ it('should restart polling when its called', done => {
+ mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } });
+
+ const Polling = new Poll({
+ resource: service,
+ method: 'fetch',
+ data: { page: 1 },
+ successCallback: () => {
+ Polling.stop();
+
+ // Let's pretend that we asynchronously restart this.
+ // setTimeout is mocked but this will actually get triggered
+ // in waitForAllCalssToFinish.
+ setTimeout(() => {
+ Polling.restart({ data: { page: 4 } });
+ }, 1);
+ },
+ errorCallback: callbacks.error,
+ });
+
+ jest.spyOn(Polling, 'stop');
+ jest.spyOn(Polling, 'enable');
+ jest.spyOn(Polling, 'restart');
+
+ Polling.makeRequest();
+
+ waitForAllCallsToFinish(2, () => {
+ Polling.stop();
+
+ expect(service.fetch.mock.calls).toHaveLength(2);
+ expect(service.fetch).toHaveBeenCalledWith({ page: 4 });
+ expect(Polling.stop).toHaveBeenCalled();
+ expect(Polling.enable).toHaveBeenCalled();
+ expect(Polling.restart).toHaveBeenCalled();
+ expect(Polling.options.data).toEqual({ page: 4 });
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/sticky_spec.js b/spec/frontend/lib/utils/sticky_spec.js
new file mode 100644
index 00000000000..4ad68cc9ff6
--- /dev/null
+++ b/spec/frontend/lib/utils/sticky_spec.js
@@ -0,0 +1,77 @@
+import { isSticky } from '~/lib/utils/sticky';
+import { setHTMLFixture } from 'helpers/fixtures';
+
+const TEST_OFFSET_TOP = 500;
+
+describe('sticky', () => {
+ let el;
+ let offsetTop;
+
+ beforeEach(() => {
+ setHTMLFixture(
+ `
+ <div class="parent">
+ <div id="js-sticky"></div>
+ </div>
+ `,
+ );
+
+ offsetTop = TEST_OFFSET_TOP;
+ el = document.getElementById('js-sticky');
+ Object.defineProperty(el, 'offsetTop', {
+ get() {
+ return offsetTop;
+ },
+ });
+ });
+
+ afterEach(() => {
+ el = null;
+ });
+
+ describe('when stuck', () => {
+ it('does not remove is-stuck class', () => {
+ isSticky(el, 0, el.offsetTop);
+ isSticky(el, 0, el.offsetTop);
+
+ expect(el.classList.contains('is-stuck')).toBeTruthy();
+ });
+
+ it('adds is-stuck class', () => {
+ isSticky(el, 0, el.offsetTop);
+
+ expect(el.classList.contains('is-stuck')).toBeTruthy();
+ });
+
+ it('inserts placeholder element', () => {
+ isSticky(el, 0, el.offsetTop, true);
+
+ expect(document.querySelector('.sticky-placeholder')).not.toBeNull();
+ });
+ });
+
+ describe('when not stuck', () => {
+ it('removes is-stuck class', () => {
+ jest.spyOn(el.classList, 'remove');
+
+ isSticky(el, 0, el.offsetTop);
+ isSticky(el, 0, 0);
+
+ expect(el.classList.remove).toHaveBeenCalledWith('is-stuck');
+ expect(el.classList.contains('is-stuck')).toBe(false);
+ });
+
+ it('does not add is-stuck class', () => {
+ isSticky(el, 0, 0);
+
+ expect(el.classList.contains('is-stuck')).toBeFalsy();
+ });
+
+ it('removes placeholder', () => {
+ isSticky(el, 0, el.offsetTop, true);
+ isSticky(el, 0, 0, true);
+
+ expect(document.querySelector('.sticky-placeholder')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index ba3e4020e66..1d616a7da0b 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -25,7 +25,7 @@ describe('init markdown', () => {
insertMarkdownText({
textArea,
text: textArea.value,
- tag: '*',
+ tag: '* ',
blockTag: null,
selected: '',
wrap: false,
@@ -43,7 +43,7 @@ describe('init markdown', () => {
insertMarkdownText({
textArea,
text: textArea.value,
- tag: '*',
+ tag: '* ',
blockTag: null,
selected: '',
wrap: false,
@@ -61,7 +61,7 @@ describe('init markdown', () => {
insertMarkdownText({
textArea,
text: textArea.value,
- tag: '*',
+ tag: '* ',
blockTag: null,
selected: '',
wrap: false,
@@ -79,7 +79,7 @@ describe('init markdown', () => {
insertMarkdownText({
textArea,
text: textArea.value,
- tag: '*',
+ tag: '* ',
blockTag: null,
selected: '',
wrap: false,
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 4960895890f..c494033badd 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -91,36 +91,75 @@ describe('URL utility', () => {
});
describe('mergeUrlParams', () => {
+ const { mergeUrlParams } = urlUtils;
+
it('adds w', () => {
- expect(urlUtils.mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag');
- expect(urlUtils.mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag');
- expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1');
- expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe(
- 'https://host/path?w=1#frag',
- );
+ expect(mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag');
+ expect(mergeUrlParams({ w: 1 }, '')).toBe('?w=1');
+ expect(mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag');
+ expect(mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1');
+ expect(mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe('https://host/path?w=1#frag');
+ expect(mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe('https://h/p?k1=v1&w=1#frag');
+ expect(mergeUrlParams({ w: 'null' }, '')).toBe('?w=null');
+ });
- expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe(
- 'https://h/p?k1=v1&w=1#frag',
- );
+ it('adds multiple params', () => {
+ expect(mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag');
});
it('updates w', () => {
- expect(urlUtils.mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag');
+ expect(mergeUrlParams({ w: 2 }, '/path?w=1#frag')).toBe('/path?w=2#frag');
+ expect(mergeUrlParams({ w: 2 }, 'https://host/path?w=1')).toBe('https://host/path?w=2');
});
- it('adds multiple params', () => {
- expect(urlUtils.mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag');
+ it('removes null w', () => {
+ expect(mergeUrlParams({ w: null }, '?w=1#frag')).toBe('#frag');
+ expect(mergeUrlParams({ w: null }, '/path?w=1#frag')).toBe('/path#frag');
+ expect(mergeUrlParams({ w: null }, 'https://host/path?w=1')).toBe('https://host/path');
+ expect(mergeUrlParams({ w: null }, 'https://host/path?w=1#frag')).toBe(
+ 'https://host/path#frag',
+ );
+ expect(mergeUrlParams({ w: null }, 'https://h/p?k1=v1&w=1#frag')).toBe(
+ 'https://h/p?k1=v1#frag',
+ );
});
- it('adds and updates encoded params', () => {
- expect(urlUtils.mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag');
+ it('adds and updates encoded param values', () => {
+ expect(mergeUrlParams({ foo: '&', q: '?' }, '?foo=%23#frag')).toBe('?foo=%26&q=%3F#frag');
+ expect(mergeUrlParams({ foo: 'a value' }, '')).toBe('?foo=a%20value');
+ expect(mergeUrlParams({ foo: 'a value' }, '?foo=1')).toBe('?foo=a%20value');
+ });
+
+ it('adds and updates encoded param names', () => {
+ expect(mergeUrlParams({ 'a name': 1 }, '')).toBe('?a%20name=1');
+ expect(mergeUrlParams({ 'a name': 2 }, '?a%20name=1')).toBe('?a%20name=2');
+ expect(mergeUrlParams({ 'a name': null }, '?a%20name=1')).toBe('');
});
it('treats "+" as "%20"', () => {
- expect(urlUtils.mergeUrlParams({ ref: 'bogus' }, '?a=lorem+ipsum&ref=charlie')).toBe(
+ expect(mergeUrlParams({ ref: 'bogus' }, '?a=lorem+ipsum&ref=charlie')).toBe(
'?a=lorem%20ipsum&ref=bogus',
);
});
+
+ it('treats question marks and slashes as part of the query', () => {
+ expect(mergeUrlParams({ ending: '!' }, '?ending=?&foo=bar')).toBe('?ending=!&foo=bar');
+ expect(mergeUrlParams({ ending: '!' }, 'https://host/path?ending=?&foo=bar')).toBe(
+ 'https://host/path?ending=!&foo=bar',
+ );
+ expect(mergeUrlParams({ ending: '?' }, '?ending=!&foo=bar')).toBe('?ending=%3F&foo=bar');
+ expect(mergeUrlParams({ ending: '?' }, 'https://host/path?ending=!&foo=bar')).toBe(
+ 'https://host/path?ending=%3F&foo=bar',
+ );
+ expect(mergeUrlParams({ ending: '!', op: '+' }, '?ending=?&op=/')).toBe('?ending=!&op=%2B');
+ expect(mergeUrlParams({ ending: '!', op: '+' }, 'https://host/path?ending=?&op=/')).toBe(
+ 'https://host/path?ending=!&op=%2B',
+ );
+ expect(mergeUrlParams({ op: '+' }, '?op=/&foo=bar')).toBe('?op=%2B&foo=bar');
+ expect(mergeUrlParams({ op: '+' }, 'https://host/path?op=/&foo=bar')).toBe(
+ 'https://host/path?op=%2B&foo=bar',
+ );
+ });
});
describe('removeParams', () => {
@@ -284,20 +323,76 @@ describe('URL utility', () => {
});
});
- describe('isAbsoluteOrRootRelative', () => {
- const validUrls = ['https://gitlab.com/', 'http://gitlab.com/', '/users/sign_in'];
-
- const invalidUrls = [' https://gitlab.com/', './file/path', 'notanurl', '<a></a>'];
+ describe('isAbsolute', () => {
+ it.each`
+ url | valid
+ ${'https://gitlab.com/'} | ${true}
+ ${'http://gitlab.com/'} | ${true}
+ ${'/users/sign_in'} | ${false}
+ ${' https://gitlab.com'} | ${false}
+ ${'somepath.php?url=https://gitlab.com'} | ${false}
+ ${'notaurl'} | ${false}
+ ${'../relative_url'} | ${false}
+ ${'<a></a>'} | ${false}
+ `('returns $valid for $url', ({ url, valid }) => {
+ expect(urlUtils.isAbsolute(url)).toBe(valid);
+ });
+ });
- it.each(validUrls)(`returns true for %s`, url => {
- expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(true);
+ describe('isRootRelative', () => {
+ it.each`
+ url | valid
+ ${'https://gitlab.com/'} | ${false}
+ ${'http://gitlab.com/'} | ${false}
+ ${'/users/sign_in'} | ${true}
+ ${' https://gitlab.com'} | ${false}
+ ${'/somepath.php?url=https://gitlab.com'} | ${true}
+ ${'notaurl'} | ${false}
+ ${'../relative_url'} | ${false}
+ ${'<a></a>'} | ${false}
+ `('returns $valid for $url', ({ url, valid }) => {
+ expect(urlUtils.isRootRelative(url)).toBe(valid);
});
+ });
- it.each(invalidUrls)(`returns false for %s`, url => {
- expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(false);
+ describe('isAbsoluteOrRootRelative', () => {
+ it.each`
+ url | valid
+ ${'https://gitlab.com/'} | ${true}
+ ${'http://gitlab.com/'} | ${true}
+ ${'/users/sign_in'} | ${true}
+ ${' https://gitlab.com'} | ${false}
+ ${'/somepath.php?url=https://gitlab.com'} | ${true}
+ ${'notaurl'} | ${false}
+ ${'../relative_url'} | ${false}
+ ${'<a></a>'} | ${false}
+ `('returns $valid for $url', ({ url, valid }) => {
+ expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(valid);
});
});
+ describe('relativePathToAbsolute', () => {
+ it.each`
+ path | base | result
+ ${'./foo'} | ${'bar/'} | ${'/bar/foo'}
+ ${'../john.md'} | ${'bar/baz/foo.php'} | ${'/bar/john.md'}
+ ${'../images/img.png'} | ${'bar/baz/foo.php'} | ${'/bar/images/img.png'}
+ ${'../images/Image 1.png'} | ${'bar/baz/foo.php'} | ${'/bar/images/Image 1.png'}
+ ${'/images/img.png'} | ${'bar/baz/foo.php'} | ${'/images/img.png'}
+ ${'/images/img.png'} | ${'/bar/baz/foo.php'} | ${'/images/img.png'}
+ ${'../john.md'} | ${'/bar/baz/foo.php'} | ${'/bar/john.md'}
+ ${'../john.md'} | ${'///bar/baz/foo.php'} | ${'/bar/john.md'}
+ ${'/images/img.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/images/img.png'}
+ ${'../images/img.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/user/images/img.png'}
+ ${'../images/Image 1.png'} | ${'https://gitlab.com/user/project/'} | ${'https://gitlab.com/user/images/Image%201.png'}
+ `(
+ 'converts relative path "$path" with base "$base" to absolute path => "expected"',
+ ({ path, base, result }) => {
+ expect(urlUtils.relativePathToAbsolute(path, base)).toBe(result);
+ },
+ );
+ });
+
describe('isSafeUrl', () => {
const absoluteUrls = [
'http://example.org',
@@ -386,6 +481,12 @@ describe('URL utility', () => {
expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' });
});
+
+ it('removes undefined values from the search query', () => {
+ const searchQuery = '?one=1&two=2&three';
+
+ expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' });
+ });
});
describe('objectToQuery', () => {
diff --git a/spec/frontend/milestones/mock_data.js b/spec/frontend/milestones/mock_data.js
new file mode 100644
index 00000000000..c64eeeba663
--- /dev/null
+++ b/spec/frontend/milestones/mock_data.js
@@ -0,0 +1,82 @@
+export const milestones = [
+ {
+ id: 41,
+ iid: 6,
+ project_id: 8,
+ title: 'v0.1',
+ description: '',
+ state: 'active',
+ created_at: '2020-04-04T01:30:40.051Z',
+ updated_at: '2020-04-04T01:30:40.051Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
+ },
+ {
+ id: 40,
+ iid: 5,
+ project_id: 8,
+ title: 'v4.0',
+ description: 'Laboriosam nisi sapiente dolores et magnam nobis ad earum.',
+ state: 'closed',
+ created_at: '2020-01-13T19:39:15.191Z',
+ updated_at: '2020-01-13T19:39:15.191Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/5',
+ },
+ {
+ id: 39,
+ iid: 4,
+ project_id: 8,
+ title: 'v3.0',
+ description: 'Necessitatibus illo alias et repellat dolorum assumenda ut.',
+ state: 'closed',
+ created_at: '2020-01-13T19:39:15.176Z',
+ updated_at: '2020-01-13T19:39:15.176Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/4',
+ },
+ {
+ id: 38,
+ iid: 3,
+ project_id: 8,
+ title: 'v2.0',
+ description: 'Doloribus qui repudiandae iste sit.',
+ state: 'closed',
+ created_at: '2020-01-13T19:39:15.161Z',
+ updated_at: '2020-01-13T19:39:15.161Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/3',
+ },
+ {
+ id: 37,
+ iid: 2,
+ project_id: 8,
+ title: 'v1.0',
+ description: 'Illo sint odio officia ea.',
+ state: 'closed',
+ created_at: '2020-01-13T19:39:15.146Z',
+ updated_at: '2020-01-13T19:39:15.146Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/2',
+ },
+ {
+ id: 36,
+ iid: 1,
+ project_id: 8,
+ title: 'v0.0',
+ description: 'Sed quae facilis deleniti at delectus assumenda nobis veritatis.',
+ state: 'active',
+ created_at: '2020-01-13T19:39:15.127Z',
+ updated_at: '2020-01-13T19:39:15.127Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/1',
+ },
+];
+
+export default milestones;
diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js
new file mode 100644
index 00000000000..a7321d21559
--- /dev/null
+++ b/spec/frontend/milestones/project_milestone_combobox_spec.js
@@ -0,0 +1,150 @@
+import { milestones as projectMilestones } from './mock_data';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { shallowMount } from '@vue/test-utils';
+import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
+import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+
+const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search';
+
+const extraLinks = [
+ { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },
+ { text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' },
+];
+
+const preselectedMilestones = [];
+const projectId = '8';
+
+describe('Milestone selector', () => {
+ let wrapper;
+ let mock;
+
+ const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' });
+
+ const factory = (options = {}) => {
+ wrapper = shallowMount(MilestoneCombobox, {
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ gon.api_version = 'v4';
+
+ mock.onGet('/api/v4/projects/8/milestones').reply(200, projectMilestones);
+
+ factory({
+ propsData: {
+ projectId,
+ preselectedMilestones,
+ extraLinks,
+ },
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders the dropdown', () => {
+ expect(wrapper.find(GlNewDropdown)).toExist();
+ });
+
+ it('renders additional links', () => {
+ const links = wrapper.findAll('[href]');
+ links.wrappers.forEach((item, idx) => {
+ expect(item.text()).toBe(extraLinks[idx].text);
+ expect(item.attributes('href')).toBe(extraLinks[idx].url);
+ });
+ });
+
+ describe('before results', () => {
+ it('should show a loading icon', () => {
+ const request = mock.onGet(TEST_SEARCH_ENDPOINT, {
+ params: { search: 'TEST_SEARCH', scope: 'milestones' },
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+
+ return wrapper.vm.$nextTick().then(() => {
+ request.reply(200, []);
+ });
+ });
+
+ it('should not show any dropdown items', () => {
+ expect(wrapper.findAll('[role="milestone option"]')).toHaveLength(0);
+ });
+
+ it('should have "No milestone" as the button text', () => {
+ expect(wrapper.find({ ref: 'buttonText' }).text()).toBe('No milestone');
+ });
+ });
+
+ describe('with empty results', () => {
+ beforeEach(() => {
+ mock
+ .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } })
+ .reply(200, []);
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', 'TEST_SEARCH');
+ return axios.waitForAll();
+ });
+
+ it('should display that no matching items are found', () => {
+ expect(findNoResultsMessage().exists()).toBe(true);
+ });
+ });
+
+ describe('with results', () => {
+ let items;
+ beforeEach(() => {
+ mock
+ .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'v0.1', scope: 'milestones' } })
+ .reply(200, [
+ {
+ id: 41,
+ iid: 6,
+ project_id: 8,
+ title: 'v0.1',
+ description: '',
+ state: 'active',
+ created_at: '2020-04-04T01:30:40.051Z',
+ updated_at: '2020-04-04T01:30:40.051Z',
+ due_date: null,
+ start_date: null,
+ web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
+ },
+ ]);
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', 'v0.1');
+ return axios.waitForAll().then(() => {
+ items = wrapper.findAll('[role="milestone option"]');
+ });
+ });
+
+ it('should display one item per result', () => {
+ expect(items).toHaveLength(1);
+ });
+
+ it('should emit a change if an item is clicked', () => {
+ items.at(0).vm.$emit('click');
+ expect(wrapper.emitted().change.length).toBe(1);
+ expect(wrapper.emitted().change[0]).toEqual([[{ title: 'v0.1' }]]);
+ });
+
+ it('should not have a selecton icon on any item', () => {
+ items.wrappers.forEach(item => {
+ expect(item.find('.selected-item').exists()).toBe(false);
+ });
+ });
+
+ it('should have a selecton icon if an item is clicked', () => {
+ items.at(0).vm.$emit('click');
+ expect(wrapper.find('.selected-item').exists()).toBe(true);
+ });
+
+ it('should not display a message about no results', () => {
+ expect(findNoResultsMessage().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js
index a33ddbbfe63..5532a22f8e6 100644
--- a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js
+++ b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js
@@ -1,8 +1 @@
-/* eslint-disable class-methods-use-this */
-export default class TreeWorkerMock {
- addEventListener() {}
-
- terminate() {}
-
- postMessage() {}
-}
+export { default } from 'helpers/web_worker_mock';
diff --git a/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js b/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js
new file mode 100644
index 00000000000..5532a22f8e6
--- /dev/null
+++ b/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js
@@ -0,0 +1 @@
+export { default } from 'helpers/web_worker_mock';
diff --git a/spec/frontend/mocks_spec.js b/spec/frontend/mocks_spec.js
index a4a1fdea396..110c418e579 100644
--- a/spec/frontend/mocks_spec.js
+++ b/spec/frontend/mocks_spec.js
@@ -8,12 +8,13 @@ describe('Mock auto-injection', () => {
failMock = jest.spyOn(global, 'fail').mockImplementation();
});
- it('~/lib/utils/axios_utils', done => {
- expect(axios.get('http://gitlab.com')).rejects.toThrow('Unexpected unmocked request');
- setImmediate(() => {
- expect(failMock).toHaveBeenCalledTimes(1);
- done();
- });
+ it('~/lib/utils/axios_utils', () => {
+ return Promise.all([
+ expect(axios.get('http://gitlab.com')).rejects.toThrow('Unexpected unmocked request'),
+ setImmediate(() => {
+ expect(failMock).toHaveBeenCalledTimes(1);
+ }),
+ ]);
});
it('jQuery.ajax()', () => {
diff --git a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap
new file mode 100644
index 00000000000..2179e7b4ab5
--- /dev/null
+++ b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap
@@ -0,0 +1,43 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AlertWidget Alert firing displays a warning icon and matches snapshot 1`] = `
+<gl-badge-stub
+ class="d-flex-center text-truncate"
+ pill=""
+ variant="danger"
+>
+ <gl-icon-stub
+ class="flex-shrink-0"
+ name="warning"
+ size="16"
+ />
+
+ <span
+ class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me"
+ >
+ Firing:
+ alert-label &gt; 42
+
+ </span>
+</gl-badge-stub>
+`;
+
+exports[`AlertWidget Alert not firing displays a warning icon and matches snapshot 1`] = `
+<gl-badge-stub
+ class="d-flex-center text-truncate"
+ pill=""
+ variant="secondary"
+>
+ <gl-icon-stub
+ class="flex-shrink-0"
+ name="warning"
+ size="16"
+ />
+
+ <span
+ class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me"
+ >
+ alert-label &gt; 42
+ </span>
+</gl-badge-stub>
+`;
diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js
new file mode 100644
index 00000000000..f0355dfa01b
--- /dev/null
+++ b/spec/frontend/monitoring/alert_widget_spec.js
@@ -0,0 +1,422 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui';
+import AlertWidget from '~/monitoring/components/alert_widget.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+
+const mockReadAlert = jest.fn();
+const mockCreateAlert = jest.fn();
+const mockUpdateAlert = jest.fn();
+const mockDeleteAlert = jest.fn();
+
+jest.mock('~/flash');
+jest.mock(
+ '~/monitoring/services/alerts_service',
+ () =>
+ function AlertsServiceMock() {
+ return {
+ readAlert: mockReadAlert,
+ createAlert: mockCreateAlert,
+ updateAlert: mockUpdateAlert,
+ deleteAlert: mockDeleteAlert,
+ };
+ },
+);
+
+describe('AlertWidget', () => {
+ let wrapper;
+
+ const nonFiringAlertResult = [
+ {
+ values: [[0, 1], [1, 42], [2, 41]],
+ },
+ ];
+ const firingAlertResult = [
+ {
+ values: [[0, 42], [1, 43], [2, 44]],
+ },
+ ];
+ const metricId = '5';
+ const alertPath = 'my/alert.json';
+
+ const relevantQueries = [
+ {
+ metricId,
+ label: 'alert-label',
+ alert_path: alertPath,
+ result: nonFiringAlertResult,
+ },
+ ];
+
+ const firingRelevantQueries = [
+ {
+ metricId,
+ label: 'alert-label',
+ alert_path: alertPath,
+ result: firingAlertResult,
+ },
+ ];
+
+ const defaultProps = {
+ alertsEndpoint: '',
+ relevantQueries,
+ alertsToManage: {},
+ modalId: 'alert-modal-1',
+ };
+
+ const propsWithAlert = {
+ relevantQueries,
+ };
+
+ const propsWithAlertData = {
+ relevantQueries,
+ alertsToManage: {
+ [alertPath]: { operator: '>', threshold: 42, alert_path: alertPath, metricId },
+ },
+ };
+
+ const createComponent = propsData => {
+ wrapper = shallowMount(AlertWidget, {
+ stubs: { GlTooltip, GlSprintf },
+ propsData: {
+ ...defaultProps,
+ ...propsData,
+ },
+ });
+ };
+ const hasLoadingIcon = () => wrapper.contains(GlLoadingIcon);
+ const findWidgetForm = () => wrapper.find({ ref: 'widgetForm' });
+ const findAlertErrorMessage = () => wrapper.find({ ref: 'alertErrorMessage' });
+ const findCurrentSettingsText = () =>
+ wrapper
+ .find({ ref: 'alertCurrentSetting' })
+ .text()
+ .replace(/\s\s+/g, ' ');
+ const findBadge = () => wrapper.find(GlBadge);
+ const findTooltip = () => wrapper.find(GlTooltip);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays a loading spinner and disables form when fetching alerts', () => {
+ let resolveReadAlert;
+ mockReadAlert.mockReturnValue(
+ new Promise(resolve => {
+ resolveReadAlert = resolve;
+ }),
+ );
+ createComponent(defaultProps);
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(hasLoadingIcon()).toBe(true);
+ expect(findWidgetForm().props('disabled')).toBe(true);
+
+ resolveReadAlert({ operator: '==', threshold: 42 });
+ })
+ .then(() => waitForPromises())
+ .then(() => {
+ expect(hasLoadingIcon()).toBe(false);
+ expect(findWidgetForm().props('disabled')).toBe(false);
+ });
+ });
+
+ it('does not render loading spinner if showLoadingState is false', () => {
+ let resolveReadAlert;
+ mockReadAlert.mockReturnValue(
+ new Promise(resolve => {
+ resolveReadAlert = resolve;
+ }),
+ );
+ createComponent({
+ ...defaultProps,
+ showLoadingState: false,
+ });
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+
+ resolveReadAlert({ operator: '==', threshold: 42 });
+ })
+ .then(() => waitForPromises())
+ .then(() => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ });
+ });
+
+ it('displays an error message when fetch fails', () => {
+ mockReadAlert.mockRejectedValue();
+ createComponent(propsWithAlert);
+ expect(hasLoadingIcon()).toBe(true);
+
+ return waitForPromises().then(() => {
+ expect(createFlash).toHaveBeenCalled();
+ expect(hasLoadingIcon()).toBe(false);
+ });
+ });
+
+ describe('Alert not firing', () => {
+ it('displays a warning icon and matches snapshot', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ createComponent(propsWithAlertData);
+
+ return waitForPromises().then(() => {
+ expect(findBadge().element).toMatchSnapshot();
+ });
+ });
+
+ it('displays an alert summary when there is a single alert', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ createComponent(propsWithAlertData);
+ return waitForPromises().then(() => {
+ expect(findCurrentSettingsText()).toEqual('alert-label > 42');
+ });
+ });
+
+ it('displays a combined alert summary when there are multiple alerts', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ const propsWithManyAlerts = {
+ relevantQueries: [
+ ...relevantQueries,
+ ...[
+ {
+ metricId: '6',
+ alert_path: 'my/alert2.json',
+ label: 'alert-label2',
+ result: [{ values: [] }],
+ },
+ ],
+ ],
+ alertsToManage: {
+ 'my/alert.json': {
+ operator: '>',
+ threshold: 42,
+ alert_path: alertPath,
+ metricId,
+ },
+ 'my/alert2.json': {
+ operator: '==',
+ threshold: 900,
+ alert_path: 'my/alert2.json',
+ metricId: '6',
+ },
+ },
+ };
+ createComponent(propsWithManyAlerts);
+ return waitForPromises().then(() => {
+ expect(findCurrentSettingsText()).toContain('2 alerts applied');
+ });
+ });
+ });
+
+ describe('Alert firing', () => {
+ it('displays a warning icon and matches snapshot', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ propsWithAlertData.relevantQueries = firingRelevantQueries;
+ createComponent(propsWithAlertData);
+
+ return waitForPromises().then(() => {
+ expect(findBadge().element).toMatchSnapshot();
+ });
+ });
+
+ it('displays an alert summary when there is a single alert', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ propsWithAlertData.relevantQueries = firingRelevantQueries;
+ createComponent(propsWithAlertData);
+ return waitForPromises().then(() => {
+ expect(findCurrentSettingsText()).toEqual('Firing: alert-label > 42');
+ });
+ });
+
+ it('displays a combined alert summary when there are multiple alerts', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ const propsWithManyAlerts = {
+ relevantQueries: [
+ ...firingRelevantQueries,
+ ...[
+ {
+ metricId: '6',
+ alert_path: 'my/alert2.json',
+ label: 'alert-label2',
+ result: [{ values: [] }],
+ },
+ ],
+ ],
+ alertsToManage: {
+ 'my/alert.json': {
+ operator: '>',
+ threshold: 42,
+ alert_path: alertPath,
+ metricId,
+ },
+ 'my/alert2.json': {
+ operator: '==',
+ threshold: 900,
+ alert_path: 'my/alert2.json',
+ metricId: '6',
+ },
+ },
+ };
+ createComponent(propsWithManyAlerts);
+
+ return waitForPromises().then(() => {
+ expect(findCurrentSettingsText()).toContain('2 alerts applied, 1 firing');
+ });
+ });
+
+ it('should display tooltip with thresholds summary', () => {
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ const propsWithManyAlerts = {
+ relevantQueries: [
+ ...firingRelevantQueries,
+ ...[
+ {
+ metricId: '6',
+ alert_path: 'my/alert2.json',
+ label: 'alert-label2',
+ result: [{ values: [] }],
+ },
+ ],
+ ],
+ alertsToManage: {
+ 'my/alert.json': {
+ operator: '>',
+ threshold: 42,
+ alert_path: alertPath,
+ metricId,
+ },
+ 'my/alert2.json': {
+ operator: '==',
+ threshold: 900,
+ alert_path: 'my/alert2.json',
+ metricId: '6',
+ },
+ },
+ };
+ createComponent(propsWithManyAlerts);
+
+ return waitForPromises().then(() => {
+ expect(
+ findTooltip()
+ .text()
+ .replace(/\s\s+/g, ' '),
+ ).toEqual('Firing: alert-label > 42');
+ });
+ });
+ });
+
+ it('creates an alert with an appropriate handler', () => {
+ const alertParams = {
+ operator: '<',
+ threshold: 4,
+ prometheus_metric_id: '5',
+ };
+ mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
+ const fakeAlertPath = 'foo/bar';
+ mockCreateAlert.mockResolvedValue({ alert_path: fakeAlertPath, ...alertParams });
+ createComponent({
+ alertsToManage: {
+ [fakeAlertPath]: {
+ alert_path: fakeAlertPath,
+ operator: '<',
+ threshold: 4,
+ prometheus_metric_id: '5',
+ metricId: '5',
+ },
+ },
+ });
+
+ findWidgetForm().vm.$emit('create', alertParams);
+
+ expect(mockCreateAlert).toHaveBeenCalledWith(alertParams);
+ });
+
+ it('updates an alert with an appropriate handler', () => {
+ const alertParams = { operator: '<', threshold: 4, alert_path: alertPath };
+ const newAlertParams = { operator: '==', threshold: 12 };
+ mockReadAlert.mockResolvedValue(alertParams);
+ mockUpdateAlert.mockResolvedValue({ ...alertParams, ...newAlertParams });
+ createComponent({
+ ...propsWithAlertData,
+ alertsToManage: {
+ [alertPath]: {
+ alert_path: alertPath,
+ operator: '==',
+ threshold: 12,
+ metricId: '5',
+ },
+ },
+ });
+
+ findWidgetForm().vm.$emit('update', {
+ alert: alertPath,
+ ...newAlertParams,
+ prometheus_metric_id: '5',
+ });
+
+ expect(mockUpdateAlert).toHaveBeenCalledWith(alertPath, newAlertParams);
+ });
+
+ it('deletes an alert with an appropriate handler', () => {
+ const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 };
+ mockReadAlert.mockResolvedValue(alertParams);
+ mockDeleteAlert.mockResolvedValue({});
+ createComponent({
+ ...propsWithAlert,
+ alertsToManage: {
+ [alertPath]: {
+ alert_path: alertPath,
+ operator: '>',
+ threshold: 42,
+ metricId: '5',
+ },
+ },
+ });
+
+ findWidgetForm().vm.$emit('delete', { alert: alertPath });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(mockDeleteAlert).toHaveBeenCalledWith(alertPath);
+ expect(findAlertErrorMessage().exists()).toBe(false);
+ });
+ });
+
+ describe('when delete fails', () => {
+ beforeEach(() => {
+ const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 };
+ mockReadAlert.mockResolvedValue(alertParams);
+ mockDeleteAlert.mockRejectedValue();
+
+ createComponent({
+ ...propsWithAlert,
+ alertsToManage: {
+ [alertPath]: {
+ alert_path: alertPath,
+ operator: '>',
+ threshold: 42,
+ metricId: '5',
+ },
+ },
+ });
+
+ findWidgetForm().vm.$emit('delete', { alert: alertPath });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('shows error message', () => {
+ expect(findAlertErrorMessage().text()).toEqual('Error deleting alert');
+ });
+
+ it('dismisses error message on cancel', () => {
+ findWidgetForm().vm.$emit('cancel');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findAlertErrorMessage().exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
index 1906ad7c6ed..9be5fa72110 100644
--- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
@@ -16,7 +16,6 @@ exports[`Dashboard template matches the default snapshot 1`] = `
data-qa-selector="dashboards_filter_dropdown"
defaultbranch="master"
id="monitor-dashboards-dropdown"
- selecteddashboard="[object Object]"
toggle-class="dropdown-menu-toggle"
/>
</div>
@@ -72,7 +71,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
<date-time-picker-stub
class="flex-grow-1 show-last-dropdown"
customenabled="true"
- data-qa-selector="show_last_dropdown"
+ data-qa-selector="range_picker_dropdown"
options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
value="[object Object]"
/>
@@ -101,6 +100,26 @@ exports[`Dashboard template matches the default snapshot 1`] = `
<div
class="d-sm-flex"
>
+ <div
+ class="mb-2 mr-2 d-flex"
+ >
+ <div
+ class="flex-grow-1"
+ title="Star dashboard"
+ >
+ <gl-deprecated-button-stub
+ class="w-100"
+ size="md"
+ variant="default"
+ >
+ <gl-icon-stub
+ name="star-o"
+ size="16"
+ />
+ </gl-deprecated-button-stub>
+ </div>
+ </div>
+
<!---->
<!---->
@@ -111,6 +130,8 @@ exports[`Dashboard template matches the default snapshot 1`] = `
</div>
</div>
+ <!---->
+
<empty-state-stub
clusterspath="/path/to/clusters"
documentationpath="/path/to/docs"
diff --git a/spec/frontend/monitoring/components/alert_widget_form_spec.js b/spec/frontend/monitoring/components/alert_widget_form_spec.js
new file mode 100644
index 00000000000..a8416216a94
--- /dev/null
+++ b/spec/frontend/monitoring/components/alert_widget_form_spec.js
@@ -0,0 +1,220 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink } from '@gitlab/ui';
+import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue';
+import ModalStub from '../stubs/modal_stub';
+
+describe('AlertWidgetForm', () => {
+ let wrapper;
+
+ const metricId = '8';
+ const alertPath = 'alert';
+ const relevantQueries = [{ metricId, alert_path: alertPath, label: 'alert-label' }];
+ const dataTrackingOptions = {
+ create: { action: 'click_button', label: 'create_alert' },
+ delete: { action: 'click_button', label: 'delete_alert' },
+ update: { action: 'click_button', label: 'update_alert' },
+ };
+
+ const defaultProps = {
+ disabled: false,
+ relevantQueries,
+ modalId: 'alert-modal-1',
+ };
+
+ const propsWithAlertData = {
+ ...defaultProps,
+ alertsToManage: {
+ alert: { alert_path: alertPath, operator: '<', threshold: 5, metricId },
+ },
+ configuredAlert: metricId,
+ };
+
+ function createComponent(props = {}) {
+ const propsData = {
+ ...defaultProps,
+ ...props,
+ };
+
+ wrapper = shallowMount(AlertWidgetForm, {
+ propsData,
+ stubs: {
+ GlModal: ModalStub,
+ },
+ });
+ }
+
+ const modal = () => wrapper.find(ModalStub);
+ const modalTitle = () => modal().attributes('title');
+ const submitButton = () => modal().find(GlLink);
+ const submitButtonTrackingOpts = () =>
+ JSON.parse(submitButton().attributes('data-tracking-options'));
+ const e = {
+ preventDefault: jest.fn(),
+ };
+
+ beforeEach(() => {
+ e.preventDefault.mockReset();
+ });
+
+ afterEach(() => {
+ if (wrapper) wrapper.destroy();
+ });
+
+ it('disables the form when disabled prop is set', () => {
+ createComponent({ disabled: true });
+
+ expect(modal().attributes('ok-disabled')).toBe('true');
+ });
+
+ it('disables the form if no query is selected', () => {
+ createComponent();
+
+ expect(modal().attributes('ok-disabled')).toBe('true');
+ });
+
+ it('shows correct title and button text', () => {
+ expect(modalTitle()).toBe('Add alert');
+ expect(submitButton().text()).toBe('Add');
+ });
+
+ it('sets tracking options for create alert', () => {
+ expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create);
+ });
+
+ it('emits a "create" event when form submitted without existing alert', () => {
+ createComponent();
+
+ wrapper.vm.selectQuery('9');
+ wrapper.setData({
+ threshold: 900,
+ });
+
+ wrapper.vm.handleSubmit(e);
+
+ expect(wrapper.emitted().create[0]).toEqual([
+ {
+ alert: undefined,
+ operator: '>',
+ threshold: 900,
+ prometheus_metric_id: '9',
+ },
+ ]);
+ expect(e.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('resets form when modal is dismissed (hidden)', () => {
+ createComponent();
+
+ wrapper.vm.selectQuery('9');
+ wrapper.vm.selectQuery('>');
+ wrapper.setData({
+ threshold: 800,
+ });
+
+ modal().vm.$emit('hidden');
+
+ expect(wrapper.vm.selectedAlert).toEqual({});
+ expect(wrapper.vm.operator).toBe(null);
+ expect(wrapper.vm.threshold).toBe(null);
+ expect(wrapper.vm.prometheusMetricId).toBe(null);
+ });
+
+ it('sets selectedAlert to the provided configuredAlert on modal show', () => {
+ createComponent(propsWithAlertData);
+
+ modal().vm.$emit('shown');
+
+ expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]);
+ });
+
+ it('sets selectedAlert to the first relevantQueries if there is only one option on modal show', () => {
+ createComponent({
+ ...propsWithAlertData,
+ configuredAlert: '',
+ });
+
+ modal().vm.$emit('shown');
+
+ expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]);
+ });
+
+ it('does not set selectedAlert to the first relevantQueries if there is more than one option on modal show', () => {
+ createComponent({
+ relevantQueries: [
+ {
+ metricId: '8',
+ alertPath: 'alert',
+ label: 'alert-label',
+ },
+ {
+ metricId: '9',
+ alertPath: 'alert',
+ label: 'alert-label',
+ },
+ ],
+ });
+
+ modal().vm.$emit('shown');
+
+ expect(wrapper.vm.selectedAlert).toEqual({});
+ });
+
+ describe('with existing alert', () => {
+ beforeEach(() => {
+ createComponent(propsWithAlertData);
+
+ wrapper.vm.selectQuery(metricId);
+ });
+
+ it('sets tracking options for delete alert', () => {
+ expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.delete);
+ });
+
+ it('updates button text', () => {
+ expect(modalTitle()).toBe('Edit alert');
+ expect(submitButton().text()).toBe('Delete');
+ });
+
+ it('emits "delete" event when form values unchanged', () => {
+ wrapper.vm.handleSubmit(e);
+
+ expect(wrapper.emitted().delete[0]).toEqual([
+ {
+ alert: 'alert',
+ operator: '<',
+ threshold: 5,
+ prometheus_metric_id: '8',
+ },
+ ]);
+ expect(e.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits "update" event when form changed', () => {
+ wrapper.setData({
+ threshold: 11,
+ });
+
+ wrapper.vm.handleSubmit(e);
+
+ expect(wrapper.emitted().update[0]).toEqual([
+ {
+ alert: 'alert',
+ operator: '<',
+ threshold: 11,
+ prometheus_metric_id: '8',
+ },
+ ]);
+ expect(e.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('sets tracking options for update alert', () => {
+ wrapper.setData({
+ threshold: 11,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js
index fb0682d0338..9cc5970da82 100644
--- a/spec/frontend/monitoring/components/charts/single_stat_spec.js
+++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import SingleStatChart from '~/monitoring/components/charts/single_stat.vue';
-import { graphDataPrometheusQuery } from '../../mock_data';
+import { singleStatMetricsResult } from '../../mock_data';
describe('Single Stat Chart component', () => {
let singleStatChart;
@@ -8,7 +8,7 @@ describe('Single Stat Chart component', () => {
beforeEach(() => {
singleStatChart = shallowMount(SingleStatChart, {
propsData: {
- graphData: graphDataPrometheusQuery,
+ graphData: singleStatMetricsResult,
},
});
});
@@ -26,7 +26,7 @@ describe('Single Stat Chart component', () => {
it('should change the value representation to a percentile one', () => {
singleStatChart.setProps({
graphData: {
- ...graphDataPrometheusQuery,
+ ...singleStatMetricsResult,
maxValue: 120,
},
});
@@ -37,7 +37,7 @@ describe('Single Stat Chart component', () => {
it('should display NaN for non numeric maxValue values', () => {
singleStatChart.setProps({
graphData: {
- ...graphDataPrometheusQuery,
+ ...singleStatMetricsResult,
maxValue: 'not a number',
},
});
@@ -48,13 +48,13 @@ describe('Single Stat Chart component', () => {
it('should display NaN for missing query values', () => {
singleStatChart.setProps({
graphData: {
- ...graphDataPrometheusQuery,
+ ...singleStatMetricsResult,
metrics: [
{
- ...graphDataPrometheusQuery.metrics[0],
+ ...singleStatMetricsResult.metrics[0],
result: [
{
- ...graphDataPrometheusQuery.metrics[0].result[0],
+ ...singleStatMetricsResult.metrics[0].result[0],
value: [''],
},
],
diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js
index 5ac716b0c63..7d5a08bc4a1 100644
--- a/spec/frontend/monitoring/components/charts/time_series_spec.js
+++ b/spec/frontend/monitoring/components/charts/time_series_spec.js
@@ -1,4 +1,4 @@
-import { mount } from '@vue/test-utils';
+import { mount, shallowMount } from '@vue/test-utils';
import { setTestTimeout } from 'helpers/timeout';
import { GlLink } from '@gitlab/ui';
import { TEST_HOST } from 'jest/helpers/test_constants';
@@ -11,6 +11,7 @@ import {
import { cloneDeep } from 'lodash';
import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper';
import { createStore } from '~/monitoring/stores';
+import { panelTypes, chartHeight } from '~/monitoring/constants';
import TimeSeries from '~/monitoring/components/charts/time_series.vue';
import * as types from '~/monitoring/stores/mutation_types';
import { deploymentData, mockProjectDir, annotationsData } from '../../mock_data';
@@ -39,10 +40,10 @@ describe('Time series component', () => {
let mockGraphData;
let store;
- const makeTimeSeriesChart = (graphData, type) =>
- mount(TimeSeries, {
+ const createWrapper = (graphData = mockGraphData, mountingMethod = shallowMount) =>
+ mountingMethod(TimeSeries, {
propsData: {
- graphData: { ...graphData, type },
+ graphData,
deploymentData: store.state.monitoringDashboard.deploymentData,
annotations: store.state.monitoringDashboard.annotations,
projectPath: `${TEST_HOST}${mockProjectDir}`,
@@ -79,9 +80,9 @@ describe('Time series component', () => {
const findChart = () => timeSeriesChart.find({ ref: 'chart' });
- beforeEach(done => {
- timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart');
- timeSeriesChart.vm.$nextTick(done);
+ beforeEach(() => {
+ timeSeriesChart = createWrapper(mockGraphData, mount);
+ return timeSeriesChart.vm.$nextTick();
});
it('allows user to override max value label text using prop', () => {
@@ -100,6 +101,21 @@ describe('Time series component', () => {
});
});
+ it('chart sets a default height', () => {
+ const wrapper = createWrapper();
+ expect(wrapper.props('height')).toBe(chartHeight);
+ });
+
+ it('chart has a configurable height', () => {
+ const mockHeight = 599;
+ const wrapper = createWrapper();
+
+ wrapper.setProps({ height: mockHeight });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.props('height')).toBe(mockHeight);
+ });
+ });
+
describe('events', () => {
describe('datazoom', () => {
let eChartMock;
@@ -125,7 +141,7 @@ describe('Time series component', () => {
}),
};
- timeSeriesChart = makeTimeSeriesChart(mockGraphData);
+ timeSeriesChart = createWrapper(mockGraphData, mount);
timeSeriesChart.vm.$nextTick(() => {
findChart().vm.$emit('created', eChartMock);
done();
@@ -535,11 +551,11 @@ describe('Time series component', () => {
describe('wrapped components', () => {
const glChartComponents = [
{
- chartType: 'area-chart',
+ chartType: panelTypes.AREA_CHART,
component: GlAreaChart,
},
{
- chartType: 'line-chart',
+ chartType: panelTypes.LINE_CHART,
component: GlLineChart,
},
];
@@ -550,7 +566,10 @@ describe('Time series component', () => {
const findChartComponent = () => timeSeriesAreaChart.find(dynamicComponent.component);
beforeEach(done => {
- timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType);
+ timeSeriesAreaChart = createWrapper(
+ { ...mockGraphData, type: dynamicComponent.chartType },
+ mount,
+ );
timeSeriesAreaChart.vm.$nextTick(done);
});
@@ -632,7 +651,7 @@ describe('Time series component', () => {
Object.assign(metric, { result: metricResultStatus.result }),
);
- timeSeriesChart = makeTimeSeriesChart(graphData, 'area-chart');
+ timeSeriesChart = createWrapper({ ...graphData, type: 'area-chart' }, mount);
timeSeriesChart.vm.$nextTick(done);
});
diff --git a/spec/frontend/monitoring/components/panel_type_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
index 819b5235284..f8c9bd56721 100644
--- a/spec/frontend/monitoring/components/panel_type_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -1,13 +1,13 @@
+import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { setTestTimeout } from 'helpers/timeout';
import invalidUrl from '~/lib/utils/invalid_url';
import axios from '~/lib/utils/axios_utils';
+import { GlDropdownItem } from '@gitlab/ui';
+import AlertWidget from '~/monitoring/components/alert_widget.vue';
-import PanelType from '~/monitoring/components/panel_type.vue';
-import EmptyChart from '~/monitoring/components/charts/empty_chart.vue';
-import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue';
-import AnomalyChart from '~/monitoring/components/charts/anomaly.vue';
+import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import {
anomalyMockGraphData,
mockLogsHref,
@@ -15,8 +15,23 @@ import {
mockNamespace,
mockNamespacedData,
mockTimeRange,
+ singleStatMetricsResult,
+ graphDataPrometheusQueryRangeMultiTrack,
+ barMockData,
+ propsData,
} from '../mock_data';
+import { panelTypes } from '~/monitoring/constants';
+
+import MonitorEmptyChart from '~/monitoring/components/charts/empty_chart.vue';
+import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue';
+import MonitorAnomalyChart from '~/monitoring/components/charts/anomaly.vue';
+import MonitorSingleStatChart from '~/monitoring/components/charts/single_stat.vue';
+import MonitorHeatmapChart from '~/monitoring/components/charts/heatmap.vue';
+import MonitorColumnChart from '~/monitoring/components/charts/column.vue';
+import MonitorBarChart from '~/monitoring/components/charts/bar.vue';
+import MonitorStackedColumnChart from '~/monitoring/components/charts/stacked_column.vue';
+
import { graphData, graphDataEmpty } from '../fixture_data';
import { createStore, monitoringDashboard } from '~/monitoring/stores';
import { createStore as createEmbedGroupStore } from '~/monitoring/stores/embed_group';
@@ -29,7 +44,7 @@ const mocks = {
},
};
-describe('Panel Type component', () => {
+describe('Dashboard Panel', () => {
let axiosMock;
let store;
let state;
@@ -38,18 +53,20 @@ describe('Panel Type component', () => {
const exampleText = 'example_text';
const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' });
- const findTimeChart = () => wrapper.find({ ref: 'timeChart' });
+ const findTimeChart = () => wrapper.find({ ref: 'timeSeriesChart' });
const findTitle = () => wrapper.find({ ref: 'graphTitle' });
const findContextualMenu = () => wrapper.find({ ref: 'contextualMenu' });
- const createWrapper = props => {
- wrapper = shallowMount(PanelType, {
+ const createWrapper = (props, options) => {
+ wrapper = shallowMount(DashboardPanel, {
propsData: {
graphData,
+ settingsPath: propsData.settingsPath,
...props,
},
store,
mocks,
+ ...options,
});
};
@@ -66,6 +83,22 @@ describe('Panel Type component', () => {
axiosMock.reset();
});
+ describe('Renders slots', () => {
+ it('renders "topLeft" slot', () => {
+ createWrapper(
+ {},
+ {
+ slots: {
+ topLeft: `<div class="top-left-content">OK</div>`,
+ },
+ },
+ );
+
+ expect(wrapper.find('.top-left-content').exists()).toBe(true);
+ expect(wrapper.find('.top-left-content').text()).toBe('OK');
+ });
+ });
+
describe('When no graphData is available', () => {
beforeEach(() => {
createWrapper({
@@ -77,27 +110,54 @@ describe('Panel Type component', () => {
wrapper.destroy();
});
- describe('Empty Chart component', () => {
- it('renders the chart title', () => {
- expect(findTitle().text()).toBe(graphDataEmpty.title);
- });
+ it('renders the chart title', () => {
+ expect(findTitle().text()).toBe(graphDataEmpty.title);
+ });
- it('renders the no download csv link', () => {
- expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
- });
+ it('renders no download csv link', () => {
+ expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
+ });
- it('does not contain graph widgets', () => {
- expect(findContextualMenu().exists()).toBe(false);
- });
+ it('does not contain graph widgets', () => {
+ expect(findContextualMenu().exists()).toBe(false);
+ });
- it('is a Vue instance', () => {
- expect(wrapper.find(EmptyChart).exists()).toBe(true);
- expect(wrapper.find(EmptyChart).isVueInstance()).toBe(true);
+ it('The Empty Chart component is rendered and is a Vue instance', () => {
+ expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
+ expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
+ });
+ });
+
+ describe('When graphData is null', () => {
+ beforeEach(() => {
+ createWrapper({
+ graphData: null,
});
});
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders no chart title', () => {
+ expect(findTitle().text()).toBe('');
+ });
+
+ it('renders no download csv link', () => {
+ expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
+ });
+
+ it('does not contain graph widgets', () => {
+ expect(findContextualMenu().exists()).toBe(false);
+ });
+
+ it('The Empty Chart component is rendered and is a Vue instance', () => {
+ expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
+ expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
+ });
});
- describe('when graph data is available', () => {
+ describe('When graphData is available', () => {
beforeEach(() => {
createWrapper();
});
@@ -134,34 +194,54 @@ describe('Panel Type component', () => {
});
});
- describe('Time Series Chart panel type', () => {
- it('is rendered', () => {
- expect(wrapper.find(TimeSeriesChart).isVueInstance()).toBe(true);
- expect(wrapper.find(TimeSeriesChart).exists()).toBe(true);
- });
+ it('includes a default group id', () => {
+ expect(wrapper.vm.groupId).toBe('dashboard-panel');
+ });
+
+ describe('Supports different panel types', () => {
+ const dataWithType = type => {
+ return {
+ ...graphData,
+ type,
+ };
+ };
- it('includes a default group id', () => {
- expect(wrapper.vm.groupId).toBe('panel-type-chart');
+ it('empty chart is rendered for empty results', () => {
+ createWrapper({ graphData: graphDataEmpty });
+ expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
+ expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
});
- });
- describe('Anomaly Chart panel type', () => {
- beforeEach(() => {
- wrapper.setProps({
- graphData: anomalyMockGraphData,
- });
- return wrapper.vm.$nextTick();
+ it('area chart is rendered by default', () => {
+ createWrapper();
+ expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true);
+ expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true);
});
- it('is rendered with an anomaly chart', () => {
- expect(wrapper.find(AnomalyChart).isVueInstance()).toBe(true);
- expect(wrapper.find(AnomalyChart).exists()).toBe(true);
+ it.each`
+ data | component
+ ${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart}
+ ${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart}
+ ${anomalyMockGraphData} | ${MonitorAnomalyChart}
+ ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart}
+ ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart}
+ ${singleStatMetricsResult} | ${MonitorSingleStatChart}
+ ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart}
+ ${barMockData} | ${MonitorBarChart}
+ `('wrapps a $data.type component binding attributes', ({ data, component }) => {
+ const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' };
+ createWrapper({ graphData: data }, { attrs });
+
+ expect(wrapper.find(component).exists()).toBe(true);
+ expect(wrapper.find(component).isVueInstance()).toBe(true);
+ expect(wrapper.find(component).attributes()).toMatchObject(attrs);
});
});
});
describe('Edit custom metric dropdown item', () => {
const findEditCustomMetricLink = () => wrapper.find({ ref: 'editMetricLink' });
+ const mockEditPath = '/root/kubernetes-gke-project/prometheus/metrics/23/edit';
beforeEach(() => {
createWrapper();
@@ -180,7 +260,7 @@ describe('Panel Type component', () => {
metrics: [
{
...graphData.metrics[0],
- edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit',
+ edit_path: mockEditPath,
},
],
},
@@ -189,10 +269,11 @@ describe('Panel Type component', () => {
return wrapper.vm.$nextTick(() => {
expect(findEditCustomMetricLink().exists()).toBe(true);
expect(findEditCustomMetricLink().text()).toBe('Edit metric');
+ expect(findEditCustomMetricLink().attributes('href')).toBe(mockEditPath);
});
});
- it('shows an "Edit metrics" link for a panel with multiple metrics', () => {
+ it('shows an "Edit metrics" link pointing to settingsPath for a panel with multiple metrics', () => {
wrapper.setProps({
graphData: {
...graphData,
@@ -211,6 +292,7 @@ describe('Panel Type component', () => {
return wrapper.vm.$nextTick(() => {
expect(findEditCustomMetricLink().text()).toBe('Edit metrics');
+ expect(findEditCustomMetricLink().attributes('href')).toBe(propsData.settingsPath);
});
});
});
@@ -294,10 +376,6 @@ describe('Panel Type component', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('sets clipboard text on the dropdown', () => {
expect(findCopyLink().exists()).toBe(true);
expect(findCopyLink().element.dataset.clipboardText).toBe(clipboardText);
@@ -314,11 +392,24 @@ describe('Panel Type component', () => {
});
});
+ describe('when cliboard data is not available', () => {
+ it('there is no "copy to clipboard" link for a null value', () => {
+ createWrapper({ clipboardText: null });
+ expect(findCopyLink().exists()).toBe(false);
+ });
+
+ it('there is no "copy to clipboard" link for an empty value', () => {
+ createWrapper({ clipboardText: '' });
+ expect(findCopyLink().exists()).toBe(false);
+ });
+ });
+
describe('when downloading metrics data as CSV', () => {
beforeEach(() => {
- wrapper = shallowMount(PanelType, {
+ wrapper = shallowMount(DashboardPanel, {
propsData: {
clipboardText: exampleText,
+ settingsPath: propsData.settingsPath,
graphData: {
y_label: 'metric',
...graphData,
@@ -365,9 +456,10 @@ describe('Panel Type component', () => {
store.registerModule(mockNamespace, monitoringDashboard);
store.state.embedGroup.modules.push(mockNamespace);
- wrapper = shallowMount(PanelType, {
+ wrapper = shallowMount(DashboardPanel, {
propsData: {
graphData,
+ settingsPath: propsData.settingsPath,
namespace: mockNamespace,
},
store,
@@ -401,8 +493,84 @@ describe('Panel Type component', () => {
});
it('it renders a time series chart with no errors', () => {
- expect(wrapper.find(TimeSeriesChart).isVueInstance()).toBe(true);
- expect(wrapper.find(TimeSeriesChart).exists()).toBe(true);
+ expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true);
+ expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true);
+ });
+ });
+
+ describe('Expand to full screen', () => {
+ const findExpandBtn = () => wrapper.find({ ref: 'expandBtn' });
+
+ describe('when there is no @expand listener', () => {
+ it('does not show `View full screen` option', () => {
+ createWrapper();
+ expect(findExpandBtn().exists()).toBe(false);
+ });
+ });
+
+ describe('when there is an @expand listener', () => {
+ beforeEach(() => {
+ createWrapper({}, { listeners: { expand: () => {} } });
+ });
+
+ it('shows the `expand` option', () => {
+ expect(findExpandBtn().exists()).toBe(true);
+ });
+
+ it('emits the `expand` event', () => {
+ const preventDefault = jest.fn();
+ findExpandBtn().vm.$emit('click', { preventDefault });
+ expect(wrapper.emitted('expand')).toHaveLength(1);
+ expect(preventDefault).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('panel alerts', () => {
+ const setMetricsSavedToDb = val =>
+ monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val);
+ const findAlertsWidget = () => wrapper.find(AlertWidget);
+ const findMenuItemAlert = () =>
+ wrapper.findAll(GlDropdownItem).filter(i => i.text() === 'Alerts');
+
+ beforeEach(() => {
+ jest.spyOn(monitoringDashboard.getters, 'metricsSavedToDb').mockReturnValue([]);
+
+ store = new Vuex.Store({
+ modules: {
+ monitoringDashboard,
+ },
+ });
+
+ createWrapper();
+ });
+
+ describe.each`
+ desc | metricsSavedToDb | props | isShown
+ ${'with permission and no metrics in db'} | ${[]} | ${{}} | ${false}
+ ${'with permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{}} | ${true}
+ ${'without permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{ prometheusAlertsAvailable: false }} | ${false}
+ ${'with permission and unrelated metrics in db'} | ${['another_metric_id']} | ${{}} | ${false}
+ `('$desc', ({ metricsSavedToDb, isShown, props }) => {
+ const showsDesc = isShown ? 'shows' : 'does not show';
+
+ beforeEach(() => {
+ setMetricsSavedToDb(metricsSavedToDb);
+ createWrapper({
+ alertsEndpoint: '/endpoint',
+ prometheusAlertsAvailable: true,
+ ...props,
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it(`${showsDesc} alert widget`, () => {
+ expect(findAlertsWidget().exists()).toBe(isShown);
+ });
+
+ it(`${showsDesc} alert configuration`, () => {
+ expect(findMenuItemAlert().exists()).toBe(isShown);
+ });
});
});
});
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 8b6ee9b3bf6..b2c9fe93cde 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -1,6 +1,8 @@
import { shallowMount, mount } from '@vue/test-utils';
import Tracking from '~/tracking';
-import { GlModal, GlDropdownItem, GlDeprecatedButton } from '@gitlab/ui';
+import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys';
+import { GlModal, GlDropdownItem, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { objectToQuery } from '~/lib/utils/url_utility';
import VueDraggable from 'vuedraggable';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
@@ -11,13 +13,23 @@ import Dashboard from '~/monitoring/components/dashboard.vue';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue';
+import EmptyState from '~/monitoring/components/empty_state.vue';
import GroupEmptyState from '~/monitoring/components/group_empty_state.vue';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import { createStore } from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
-import { setupStoreWithDashboard, setMetricResult, setupStoreWithData } from '../store_utils';
+import {
+ setupAllDashboards,
+ setupStoreWithDashboard,
+ setMetricResult,
+ setupStoreWithData,
+ setupStoreWithVariable,
+} from '../store_utils';
import { environmentData, dashboardGitResponse, propsData } from '../mock_data';
import { metricsDashboardViewModel, metricsDashboardPanelCount } from '../fixture_data';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
describe('Dashboard', () => {
let store;
@@ -27,15 +39,12 @@ describe('Dashboard', () => {
const findEnvironmentsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' });
const findAllEnvironmentsDropdownItems = () => findEnvironmentsDropdown().findAll(GlDropdownItem);
const setSearchTerm = searchTerm => {
- wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm);
+ store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm);
};
const createShallowWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(Dashboard, {
propsData: { ...propsData, ...props },
- methods: {
- fetchData: jest.fn(),
- },
store,
...options,
});
@@ -44,10 +53,8 @@ describe('Dashboard', () => {
const createMountedWrapper = (props = {}, options = {}) => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
- methods: {
- fetchData: jest.fn(),
- },
store,
+ stubs: ['graph-group', 'dashboard-panel'],
...options,
});
};
@@ -55,19 +62,18 @@ describe('Dashboard', () => {
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
});
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
mock.restore();
+ if (store.dispatch.mockReset) {
+ store.dispatch.mockReset();
+ }
});
describe('no metrics are available yet', () => {
beforeEach(() => {
- jest.spyOn(store, 'dispatch');
createShallowWrapper();
});
@@ -103,9 +109,7 @@ describe('Dashboard', () => {
describe('request information to the server', () => {
it('calls to set time range and fetch data', () => {
- jest.spyOn(store, 'dispatch');
-
- createShallowWrapper({ hasMetrics: true }, { methods: {} });
+ createShallowWrapper({ hasMetrics: true });
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith(
@@ -118,20 +122,20 @@ describe('Dashboard', () => {
});
it('shows up a loading state', () => {
- createShallowWrapper({ hasMetrics: true }, { methods: {} });
+ store.state.monitoringDashboard.emptyState = 'loading';
+
+ createShallowWrapper({ hasMetrics: true });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.emptyState).toEqual('loading');
+ expect(wrapper.find(EmptyState).exists()).toBe(true);
+ expect(wrapper.find(EmptyState).props('selectedState')).toBe('loading');
});
});
it('hides the group panels when showPanels is false', () => {
- createMountedWrapper(
- { hasMetrics: true, showPanels: false },
- { stubs: ['graph-group', 'panel-type'] },
- );
+ createMountedWrapper({ hasMetrics: true, showPanels: false });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.showEmptyState).toEqual(false);
@@ -142,9 +146,9 @@ describe('Dashboard', () => {
it('fetches the metrics data with proper time window', () => {
jest.spyOn(store, 'dispatch');
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- wrapper.vm.$store.commit(
+ store.commit(
`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`,
environmentData,
);
@@ -155,11 +159,176 @@ describe('Dashboard', () => {
});
});
+ describe('when the URL contains a reference to a panel', () => {
+ let location;
+
+ const setSearch = search => {
+ window.location = { ...location, search };
+ };
+
+ beforeEach(() => {
+ location = window.location;
+ delete window.location;
+ });
+
+ afterEach(() => {
+ window.location = location;
+ });
+
+ it('when the URL points to a panel it expands', () => {
+ const panelGroup = metricsDashboardViewModel.panelGroups[0];
+ const panel = panelGroup.panels[0];
+
+ setSearch(
+ objectToQuery({
+ group: panelGroup.group,
+ title: panel.title,
+ y_label: panel.y_label,
+ }),
+ );
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', {
+ group: panelGroup.group,
+ panel: expect.objectContaining({
+ title: panel.title,
+ y_label: panel.y_label,
+ }),
+ });
+ });
+ });
+
+ it('when the URL does not link to any panel, no panel is expanded', () => {
+ setSearch('');
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(store.dispatch).not.toHaveBeenCalledWith(
+ 'monitoringDashboard/setExpandedPanel',
+ expect.anything(),
+ );
+ });
+ });
+
+ it('when the URL points to an incorrect panel it shows an error', () => {
+ const panelGroup = metricsDashboardViewModel.panelGroups[0];
+ const panel = panelGroup.panels[0];
+
+ setSearch(
+ objectToQuery({
+ group: panelGroup.group,
+ title: 'incorrect',
+ y_label: panel.y_label,
+ }),
+ );
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(createFlash).toHaveBeenCalled();
+ expect(store.dispatch).not.toHaveBeenCalledWith(
+ 'monitoringDashboard/setExpandedPanel',
+ expect.anything(),
+ );
+ });
+ });
+ });
+
+ describe('when the panel is expanded', () => {
+ let group;
+ let panel;
+
+ const expandPanel = (mockGroup, mockPanel) => {
+ store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, {
+ group: mockGroup,
+ panel: mockPanel,
+ });
+ };
+
+ beforeEach(() => {
+ setupStoreWithData(store);
+
+ const { panelGroups } = store.state.monitoringDashboard.dashboard;
+ group = panelGroups[0].group;
+ [panel] = panelGroups[0].panels;
+
+ jest.spyOn(window.history, 'pushState').mockImplementation();
+ });
+
+ afterEach(() => {
+ window.history.pushState.mockRestore();
+ });
+
+ it('URL is updated with panel parameters', () => {
+ createMountedWrapper({ hasMetrics: true });
+ expandPanel(group, panel);
+
+ const expectedSearch = objectToQuery({
+ group,
+ title: panel.title,
+ y_label: panel.y_label,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(), // state
+ expect.any(String), // document title
+ expect.stringContaining(`${expectedSearch}`),
+ );
+ });
+ });
+
+ it('URL is updated with panel parameters and custom dashboard', () => {
+ const dashboard = 'dashboard.yml';
+
+ createMountedWrapper({ hasMetrics: true, currentDashboard: dashboard });
+ expandPanel(group, panel);
+
+ const expectedSearch = objectToQuery({
+ dashboard,
+ group,
+ title: panel.title,
+ y_label: panel.y_label,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(), // state
+ expect.any(String), // document title
+ expect.stringContaining(`${expectedSearch}`),
+ );
+ });
+ });
+
+ it('URL is updated with no parameters', () => {
+ expandPanel(group, panel);
+ createMountedWrapper({ hasMetrics: true });
+ expandPanel(null, null);
+
+ return wrapper.vm.$nextTick(() => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(), // state
+ expect.any(String), // document title
+ expect.not.stringMatching(/group|title|y_label/), // no panel params
+ );
+ });
+ });
+ });
+
describe('when all requests have been commited by the store', () => {
beforeEach(() => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick();
});
@@ -185,10 +354,89 @@ describe('Dashboard', () => {
});
});
+ describe('star dashboards', () => {
+ const findToggleStar = () => wrapper.find({ ref: 'toggleStarBtn' });
+ const findToggleStarIcon = () => findToggleStar().find(GlIcon);
+
+ beforeEach(() => {
+ createShallowWrapper();
+ setupAllDashboards(store);
+ });
+
+ it('toggle star button is shown', () => {
+ expect(findToggleStar().exists()).toBe(true);
+ expect(findToggleStar().props('disabled')).toBe(false);
+ });
+
+ it('toggle star button is disabled when starring is taking place', () => {
+ store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`);
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findToggleStar().exists()).toBe(true);
+ expect(findToggleStar().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('when the dashboard list is loaded', () => {
+ // Tooltip element should wrap directly
+ const getToggleTooltip = () => findToggleStar().element.parentElement.getAttribute('title');
+
+ beforeEach(() => {
+ setupAllDashboards(store);
+ jest.spyOn(store, 'dispatch');
+ });
+
+ it('dispatches a toggle star action', () => {
+ findToggleStar().vm.$emit('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'monitoringDashboard/toggleStarredValue',
+ undefined,
+ );
+ });
+ });
+
+ describe('when dashboard is not starred', () => {
+ beforeEach(() => {
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ currentDashboard: dashboardGitResponse[0].path,
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('toggle star button shows "Star dashboard"', () => {
+ expect(getToggleTooltip()).toBe('Star dashboard');
+ });
+
+ it('toggle star button shows an unstarred state', () => {
+ expect(findToggleStarIcon().attributes('name')).toBe('star-o');
+ });
+ });
+
+ describe('when dashboard is starred', () => {
+ beforeEach(() => {
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ currentDashboard: dashboardGitResponse[1].path,
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('toggle star button shows "Star dashboard"', () => {
+ expect(getToggleTooltip()).toBe('Unstar dashboard');
+ });
+
+ it('toggle star button shows a starred state', () => {
+ expect(findToggleStarIcon().attributes('name')).toBe('star');
+ });
+ });
+ });
+ });
+
it('hides the environments dropdown list when there is no environments', () => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- setupStoreWithDashboard(wrapper.vm.$store);
+ setupStoreWithDashboard(store);
return wrapper.vm.$nextTick().then(() => {
expect(findAllEnvironmentsDropdownItems()).toHaveLength(0);
@@ -196,9 +444,9 @@ describe('Dashboard', () => {
});
it('renders the datetimepicker dropdown', () => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DateTimePicker).exists()).toBe(true);
@@ -206,9 +454,9 @@ describe('Dashboard', () => {
});
it('renders the refresh dashboard button', () => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
+ createMountedWrapper({ hasMetrics: true });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick().then(() => {
const refreshBtn = wrapper.findAll({ ref: 'refreshDashboardBtn' });
@@ -218,14 +466,135 @@ describe('Dashboard', () => {
});
});
- describe('when one of the metrics is missing', () => {
+ describe('variables section', () => {
beforeEach(() => {
createShallowWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+ setupStoreWithVariable(store);
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('shows the variables section', () => {
+ expect(wrapper.vm.shouldShowVariablesSection).toBe(true);
+ });
+ });
+
+ describe('single panel expands to "full screen" mode', () => {
+ const findExpandedPanel = () => wrapper.find({ ref: 'expandedPanel' });
- const { $store } = wrapper.vm;
+ describe('when the panel is not expanded', () => {
+ beforeEach(() => {
+ createShallowWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
+ return wrapper.vm.$nextTick();
+ });
+
+ it('expanded panel is not visible', () => {
+ expect(findExpandedPanel().isVisible()).toBe(false);
+ });
+
+ it('can set a panel as expanded', () => {
+ const panel = wrapper.findAll(DashboardPanel).at(1);
+
+ jest.spyOn(store, 'dispatch');
+
+ panel.vm.$emit('expand');
+
+ const groupData = metricsDashboardViewModel.panelGroups[0];
+
+ expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', {
+ group: groupData.group,
+ panel: expect.objectContaining({
+ id: groupData.panels[0].id,
+ }),
+ });
+ });
+ });
+
+ describe('when the panel is expanded', () => {
+ let group;
+ let panel;
+
+ const mockKeyup = key => window.dispatchEvent(new KeyboardEvent('keyup', { key }));
+
+ const MockPanel = {
+ template: `<div><slot name="topLeft"/></div>`,
+ };
+
+ beforeEach(() => {
+ createShallowWrapper({ hasMetrics: true }, { stubs: { DashboardPanel: MockPanel } });
+ setupStoreWithData(store);
+
+ const { panelGroups } = store.state.monitoringDashboard.dashboard;
+
+ group = panelGroups[0].group;
+ [panel] = panelGroups[0].panels;
+
+ store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, {
+ group,
+ panel,
+ });
+
+ jest.spyOn(store, 'dispatch');
+
+ return wrapper.vm.$nextTick();
+ });
- setupStoreWithDashboard($store);
- setMetricResult({ $store, result: [], panel: 2 });
+ it('displays a single panel and others are hidden', () => {
+ const panels = wrapper.findAll(MockPanel);
+ const visiblePanels = panels.filter(w => w.isVisible());
+
+ expect(findExpandedPanel().isVisible()).toBe(true);
+ // v-show for hiding panels is more performant than v-if
+ // check for panels to be hidden.
+ expect(panels.length).toBe(metricsDashboardPanelCount + 1);
+ expect(visiblePanels.length).toBe(1);
+ });
+
+ it('sets a link to the expanded panel', () => {
+ const searchQuery =
+ '?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&group=System%20metrics%20(Kubernetes)&title=Memory%20Usage%20(Total)&y_label=Total%20Memory%20Used%20(GB)';
+
+ expect(findExpandedPanel().attributes('clipboard-text')).toEqual(
+ expect.stringContaining(searchQuery),
+ );
+ });
+
+ it('restores full dashboard by clicking `back`', () => {
+ wrapper.find({ ref: 'goBackBtn' }).vm.$emit('click');
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'monitoringDashboard/clearExpandedPanel',
+ undefined,
+ );
+ });
+
+ it('restores dashboard from full screen by typing the Escape key', () => {
+ mockKeyup(ESC_KEY);
+ expect(store.dispatch).toHaveBeenCalledWith(
+ `monitoringDashboard/clearExpandedPanel`,
+ undefined,
+ );
+ });
+
+ it('restores dashboard from full screen by typing the Escape key on IE11', () => {
+ mockKeyup(ESC_KEY_IE11);
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ `monitoringDashboard/clearExpandedPanel`,
+ undefined,
+ );
+ });
+ });
+ });
+
+ describe('when one of the metrics is missing', () => {
+ beforeEach(() => {
+ createShallowWrapper({ hasMetrics: true });
+
+ setupStoreWithDashboard(store);
+ setMetricResult({ store, result: [], panel: 2 });
return wrapper.vm.$nextTick();
});
@@ -249,19 +618,17 @@ describe('Dashboard', () => {
describe('searchable environments dropdown', () => {
beforeEach(() => {
- createMountedWrapper(
- { hasMetrics: true },
- {
- attachToDocument: true,
- stubs: ['graph-group', 'panel-type'],
- },
- );
+ createMountedWrapper({ hasMetrics: true }, { attachToDocument: true });
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
return wrapper.vm.$nextTick();
});
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
it('renders a search input', () => {
expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownSearch' }).exists()).toBe(true);
});
@@ -304,7 +671,7 @@ describe('Dashboard', () => {
});
it('shows loading element when environments fetch is still loading', () => {
- wrapper.vm.$store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`);
+ store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`);
return wrapper.vm
.$nextTick()
@@ -312,7 +679,7 @@ describe('Dashboard', () => {
expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownLoading' }).exists()).toBe(true);
})
.then(() => {
- wrapper.vm.$store.commit(
+ store.commit(
`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`,
environmentData,
);
@@ -330,9 +697,11 @@ describe('Dashboard', () => {
const findRearrangeButton = () => wrapper.find('.js-rearrange-button');
beforeEach(() => {
- createShallowWrapper({ hasMetrics: true });
+ // call original dispatch
+ store.dispatch.mockRestore();
- setupStoreWithData(wrapper.vm.$store);
+ createShallowWrapper({ hasMetrics: true });
+ setupStoreWithData(store);
return wrapper.vm.$nextTick();
});
@@ -420,7 +789,7 @@ describe('Dashboard', () => {
createShallowWrapper({ hasMetrics: true, showHeader: false });
// all_dashboards is not defined in health dashboards
- wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined);
+ store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined);
return wrapper.vm.$nextTick();
});
@@ -440,10 +809,7 @@ describe('Dashboard', () => {
beforeEach(() => {
createShallowWrapper({ hasMetrics: true });
- wrapper.vm.$store.commit(
- `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`,
- dashboardGitResponse,
- );
+ setupAllDashboards(store);
return wrapper.vm.$nextTick();
});
@@ -452,10 +818,11 @@ describe('Dashboard', () => {
});
it('is present for a custom dashboard, and links to its edit_path', () => {
- const dashboard = dashboardGitResponse[1]; // non-default dashboard
- const currentDashboard = dashboard.path;
+ const dashboard = dashboardGitResponse[1];
+ store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
+ currentDashboard: dashboard.path,
+ });
- wrapper.setProps({ currentDashboard });
return wrapper.vm.$nextTick().then(() => {
expect(findEditLink().exists()).toBe(true);
expect(findEditLink().attributes('href')).toBe(dashboard.project_blob_path);
@@ -465,13 +832,8 @@ describe('Dashboard', () => {
describe('Dashboard dropdown', () => {
beforeEach(() => {
- createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
-
- wrapper.vm.$store.commit(
- `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`,
- dashboardGitResponse,
- );
-
+ createMountedWrapper({ hasMetrics: true });
+ setupAllDashboards(store);
return wrapper.vm.$nextTick();
});
@@ -484,15 +846,12 @@ describe('Dashboard', () => {
describe('external dashboard link', () => {
beforeEach(() => {
- createMountedWrapper(
- {
- hasMetrics: true,
- showPanels: false,
- showTimeWindowDropdown: false,
- externalDashboardUrl: '/mockUrl',
- },
- { stubs: ['graph-group', 'panel-type'] },
- );
+ createMountedWrapper({
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: false,
+ externalDashboardUrl: '/mockUrl',
+ });
return wrapper.vm.$nextTick();
});
@@ -507,45 +866,29 @@ describe('Dashboard', () => {
});
describe('Clipboard text in panels', () => {
- const currentDashboard = 'TEST_DASHBOARD';
+ const currentDashboard = dashboardGitResponse[1].path;
+ const panelIndex = 1; // skip expanded panel
- const getClipboardTextAt = i =>
+ const getClipboardTextFirstPanel = () =>
wrapper
- .findAll(PanelType)
- .at(i)
+ .findAll(DashboardPanel)
+ .at(panelIndex)
.props('clipboardText');
beforeEach(() => {
+ setupStoreWithData(store);
createShallowWrapper({ hasMetrics: true, currentDashboard });
- setupStoreWithData(wrapper.vm.$store);
-
return wrapper.vm.$nextTick();
});
it('contains a link to the dashboard', () => {
- expect(getClipboardTextAt(0)).toContain(`dashboard=${currentDashboard}`);
- expect(getClipboardTextAt(0)).toContain(`group=`);
- expect(getClipboardTextAt(0)).toContain(`title=`);
- expect(getClipboardTextAt(0)).toContain(`y_label=`);
- });
-
- it('strips the undefined parameter', () => {
- wrapper.setProps({ currentDashboard: undefined });
-
- return wrapper.vm.$nextTick(() => {
- expect(getClipboardTextAt(0)).not.toContain(`dashboard=`);
- expect(getClipboardTextAt(0)).toContain(`y_label=`);
- });
- });
+ const dashboardParam = `dashboard=${encodeURIComponent(currentDashboard)}`;
- it('null parameter is stripped', () => {
- wrapper.setProps({ currentDashboard: null });
-
- return wrapper.vm.$nextTick(() => {
- expect(getClipboardTextAt(0)).not.toContain(`dashboard=`);
- expect(getClipboardTextAt(0)).toContain(`y_label=`);
- });
+ expect(getClipboardTextFirstPanel()).toContain(dashboardParam);
+ expect(getClipboardTextFirstPanel()).toContain(`group=`);
+ expect(getClipboardTextFirstPanel()).toContain(`title=`);
+ expect(getClipboardTextFirstPanel()).toContain(`y_label=`);
});
});
@@ -572,7 +915,7 @@ describe('Dashboard', () => {
customMetricsPath: '/endpoint',
customMetricsAvailable: true,
});
- setupStoreWithData(wrapper.vm.$store);
+ setupStoreWithData(store);
origPage = document.body.dataset.page;
document.body.dataset.page = 'projects:environments:metrics';
diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js
index d1790df4189..cc0ac348b11 100644
--- a/spec/frontend/monitoring/components/dashboard_template_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_template_spec.js
@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
+import { setupAllDashboards } from '../store_utils';
import { propsData } from '../mock_data';
jest.mock('~/lib/utils/url_utility');
@@ -15,24 +16,16 @@ describe('Dashboard template', () => {
beforeEach(() => {
store = createStore();
mock = new MockAdapter(axios);
+
+ setupAllDashboards(store);
});
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
mock.restore();
});
it('matches the default snapshot', () => {
- wrapper = shallowMount(Dashboard, {
- propsData: { ...propsData },
- methods: {
- fetchData: jest.fn(),
- },
- store,
- });
+ wrapper = shallowMount(Dashboard, { propsData: { ...propsData }, store });
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js
index 65e9d036d1a..9bba5280007 100644
--- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js
@@ -27,7 +27,7 @@ describe('dashboard invalid url parameters', () => {
wrapper = mount(Dashboard, {
propsData: { ...propsData, ...props },
store,
- stubs: ['graph-group', 'panel-type'],
+ stubs: ['graph-group', 'dashboard-panel'],
...options,
});
};
diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js
index 0bcfabe6415..b29d86cbc5b 100644
--- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js
+++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert, GlIcon } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue';
@@ -9,36 +9,48 @@ import { dashboardGitResponse } from '../mock_data';
const defaultBranch = 'master';
-function createComponent(props, opts = {}) {
- const storeOpts = {
- methods: {
- duplicateSystemDashboard: jest.fn(),
- },
- computed: {
- allDashboards: () => dashboardGitResponse,
- },
- };
-
- return shallowMount(DashboardsDropdown, {
- propsData: {
- ...props,
- defaultBranch,
- },
- sync: false,
- ...storeOpts,
- ...opts,
- });
-}
+const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred);
+const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred);
describe('DashboardsDropdown', () => {
let wrapper;
+ let mockDashboards;
+ let mockSelectedDashboard;
+
+ function createComponent(props, opts = {}) {
+ const storeOpts = {
+ methods: {
+ duplicateSystemDashboard: jest.fn(),
+ },
+ computed: {
+ allDashboards: () => mockDashboards,
+ selectedDashboard: () => mockSelectedDashboard,
+ },
+ };
+
+ return shallowMount(DashboardsDropdown, {
+ propsData: {
+ ...props,
+ defaultBranch,
+ },
+ sync: false,
+ ...storeOpts,
+ ...opts,
+ });
+ }
const findItems = () => wrapper.findAll(GlDropdownItem);
const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i);
const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' });
const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' });
+ const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' });
const setSearchTerm = searchTerm => wrapper.setData({ searchTerm });
+ beforeEach(() => {
+ mockDashboards = dashboardGitResponse;
+ mockSelectedDashboard = null;
+ });
+
describe('when it receives dashboards data', () => {
beforeEach(() => {
wrapper = createComponent();
@@ -48,10 +60,14 @@ describe('DashboardsDropdown', () => {
expect(findItems().length).toEqual(dashboardGitResponse.length);
});
- it('displays items with the dashboard display name', () => {
- expect(findItemAt(0).text()).toBe(dashboardGitResponse[0].display_name);
- expect(findItemAt(1).text()).toBe(dashboardGitResponse[1].display_name);
- expect(findItemAt(2).text()).toBe(dashboardGitResponse[2].display_name);
+ it('displays items with the dashboard display name, with starred dashboards first', () => {
+ expect(findItemAt(0).text()).toBe(starredDashboards[0].display_name);
+ expect(findItemAt(1).text()).toBe(notStarredDashboards[0].display_name);
+ expect(findItemAt(2).text()).toBe(notStarredDashboards[1].display_name);
+ });
+
+ it('displays separator between starred and not starred dashboards', () => {
+ expect(findStarredListDivider().exists()).toBe(true);
});
it('displays a search input', () => {
@@ -81,18 +97,71 @@ describe('DashboardsDropdown', () => {
});
});
+ describe('when the dashboard is missing a display name', () => {
+ beforeEach(() => {
+ mockDashboards = dashboardGitResponse.map(d => ({ ...d, display_name: undefined }));
+ wrapper = createComponent();
+ });
+
+ it('displays items with the dashboard path, with starred dashboards first', () => {
+ expect(findItemAt(0).text()).toBe(starredDashboards[0].path);
+ expect(findItemAt(1).text()).toBe(notStarredDashboards[0].path);
+ expect(findItemAt(2).text()).toBe(notStarredDashboards[1].path);
+ });
+ });
+
+ describe('when it receives starred dashboards', () => {
+ beforeEach(() => {
+ mockDashboards = starredDashboards;
+ wrapper = createComponent();
+ });
+
+ it('displays an item for each dashboard', () => {
+ expect(findItems().length).toEqual(starredDashboards.length);
+ });
+
+ it('displays a star icon', () => {
+ const star = findItemAt(0).find(GlIcon);
+ expect(star.exists()).toBe(true);
+ expect(star.attributes('name')).toBe('star');
+ });
+
+ it('displays no separator between starred and not starred dashboards', () => {
+ expect(findStarredListDivider().exists()).toBe(false);
+ });
+ });
+
+ describe('when it receives only not-starred dashboards', () => {
+ beforeEach(() => {
+ mockDashboards = notStarredDashboards;
+ wrapper = createComponent();
+ });
+
+ it('displays an item for each dashboard', () => {
+ expect(findItems().length).toEqual(notStarredDashboards.length);
+ });
+
+ it('displays no star icon', () => {
+ const star = findItemAt(0).find(GlIcon);
+ expect(star.exists()).toBe(false);
+ });
+
+ it('displays no separator between starred and not starred dashboards', () => {
+ expect(findStarredListDivider().exists()).toBe(false);
+ });
+ });
+
describe('when a system dashboard is selected', () => {
let duplicateDashboardAction;
let modalDirective;
beforeEach(() => {
+ [mockSelectedDashboard] = dashboardGitResponse;
modalDirective = jest.fn();
duplicateDashboardAction = jest.fn().mockResolvedValue();
wrapper = createComponent(
- {
- selectedDashboard: dashboardGitResponse[0],
- },
+ {},
{
directives: {
GlModal: modalDirective,
@@ -260,7 +329,7 @@ describe('DashboardsDropdown', () => {
expect(wrapper.emitted().selectDashboard).toBeTruthy();
});
it('emits a "selectDashboard" event with dashboard information', () => {
- expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[1]]);
+ expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[0]]);
});
});
});
diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
index 10fd58f749d..216ec345552 100644
--- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
+++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
@@ -81,7 +81,8 @@ describe('DuplicateDashboardForm', () => {
it('with the inital form values', () => {
expect(wrapper.emitted().change).toHaveLength(1);
- expect(lastChange()).resolves.toEqual({
+
+ return expect(lastChange()).resolves.toEqual({
branch: '',
commitMessage: expect.any(String),
dashboard: dashboardGitResponse[0].path,
@@ -92,7 +93,7 @@ describe('DuplicateDashboardForm', () => {
it('containing an inputted file name', () => {
setValue('fileName', 'my_dashboard.yml');
- expect(lastChange()).resolves.toMatchObject({
+ return expect(lastChange()).resolves.toMatchObject({
fileName: 'my_dashboard.yml',
});
});
@@ -100,7 +101,7 @@ describe('DuplicateDashboardForm', () => {
it('containing a default commit message when no message is set', () => {
setValue('commitMessage', '');
- expect(lastChange()).resolves.toMatchObject({
+ return expect(lastChange()).resolves.toMatchObject({
commitMessage: expect.stringContaining('Create custom dashboard'),
});
});
@@ -108,7 +109,7 @@ describe('DuplicateDashboardForm', () => {
it('containing an inputted commit message', () => {
setValue('commitMessage', 'My commit message');
- expect(lastChange()).resolves.toMatchObject({
+ return expect(lastChange()).resolves.toMatchObject({
commitMessage: expect.stringContaining('My commit message'),
});
});
@@ -116,7 +117,7 @@ describe('DuplicateDashboardForm', () => {
it('containing an inputted branch name', () => {
setValue('branchName', 'a-new-branch');
- expect(lastChange()).resolves.toMatchObject({
+ return expect(lastChange()).resolves.toMatchObject({
branch: 'a-new-branch',
});
});
@@ -125,13 +126,14 @@ describe('DuplicateDashboardForm', () => {
setChecked(wrapper.vm.$options.radioVals.DEFAULT);
setValue('branchName', 'a-new-branch');
- expect(lastChange()).resolves.toMatchObject({
- branch: defaultBranch,
- });
-
- return wrapper.vm.$nextTick(() => {
- expect(findByRef('branchName').isVisible()).toBe(false);
- });
+ return Promise.all([
+ expect(lastChange()).resolves.toMatchObject({
+ branch: defaultBranch,
+ }),
+ wrapper.vm.$nextTick(() => {
+ expect(findByRef('branchName').isVisible()).toBe(false);
+ }),
+ ]);
});
it('when `new` branch option is chosen, focuses on the branch name input', () => {
diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
index b829cd53479..f23823ccad6 100644
--- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
+++ b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js
@@ -1,6 +1,6 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import { TEST_HOST } from 'helpers/test_constants';
import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue';
import { groups, initialState, metricsData, metricsWithData } from './mock_data';
@@ -62,7 +62,7 @@ describe('MetricEmbed', () => {
it('shows an empty state when no metrics are present', () => {
expect(wrapper.find('.metrics-embed').exists()).toBe(true);
- expect(wrapper.find(PanelType).exists()).toBe(false);
+ expect(wrapper.find(DashboardPanel).exists()).toBe(false);
});
});
@@ -90,12 +90,12 @@ describe('MetricEmbed', () => {
it('shows a chart when metrics are present', () => {
expect(wrapper.find('.metrics-embed').exists()).toBe(true);
- expect(wrapper.find(PanelType).exists()).toBe(true);
- expect(wrapper.findAll(PanelType).length).toBe(2);
+ expect(wrapper.find(DashboardPanel).exists()).toBe(true);
+ expect(wrapper.findAll(DashboardPanel).length).toBe(2);
});
it('includes groupId with dashboardUrl', () => {
- expect(wrapper.find(PanelType).props('groupId')).toBe(TEST_HOST);
+ expect(wrapper.find(DashboardPanel).props('groupId')).toBe(TEST_HOST);
});
});
});
diff --git a/spec/frontend/monitoring/components/variables/custom_variable_spec.js b/spec/frontend/monitoring/components/variables/custom_variable_spec.js
new file mode 100644
index 00000000000..5a2b26219b6
--- /dev/null
+++ b/spec/frontend/monitoring/components/variables/custom_variable_spec.js
@@ -0,0 +1,52 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import CustomVariable from '~/monitoring/components/variables/custom_variable.vue';
+
+describe('Custom variable component', () => {
+ let wrapper;
+ const propsData = {
+ name: 'env',
+ label: 'Select environment',
+ value: 'Production',
+ options: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }],
+ };
+ const createShallowWrapper = () => {
+ wrapper = shallowMount(CustomVariable, {
+ propsData,
+ });
+ };
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+
+ it('renders dropdown element when all necessary props are passed', () => {
+ createShallowWrapper();
+
+ expect(findDropdown()).toExist();
+ });
+
+ it('renders dropdown element with a text', () => {
+ createShallowWrapper();
+
+ expect(findDropdown().attributes('text')).toBe(propsData.value);
+ });
+
+ it('renders all the dropdown items', () => {
+ createShallowWrapper();
+
+ expect(findDropdownItems()).toHaveLength(propsData.options.length);
+ });
+
+ it('changing dropdown items triggers update', () => {
+ createShallowWrapper();
+ jest.spyOn(wrapper.vm, '$emit');
+
+ findDropdownItems()
+ .at(1)
+ .vm.$emit('click');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'env', 'canary');
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/variables/text_variable_spec.js b/spec/frontend/monitoring/components/variables/text_variable_spec.js
new file mode 100644
index 00000000000..f01584ae8bc
--- /dev/null
+++ b/spec/frontend/monitoring/components/variables/text_variable_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlFormInput } from '@gitlab/ui';
+import TextVariable from '~/monitoring/components/variables/text_variable.vue';
+
+describe('Text variable component', () => {
+ let wrapper;
+ const propsData = {
+ name: 'pod',
+ label: 'Select pod',
+ value: 'test-pod',
+ };
+ const createShallowWrapper = () => {
+ wrapper = shallowMount(TextVariable, {
+ propsData,
+ });
+ };
+
+ const findInput = () => wrapper.find(GlFormInput);
+
+ it('renders a text input when all props are passed', () => {
+ createShallowWrapper();
+
+ expect(findInput()).toExist();
+ });
+
+ it('always has a default value', () => {
+ createShallowWrapper();
+
+ return wrapper.vm.$nextTick(() => {
+ expect(findInput().attributes('value')).toBe(propsData.value);
+ });
+ });
+
+ it('triggers keyup enter', () => {
+ createShallowWrapper();
+ jest.spyOn(wrapper.vm, '$emit');
+
+ findInput().element.value = 'prod-pod';
+ findInput().trigger('input');
+ findInput().trigger('keyup.enter');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'prod-pod');
+ });
+ });
+
+ it('triggers blur enter', () => {
+ createShallowWrapper();
+ jest.spyOn(wrapper.vm, '$emit');
+
+ findInput().element.value = 'canary-pod';
+ findInput().trigger('input');
+ findInput().trigger('blur');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'canary-pod');
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js
new file mode 100644
index 00000000000..095d89c9231
--- /dev/null
+++ b/spec/frontend/monitoring/components/variables_section_spec.js
@@ -0,0 +1,126 @@
+import { shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
+import VariablesSection from '~/monitoring/components/variables_section.vue';
+import CustomVariable from '~/monitoring/components/variables/custom_variable.vue';
+import TextVariable from '~/monitoring/components/variables/text_variable.vue';
+import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility';
+import { createStore } from '~/monitoring/stores';
+import { convertVariablesForURL } from '~/monitoring/utils';
+import * as types from '~/monitoring/stores/mutation_types';
+import { mockTemplatingDataResponses } from '../mock_data';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ updateHistory: jest.fn(),
+ mergeUrlParams: jest.fn(),
+}));
+
+describe('Metrics dashboard/variables section component', () => {
+ let store;
+ let wrapper;
+ const sampleVariables = {
+ label1: mockTemplatingDataResponses.simpleText.simpleText,
+ label2: mockTemplatingDataResponses.advText.advText,
+ label3: mockTemplatingDataResponses.simpleCustom.simpleCustom,
+ };
+
+ const createShallowWrapper = () => {
+ wrapper = shallowMount(VariablesSection, {
+ store,
+ });
+ };
+
+ const findTextInput = () => wrapper.findAll(TextVariable);
+ const findCustomInput = () => wrapper.findAll(CustomVariable);
+
+ beforeEach(() => {
+ store = createStore();
+
+ store.state.monitoringDashboard.showEmptyState = false;
+ });
+
+ it('does not show the variables section', () => {
+ createShallowWrapper();
+ const allInputs = findTextInput().length + findCustomInput().length;
+
+ expect(allInputs).toBe(0);
+ });
+
+ it('shows the variables section', () => {
+ createShallowWrapper();
+ store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables);
+
+ return wrapper.vm.$nextTick(() => {
+ const allInputs = findTextInput().length + findCustomInput().length;
+
+ expect(allInputs).toBe(Object.keys(sampleVariables).length);
+ });
+ });
+
+ describe('when changing the variable inputs', () => {
+ const fetchDashboardData = jest.fn();
+ const updateVariableValues = jest.fn();
+
+ beforeEach(() => {
+ store = new Vuex.Store({
+ modules: {
+ monitoringDashboard: {
+ namespaced: true,
+ state: {
+ showEmptyState: false,
+ promVariables: sampleVariables,
+ },
+ actions: {
+ fetchDashboardData,
+ updateVariableValues,
+ },
+ },
+ },
+ });
+
+ createShallowWrapper();
+ });
+
+ it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', () => {
+ const firstInput = findTextInput().at(0);
+
+ firstInput.vm.$emit('onUpdate', 'label1', 'test');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(updateVariableValues).toHaveBeenCalled();
+ expect(mergeUrlParams).toHaveBeenCalledWith(
+ convertVariablesForURL(sampleVariables),
+ window.location.href,
+ );
+ expect(updateHistory).toHaveBeenCalled();
+ expect(fetchDashboardData).toHaveBeenCalled();
+ });
+ });
+
+ it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', () => {
+ const firstInput = findCustomInput().at(0);
+
+ firstInput.vm.$emit('onUpdate', 'label1', 'test');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(updateVariableValues).toHaveBeenCalled();
+ expect(mergeUrlParams).toHaveBeenCalledWith(
+ convertVariablesForURL(sampleVariables),
+ window.location.href,
+ );
+ expect(updateHistory).toHaveBeenCalled();
+ expect(fetchDashboardData).toHaveBeenCalled();
+ });
+ });
+
+ it('does not merge the url params and refreshes the dashboard if the value entered is not different that is what currently stored', () => {
+ const firstInput = findTextInput().at(0);
+
+ firstInput.vm.$emit('onUpdate', 'label1', 'Simple text');
+
+ expect(updateVariableValues).not.toHaveBeenCalled();
+ expect(mergeUrlParams).not.toHaveBeenCalled();
+ expect(updateHistory).not.toHaveBeenCalled();
+ expect(fetchDashboardData).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js
index 56236918c68..4611e6f1b18 100644
--- a/spec/frontend/monitoring/mock_data.js
+++ b/spec/frontend/monitoring/mock_data.js
@@ -34,6 +34,7 @@ const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({
system_dashboard: false,
project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`,
path: `.gitlab/dashboards/dashboard_${idx}.yml`,
+ starred: false,
}));
export const mockDashboardsErrorResponse = {
@@ -323,6 +324,18 @@ export const dashboardGitResponse = [
system_dashboard: true,
project_blob_path: null,
path: 'config/prometheus/common_metrics.yml',
+ starred: false,
+ user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/common_metrics.yml`,
+ },
+ {
+ default: false,
+ display_name: 'dashboard.yml',
+ can_edit: true,
+ system_dashboard: false,
+ project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`,
+ path: '.gitlab/dashboards/dashboard.yml',
+ starred: true,
+ user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`,
},
...customDashboardsData,
];
@@ -341,7 +354,7 @@ export const metricsResult = [
},
];
-export const graphDataPrometheusQuery = {
+export const singleStatMetricsResult = {
title: 'Super Chart A2',
type: 'single-stat',
weight: 2,
@@ -489,7 +502,7 @@ export const stackedColumnMockedData = {
export const barMockData = {
title: 'SLA Trends - Primary Services',
- type: 'bar-chart',
+ type: 'bar',
xLabel: 'service',
y_label: 'percentile',
metrics: [
@@ -549,3 +562,217 @@ export const mockNamespacedData = {
export const mockLogsPath = '/mockLogsPath';
export const mockLogsHref = `${mockLogsPath}?duration_seconds=${mockTimeRange.duration.seconds}`;
+
+const templatingVariableTypes = {
+ text: {
+ simple: 'Simple text',
+ advanced: {
+ label: 'Variable 4',
+ type: 'text',
+ options: {
+ default_value: 'default',
+ },
+ },
+ },
+ custom: {
+ simple: ['value1', 'value2', 'value3'],
+ advanced: {
+ normal: {
+ label: 'Advanced Var',
+ type: 'custom',
+ options: {
+ values: [
+ { value: 'value1', text: 'Var 1 Option 1' },
+ {
+ value: 'value2',
+ text: 'Var 1 Option 2',
+ default: true,
+ },
+ ],
+ },
+ },
+ withoutOpts: {
+ type: 'custom',
+ options: {},
+ },
+ withoutLabel: {
+ type: 'custom',
+ options: {
+ values: [
+ { value: 'value1', text: 'Var 1 Option 1' },
+ {
+ value: 'value2',
+ text: 'Var 1 Option 2',
+ default: true,
+ },
+ ],
+ },
+ },
+ withoutType: {
+ label: 'Variable 2',
+ options: {
+ values: [
+ { value: 'value1', text: 'Var 1 Option 1' },
+ {
+ value: 'value2',
+ text: 'Var 1 Option 2',
+ default: true,
+ },
+ ],
+ },
+ },
+ },
+ },
+};
+
+const generateMockTemplatingData = data => {
+ const vars = data
+ ? {
+ variables: {
+ ...data,
+ },
+ }
+ : {};
+ return {
+ dashboard: {
+ templating: vars,
+ },
+ };
+};
+
+const responseForSimpleTextVariable = {
+ simpleText: {
+ label: 'simpleText',
+ type: 'text',
+ value: 'Simple text',
+ },
+};
+
+const responseForAdvTextVariable = {
+ advText: {
+ label: 'Variable 4',
+ type: 'text',
+ value: 'default',
+ },
+};
+
+const responseForSimpleCustomVariable = {
+ simpleCustom: {
+ label: 'simpleCustom',
+ value: 'value1',
+ options: [
+ {
+ default: false,
+ text: 'value1',
+ value: 'value1',
+ },
+ {
+ default: false,
+ text: 'value2',
+ value: 'value2',
+ },
+ {
+ default: false,
+ text: 'value3',
+ value: 'value3',
+ },
+ ],
+ type: 'custom',
+ },
+};
+
+const responseForAdvancedCustomVariableWithoutOptions = {
+ advCustomWithoutOpts: {
+ label: 'advCustomWithoutOpts',
+ options: [],
+ type: 'custom',
+ },
+};
+
+const responseForAdvancedCustomVariableWithoutLabel = {
+ advCustomWithoutLabel: {
+ label: 'advCustomWithoutLabel',
+ value: 'value2',
+ options: [
+ {
+ default: false,
+ text: 'Var 1 Option 1',
+ value: 'value1',
+ },
+ {
+ default: true,
+ text: 'Var 1 Option 2',
+ value: 'value2',
+ },
+ ],
+ type: 'custom',
+ },
+};
+
+const responseForAdvancedCustomVariable = {
+ ...responseForSimpleCustomVariable,
+ advCustomNormal: {
+ label: 'Advanced Var',
+ value: 'value2',
+ options: [
+ {
+ default: false,
+ text: 'Var 1 Option 1',
+ value: 'value1',
+ },
+ {
+ default: true,
+ text: 'Var 1 Option 2',
+ value: 'value2',
+ },
+ ],
+ type: 'custom',
+ },
+};
+
+const responsesForAllVariableTypes = {
+ ...responseForSimpleTextVariable,
+ ...responseForAdvTextVariable,
+ ...responseForSimpleCustomVariable,
+ ...responseForAdvancedCustomVariable,
+};
+
+export const mockTemplatingData = {
+ emptyTemplatingProp: generateMockTemplatingData(),
+ emptyVariablesProp: generateMockTemplatingData({}),
+ simpleText: generateMockTemplatingData({ simpleText: templatingVariableTypes.text.simple }),
+ advText: generateMockTemplatingData({ advText: templatingVariableTypes.text.advanced }),
+ simpleCustom: generateMockTemplatingData({ simpleCustom: templatingVariableTypes.custom.simple }),
+ advCustomWithoutOpts: generateMockTemplatingData({
+ advCustomWithoutOpts: templatingVariableTypes.custom.advanced.withoutOpts,
+ }),
+ advCustomWithoutType: generateMockTemplatingData({
+ advCustomWithoutType: templatingVariableTypes.custom.advanced.withoutType,
+ }),
+ advCustomWithoutLabel: generateMockTemplatingData({
+ advCustomWithoutLabel: templatingVariableTypes.custom.advanced.withoutLabel,
+ }),
+ simpleAndAdv: generateMockTemplatingData({
+ simpleCustom: templatingVariableTypes.custom.simple,
+ advCustomNormal: templatingVariableTypes.custom.advanced.normal,
+ }),
+ allVariableTypes: generateMockTemplatingData({
+ simpleText: templatingVariableTypes.text.simple,
+ advText: templatingVariableTypes.text.advanced,
+ simpleCustom: templatingVariableTypes.custom.simple,
+ advCustomNormal: templatingVariableTypes.custom.advanced.normal,
+ }),
+};
+
+export const mockTemplatingDataResponses = {
+ emptyTemplatingProp: {},
+ emptyVariablesProp: {},
+ simpleText: responseForSimpleTextVariable,
+ advText: responseForAdvTextVariable,
+ simpleCustom: responseForSimpleCustomVariable,
+ advCustomWithoutOpts: responseForAdvancedCustomVariableWithoutOptions,
+ advCustomWithoutType: {},
+ advCustomWithoutLabel: responseForAdvancedCustomVariableWithoutLabel,
+ simpleAndAdv: responseForAdvancedCustomVariable,
+ allVariableTypes: responsesForAllVariableTypes,
+};
diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js
index f312aa1fd34..8914f2e66ea 100644
--- a/spec/frontend/monitoring/store/actions_spec.js
+++ b/spec/frontend/monitoring/store/actions_spec.js
@@ -11,17 +11,22 @@ import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
import store from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
import {
+ fetchData,
fetchDashboard,
receiveMetricsDashboardSuccess,
fetchDeploymentsData,
fetchEnvironmentsData,
fetchDashboardData,
fetchAnnotations,
+ toggleStarredValue,
fetchPrometheusMetric,
setInitialState,
filterEnvironments,
+ setExpandedPanel,
+ clearExpandedPanel,
setGettingStartedEmptyState,
duplicateSystemDashboard,
+ updateVariableValues,
} from '~/monitoring/stores/actions';
import {
gqClient,
@@ -35,6 +40,7 @@ import {
deploymentData,
environmentData,
annotationsData,
+ mockTemplatingData,
dashboardGitResponse,
mockDashboardsErrorResponse,
} from '../mock_data';
@@ -62,9 +68,6 @@ describe('Monitoring store actions', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
- // Mock `backOff` function to remove exponential algorithm delay.
- jest.useFakeTimers();
-
jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => {
const q = new Promise((resolve, reject) => {
const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
@@ -87,6 +90,45 @@ describe('Monitoring store actions', () => {
createFlash.mockReset();
});
+ describe('fetchData', () => {
+ it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => {
+ const { state } = store;
+
+ return testAction(
+ fetchData,
+ null,
+ state,
+ [],
+ [
+ { type: 'fetchEnvironmentsData' },
+ { type: 'fetchDashboard' },
+ { type: 'fetchAnnotations' },
+ ],
+ );
+ });
+
+ it('dispatches when feature metricsDashboardAnnotations is on', () => {
+ const origGon = window.gon;
+ window.gon = { features: { metricsDashboardAnnotations: true } };
+
+ const { state } = store;
+
+ return testAction(
+ fetchData,
+ null,
+ state,
+ [],
+ [
+ { type: 'fetchEnvironmentsData' },
+ { type: 'fetchDashboard' },
+ { type: 'fetchAnnotations' },
+ ],
+ ).then(() => {
+ window.gon = origGon;
+ });
+ });
+ });
+
describe('fetchDeploymentsData', () => {
it('dispatches receiveDeploymentsDataSuccess on success', () => {
const { state } = store;
@@ -310,6 +352,49 @@ describe('Monitoring store actions', () => {
});
});
+ describe('Toggles starred value of current dashboard', () => {
+ const { state } = store;
+ let unstarredDashboard;
+ let starredDashboard;
+
+ beforeEach(() => {
+ state.isUpdatingStarredValue = false;
+ [unstarredDashboard, starredDashboard] = dashboardGitResponse;
+ });
+
+ describe('toggleStarredValue', () => {
+ it('performs no changes if no dashboard is selected', () => {
+ return testAction(toggleStarredValue, null, state, [], []);
+ });
+
+ it('performs no changes if already changing starred value', () => {
+ state.selectedDashboard = unstarredDashboard;
+ state.isUpdatingStarredValue = true;
+ return testAction(toggleStarredValue, null, state, [], []);
+ });
+
+ it('stars dashboard if it is not starred', () => {
+ state.selectedDashboard = unstarredDashboard;
+ mock.onPost(unstarredDashboard.user_starred_path).reply(200);
+
+ return testAction(toggleStarredValue, null, state, [
+ { type: types.REQUEST_DASHBOARD_STARRING },
+ { type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, payload: true },
+ ]);
+ });
+
+ it('unstars dashboard if it is starred', () => {
+ state.selectedDashboard = starredDashboard;
+ mock.onPost(starredDashboard.user_starred_path).reply(200);
+
+ return testAction(toggleStarredValue, null, state, [
+ { type: types.REQUEST_DASHBOARD_STARRING },
+ { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE },
+ ]);
+ });
+ });
+ });
+
describe('Set initial state', () => {
let mockedState;
beforeEach(() => {
@@ -357,6 +442,29 @@ describe('Monitoring store actions', () => {
);
});
});
+
+ describe('updateVariableValues', () => {
+ let mockedState;
+ beforeEach(() => {
+ mockedState = storeState();
+ });
+ it('should commit UPDATE_VARIABLE_VALUES mutation', done => {
+ testAction(
+ updateVariableValues,
+ { pod: 'POD' },
+ mockedState,
+ [
+ {
+ type: types.UPDATE_VARIABLE_VALUES,
+ payload: { pod: 'POD' },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
describe('fetchDashboard', () => {
let dispatch;
let state;
@@ -467,6 +575,33 @@ describe('Monitoring store actions', () => {
);
expect(dispatch).toHaveBeenCalledWith('fetchDashboardData');
});
+
+ it('stores templating variables', () => {
+ const response = {
+ ...metricsDashboardResponse.dashboard,
+ ...mockTemplatingData.allVariableTypes.dashboard,
+ };
+
+ receiveMetricsDashboardSuccess(
+ { state, commit, dispatch },
+ {
+ response: {
+ ...metricsDashboardResponse,
+ dashboard: {
+ ...metricsDashboardResponse.dashboard,
+ ...mockTemplatingData.allVariableTypes.dashboard,
+ },
+ },
+ },
+ );
+
+ expect(commit).toHaveBeenCalledWith(
+ types.RECEIVE_METRICS_DASHBOARD_SUCCESS,
+
+ response,
+ );
+ });
+
it('sets the dashboards loaded from the repository', () => {
const params = {};
const response = metricsDashboardResponse;
@@ -873,4 +1008,43 @@ describe('Monitoring store actions', () => {
});
});
});
+
+ describe('setExpandedPanel', () => {
+ let state;
+
+ beforeEach(() => {
+ state = storeState();
+ });
+
+ it('Sets a panel as expanded', () => {
+ const group = 'group_1';
+ const panel = { title: 'A Panel' };
+
+ return testAction(
+ setExpandedPanel,
+ { group, panel },
+ state,
+ [{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }],
+ [],
+ );
+ });
+ });
+
+ describe('clearExpandedPanel', () => {
+ let state;
+
+ beforeEach(() => {
+ state = storeState();
+ });
+
+ it('Clears a panel as expanded', () => {
+ return testAction(
+ clearExpandedPanel,
+ undefined,
+ state,
+ [{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }],
+ [],
+ );
+ });
+ });
});
diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js
index f040876b832..365052e68e3 100644
--- a/spec/frontend/monitoring/store/getters_spec.js
+++ b/spec/frontend/monitoring/store/getters_spec.js
@@ -3,7 +3,12 @@ import * as getters from '~/monitoring/stores/getters';
import mutations from '~/monitoring/stores/mutations';
import * as types from '~/monitoring/stores/mutation_types';
import { metricStates } from '~/monitoring/constants';
-import { environmentData, metricsResult } from '../mock_data';
+import {
+ environmentData,
+ metricsResult,
+ dashboardGitResponse,
+ mockTemplatingDataResponses,
+} from '../mock_data';
import {
metricsDashboardPayload,
metricResultStatus,
@@ -323,4 +328,81 @@ describe('Monitoring store Getters', () => {
expect(metricsSavedToDb).toEqual([`${id1}_${metric1.id}`, `${id2}_${metric2.id}`]);
});
});
+
+ describe('getCustomVariablesArray', () => {
+ let state;
+
+ beforeEach(() => {
+ state = {
+ promVariables: {},
+ };
+ });
+
+ it('transforms the promVariables object to an array in the [variable, variable_value] format for all variable types', () => {
+ mutations[types.SET_VARIABLES](state, mockTemplatingDataResponses.allVariableTypes);
+ const variablesArray = getters.getCustomVariablesArray(state);
+
+ expect(variablesArray).toEqual([
+ 'simpleText',
+ 'Simple text',
+ 'advText',
+ 'default',
+ 'simpleCustom',
+ 'value1',
+ 'advCustomNormal',
+ 'value2',
+ ]);
+ });
+
+ it('transforms the promVariables object to an empty array when no keys are present', () => {
+ mutations[types.SET_VARIABLES](state, {});
+ const variablesArray = getters.getCustomVariablesArray(state);
+
+ expect(variablesArray).toEqual([]);
+ });
+ });
+
+ describe('selectedDashboard', () => {
+ const { selectedDashboard } = getters;
+
+ it('returns a dashboard', () => {
+ const state = {
+ allDashboards: dashboardGitResponse,
+ currentDashboard: dashboardGitResponse[0].path,
+ };
+ expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
+ });
+
+ it('returns a non-default dashboard', () => {
+ const state = {
+ allDashboards: dashboardGitResponse,
+ currentDashboard: dashboardGitResponse[1].path,
+ };
+ expect(selectedDashboard(state)).toEqual(dashboardGitResponse[1]);
+ });
+
+ it('returns a default dashboard when no dashboard is selected', () => {
+ const state = {
+ allDashboards: dashboardGitResponse,
+ currentDashboard: null,
+ };
+ expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
+ });
+
+ it('returns a default dashboard when dashboard cannot be found', () => {
+ const state = {
+ allDashboards: dashboardGitResponse,
+ currentDashboard: 'wrong_path',
+ };
+ expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
+ });
+
+ it('returns null when no dashboards are present', () => {
+ const state = {
+ allDashboards: [],
+ currentDashboard: dashboardGitResponse[0].path,
+ };
+ expect(selectedDashboard(state)).toEqual(null);
+ });
+ });
});
diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js
index 1452e9bc491..4306243689a 100644
--- a/spec/frontend/monitoring/store/mutations_spec.js
+++ b/spec/frontend/monitoring/store/mutations_spec.js
@@ -72,6 +72,49 @@ describe('Monitoring mutations', () => {
});
});
+ describe('Dashboard starring mutations', () => {
+ it('REQUEST_DASHBOARD_STARRING', () => {
+ stateCopy = { isUpdatingStarredValue: false };
+ mutations[types.REQUEST_DASHBOARD_STARRING](stateCopy);
+
+ expect(stateCopy.isUpdatingStarredValue).toBe(true);
+ });
+
+ describe('RECEIVE_DASHBOARD_STARRING_SUCCESS', () => {
+ let allDashboards;
+
+ beforeEach(() => {
+ allDashboards = [...dashboardGitResponse];
+ stateCopy = {
+ allDashboards,
+ currentDashboard: allDashboards[1].path,
+ isUpdatingStarredValue: true,
+ };
+ });
+
+ it('sets a dashboard as starred', () => {
+ mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, true);
+
+ expect(stateCopy.isUpdatingStarredValue).toBe(false);
+ expect(stateCopy.allDashboards[1].starred).toBe(true);
+ });
+
+ it('sets a dashboard as unstarred', () => {
+ mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, false);
+
+ expect(stateCopy.isUpdatingStarredValue).toBe(false);
+ expect(stateCopy.allDashboards[1].starred).toBe(false);
+ });
+ });
+
+ it('RECEIVE_DASHBOARD_STARRING_FAILURE', () => {
+ stateCopy = { isUpdatingStarredValue: true };
+ mutations[types.RECEIVE_DASHBOARD_STARRING_FAILURE](stateCopy);
+
+ expect(stateCopy.isUpdatingStarredValue).toBe(false);
+ });
+ });
+
describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => {
it('stores the deployment data', () => {
stateCopy.deploymentData = [];
@@ -342,4 +385,53 @@ describe('Monitoring mutations', () => {
expect(stateCopy.allDashboards).toEqual(dashboardGitResponse);
});
});
+
+ describe('SET_EXPANDED_PANEL', () => {
+ it('no expanded panel is set initally', () => {
+ expect(stateCopy.expandedPanel.panel).toEqual(null);
+ expect(stateCopy.expandedPanel.group).toEqual(null);
+ });
+
+ it('sets a panel id as the expanded panel', () => {
+ const group = 'group_1';
+ const panel = { title: 'A Panel' };
+ mutations[types.SET_EXPANDED_PANEL](stateCopy, { group, panel });
+
+ expect(stateCopy.expandedPanel).toEqual({ group, panel });
+ });
+
+ it('clears panel as the expanded panel', () => {
+ mutations[types.SET_EXPANDED_PANEL](stateCopy, { group: null, panel: null });
+
+ expect(stateCopy.expandedPanel.group).toEqual(null);
+ expect(stateCopy.expandedPanel.panel).toEqual(null);
+ });
+ });
+
+ describe('SET_VARIABLES', () => {
+ it('stores an empty variables array when no custom variables are given', () => {
+ mutations[types.SET_VARIABLES](stateCopy, {});
+
+ expect(stateCopy.promVariables).toEqual({});
+ });
+
+ it('stores variables in the key key_value format in the array', () => {
+ mutations[types.SET_VARIABLES](stateCopy, { pod: 'POD', stage: 'main ops' });
+
+ expect(stateCopy.promVariables).toEqual({ pod: 'POD', stage: 'main ops' });
+ });
+ });
+
+ describe('UPDATE_VARIABLE_VALUES', () => {
+ afterEach(() => {
+ mutations[types.SET_VARIABLES](stateCopy, {});
+ });
+
+ it('updates only the value of the variable in promVariables', () => {
+ mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } });
+ mutations[types.UPDATE_VARIABLE_VALUES](stateCopy, { key: 'environment', value: 'new prod' });
+
+ expect(stateCopy.promVariables).toEqual({ environment: { value: 'new prod', type: 'text' } });
+ });
+ });
});
diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js
index 7ee2a16b4bd..fe5754e1216 100644
--- a/spec/frontend/monitoring/store/utils_spec.js
+++ b/spec/frontend/monitoring/store/utils_spec.js
@@ -27,6 +27,7 @@ describe('mapToDashboardViewModel', () => {
group: 'Group 1',
panels: [
{
+ id: 'ID_ABC',
title: 'Title A',
xLabel: '',
xAxis: {
@@ -49,6 +50,7 @@ describe('mapToDashboardViewModel', () => {
key: 'group-1-0',
panels: [
{
+ id: 'ID_ABC',
title: 'Title A',
type: 'chart-type',
xLabel: '',
@@ -127,11 +129,13 @@ describe('mapToDashboardViewModel', () => {
it('panel with x_label', () => {
setupWithPanel({
+ id: 'ID_123',
title: panelTitle,
x_label: 'x label',
});
expect(getMappedPanel()).toEqual({
+ id: 'ID_123',
title: panelTitle,
xLabel: 'x label',
xAxis: {
@@ -149,10 +153,12 @@ describe('mapToDashboardViewModel', () => {
it('group y_axis defaults', () => {
setupWithPanel({
+ id: 'ID_456',
title: panelTitle,
});
expect(getMappedPanel()).toEqual({
+ id: 'ID_456',
title: panelTitle,
xLabel: '',
y_label: '',
diff --git a/spec/frontend/monitoring/store/variable_mapping_spec.js b/spec/frontend/monitoring/store/variable_mapping_spec.js
new file mode 100644
index 00000000000..47681ac7c65
--- /dev/null
+++ b/spec/frontend/monitoring/store/variable_mapping_spec.js
@@ -0,0 +1,22 @@
+import { parseTemplatingVariables } from '~/monitoring/stores/variable_mapping';
+import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data';
+
+describe('parseTemplatingVariables', () => {
+ it.each`
+ case | input | expected
+ ${'Returns empty object for no dashboard input'} | ${{}} | ${{}}
+ ${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}}
+ ${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}}
+ ${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}}
+ ${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText}
+ ${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText}
+ ${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom}
+ ${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts}
+ ${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}}
+ ${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel}
+ ${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv}
+ ${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes}
+ `('$case', ({ input, expected }) => {
+ expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected);
+ });
+});
diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js
index d764a79ccc3..338af79dbbe 100644
--- a/spec/frontend/monitoring/store_utils.js
+++ b/spec/frontend/monitoring/store_utils.js
@@ -1,34 +1,49 @@
import * as types from '~/monitoring/stores/mutation_types';
-import { metricsResult, environmentData } from './mock_data';
+import { metricsResult, environmentData, dashboardGitResponse } from './mock_data';
import { metricsDashboardPayload } from './fixture_data';
-export const setMetricResult = ({ $store, result, group = 0, panel = 0, metric = 0 }) => {
- const { dashboard } = $store.state.monitoringDashboard;
+export const setMetricResult = ({ store, result, group = 0, panel = 0, metric = 0 }) => {
+ const { dashboard } = store.state.monitoringDashboard;
const { metricId } = dashboard.panelGroups[group].panels[panel].metrics[metric];
- $store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, {
+ store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, {
metricId,
result,
});
};
-const setEnvironmentData = $store => {
- $store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData);
+const setEnvironmentData = store => {
+ store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData);
};
-export const setupStoreWithDashboard = $store => {
- $store.commit(
+export const setupAllDashboards = store => {
+ store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse);
+};
+
+export const setupStoreWithDashboard = store => {
+ store.commit(
+ `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`,
+ metricsDashboardPayload,
+ );
+ store.commit(
`monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`,
metricsDashboardPayload,
);
};
-export const setupStoreWithData = $store => {
- setupStoreWithDashboard($store);
+export const setupStoreWithVariable = store => {
+ store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, {
+ label1: 'pod',
+ });
+};
+
+export const setupStoreWithData = store => {
+ setupAllDashboards(store);
+ setupStoreWithDashboard(store);
- setMetricResult({ $store, result: [], panel: 0 });
- setMetricResult({ $store, result: metricsResult, panel: 1 });
- setMetricResult({ $store, result: metricsResult, panel: 2 });
+ setMetricResult({ store, result: [], panel: 0 });
+ setMetricResult({ store, result: metricsResult, panel: 1 });
+ setMetricResult({ store, result: metricsResult, panel: 2 });
- setEnvironmentData($store);
+ setEnvironmentData(store);
};
diff --git a/spec/frontend/monitoring/stubs/modal_stub.js b/spec/frontend/monitoring/stubs/modal_stub.js
new file mode 100644
index 00000000000..4cd0362096e
--- /dev/null
+++ b/spec/frontend/monitoring/stubs/modal_stub.js
@@ -0,0 +1,11 @@
+const ModalStub = {
+ name: 'glmodal-stub',
+ template: `
+ <div>
+ <slot></slot>
+ <slot name="modal-ok"></slot>
+ </div>
+ `,
+};
+
+export default ModalStub;
diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js
index 0bb1b987b2e..aa5a4459a72 100644
--- a/spec/frontend/monitoring/utils_spec.js
+++ b/spec/frontend/monitoring/utils_spec.js
@@ -1,15 +1,13 @@
import * as monitoringUtils from '~/monitoring/utils';
-import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
+import * as urlUtils from '~/lib/utils/url_utility';
import { TEST_HOST } from 'jest/helpers/test_constants';
import {
mockProjectDir,
- graphDataPrometheusQuery,
+ singleStatMetricsResult,
anomalyMockGraphData,
barMockData,
} from './mock_data';
-import { graphData } from './fixture_data';
-
-jest.mock('~/lib/utils/url_utility');
+import { metricsDashboardViewModel, graphData } from './fixture_data';
const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`;
@@ -27,11 +25,6 @@ const rollingRange = {
};
describe('monitoring/utils', () => {
- afterEach(() => {
- mergeUrlParams.mockReset();
- queryToObject.mockReset();
- });
-
describe('trackGenerateLinkToChartEventOptions', () => {
it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
document.body.dataset.page = 'groups:clusters:show';
@@ -89,7 +82,7 @@ describe('monitoring/utils', () => {
it('validates data with the query format', () => {
const validGraphData = monitoringUtils.graphDataValidatorForValues(
true,
- graphDataPrometheusQuery,
+ singleStatMetricsResult,
);
expect(validGraphData).toBe(true);
@@ -112,7 +105,7 @@ describe('monitoring/utils', () => {
let threeMetrics;
let fourMetrics;
beforeEach(() => {
- oneMetric = graphDataPrometheusQuery;
+ oneMetric = singleStatMetricsResult;
threeMetrics = anomalyMockGraphData;
const metrics = [...threeMetrics.metrics];
@@ -139,18 +132,25 @@ describe('monitoring/utils', () => {
});
describe('timeRangeFromUrl', () => {
- const { timeRangeFromUrl } = monitoringUtils;
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'queryToObject');
+ });
+
+ afterEach(() => {
+ urlUtils.queryToObject.mockRestore();
+ });
- it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
- queryToObject.mockReturnValueOnce(range);
+ const { timeRangeFromUrl } = monitoringUtils;
+ it('returns a fixed range when query contains `start` and `end` parameters are given', () => {
+ urlUtils.queryToObject.mockReturnValueOnce(range);
expect(timeRangeFromUrl()).toEqual(range);
});
- it('returns a rolling range when query contains `duration_seconds` paramters are given', () => {
+ it('returns a rolling range when query contains `duration_seconds` parameters are given', () => {
const { seconds } = rollingRange.duration;
- queryToObject.mockReturnValueOnce({
+ urlUtils.queryToObject.mockReturnValueOnce({
dashboard: '.gitlab/dashboard/my_dashboard.yml',
duration_seconds: `${seconds}`,
});
@@ -158,23 +158,59 @@ describe('monitoring/utils', () => {
expect(timeRangeFromUrl()).toEqual(rollingRange);
});
- it('returns null when no time range paramters are given', () => {
- const params = {
+ it('returns null when no time range parameters are given', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
dashboard: '.gitlab/dashboards/custom_dashboard.yml',
param1: 'value1',
param2: 'value2',
- };
+ });
- expect(timeRangeFromUrl(params, mockPath)).toBe(null);
+ expect(timeRangeFromUrl()).toBe(null);
+ });
+ });
+
+ describe('getPromCustomVariablesFromUrl', () => {
+ const { getPromCustomVariablesFromUrl } = monitoringUtils;
+
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'queryToObject');
+ });
+
+ afterEach(() => {
+ urlUtils.queryToObject.mockRestore();
+ });
+
+ it('returns an object with only the custom variables', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
+ dashboard: '.gitlab/dashboards/custom_dashboard.yml',
+ y_label: 'memory usage',
+ group: 'kubernetes',
+ title: 'Kubernetes memory total',
+ start: '2020-05-06',
+ end: '2020-05-07',
+ duration_seconds: '86400',
+ direction: 'left',
+ anchor: 'top',
+ pod: 'POD',
+ 'var-pod': 'POD',
+ });
+
+ expect(getPromCustomVariablesFromUrl()).toEqual(expect.objectContaining({ pod: 'POD' }));
+ });
+
+ it('returns an empty object when no custom variables are present', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
+ dashboard: '.gitlab/dashboards/custom_dashboard.yml',
+ });
+
+ expect(getPromCustomVariablesFromUrl()).toStrictEqual({});
});
});
describe('removeTimeRangeParams', () => {
const { removeTimeRangeParams } = monitoringUtils;
- it('returns when query contains `start` and `end` paramters are given', () => {
- removeParams.mockReturnValueOnce(mockPath);
-
+ it('returns when query contains `start` and `end` parameters are given', () => {
expect(removeTimeRangeParams(`${mockPath}?start=${range.start}&end=${range.end}`)).toEqual(
mockPath,
);
@@ -184,28 +220,126 @@ describe('monitoring/utils', () => {
describe('timeRangeToUrl', () => {
const { timeRangeToUrl } = monitoringUtils;
- it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'mergeUrlParams');
+ jest.spyOn(urlUtils, 'removeParams');
+ });
+
+ afterEach(() => {
+ urlUtils.mergeUrlParams.mockRestore();
+ urlUtils.removeParams.mockRestore();
+ });
+
+ it('returns a fixed range when query contains `start` and `end` parameters are given', () => {
const toUrl = `${mockPath}?start=${range.start}&end=${range.end}`;
const fromUrl = mockPath;
- removeParams.mockReturnValueOnce(fromUrl);
- mergeUrlParams.mockReturnValueOnce(toUrl);
+ urlUtils.removeParams.mockReturnValueOnce(fromUrl);
+ urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl);
expect(timeRangeToUrl(range)).toEqual(toUrl);
- expect(mergeUrlParams).toHaveBeenCalledWith(range, fromUrl);
+ expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(range, fromUrl);
});
- it('returns a rolling range when query contains `duration_seconds` paramters are given', () => {
+ it('returns a rolling range when query contains `duration_seconds` parameters are given', () => {
const { seconds } = rollingRange.duration;
const toUrl = `${mockPath}?duration_seconds=${seconds}`;
const fromUrl = mockPath;
- removeParams.mockReturnValueOnce(fromUrl);
- mergeUrlParams.mockReturnValueOnce(toUrl);
+ urlUtils.removeParams.mockReturnValueOnce(fromUrl);
+ urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl);
expect(timeRangeToUrl(rollingRange)).toEqual(toUrl);
- expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: `${seconds}` }, fromUrl);
+ expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(
+ { duration_seconds: `${seconds}` },
+ fromUrl,
+ );
+ });
+ });
+
+ describe('expandedPanelPayloadFromUrl', () => {
+ const { expandedPanelPayloadFromUrl } = monitoringUtils;
+ const [panelGroup] = metricsDashboardViewModel.panelGroups;
+ const [panel] = panelGroup.panels;
+
+ const { group } = panelGroup;
+ const { title, y_label: yLabel } = panel;
+
+ it('returns payload for a panel when query parameters are given', () => {
+ const search = `?group=${group}&title=${title}&y_label=${yLabel}`;
+
+ expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toEqual({
+ group: panelGroup.group,
+ panel,
+ });
+ });
+
+ it('returns null when no parameters are given', () => {
+ expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, '')).toBe(null);
+ });
+
+ it('throws an error when no group is provided', () => {
+ const search = `?title=${panel.title}&y_label=${yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ it('throws an error when no title is provided', () => {
+ const search = `?title=${title}&y_label=${yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ it('throws an error when no y_label group is provided', () => {
+ const search = `?group=${group}&title=${title}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ test.each`
+ group | title | yLabel | missingField
+ ${'NOT_A_GROUP'} | ${title} | ${yLabel} | ${'group'}
+ ${group} | ${'NOT_A_TITLE'} | ${yLabel} | ${'title'}
+ ${group} | ${title} | ${'NOT_A_Y_LABEL'} | ${'y_label'}
+ `('throws an error when $missingField is incorrect', params => {
+ const search = `?group=${params.group}&title=${params.title}&y_label=${params.yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+ });
+
+ describe('panelToUrl', () => {
+ const { panelToUrl } = monitoringUtils;
+
+ const dashboard = 'metrics.yml';
+ const [panelGroup] = metricsDashboardViewModel.panelGroups;
+ const [panel] = panelGroup.panels;
+
+ const getUrlParams = url => urlUtils.queryToObject(url.split('?')[1]);
+
+ it('returns URL for a panel when query parameters are given', () => {
+ const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, panel));
+
+ expect(params).toEqual(
+ expect.objectContaining({
+ dashboard,
+ group: panelGroup.group,
+ title: panel.title,
+ y_label: panel.y_label,
+ }),
+ );
+ });
+
+ it('returns a dashboard only URL if group is missing', () => {
+ const params = getUrlParams(panelToUrl(dashboard, {}, null, panel));
+ expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' }));
+ });
+
+ it('returns a dashboard only URL if panel is missing', () => {
+ const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, null));
+ expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' }));
+ });
+
+ it('returns URL for a panel when query paramters are given including custom variables', () => {
+ const params = getUrlParams(panelToUrl(dashboard, { pod: 'pod' }, panelGroup.group, null));
+ expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml', pod: 'pod' }));
});
});
@@ -271,4 +405,108 @@ describe('monitoring/utils', () => {
});
});
});
+
+ describe('removePrefixFromLabel', () => {
+ it.each`
+ input | expected
+ ${undefined} | ${''}
+ ${null} | ${''}
+ ${''} | ${''}
+ ${' '} | ${' '}
+ ${'pod-1'} | ${'pod-1'}
+ ${'pod-var-1'} | ${'pod-var-1'}
+ ${'pod-1-var'} | ${'pod-1-var'}
+ ${'podvar--1'} | ${'podvar--1'}
+ ${'povar-d-1'} | ${'povar-d-1'}
+ ${'var-pod-1'} | ${'pod-1'}
+ ${'var-var-pod-1'} | ${'var-pod-1'}
+ ${'varvar-pod-1'} | ${'varvar-pod-1'}
+ ${'var-pod-1-var-'} | ${'pod-1-var-'}
+ `('removePrefixFromLabel returns $expected with input $input', ({ input, expected }) => {
+ expect(monitoringUtils.removePrefixFromLabel(input)).toEqual(expected);
+ });
+ });
+
+ describe('mergeURLVariables', () => {
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'queryToObject');
+ });
+
+ afterEach(() => {
+ urlUtils.queryToObject.mockRestore();
+ });
+
+ it('returns empty object if variables are not defined in yml or URL', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({});
+
+ expect(monitoringUtils.mergeURLVariables({})).toEqual({});
+ });
+
+ it('returns empty object if variables are defined in URL but not in yml', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
+ 'var-env': 'one',
+ 'var-instance': 'localhost',
+ });
+
+ expect(monitoringUtils.mergeURLVariables({})).toEqual({});
+ });
+
+ it('returns yml variables if variables defined in yml but not in the URL', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({});
+
+ const params = {
+ env: 'one',
+ instance: 'localhost',
+ };
+
+ expect(monitoringUtils.mergeURLVariables(params)).toEqual(params);
+ });
+
+ it('returns yml variables if variables defined in URL do not match with yml variables', () => {
+ const urlParams = {
+ 'var-env': 'one',
+ 'var-instance': 'localhost',
+ };
+ const ymlParams = {
+ pod: { value: 'one' },
+ service: { value: 'database' },
+ };
+ urlUtils.queryToObject.mockReturnValueOnce(urlParams);
+
+ expect(monitoringUtils.mergeURLVariables(ymlParams)).toEqual(ymlParams);
+ });
+
+ it('returns merged yml and URL variables if there is some match', () => {
+ const urlParams = {
+ 'var-env': 'one',
+ 'var-instance': 'localhost:8080',
+ };
+ const ymlParams = {
+ instance: { value: 'localhost' },
+ service: { value: 'database' },
+ };
+
+ const merged = {
+ instance: { value: 'localhost:8080' },
+ service: { value: 'database' },
+ };
+
+ urlUtils.queryToObject.mockReturnValueOnce(urlParams);
+
+ expect(monitoringUtils.mergeURLVariables(ymlParams)).toEqual(merged);
+ });
+ });
+
+ describe('convertVariablesForURL', () => {
+ it.each`
+ input | expected
+ ${undefined} | ${{}}
+ ${null} | ${{}}
+ ${{}} | ${{}}
+ ${{ env: { value: 'prod' } }} | ${{ 'var-env': 'prod' }}
+ ${{ 'var-env': { value: 'prod' } }} | ${{ 'var-var-env': 'prod' }}
+ `('convertVariablesForURL returns $expected with input $input', ({ input, expected }) => {
+ expect(monitoringUtils.convertVariablesForURL(input)).toEqual(expected);
+ });
+ });
});
diff --git a/spec/frontend/monitoring/validators_spec.js b/spec/frontend/monitoring/validators_spec.js
new file mode 100644
index 00000000000..0c3d77a7d98
--- /dev/null
+++ b/spec/frontend/monitoring/validators_spec.js
@@ -0,0 +1,80 @@
+import { alertsValidator, queriesValidator } from '~/monitoring/validators';
+
+describe('alertsValidator', () => {
+ const validAlert = {
+ alert_path: 'my/alert.json',
+ operator: '<',
+ threshold: 5,
+ metricId: '8',
+ };
+ it('requires all alerts to have an alert path', () => {
+ const { operator, threshold, metricId } = validAlert;
+ const input = {
+ [validAlert.alert_path]: {
+ operator,
+ threshold,
+ metricId,
+ },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires that the object key matches the alert path', () => {
+ const input = {
+ undefined: validAlert,
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires all alerts to have a metric id', () => {
+ const input = {
+ [validAlert.alert_path]: { ...validAlert, metricId: undefined },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires the metricId to be a string', () => {
+ const input = {
+ [validAlert.alert_path]: { ...validAlert, metricId: 8 },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires all alerts to have an operator', () => {
+ const input = {
+ [validAlert.alert_path]: { ...validAlert, operator: '' },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('requires all alerts to have an numeric threshold', () => {
+ const input = {
+ [validAlert.alert_path]: { ...validAlert, threshold: '60' },
+ };
+ expect(alertsValidator(input)).toEqual(false);
+ });
+ it('correctly identifies a valid alerts object', () => {
+ const input = {
+ [validAlert.alert_path]: validAlert,
+ };
+ expect(alertsValidator(input)).toEqual(true);
+ });
+});
+describe('queriesValidator', () => {
+ const validQuery = {
+ metricId: '8',
+ alert_path: 'alert',
+ label: 'alert-label',
+ };
+ it('requires all alerts to have a metric id', () => {
+ const input = [{ ...validQuery, metricId: undefined }];
+ expect(queriesValidator(input)).toEqual(false);
+ });
+ it('requires the metricId to be a string', () => {
+ const input = [{ ...validQuery, metricId: 8 }];
+ expect(queriesValidator(input)).toEqual(false);
+ });
+ it('requires all queries to have a label', () => {
+ const input = [{ ...validQuery, label: undefined }];
+ expect(queriesValidator(input)).toEqual(false);
+ });
+ it('correctly identifies a valid queries array', () => {
+ const input = [validQuery];
+ expect(queriesValidator(input)).toEqual(true);
+ });
+});
diff --git a/spec/frontend/notebook/cells/code_spec.js b/spec/frontend/notebook/cells/code_spec.js
new file mode 100644
index 00000000000..33dabe2b6dc
--- /dev/null
+++ b/spec/frontend/notebook/cells/code_spec.js
@@ -0,0 +1,90 @@
+import Vue from 'vue';
+import CodeComponent from '~/notebook/cells/code.vue';
+
+const Component = Vue.extend(CodeComponent);
+
+describe('Code component', () => {
+ let vm;
+ let json;
+
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/basic.json');
+ });
+
+ const setupComponent = cell => {
+ const comp = new Component({
+ propsData: {
+ cell,
+ },
+ });
+ comp.$mount();
+ return comp;
+ };
+
+ describe('without output', () => {
+ beforeEach(done => {
+ vm = setupComponent(json.cells[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('does not render output prompt', () => {
+ expect(vm.$el.querySelectorAll('.prompt').length).toBe(1);
+ });
+ });
+
+ describe('with output', () => {
+ beforeEach(done => {
+ vm = setupComponent(json.cells[2]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('does not render output prompt', () => {
+ expect(vm.$el.querySelectorAll('.prompt').length).toBe(2);
+ });
+
+ it('renders output cell', () => {
+ expect(vm.$el.querySelector('.output')).toBeDefined();
+ });
+ });
+
+ describe('with string for output', () => {
+ // NBFormat Version 4.1 allows outputs.text to be a string
+ beforeEach(() => {
+ const cell = json.cells[2];
+ cell.outputs[0].text = cell.outputs[0].text.join('');
+
+ vm = setupComponent(cell);
+ return vm.$nextTick();
+ });
+
+ it('does not render output prompt', () => {
+ expect(vm.$el.querySelectorAll('.prompt').length).toBe(2);
+ });
+
+ it('renders output cell', () => {
+ expect(vm.$el.querySelector('.output')).toBeDefined();
+ });
+ });
+
+ describe('with string for cell.source', () => {
+ beforeEach(() => {
+ const cell = json.cells[0];
+ cell.source = cell.source.join('');
+
+ vm = setupComponent(cell);
+ return vm.$nextTick();
+ });
+
+ it('renders the same input as when cell.source is an array', () => {
+ const expected = "console.log('test')";
+
+ expect(vm.$el.querySelector('.input').innerText).toContain(expected);
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js
new file mode 100644
index 00000000000..ad33858da22
--- /dev/null
+++ b/spec/frontend/notebook/cells/markdown_spec.js
@@ -0,0 +1,167 @@
+import Vue from 'vue';
+import katex from 'katex';
+import MarkdownComponent from '~/notebook/cells/markdown.vue';
+
+const Component = Vue.extend(MarkdownComponent);
+
+window.katex = katex;
+
+describe('Markdown component', () => {
+ let vm;
+ let cell;
+ let json;
+
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/basic.json');
+
+ // eslint-disable-next-line prefer-destructuring
+ cell = json.cells[1];
+
+ vm = new Component({
+ propsData: {
+ cell,
+ },
+ });
+ vm.$mount();
+
+ return vm.$nextTick();
+ });
+
+ it('does not render promot', () => {
+ expect(vm.$el.querySelector('.prompt span')).toBeNull();
+ });
+
+ it('does not render the markdown text', () => {
+ expect(vm.$el.querySelector('.markdown').innerHTML.trim()).not.toEqual(cell.source.join(''));
+ });
+
+ it('renders the markdown HTML', () => {
+ expect(vm.$el.querySelector('.markdown h1')).not.toBeNull();
+ });
+
+ it('sanitizes output', () => {
+ Object.assign(cell, {
+ source: [
+ '[XSS](data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ+Cg==)\n',
+ ],
+ });
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('a').getAttribute('href')).toBeNull();
+ });
+ });
+
+ describe('katex', () => {
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/math.json');
+ });
+
+ it('renders multi-line katex', () => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[0],
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.katex')).not.toBeNull();
+ });
+ });
+
+ it('renders inline katex', () => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[1],
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull();
+ });
+ });
+
+ it('renders multiple inline katex', () => {
+ vm = new Component({
+ propsData: {
+ cell: json.cells[1],
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelectorAll('p:nth-child(2) .katex').length).toBe(4);
+ });
+ });
+
+ it('output cell in case of katex error', () => {
+ vm = new Component({
+ propsData: {
+ cell: {
+ cell_type: 'markdown',
+ metadata: {},
+ source: ['Some invalid $a & b$ inline formula $b & c$\n', '\n'],
+ },
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ // expect one paragraph with no katex formula in it
+ expect(vm.$el.querySelectorAll('p').length).toBe(1);
+ expect(vm.$el.querySelectorAll('p .katex').length).toBe(0);
+ });
+ });
+
+ it('output cell and render remaining formula in case of katex error', () => {
+ vm = new Component({
+ propsData: {
+ cell: {
+ cell_type: 'markdown',
+ metadata: {},
+ source: ['An invalid $a & b$ inline formula and a vaild one $b = c$\n', '\n'],
+ },
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ // expect one paragraph with no katex formula in it
+ expect(vm.$el.querySelectorAll('p').length).toBe(1);
+ expect(vm.$el.querySelectorAll('p .katex').length).toBe(1);
+ });
+ });
+
+ it('renders math formula in list object', () => {
+ vm = new Component({
+ propsData: {
+ cell: {
+ cell_type: 'markdown',
+ metadata: {},
+ source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'],
+ },
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ // expect one list with a katex formula in it
+ expect(vm.$el.querySelectorAll('li').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li .katex').length).toBe(2);
+ });
+ });
+
+ it("renders math formula with tick ' in it", () => {
+ vm = new Component({
+ propsData: {
+ cell: {
+ cell_type: 'markdown',
+ metadata: {},
+ source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'],
+ },
+ },
+ }).$mount();
+
+ return vm.$nextTick().then(() => {
+ // expect one list with a katex formula in it
+ expect(vm.$el.querySelectorAll('li').length).toBe(1);
+ expect(vm.$el.querySelectorAll('li .katex').length).toBe(2);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/output/html_sanitize_tests.js b/spec/frontend/notebook/cells/output/html_sanitize_tests.js
new file mode 100644
index 00000000000..74c48f04367
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/html_sanitize_tests.js
@@ -0,0 +1,68 @@
+export default {
+ 'protocol-based JS injection: simple, no spaces': {
+ input: '<a href="javascript:alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: simple, spaces before': {
+ input: '<a href="javascript :alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: simple, spaces after': {
+ input: '<a href="javascript: alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: simple, spaces before and after': {
+ input: '<a href="javascript : alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: preceding colon': {
+ input: '<a href=":javascript:alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: UTF-8 encoding': {
+ input: '<a href="javascript&#58;">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: long UTF-8 encoding': {
+ input: '<a href="javascript&#0058;">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: long UTF-8 encoding without semicolons': {
+ input:
+ '<a href=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041>foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: hex encoding': {
+ input: '<a href="javascript&#x3A;">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: long hex encoding': {
+ input: '<a href="javascript&#x003A;">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: hex encoding without semicolons': {
+ input:
+ '<a href=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29>foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: null char': {
+ input: '<a href=java\0script:alert("XSS")>foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: invalid URL char': {
+ input: '<img src=javascript:alert("XSS")>',
+ output: '<img>',
+ },
+ 'protocol-based JS injection: Unicode': {
+ input: '<a href="\u0001java\u0003script:alert(\'XSS\')">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'protocol-based JS injection: spaces and entities': {
+ input: '<a href=" &#14; javascript:alert(\'XSS\');">foo</a>',
+ output: '<a>foo</a>',
+ },
+ 'img on error': {
+ input: '<img src="x" onerror="alert(document.domain)" />',
+ output: '<img src="x">',
+ },
+};
diff --git a/spec/frontend/notebook/cells/output/html_spec.js b/spec/frontend/notebook/cells/output/html_spec.js
new file mode 100644
index 00000000000..3ee404fb187
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/html_spec.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import htmlOutput from '~/notebook/cells/output/html.vue';
+import sanitizeTests from './html_sanitize_tests';
+
+describe('html output cell', () => {
+ function createComponent(rawCode) {
+ const Component = Vue.extend(htmlOutput);
+
+ return new Component({
+ propsData: {
+ rawCode,
+ count: 0,
+ index: 0,
+ },
+ }).$mount();
+ }
+
+ describe('sanitizes output', () => {
+ Object.keys(sanitizeTests).forEach(key => {
+ it(key, () => {
+ const test = sanitizeTests[key];
+ const vm = createComponent(test.input);
+ const outputEl = [...vm.$el.querySelectorAll('div')].pop();
+
+ expect(outputEl.innerHTML).toEqual(test.output);
+
+ vm.$destroy();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js
new file mode 100644
index 00000000000..2b1aa5317c5
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/index_spec.js
@@ -0,0 +1,115 @@
+import Vue from 'vue';
+import CodeComponent from '~/notebook/cells/output/index.vue';
+
+const Component = Vue.extend(CodeComponent);
+
+describe('Output component', () => {
+ let vm;
+ let json;
+
+ const createComponent = output => {
+ vm = new Component({
+ propsData: {
+ outputs: [].concat(output),
+ count: 1,
+ },
+ });
+ vm.$mount();
+ };
+
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/basic.json');
+ });
+
+ describe('text output', () => {
+ beforeEach(done => {
+ createComponent(json.cells[2].outputs[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders as plain text', () => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ });
+
+ it('renders promot', () => {
+ expect(vm.$el.querySelector('.prompt span')).not.toBeNull();
+ });
+ });
+
+ describe('image output', () => {
+ beforeEach(done => {
+ createComponent(json.cells[3].outputs[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders as an image', () => {
+ expect(vm.$el.querySelector('img')).not.toBeNull();
+ });
+ });
+
+ describe('html output', () => {
+ it('renders raw HTML', () => {
+ createComponent(json.cells[4].outputs[0]);
+
+ expect(vm.$el.querySelector('p')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('p').length).toBe(1);
+ expect(vm.$el.textContent.trim()).toContain('test');
+ });
+
+ it('renders multiple raw HTML outputs', () => {
+ createComponent([json.cells[4].outputs[0], json.cells[4].outputs[0]]);
+
+ expect(vm.$el.querySelectorAll('p').length).toBe(2);
+ });
+ });
+
+ describe('svg output', () => {
+ beforeEach(done => {
+ createComponent(json.cells[5].outputs[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders as an svg', () => {
+ expect(vm.$el.querySelector('svg')).not.toBeNull();
+ });
+ });
+
+ describe('default to plain text', () => {
+ beforeEach(done => {
+ createComponent(json.cells[6].outputs[0]);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders as plain text', () => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ expect(vm.$el.textContent.trim()).toContain('testing');
+ });
+
+ it('renders promot', () => {
+ expect(vm.$el.querySelector('.prompt span')).not.toBeNull();
+ });
+
+ it("renders as plain text when doesn't recognise other types", done => {
+ createComponent(json.cells[7].outputs[0]);
+
+ setImmediate(() => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ expect(vm.$el.textContent.trim()).toContain('testing');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/prompt_spec.js b/spec/frontend/notebook/cells/prompt_spec.js
new file mode 100644
index 00000000000..cf5a7a603c6
--- /dev/null
+++ b/spec/frontend/notebook/cells/prompt_spec.js
@@ -0,0 +1,56 @@
+import Vue from 'vue';
+import PromptComponent from '~/notebook/cells/prompt.vue';
+
+const Component = Vue.extend(PromptComponent);
+
+describe('Prompt component', () => {
+ let vm;
+
+ describe('input', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ type: 'In',
+ count: 1,
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders in label', () => {
+ expect(vm.$el.textContent.trim()).toContain('In');
+ });
+
+ it('renders count', () => {
+ expect(vm.$el.textContent.trim()).toContain('1');
+ });
+ });
+
+ describe('output', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ type: 'Out',
+ count: 1,
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders in label', () => {
+ expect(vm.$el.textContent.trim()).toContain('Out');
+ });
+
+ it('renders count', () => {
+ expect(vm.$el.textContent.trim()).toContain('1');
+ });
+ });
+});
diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js
new file mode 100644
index 00000000000..36b092be976
--- /dev/null
+++ b/spec/frontend/notebook/index_spec.js
@@ -0,0 +1,100 @@
+import Vue from 'vue';
+import Notebook from '~/notebook/index.vue';
+
+const Component = Vue.extend(Notebook);
+
+describe('Notebook component', () => {
+ let vm;
+ let json;
+ let jsonWithWorksheet;
+
+ beforeEach(() => {
+ json = getJSONFixture('blob/notebook/basic.json');
+ jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json');
+ });
+
+ describe('without JSON', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ notebook: {},
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('does not render', () => {
+ expect(vm.$el.tagName).toBeUndefined();
+ });
+ });
+
+ describe('with JSON', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ notebook: json,
+ codeCssClass: 'js-code-class',
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders cells', () => {
+ expect(vm.$el.querySelectorAll('.cell').length).toBe(json.cells.length);
+ });
+
+ it('renders markdown cell', () => {
+ expect(vm.$el.querySelector('.markdown')).not.toBeNull();
+ });
+
+ it('renders code cell', () => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ });
+
+ it('add code class to code blocks', () => {
+ expect(vm.$el.querySelector('.js-code-class')).not.toBeNull();
+ });
+ });
+
+ describe('with worksheets', () => {
+ beforeEach(done => {
+ vm = new Component({
+ propsData: {
+ notebook: jsonWithWorksheet,
+ codeCssClass: 'js-code-class',
+ },
+ });
+ vm.$mount();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('renders cells', () => {
+ expect(vm.$el.querySelectorAll('.cell').length).toBe(
+ jsonWithWorksheet.worksheets[0].cells.length,
+ );
+ });
+
+ it('renders markdown cell', () => {
+ expect(vm.$el.querySelector('.markdown')).not.toBeNull();
+ });
+
+ it('renders code cell', () => {
+ expect(vm.$el.querySelector('pre')).not.toBeNull();
+ });
+
+ it('add code class to code blocks', () => {
+ expect(vm.$el.querySelector('.js-code-class')).not.toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index a2c7f0b3767..dc68c4371aa 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -9,12 +9,7 @@ import CommentForm from '~/notes/components/comment_form.vue';
import * as constants from '~/notes/constants';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { keyboardDownEvent } from '../../issue_show/helpers';
-import {
- loggedOutnoteableData,
- notesDataMock,
- userDataMock,
- noteableDataMock,
-} from '../../notes/mock_data';
+import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
jest.mock('autosize');
jest.mock('~/commons/nav/user_merge_requests');
diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js
index 5101b81e3ee..44dc148933c 100644
--- a/spec/frontend/notes/components/discussion_actions_spec.js
+++ b/spec/frontend/notes/components/discussion_actions_spec.js
@@ -1,5 +1,5 @@
import { shallowMount, mount } from '@vue/test-utils';
-import { discussionMock } from '../../notes/mock_data';
+import { discussionMock } from '../mock_data';
import DiscussionActions from '~/notes/components/discussion_actions.vue';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ResolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue';
diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js
index 77603c16f82..04535aa17c5 100644
--- a/spec/frontend/notes/components/discussion_counter_spec.js
+++ b/spec/frontend/notes/components/discussion_counter_spec.js
@@ -75,15 +75,14 @@ describe('DiscussionCounter component', () => {
});
it.each`
- title | resolved | isActive | icon | groupLength
- ${'not allResolved'} | ${false} | ${false} | ${'check-circle'} | ${3}
- ${'allResolved'} | ${true} | ${true} | ${'check-circle-filled'} | ${1}
- `('renders correctly if $title', ({ resolved, isActive, icon, groupLength }) => {
+ title | resolved | isActive | groupLength
+ ${'not allResolved'} | ${false} | ${false} | ${3}
+ ${'allResolved'} | ${true} | ${true} | ${1}
+ `('renders correctly if $title', ({ resolved, isActive, groupLength }) => {
updateStore({ resolvable: true, resolved });
wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find(`.is-active`).exists()).toBe(isActive);
- expect(wrapper.find({ name: icon }).exists()).toBe(true);
expect(wrapper.findAll('[role="group"').length).toBe(groupLength);
});
});
diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js
index b8d2d721443..7f042c0e9de 100644
--- a/spec/frontend/notes/components/discussion_filter_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_spec.js
@@ -1,4 +1,4 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
@@ -132,7 +132,7 @@ describe('DiscussionFilter component', () => {
});
describe('Merge request tabs', () => {
- eventHub = new Vue();
+ eventHub = createEventHub();
beforeEach(() => {
window.mrTabs = {
diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js
index 81773752037..5a10deefd09 100644
--- a/spec/frontend/notes/components/discussion_notes_spec.js
+++ b/spec/frontend/notes/components/discussion_notes_spec.js
@@ -7,7 +7,7 @@ import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'
import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import SystemNote from '~/vue_shared/components/notes/system_note.vue';
import createStore from '~/notes/stores';
-import { noteableDataMock, discussionMock, notesDataMock } from '../../notes/mock_data';
+import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data';
describe('DiscussionNotes', () => {
let wrapper;
diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js
index bccac03126c..8270c148fb5 100644
--- a/spec/frontend/notes/components/note_form_spec.js
+++ b/spec/frontend/notes/components/note_form_spec.js
@@ -161,18 +161,18 @@ describe('issue_note_form component', () => {
describe('actions', () => {
it('should be possible to cancel', () => {
- // TODO: do not spy on vm
- jest.spyOn(wrapper.vm, 'cancelHandler');
+ const cancelHandler = jest.fn();
wrapper.setProps({
...props,
isEditing: true,
});
+ wrapper.setMethods({ cancelHandler });
return wrapper.vm.$nextTick().then(() => {
- const cancelButton = wrapper.find('.note-edit-cancel');
+ const cancelButton = wrapper.find('[data-testid="cancel"]');
cancelButton.trigger('click');
- expect(wrapper.vm.cancelHandler).toHaveBeenCalled();
+ expect(cancelHandler).toHaveBeenCalledWith(true);
});
});
diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js
index d477de69716..2bb08b60569 100644
--- a/spec/frontend/notes/components/note_header_spec.js
+++ b/spec/frontend/notes/components/note_header_spec.js
@@ -1,7 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
import Vuex from 'vuex';
import NoteHeader from '~/notes/components/note_header.vue';
-import GitlabTeamMemberBadge from '~/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -18,6 +18,7 @@ describe('NoteHeader component', () => {
const findActionText = () => wrapper.find({ ref: 'actionText' });
const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' });
const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' });
+ const findConfidentialIndicator = () => wrapper.find('[data-testid="confidentialIndicator"]');
const findSpinner = () => wrapper.find({ ref: 'spinner' });
const author = {
@@ -140,20 +141,6 @@ describe('NoteHeader component', () => {
});
});
- test.each`
- props | expected | message1 | message2
- ${{ author: { ...author, is_gitlab_employee: true } }} | ${true} | ${'renders'} | ${'true'}
- ${{ author: { ...author, is_gitlab_employee: false } }} | ${false} | ${"doesn't render"} | ${'false'}
- ${{ author }} | ${false} | ${"doesn't render"} | ${'undefined'}
- `(
- '$message1 GitLab team member badge when `is_gitlab_employee` is $message2',
- ({ props, expected }) => {
- createComponent(props);
-
- expect(wrapper.find(GitlabTeamMemberBadge).exists()).toBe(expected);
- },
- );
-
describe('loading spinner', () => {
it('shows spinner when showSpinner is true', () => {
createComponent();
@@ -179,4 +166,81 @@ describe('NoteHeader component', () => {
expect(findTimestamp().exists()).toBe(true);
});
});
+
+ describe('author username link', () => {
+ it('proxies `mouseenter` event to author name link', () => {
+ createComponent({ author });
+
+ const dispatchEvent = jest.spyOn(wrapper.vm.$refs.authorNameLink, 'dispatchEvent');
+
+ wrapper.find({ ref: 'authorUsernameLink' }).trigger('mouseenter');
+
+ expect(dispatchEvent).toHaveBeenCalledWith(new Event('mouseenter'));
+ });
+
+ it('proxies `mouseleave` event to author name link', () => {
+ createComponent({ author });
+
+ const dispatchEvent = jest.spyOn(wrapper.vm.$refs.authorNameLink, 'dispatchEvent');
+
+ wrapper.find({ ref: 'authorUsernameLink' }).trigger('mouseleave');
+
+ expect(dispatchEvent).toHaveBeenCalledWith(new Event('mouseleave'));
+ });
+ });
+
+ describe('when author status tooltip is opened', () => {
+ it('removes `title` attribute from emoji to prevent duplicate tooltips', () => {
+ createComponent({
+ author: {
+ ...author,
+ status_tooltip_html:
+ '"<span class="user-status-emoji has-tooltip" title="foo bar" data-html="true" data-placement="top"><gl-emoji title="basketball and hoop" data-name="basketball" data-unicode-version="6.0">🏀</gl-emoji></span>"',
+ },
+ });
+
+ return nextTick().then(() => {
+ const authorStatus = wrapper.find({ ref: 'authorStatus' });
+ authorStatus.trigger('mouseenter');
+
+ expect(authorStatus.find('gl-emoji').attributes('title')).toBeUndefined();
+ });
+ });
+ });
+
+ describe('when author username link is hovered', () => {
+ it('toggles hover specific CSS classes on author name link', done => {
+ createComponent({ author });
+
+ const authorUsernameLink = wrapper.find({ ref: 'authorUsernameLink' });
+ const authorNameLink = wrapper.find({ ref: 'authorNameLink' });
+
+ authorUsernameLink.trigger('mouseenter');
+
+ nextTick(() => {
+ expect(authorNameLink.classes()).toContain('hover');
+ expect(authorNameLink.classes()).toContain('text-underline');
+
+ authorUsernameLink.trigger('mouseleave');
+
+ nextTick(() => {
+ expect(authorNameLink.classes()).not.toContain('hover');
+ expect(authorNameLink.classes()).not.toContain('text-underline');
+
+ done();
+ });
+ });
+ });
+ });
+
+ describe('with confidentiality indicator', () => {
+ it.each`
+ status | condition
+ ${true} | ${'shows'}
+ ${false} | ${'hides'}
+ `('$condition icon indicator when isConfidential is $status', ({ status }) => {
+ createComponent({ isConfidential: status });
+ expect(findConfidentialIndicator().exists()).toBe(status);
+ });
+ });
});
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index b91f599f158..b14ec2a65be 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -138,7 +138,7 @@ describe('noteable_discussion component', () => {
describe('signout widget', () => {
beforeEach(() => {
- originalGon = Object.assign({}, window.gon);
+ originalGon = { ...window.gon };
window.gon = window.gon || {};
});
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index e22dd85f221..fbfba2efb1d 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -10,7 +10,7 @@ import createStore from '~/notes/stores';
import * as constants from '~/notes/constants';
import '~/behaviors/markdown/render_gfm';
// TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-foss/issues/62491)
-import * as mockData from '../../notes/mock_data';
+import * as mockData from '../mock_data';
import * as urlUtility from '~/lib/utils/url_utility';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js
index 4e5325b8bc3..120de023099 100644
--- a/spec/frontend/notes/mixins/discussion_navigation_spec.js
+++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js
@@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import * as utils from '~/lib/utils/common_utils';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
import eventHub from '~/notes/event_hub';
+import createEventHub from '~/helpers/event_hub_factory';
import notesModule from '~/notes/stores/modules';
import { setHTMLFixture } from 'helpers/fixtures';
@@ -67,8 +68,7 @@ describe('Discussion navigation mixin', () => {
describe('cycle through discussions', () => {
beforeEach(() => {
- // eslint-disable-next-line new-cap
- window.mrTabs = { eventHub: new localVue(), tabShown: jest.fn() };
+ window.mrTabs = { eventHub: createEventHub(), tabShown: jest.fn() };
});
describe.each`
diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js
index 9ed79c61c22..980faac2b04 100644
--- a/spec/frontend/notes/mock_data.js
+++ b/spec/frontend/notes/mock_data.js
@@ -57,6 +57,7 @@ export const noteableDataMock = {
updated_by_id: 1,
web_url: '/gitlab-org/gitlab-foss/issues/26',
noteableType: 'issue',
+ blocked_by_issues: [],
};
export const lastFetchedAt = '1501862675';
diff --git a/spec/frontend/notes/old_notes_spec.js b/spec/frontend/notes/old_notes_spec.js
index 49b887b21b4..cb1d563ece7 100644
--- a/spec/frontend/notes/old_notes_spec.js
+++ b/spec/frontend/notes/old_notes_spec.js
@@ -33,7 +33,6 @@ gl.utils.disableButtonIfEmptyField = () => {};
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('Old Notes (~/notes.js)', () => {
beforeEach(() => {
- jest.useFakeTimers();
loadFixtures(fixture);
// Re-declare this here so that test_setup.js#beforeEach() doesn't
@@ -194,7 +193,7 @@ describe.skip('Old Notes (~/notes.js)', () => {
$('.js-comment-button').click();
const $targetNote = $notesContainer.find(`#note_${noteEntity.id}`);
- const updatedNote = Object.assign({}, noteEntity);
+ const updatedNote = { ...noteEntity };
updatedNote.note = 'bar';
notes.updateNote(updatedNote, $targetNote);
@@ -213,13 +212,6 @@ describe.skip('Old Notes (~/notes.js)', () => {
jest.spyOn($note, 'toggleClass');
});
- afterEach(() => {
- expect(typeof urlUtility.getLocationHash.mock).toBe('object');
- urlUtility.getLocationHash.mockRestore();
- expect(urlUtility.getLocationHash.mock).toBeUndefined();
- expect(urlUtility.getLocationHash()).toBeNull();
- });
-
// urlUtility is a dependency of the notes module. Its getLocatinHash() method should be called internally.
it('sets target when hash matches', () => {
@@ -630,48 +622,6 @@ describe.skip('Old Notes (~/notes.js)', () => {
done();
});
});
-
- // This is a bad test carried over from the Karma -> Jest migration.
- // The corresponding test in the Karma suite tests for
- // elements and methods that don't actually exist, and gives a false
- // positive pass.
- /*
- it('should show flash error message when comment failed to be updated', done => {
- mockNotesPost();
- jest.spyOn(notes, 'addFlash').mockName('addFlash');
-
- $('.js-comment-button').click();
-
- deferredPromise()
- .then(() => {
- const $noteEl = $notesContainer.find(`#note_${note.id}`);
- $noteEl.find('.js-note-edit').click();
- $noteEl.find('textarea.js-note-text').val(updatedComment);
-
- mockNotesPostError();
-
- $noteEl.find('.js-comment-save-button').click();
- notes.updateComment({preventDefault: () => {}});
- })
- .then(() => deferredPromise())
- .then(() => {
- const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`);
-
- expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals
- expect(
- $updatedNoteEl
- .find('.note-text')
- .text()
- .trim(),
- ).toEqual(sampleComment); // See if comment reverted back to original
-
- expect(notes.addFlash).toHaveBeenCalled();
- expect(notes.flashContainer.style.display).not.toBe('none');
- done();
- })
- .catch(done.fail);
- }, 5000);
- */
});
describe('postComment with Slash commands', () => {
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index 544d482e7fc..cbfb9597159 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -34,6 +34,11 @@ describe('Actions Notes Store', () => {
dispatch = jest.fn();
state = {};
axiosMock = new AxiosMockAdapter(axios);
+
+ // This is necessary as we query Close issue button at the top of issue page when clicking bottom button
+ setFixtures(
+ '<div class="detail-page-header-actions"><button class="btn-close btn-grouped"></button></div>',
+ );
});
afterEach(() => {
@@ -242,9 +247,31 @@ describe('Actions Notes Store', () => {
});
});
- describe('poll', () => {
- jest.useFakeTimers();
+ describe('toggleBlockedIssueWarning', () => {
+ it('should set issue warning as true', done => {
+ testAction(
+ actions.toggleBlockedIssueWarning,
+ true,
+ {},
+ [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: true }],
+ [],
+ done,
+ );
+ });
+ it('should set issue warning as false', done => {
+ testAction(
+ actions.toggleBlockedIssueWarning,
+ false,
+ {},
+ [{ type: 'TOGGLE_BLOCKED_ISSUE_WARNING', payload: false }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('poll', () => {
beforeEach(done => {
jest.spyOn(axios, 'get');
diff --git a/spec/frontend/notes/stores/collapse_utils_spec.js b/spec/frontend/notes/stores/collapse_utils_spec.js
index d3019f4b9a4..a74809eed79 100644
--- a/spec/frontend/notes/stores/collapse_utils_spec.js
+++ b/spec/frontend/notes/stores/collapse_utils_spec.js
@@ -18,9 +18,7 @@ describe('Collapse utils', () => {
});
it('returns false when a system note is not a description type', () => {
- expect(isDescriptionSystemNote(Object.assign({}, mockSystemNote, { note: 'foo' }))).toEqual(
- false,
- );
+ expect(isDescriptionSystemNote({ ...mockSystemNote, note: 'foo' })).toEqual(false);
});
it('gets the time difference between two notes', () => {
diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js
index 06d2654ceca..27e3490d64b 100644
--- a/spec/frontend/notes/stores/mutation_spec.js
+++ b/spec/frontend/notes/stores/mutation_spec.js
@@ -50,7 +50,7 @@ describe('Notes Store mutations', () => {
});
describe('ADD_NEW_REPLY_TO_DISCUSSION', () => {
- const newReply = Object.assign({}, note, { discussion_id: discussionMock.id });
+ const newReply = { ...note, discussion_id: discussionMock.id };
let state;
@@ -86,7 +86,7 @@ describe('Notes Store mutations', () => {
describe('EXPAND_DISCUSSION', () => {
it('should expand a collapsed discussion', () => {
- const discussion = Object.assign({}, discussionMock, { expanded: false });
+ const discussion = { ...discussionMock, expanded: false };
const state = {
discussions: [discussion],
@@ -100,7 +100,7 @@ describe('Notes Store mutations', () => {
describe('COLLAPSE_DISCUSSION', () => {
it('should collapse an expanded discussion', () => {
- const discussion = Object.assign({}, discussionMock, { expanded: true });
+ const discussion = { ...discussionMock, expanded: true };
const state = {
discussions: [discussion],
@@ -114,7 +114,7 @@ describe('Notes Store mutations', () => {
describe('REMOVE_PLACEHOLDER_NOTES', () => {
it('should remove all placeholder notes in indivudal notes and discussion', () => {
- const placeholderNote = Object.assign({}, individualNote, { isPlaceholderNote: true });
+ const placeholderNote = { ...individualNote, isPlaceholderNote: true };
const state = { discussions: [placeholderNote] };
mutations.REMOVE_PLACEHOLDER_NOTES(state);
@@ -298,7 +298,7 @@ describe('Notes Store mutations', () => {
describe('TOGGLE_DISCUSSION', () => {
it('should open a closed discussion', () => {
- const discussion = Object.assign({}, discussionMock, { expanded: false });
+ const discussion = { ...discussionMock, expanded: false };
const state = {
discussions: [discussion],
@@ -348,8 +348,8 @@ describe('Notes Store mutations', () => {
});
it('should open all closed discussions', () => {
- const discussion1 = Object.assign({}, discussionMock, { id: 0, expanded: false });
- const discussion2 = Object.assign({}, discussionMock, { id: 1, expanded: true });
+ const discussion1 = { ...discussionMock, id: 0, expanded: false };
+ const discussion2 = { ...discussionMock, id: 1, expanded: true };
const discussionIds = [discussion1.id, discussion2.id];
const state = { discussions: [discussion1, discussion2] };
@@ -362,8 +362,8 @@ describe('Notes Store mutations', () => {
});
it('should close all opened discussions', () => {
- const discussion1 = Object.assign({}, discussionMock, { id: 0, expanded: false });
- const discussion2 = Object.assign({}, discussionMock, { id: 1, expanded: true });
+ const discussion1 = { ...discussionMock, id: 0, expanded: false };
+ const discussion2 = { ...discussionMock, id: 1, expanded: true };
const discussionIds = [discussion1.id, discussion2.id];
const state = { discussions: [discussion1, discussion2] };
@@ -382,7 +382,7 @@ describe('Notes Store mutations', () => {
discussions: [individualNote],
};
- const updated = Object.assign({}, individualNote.notes[0], { note: 'Foo' });
+ const updated = { ...individualNote.notes[0], note: 'Foo' };
mutations.UPDATE_NOTE(state, updated);
@@ -664,4 +664,40 @@ describe('Notes Store mutations', () => {
expect(state.discussionSortOrder).toBe(DESC);
});
});
+
+ describe('TOGGLE_BLOCKED_ISSUE_WARNING', () => {
+ it('should set isToggleBlockedIssueWarning as true', () => {
+ const state = {
+ discussions: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ isToggleStateButtonLoading: false,
+ isToggleBlockedIssueWarning: false,
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ };
+
+ mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, true);
+
+ expect(state.isToggleBlockedIssueWarning).toEqual(true);
+ });
+
+ it('should set isToggleBlockedIssueWarning as false', () => {
+ const state = {
+ discussions: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ isToggleStateButtonLoading: false,
+ isToggleBlockedIssueWarning: true,
+ notesData: {},
+ userData: {},
+ noteableData: {},
+ };
+
+ mutations.TOGGLE_BLOCKED_ISSUE_WARNING(state, false);
+
+ expect(state.isToggleBlockedIssueWarning).toEqual(false);
+ });
+ });
});
diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js
new file mode 100644
index 00000000000..381be82697e
--- /dev/null
+++ b/spec/frontend/oauth_remember_me_spec.js
@@ -0,0 +1,39 @@
+import $ from 'jquery';
+import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me';
+
+describe('OAuthRememberMe', () => {
+ preloadFixtures('static/oauth_remember_me.html');
+
+ beforeEach(() => {
+ loadFixtures('static/oauth_remember_me.html');
+
+ new OAuthRememberMe({ container: $('#oauth-container') }).bindEvents();
+ });
+
+ it('adds the "remember_me" query parameter to all OAuth login buttons', () => {
+ $('#oauth-container #remember_me').click();
+
+ expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe(
+ 'http://example.com/?remember_me=1',
+ );
+
+ expect($('#oauth-container .oauth-login.github').attr('href')).toBe(
+ 'http://example.com/?remember_me=1',
+ );
+
+ expect($('#oauth-container .oauth-login.facebook').attr('href')).toBe(
+ 'http://example.com/?redirect_fragment=L1&remember_me=1',
+ );
+ });
+
+ it('removes the "remember_me" query parameter from all OAuth login buttons', () => {
+ $('#oauth-container #remember_me').click();
+ $('#oauth-container #remember_me').click();
+
+ expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe('http://example.com/');
+ expect($('#oauth-container .oauth-login.github').attr('href')).toBe('http://example.com/');
+ expect($('#oauth-container .oauth-login.facebook').attr('href')).toBe(
+ 'http://example.com/?redirect_fragment=L1',
+ );
+ });
+});
diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
new file mode 100644
index 00000000000..6a239e307e9
--- /dev/null
+++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+import initUserInternalRegexPlaceholder, {
+ PLACEHOLDER_USER_EXTERNAL_DEFAULT_FALSE,
+ PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE,
+} from '~/pages/admin/application_settings/account_and_limits';
+
+describe('AccountAndLimits', () => {
+ const FIXTURE = 'application_settings/accounts_and_limit.html';
+ let $userDefaultExternal;
+ let $userInternalRegex;
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ initUserInternalRegexPlaceholder();
+ $userDefaultExternal = $('#application_setting_user_default_external');
+ $userInternalRegex = document.querySelector('#application_setting_user_default_internal_regex');
+ });
+
+ describe('Changing of userInternalRegex when userDefaultExternal', () => {
+ it('is unchecked', () => {
+ expect($userDefaultExternal.prop('checked')).toBeFalsy();
+ expect($userInternalRegex.placeholder).toEqual(PLACEHOLDER_USER_EXTERNAL_DEFAULT_FALSE);
+ expect($userInternalRegex.readOnly).toBeTruthy();
+ });
+
+ it('is checked', done => {
+ if (!$userDefaultExternal.prop('checked')) $userDefaultExternal.click();
+
+ expect($userDefaultExternal.prop('checked')).toBeTruthy();
+ expect($userInternalRegex.placeholder).toEqual(PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE);
+ expect($userInternalRegex.readOnly).toBeFalsy();
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
new file mode 100644
index 00000000000..fe17c03389c
--- /dev/null
+++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
@@ -0,0 +1,64 @@
+import Vue from 'vue';
+import { redirectTo } from '~/lib/utils/url_utility';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import axios from '~/lib/utils/axios_utils';
+import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
+
+describe('stop_jobs_modal.vue', () => {
+ const props = {
+ url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`,
+ };
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ beforeEach(() => {
+ const Component = Vue.extend(stopJobsModal);
+ vm = mountComponent(Component, props);
+ });
+
+ describe('onSubmit', () => {
+ it('stops jobs and redirects to overview page', done => {
+ const responseURL = `${gl.TEST_HOST}/stop_jobs_modal.vue/jobs`;
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(props.url);
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(redirectTo).toHaveBeenCalledWith(responseURL);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays error if stopping jobs failed', done => {
+ const dummyError = new Error('stopping jobs failed');
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(props.url);
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .then(done.fail)
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ expect(redirectTo).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
index ea3bedf59e0..82589e5147c 100644
--- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
+++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
@@ -37,7 +37,6 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
value=""
/>
</form>
-
<gl-deprecated-button-stub
size="md"
variant="secondary"
diff --git a/spec/frontend/pages/admin/users/new/index_spec.js b/spec/frontend/pages/admin/users/new/index_spec.js
new file mode 100644
index 00000000000..3896323eef7
--- /dev/null
+++ b/spec/frontend/pages/admin/users/new/index_spec.js
@@ -0,0 +1,43 @@
+import $ from 'jquery';
+import UserInternalRegexHandler from '~/pages/admin/users/new/index';
+
+describe('UserInternalRegexHandler', () => {
+ const FIXTURE = 'admin/users/new_with_internal_user_regex.html';
+ let $userExternal;
+ let $userEmail;
+ let $warningMessage;
+
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ // eslint-disable-next-line no-new
+ new UserInternalRegexHandler();
+ $userExternal = $('#user_external');
+ $userEmail = $('#user_email');
+ $warningMessage = $('#warning_external_automatically_set');
+ if (!$userExternal.prop('checked')) $userExternal.prop('checked', 'checked');
+ });
+
+ describe('Behaviour of userExternal checkbox when', () => {
+ it('matches email as internal', done => {
+ expect($warningMessage.hasClass('hidden')).toBeTruthy();
+
+ $userEmail.val('test@').trigger('input');
+
+ expect($userExternal.prop('checked')).toBeFalsy();
+ expect($warningMessage.hasClass('hidden')).toBeFalsy();
+ done();
+ });
+
+ it('matches email as external', done => {
+ expect($warningMessage.hasClass('hidden')).toBeTruthy();
+
+ $userEmail.val('test.ext@').trigger('input');
+
+ expect($userExternal.prop('checked')).toBeTruthy();
+ expect($warningMessage.hasClass('hidden')).toBeTruthy();
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/pages/labels/components/promote_label_modal_spec.js
new file mode 100644
index 00000000000..9d5beca70b5
--- /dev/null
+++ b/spec/frontend/pages/labels/components/promote_label_modal_spec.js
@@ -0,0 +1,103 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue';
+import eventHub from '~/pages/projects/labels/event_hub';
+import axios from '~/lib/utils/axios_utils';
+
+describe('Promote label modal', () => {
+ let vm;
+ const Component = Vue.extend(promoteLabelModal);
+ const labelMockData = {
+ labelTitle: 'Documentation',
+ labelColor: '#5cb85c',
+ labelTextColor: '#ffffff',
+ url: `${gl.TEST_HOST}/dummy/promote/labels`,
+ groupName: 'group',
+ };
+
+ describe('Modal title and description', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, labelMockData);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('contains the proper description', () => {
+ expect(vm.text).toContain(
+ `Promoting ${labelMockData.labelTitle} will make it available for all projects inside ${labelMockData.groupName}`,
+ );
+ });
+
+ it('contains a label span with the color', () => {
+ const labelFromTitle = vm.$el.querySelector('.modal-header .label.color-label');
+
+ expect(labelFromTitle.style.backgroundColor).not.toBe(null);
+ expect(labelFromTitle.textContent).toContain(vm.labelTitle);
+ });
+ });
+
+ describe('When requesting a label promotion', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...labelMockData,
+ });
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('redirects when a label is promoted', done => {
+ const responseURL = `${gl.TEST_HOST}/dummy/endpoint`;
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(labelMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'promoteLabelModal.requestStarted',
+ labelMockData.url,
+ );
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
+ labelUrl: labelMockData.url,
+ successful: true,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays an error if promoting a label failed', done => {
+ const dummyError = new Error('promoting label failed');
+ dummyError.response = { status: 500 };
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(labelMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'promoteLabelModal.requestStarted',
+ labelMockData.url,
+ );
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
+ labelUrl: labelMockData.url,
+ successful: false,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js
new file mode 100644
index 00000000000..ff5dc6d8988
--- /dev/null
+++ b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js
@@ -0,0 +1,109 @@
+import Vue from 'vue';
+import { redirectTo } from '~/lib/utils/url_utility';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import axios from '~/lib/utils/axios_utils';
+import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue';
+import eventHub from '~/pages/milestones/shared/event_hub';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
+
+describe('delete_milestone_modal.vue', () => {
+ const Component = Vue.extend(deleteMilestoneModal);
+ const props = {
+ issueCount: 1,
+ mergeRequestCount: 2,
+ milestoneId: 3,
+ milestoneTitle: 'my milestone title',
+ milestoneUrl: `${gl.TEST_HOST}/delete_milestone_modal.vue/milestone`,
+ };
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('onSubmit', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, props);
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ it('deletes milestone and redirects to overview page', done => {
+ const responseURL = `${gl.TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`;
+ jest.spyOn(axios, 'delete').mockImplementation(url => {
+ expect(url).toBe(props.milestoneUrl);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'deleteMilestoneModal.requestStarted',
+ props.milestoneUrl,
+ );
+ eventHub.$emit.mockReset();
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(redirectTo).toHaveBeenCalledWith(responseURL);
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
+ milestoneUrl: props.milestoneUrl,
+ successful: true,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays error if deleting milestone failed', done => {
+ const dummyError = new Error('deleting milestone failed');
+ dummyError.response = { status: 418 };
+ jest.spyOn(axios, 'delete').mockImplementation(url => {
+ expect(url).toBe(props.milestoneUrl);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'deleteMilestoneModal.requestStarted',
+ props.milestoneUrl,
+ );
+ eventHub.$emit.mockReset();
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ expect(redirectTo).not.toHaveBeenCalled();
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
+ milestoneUrl: props.milestoneUrl,
+ successful: false,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('text', () => {
+ it('contains the issue and milestone count', () => {
+ vm = mountComponent(Component, props);
+ const value = vm.text;
+
+ expect(value).toContain('remove it from 1 issue and 2 merge requests');
+ });
+
+ it('contains neither issue nor milestone count', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ issueCount: 0,
+ mergeRequestCount: 0,
+ });
+
+ const value = vm.text;
+
+ expect(value).toContain('is not currently used');
+ });
+ });
+});
diff --git a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js
new file mode 100644
index 00000000000..ff896354d96
--- /dev/null
+++ b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js
@@ -0,0 +1,98 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue';
+import eventHub from '~/pages/milestones/shared/event_hub';
+import axios from '~/lib/utils/axios_utils';
+
+describe('Promote milestone modal', () => {
+ let vm;
+ const Component = Vue.extend(promoteMilestoneModal);
+ const milestoneMockData = {
+ milestoneTitle: 'v1.0',
+ url: `${gl.TEST_HOST}/dummy/promote/milestones`,
+ groupName: 'group',
+ };
+
+ describe('Modal title and description', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, milestoneMockData);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('contains the proper description', () => {
+ expect(vm.text).toContain(
+ `Promoting ${milestoneMockData.milestoneTitle} will make it available for all projects inside ${milestoneMockData.groupName}.`,
+ );
+ });
+
+ it('contains the correct title', () => {
+ expect(vm.title).toEqual('Promote v1.0 to group milestone?');
+ });
+ });
+
+ describe('When requesting a milestone promotion', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...milestoneMockData,
+ });
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('redirects when a milestone is promoted', done => {
+ const responseURL = `${gl.TEST_HOST}/dummy/endpoint`;
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(milestoneMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'promoteMilestoneModal.requestStarted',
+ milestoneMockData.url,
+ );
+ return Promise.resolve({
+ request: {
+ responseURL,
+ },
+ });
+ });
+
+ vm.onSubmit()
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', {
+ milestoneUrl: milestoneMockData.url,
+ successful: true,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays an error if promoting a milestone failed', done => {
+ const dummyError = new Error('promoting milestone failed');
+ dummyError.response = { status: 500 };
+ jest.spyOn(axios, 'post').mockImplementation(url => {
+ expect(url).toBe(milestoneMockData.url);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'promoteMilestoneModal.requestStarted',
+ milestoneMockData.url,
+ );
+ return Promise.reject(dummyError);
+ });
+
+ vm.onSubmit()
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteMilestoneModal.requestFinished', {
+ milestoneUrl: milestoneMockData.url,
+ successful: false,
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
new file mode 100644
index 00000000000..9cc1d6eeb5a
--- /dev/null
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
@@ -0,0 +1,154 @@
+import { shallowMount } from '@vue/test-utils';
+import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
+
+describe('Interval Pattern Input Component', () => {
+ let oldWindowGl;
+ let wrapper;
+
+ const mockHour = 4;
+ const mockWeekDayIndex = 1;
+ const mockDay = 1;
+
+ const cronIntervalPresets = {
+ everyDay: `0 ${mockHour} * * *`,
+ everyWeek: `0 ${mockHour} * * ${mockWeekDayIndex}`,
+ everyMonth: `0 ${mockHour} ${mockDay} * *`,
+ };
+
+ const findEveryDayRadio = () => wrapper.find('#every-day');
+ const findEveryWeekRadio = () => wrapper.find('#every-week');
+ const findEveryMonthRadio = () => wrapper.find('#every-month');
+ const findCustomRadio = () => wrapper.find('#custom');
+ const findCustomInput = () => wrapper.find('#schedule_cron');
+ const selectEveryDayRadio = () => findEveryDayRadio().setChecked();
+ const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked();
+ const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked();
+ const selectCustomRadio = () => findCustomRadio().trigger('click');
+
+ const createWrapper = (props = {}, data = {}) => {
+ if (wrapper) {
+ throw new Error('A wrapper already exists');
+ }
+
+ wrapper = shallowMount(IntervalPatternInput, {
+ propsData: { ...props },
+ data() {
+ return {
+ randomHour: data?.hour || mockHour,
+ randomWeekDayIndex: mockWeekDayIndex,
+ randomDay: mockDay,
+ };
+ },
+ });
+ };
+
+ beforeEach(() => {
+ oldWindowGl = window.gl;
+ window.gl = {
+ ...(window.gl || {}),
+ pipelineScheduleFieldErrors: {
+ updateFormValidityState: jest.fn(),
+ },
+ };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ window.gl = oldWindowGl;
+ });
+
+ describe('the input field defaults', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('to a non empty string when no initial value is not passed', () => {
+ expect(findCustomInput()).not.toBe('');
+ });
+ });
+
+ describe('the input field', () => {
+ const initialCron = '0 * * * *';
+
+ beforeEach(() => {
+ createWrapper({ initialCronInterval: initialCron });
+ });
+
+ it('is equal to the prop `initialCronInterval` when passed', () => {
+ expect(findCustomInput().element.value).toBe(initialCron);
+ });
+ });
+
+ describe('The input field is enabled', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('when a default option is selected', () => {
+ selectEveryDayRadio();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCustomInput().attributes('disabled')).toBeUndefined();
+ });
+ });
+
+ it('when the custom option is selected', () => {
+ selectCustomRadio();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCustomInput().attributes('disabled')).toBeUndefined();
+ });
+ });
+ });
+
+ describe('formattedTime computed property', () => {
+ it.each`
+ desc | hour | expectedValue
+ ${'returns a time in the afternoon if the value of `random time` is higher than 12'} | ${13} | ${'1:00pm'}
+ ${'returns a time in the morning if the value of `random time` is lower than 12'} | ${11} | ${'11:00am'}
+ ${'returns "12:00pm" if the value of `random time` is exactly 12'} | ${12} | ${'12:00pm'}
+ `('$desc', ({ hour, expectedValue }) => {
+ createWrapper({}, { hour });
+
+ expect(wrapper.vm.formattedTime).toBe(expectedValue);
+ });
+ });
+
+ describe('User Actions with radio buttons', () => {
+ it.each`
+ desc | initialCronInterval | act | expectedValue
+ ${'when everyday is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryDayRadio} | ${cronIntervalPresets.everyDay}
+ ${'when everyweek is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryWeekRadio} | ${cronIntervalPresets.everyWeek}
+ ${'when everymonth is selected, update value'} | ${'1 2 3 4 5'} | ${selectEveryMonthRadio} | ${cronIntervalPresets.everyMonth}
+ ${'when custom is selected, add space to value'} | ${cronIntervalPresets.everyMonth} | ${selectCustomRadio} | ${`${cronIntervalPresets.everyMonth} `}
+ `('$desc', ({ initialCronInterval, act, expectedValue }) => {
+ createWrapper({ initialCronInterval });
+
+ act();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findCustomInput().element.value).toBe(expectedValue);
+ });
+ });
+ });
+ describe('User actions with input field for Cron syntax', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('when editing the cron input it selects the custom radio button', () => {
+ const newValue = '0 * * * *';
+
+ findCustomInput().setValue(newValue);
+
+ expect(wrapper.vm.cronInterval).toBe(newValue);
+ });
+
+ it('when value of input is one of the defaults, it selects the corresponding radio button', () => {
+ findCustomInput().setValue(cronIntervalPresets.everyWeek);
+
+ expect(wrapper.vm.cronInterval).toBe(cronIntervalPresets.everyWeek);
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
new file mode 100644
index 00000000000..5a61f9fca69
--- /dev/null
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
@@ -0,0 +1,114 @@
+import Vue from 'vue';
+import Cookies from 'js-cookie';
+import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue';
+import '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
+
+jest.mock(
+ '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg',
+ () => '<svg></svg>',
+);
+
+const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
+const cookieKey = 'pipeline_schedules_callout_dismissed';
+const docsUrl = 'help/ci/scheduled_pipelines';
+
+describe('Pipeline Schedule Callout', () => {
+ let calloutComponent;
+
+ beforeEach(() => {
+ setFixtures(`
+ <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div>
+ `);
+ });
+
+ describe('independent of cookies', () => {
+ beforeEach(() => {
+ calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('the component can be initialized', () => {
+ expect(calloutComponent).toBeDefined();
+ });
+
+ it('correctly sets illustrationSvg', () => {
+ expect(calloutComponent.illustrationSvg).toContain('<svg');
+ });
+
+ it('correctly sets docsUrl', () => {
+ expect(calloutComponent.docsUrl).toContain(docsUrl);
+ });
+ });
+
+ describe(`when ${cookieKey} cookie is set`, () => {
+ beforeEach(() => {
+ Cookies.set(cookieKey, true);
+ calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('correctly sets calloutDismissed to true', () => {
+ expect(calloutComponent.calloutDismissed).toBe(true);
+ });
+
+ it('does not render the callout', () => {
+ expect(calloutComponent.$el.childNodes.length).toBe(0);
+ });
+ });
+
+ describe('when cookie is not set', () => {
+ beforeEach(() => {
+ Cookies.remove(cookieKey);
+ calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ });
+
+ it('correctly sets calloutDismissed to false', () => {
+ expect(calloutComponent.calloutDismissed).toBe(false);
+ });
+
+ it('renders the callout container', () => {
+ expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
+ });
+
+ it('renders the callout svg', () => {
+ expect(calloutComponent.$el.outerHTML).toContain('<svg');
+ });
+
+ it('renders the callout title', () => {
+ expect(calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines');
+ });
+
+ it('renders the callout text', () => {
+ expect(calloutComponent.$el.outerHTML).toContain('runs pipelines in the future');
+ });
+
+ it('renders the documentation url', () => {
+ expect(calloutComponent.$el.outerHTML).toContain(docsUrl);
+ });
+
+ it('updates calloutDismissed when close button is clicked', done => {
+ calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
+
+ Vue.nextTick(() => {
+ expect(calloutComponent.calloutDismissed).toBe(true);
+ done();
+ });
+ });
+
+ it('#dismissCallout updates calloutDismissed', done => {
+ calloutComponent.dismissCallout();
+
+ Vue.nextTick(() => {
+ expect(calloutComponent.calloutDismissed).toBe(true);
+ done();
+ });
+ });
+
+ it('is hidden when close button is clicked', done => {
+ calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
+
+ Vue.nextTick(() => {
+ expect(calloutComponent.$el.childNodes.length).toBe(0);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
index 9c292fa0f2b..1f7eec567b8 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
@@ -23,6 +23,7 @@ const defaultProps = {
lfsEnabled: true,
emailsDisabled: false,
packagesEnabled: true,
+ showDefaultAwardEmojis: true,
},
canDisableEmails: true,
canChangeVisibilityLevel: true,
@@ -57,9 +58,6 @@ describe('Settings Panel', () => {
return mountFn(settingsPanel, {
propsData,
- provide: {
- glFeatures: { metricsDashboardVisibilitySwitchingAvailable: true },
- },
});
};
@@ -477,6 +475,18 @@ describe('Settings Panel', () => {
});
});
+ describe('Default award emojis', () => {
+ it('should show the "Show default award emojis" input', () => {
+ return wrapper.vm.$nextTick(() => {
+ expect(
+ wrapper
+ .find('input[name="project[project_setting_attributes][show_default_award_emojis]"]')
+ .exists(),
+ ).toBe(true);
+ });
+ });
+ });
+
describe('Metrics dashboard', () => {
it('should show the metrics dashboard access toggle', () => {
return wrapper.vm.$nextTick(() => {
@@ -489,15 +499,22 @@ describe('Settings Panel', () => {
.find('[name="project[project_feature_attributes][metrics_dashboard_access_level]"]')
.setValue(visibilityOptions.PUBLIC);
- expect(wrapper.vm.metricsAccessLevel).toBe(visibilityOptions.PUBLIC);
+ expect(wrapper.vm.metricsDashboardAccessLevel).toBe(visibilityOptions.PUBLIC);
});
it('should contain help text', () => {
- wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE });
-
expect(wrapper.find({ ref: 'metrics-visibility-settings' }).props().helpText).toEqual(
'With Metrics Dashboard you can visualize this project performance metrics',
);
});
+
+ it('should disable the metrics visibility dropdown when the project visibility level changes to private', () => {
+ wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE });
+
+ const metricsSettingsRow = wrapper.find({ ref: 'metrics-visibility-settings' });
+
+ expect(wrapper.vm.metricsOptionsDropdownEnabled).toBe(true);
+ expect(metricsSettingsRow.find('select').attributes('disabled')).toEqual('disabled');
+ });
});
});
diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
new file mode 100644
index 00000000000..1809e92e1d9
--- /dev/null
+++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
@@ -0,0 +1,61 @@
+import $ from 'jquery';
+import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment';
+
+describe('preserve_url_fragment', () => {
+ preloadFixtures('sessions/new.html');
+
+ beforeEach(() => {
+ loadFixtures('sessions/new.html');
+ });
+
+ it('adds the url fragment to all login and sign up form actions', () => {
+ preserveUrlFragment('#L65');
+
+ expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in#L65');
+ expect($('#new_new_user').attr('action')).toBe('http://test.host/users#L65');
+ });
+
+ it('does not add an empty url fragment to login and sign up form actions', () => {
+ preserveUrlFragment();
+
+ expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in');
+ expect($('#new_new_user').attr('action')).toBe('http://test.host/users');
+ });
+
+ it('does not add an empty query parameter to OmniAuth login buttons', () => {
+ preserveUrlFragment();
+
+ expect($('#oauth-login-cas3').attr('href')).toBe('http://test.host/users/auth/cas3');
+
+ expect($('.omniauth-container #oauth-login-auth0').attr('href')).toBe(
+ 'http://test.host/users/auth/auth0',
+ );
+ });
+
+ describe('adds "redirect_fragment" query parameter to OmniAuth login buttons', () => {
+ it('when "remember_me" is not present', () => {
+ preserveUrlFragment('#L65');
+
+ expect($('#oauth-login-cas3').attr('href')).toBe(
+ 'http://test.host/users/auth/cas3?redirect_fragment=L65',
+ );
+
+ expect($('.omniauth-container #oauth-login-auth0').attr('href')).toBe(
+ 'http://test.host/users/auth/auth0?redirect_fragment=L65',
+ );
+ });
+
+ it('when "remember-me" is present', () => {
+ $('a.omniauth-btn').attr('href', (i, href) => `${href}?remember_me=1`);
+ preserveUrlFragment('#L65');
+
+ expect($('#oauth-login-cas3').attr('href')).toBe(
+ 'http://test.host/users/auth/cas3?remember_me=1&redirect_fragment=L65',
+ );
+
+ expect($('#oauth-login-auth0').attr('href')).toBe(
+ 'http://test.host/users/auth/auth0?remember_me=1&redirect_fragment=L65',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
new file mode 100644
index 00000000000..12c6fab9c41
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
@@ -0,0 +1,97 @@
+import Api from '~/api';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import PipelinesFilteredSearch from '~/pipelines/components/pipelines_filtered_search.vue';
+import {
+ users,
+ mockSearch,
+ pipelineWithStages,
+ branches,
+ mockBranchesAfterMap,
+} from '../mock_data';
+import { GlFilteredSearch } from '@gitlab/ui';
+
+describe('Pipelines filtered search', () => {
+ let wrapper;
+ let mock;
+
+ const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
+ const getSearchToken = type =>
+ findFilteredSearch()
+ .props('availableTokens')
+ .find(token => token.type === type);
+
+ const createComponent = () => {
+ wrapper = mount(PipelinesFilteredSearch, {
+ propsData: {
+ pipelines: [pipelineWithStages],
+ projectId: '21',
+ },
+ attachToDocument: true,
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays UI elements', () => {
+ expect(wrapper.isVueInstance()).toBe(true);
+ expect(wrapper.isEmpty()).toBe(false);
+
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+
+ it('displays search tokens', () => {
+ expect(getSearchToken('username')).toMatchObject({
+ type: 'username',
+ icon: 'user',
+ title: 'Trigger author',
+ unique: true,
+ triggerAuthors: users,
+ projectId: '21',
+ operators: [expect.objectContaining({ value: '=' })],
+ });
+
+ expect(getSearchToken('ref')).toMatchObject({
+ type: 'ref',
+ icon: 'branch',
+ title: 'Branch name',
+ unique: true,
+ branches: mockBranchesAfterMap,
+ projectId: '21',
+ operators: [expect.objectContaining({ value: '=' })],
+ });
+ });
+
+ it('fetches and sets project users', () => {
+ expect(Api.projectUsers).toHaveBeenCalled();
+
+ expect(wrapper.vm.projectUsers).toEqual(users);
+ });
+
+ it('fetches and sets branches', () => {
+ expect(Api.branches).toHaveBeenCalled();
+
+ expect(wrapper.vm.projectBranches).toEqual(mockBranchesAfterMap);
+ });
+
+ it('emits filterPipelines on submit with correct filter', () => {
+ findFilteredSearch().vm.$emit('submit', mockSearch);
+
+ expect(wrapper.emitted('filterPipelines')).toBeTruthy();
+ expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]);
+ });
+});
diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js
index 88e56eee1d6..d32534326c5 100644
--- a/spec/frontend/pipelines/graph/stage_column_component_spec.js
+++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js
@@ -26,7 +26,7 @@ describe('stage column component', () => {
beforeEach(() => {
const mockGroups = [];
for (let i = 0; i < 3; i += 1) {
- const mockedJob = Object.assign({}, mockJob);
+ const mockedJob = { ...mockJob };
mockedJob.id += i;
mockGroups.push(mockedJob);
}
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
new file mode 100644
index 00000000000..1c3a6c545a0
--- /dev/null
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -0,0 +1,116 @@
+import { shallowMount } from '@vue/test-utils';
+import HeaderComponent from '~/pipelines/components/header_component.vue';
+import CiHeader from '~/vue_shared/components/header_ci_component.vue';
+import eventHub from '~/pipelines/event_hub';
+import { GlModal } from '@gitlab/ui';
+
+describe('Pipeline details header', () => {
+ let wrapper;
+ let glModalDirective;
+
+ const threeWeeksAgo = new Date();
+ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
+ const findDeleteModal = () => wrapper.find(GlModal);
+
+ const defaultProps = {
+ pipeline: {
+ details: {
+ status: {
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ details_path: 'path',
+ },
+ },
+ id: 123,
+ created_at: threeWeeksAgo.toISOString(),
+ user: {
+ web_url: 'path',
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatar_url: 'link',
+ },
+ retry_path: 'retry',
+ cancel_path: 'cancel',
+ delete_path: 'delete',
+ },
+ isLoading: false,
+ };
+
+ const createComponent = (props = {}) => {
+ glModalDirective = jest.fn();
+
+ wrapper = shallowMount(HeaderComponent, {
+ propsData: {
+ ...props,
+ },
+ directives: {
+ glModal: {
+ bind(el, { value }) {
+ glModalDirective(value);
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$emit');
+
+ createComponent(defaultProps);
+ });
+
+ afterEach(() => {
+ eventHub.$off();
+
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render provided pipeline info', () => {
+ expect(wrapper.find(CiHeader).props()).toMatchObject({
+ status: defaultProps.pipeline.details.status,
+ itemId: defaultProps.pipeline.id,
+ time: defaultProps.pipeline.created_at,
+ user: defaultProps.pipeline.user,
+ });
+ });
+
+ describe('action buttons', () => {
+ it('should not trigger eventHub when nothing happens', () => {
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ });
+
+ it('should call postAction when retry button action is clicked', () => {
+ wrapper.find('.js-retry-button').vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
+ });
+
+ it('should call postAction when cancel button action is clicked', () => {
+ wrapper.find('.js-btn-cancel-pipeline').vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
+ });
+
+ it('does not show delete modal', () => {
+ expect(findDeleteModal()).not.toBeVisible();
+ });
+
+ describe('when delete button action is clicked', () => {
+ it('displays delete modal', () => {
+ expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
+ expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID);
+ });
+
+ it('should call delete when modal is submitted', () => {
+ findDeleteModal().vm.$emit('ok');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/linked_pipelines_mock.json b/spec/frontend/pipelines/linked_pipelines_mock.json
new file mode 100644
index 00000000000..8ad19ef4865
--- /dev/null
+++ b/spec/frontend/pipelines/linked_pipelines_mock.json
@@ -0,0 +1,3536 @@
+{
+ "id": 23211253,
+ "user": {
+ "id": 3585,
+ "name": "Achilleas Pipinellis",
+ "username": "axil",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
+ "web_url": "https://gitlab.com/axil",
+ "status_tooltip_html": "\u003cspan class=\"user-status-emoji has-tooltip\" title=\"I like pizza\" data-html=\"true\" data-placement=\"top\"\u003e\u003cgl-emoji title=\"slice of pizza\" data-name=\"pizza\" data-unicode-version=\"6.0\"\u003e🍕\u003c/gl-emoji\u003e\u003c/span\u003e",
+ "path": "/axil"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "push",
+ "created_at": "2018-06-05T11:31:30.452Z",
+ "updated_at": "2018-10-31T16:35:31.305Z",
+ "path": "/gitlab-org/gitlab-runner/pipelines/23211253",
+ "flags": {
+ "latest": false,
+ "stuck": false,
+ "auto_devops": false,
+ "merge_request": false,
+ "yaml_errors": false,
+ "retryable": false,
+ "cancelable": false,
+ "failure_reason": false
+ },
+ "details": {
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "duration": 53,
+ "finished_at": "2018-10-31T16:35:31.299Z",
+ "stages": [
+ {
+ "name": "prebuild",
+ "title": "prebuild: passed",
+ "groups": [
+ {
+ "name": "review-docs-deploy",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "manual play action",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 72469032,
+ "name": "review-docs-deploy",
+ "started": "2018-10-31T16:34:58.778Z",
+ "archived": false,
+ "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
+ "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry",
+ "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-06-05T11:31:30.495Z",
+ "updated_at": "2018-10-31T16:35:31.251Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "manual play action",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild",
+ "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild"
+ },
+ {
+ "name": "test",
+ "title": "test: passed",
+ "groups": [
+ {
+ "name": "docs check links",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 72469033,
+ "name": "docs check links",
+ "started": "2018-06-05T11:31:33.240Z",
+ "archived": false,
+ "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
+ "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-06-05T11:31:30.627Z",
+ "updated_at": "2018-06-05T11:31:54.363Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test",
+ "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test"
+ },
+ {
+ "name": "cleanup",
+ "title": "cleanup: skipped",
+ "groups": [
+ {
+ "name": "review-docs-cleanup",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual stop action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "stop",
+ "title": "Stop",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
+ "method": "post",
+ "button_title": "Stop this environment"
+ }
+ },
+ "jobs": [
+ {
+ "id": 72469034,
+ "name": "review-docs-cleanup",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
+ "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-06-05T11:31:30.760Z",
+ "updated_at": "2018-06-05T11:31:56.037Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual stop action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "stop",
+ "title": "Stop",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
+ "method": "post",
+ "button_title": "Stop this environment"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup",
+ "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "review-docs-cleanup",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review-docs-deploy",
+ "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play",
+ "playable": true,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "ref": {
+ "name": "docs/add-development-guide-to-readme",
+ "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme",
+ "tag": false,
+ "branch": true,
+ "merge_request": false
+ },
+ "commit": {
+ "id": "8083eb0a920572214d0dccedd7981f05d535ad46",
+ "short_id": "8083eb0a",
+ "title": "Add link to development guide in readme",
+ "created_at": "2018-06-05T11:30:48.000Z",
+ "parent_ids": ["1d7cf79b5a1a2121b9474ac20d61c1b8f621289d"],
+ "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n",
+ "author_name": "Achilleas Pipinellis",
+ "author_email": "axil@gitlab.com",
+ "authored_date": "2018-06-05T11:30:48.000Z",
+ "committer_name": "Achilleas Pipinellis",
+ "committer_email": "axil@gitlab.com",
+ "committed_date": "2018-06-05T11:30:48.000Z",
+ "author": {
+ "id": 3585,
+ "name": "Achilleas Pipinellis",
+ "username": "axil",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png",
+ "web_url": "https://gitlab.com/axil",
+ "status_tooltip_html": null,
+ "path": "/axil"
+ },
+ "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon",
+ "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46",
+ "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46"
+ },
+ "project": {
+ "id": 1794617
+ },
+ "triggered_by": {
+ "id": 12,
+ "user": {
+ "id": 376774,
+ "name": "Alessio Caiazza",
+ "username": "nolith",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
+ "web_url": "https://gitlab.com/nolith",
+ "status_tooltip_html": null,
+ "path": "/nolith"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "pipeline",
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "details": {
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "duration": 118,
+ "finished_at": "2018-10-31T16:41:40.615Z",
+ "stages": [
+ {
+ "name": "build-images",
+ "title": "build-images: skipped",
+ "groups": [
+ {
+ "name": "image:bootstrap",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 11421321982853,
+ "name": "image:bootstrap",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.704Z",
+ "updated_at": "2018-10-31T16:35:24.118Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:builder-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1149822131854,
+ "name": "image:builder-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.728Z",
+ "updated_at": "2018-10-31T16:35:24.070Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 11498285523424,
+ "name": "image:nginx-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.753Z",
+ "updated_at": "2018-10-31T16:35:24.033Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
+ },
+ {
+ "name": "build",
+ "title": "build: failed",
+ "groups": [
+ {
+ "name": "compile_dev",
+ "size": 1,
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1149846949786,
+ "name": "compile_dev",
+ "started": "2018-10-31T16:39:41.598Z",
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:39:41.138Z",
+ "updated_at": "2018-10-31T16:41:40.072Z",
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "recoverable": false
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: skipped",
+ "groups": [
+ {
+ "name": "review",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 11498282342357,
+ "name": "review",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.805Z",
+ "updated_at": "2018-10-31T16:41:40.569Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "review_stop",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114982858,
+ "name": "review_stop",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.840Z",
+ "updated_at": "2018-10-31T16:41:40.480Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "image:bootstrap",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:builder-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review_stop",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
+ "playable": false,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "project": {
+ "id": 1794617,
+ "name": "Test",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ },
+ "triggered_by": {
+ "id": 349932310342451,
+ "user": {
+ "id": 376774,
+ "name": "Alessio Caiazza",
+ "username": "nolith",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
+ "web_url": "https://gitlab.com/nolith",
+ "status_tooltip_html": null,
+ "path": "/nolith"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "pipeline",
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "details": {
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "duration": 118,
+ "finished_at": "2018-10-31T16:41:40.615Z",
+ "stages": [
+ {
+ "name": "build-images",
+ "title": "build-images: skipped",
+ "groups": [
+ {
+ "name": "image:bootstrap",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 11421321982853,
+ "name": "image:bootstrap",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.704Z",
+ "updated_at": "2018-10-31T16:35:24.118Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:builder-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1149822131854,
+ "name": "image:builder-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.728Z",
+ "updated_at": "2018-10-31T16:35:24.070Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 11498285523424,
+ "name": "image:nginx-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.753Z",
+ "updated_at": "2018-10-31T16:35:24.033Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
+ },
+ {
+ "name": "build",
+ "title": "build: failed",
+ "groups": [
+ {
+ "name": "compile_dev",
+ "size": 1,
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1149846949786,
+ "name": "compile_dev",
+ "started": "2018-10-31T16:39:41.598Z",
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:39:41.138Z",
+ "updated_at": "2018-10-31T16:41:40.072Z",
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "recoverable": false
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: skipped",
+ "groups": [
+ {
+ "name": "review",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 11498282342357,
+ "name": "review",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.805Z",
+ "updated_at": "2018-10-31T16:41:40.569Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "review_stop",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114982858,
+ "name": "review_stop",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.840Z",
+ "updated_at": "2018-10-31T16:41:40.480Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "image:bootstrap",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:builder-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review_stop",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
+ "playable": false,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "project": {
+ "id": 1794617,
+ "name": "GitLab Docs",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ }
+ },
+ "triggered": []
+ },
+ "triggered": [
+ {
+ "id": 34993051,
+ "user": {
+ "id": 376774,
+ "name": "Alessio Caiazza",
+ "username": "nolith",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
+ "web_url": "https://gitlab.com/nolith",
+ "status_tooltip_html": null,
+ "path": "/nolith"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "pipeline",
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "details": {
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "duration": 118,
+ "finished_at": "2018-10-31T16:41:40.615Z",
+ "stages": [
+ {
+ "name": "build-images",
+ "title": "build-images: skipped",
+ "groups": [
+ {
+ "name": "image:bootstrap",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982853,
+ "name": "image:bootstrap",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.704Z",
+ "updated_at": "2018-10-31T16:35:24.118Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:builder-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982854,
+ "name": "image:builder-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.728Z",
+ "updated_at": "2018-10-31T16:35:24.070Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982855,
+ "name": "image:nginx-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.753Z",
+ "updated_at": "2018-10-31T16:35:24.033Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
+ },
+ {
+ "name": "build",
+ "title": "build: failed",
+ "groups": [
+ {
+ "name": "compile_dev",
+ "size": 1,
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114984694,
+ "name": "compile_dev",
+ "started": "2018-10-31T16:39:41.598Z",
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:39:41.138Z",
+ "updated_at": "2018-10-31T16:41:40.072Z",
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "recoverable": false
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: skipped",
+ "groups": [
+ {
+ "name": "review",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114982857,
+ "name": "review",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.805Z",
+ "updated_at": "2018-10-31T16:41:40.569Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "review_stop",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114982858,
+ "name": "review_stop",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.840Z",
+ "updated_at": "2018-10-31T16:41:40.480Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "image:bootstrap",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:builder-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review_stop",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
+ "playable": false,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "project": {
+ "id": 1794617,
+ "name": "GitLab Docs",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ },
+ "triggered": [{}]
+ },
+ {
+ "id": 34993052,
+ "user": {
+ "id": 376774,
+ "name": "Alessio Caiazza",
+ "username": "nolith",
+ "state": "active",
+ "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png",
+ "web_url": "https://gitlab.com/nolith",
+ "status_tooltip_html": null,
+ "path": "/nolith"
+ },
+ "active": false,
+ "coverage": null,
+ "source": "pipeline",
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "details": {
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "duration": 118,
+ "finished_at": "2018-10-31T16:41:40.615Z",
+ "stages": [
+ {
+ "name": "build-images",
+ "title": "build-images: skipped",
+ "groups": [
+ {
+ "name": "image:bootstrap",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982853,
+ "name": "image:bootstrap",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.704Z",
+ "updated_at": "2018-10-31T16:35:24.118Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:builder-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 114982854,
+ "name": "image:builder-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.728Z",
+ "updated_at": "2018-10-31T16:35:24.070Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "size": 1,
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1224982855,
+ "name": "image:nginx-onbuild",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.753Z",
+ "updated_at": "2018-10-31T16:35:24.033Z",
+ "status": {
+ "icon": "status_manual",
+ "text": "manual",
+ "label": "manual play action",
+ "group": "manual",
+ "tooltip": "manual action",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images"
+ },
+ {
+ "name": "build",
+ "title": "build: failed",
+ "groups": [
+ {
+ "name": "compile_dev",
+ "size": 1,
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 1123984694,
+ "name": "compile_dev",
+ "started": "2018-10-31T16:39:41.598Z",
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:39:41.138Z",
+ "updated_at": "2018-10-31T16:41:40.072Z",
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed - (script failure)",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "recoverable": false
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_failed",
+ "text": "failed",
+ "label": "failed",
+ "group": "failed",
+ "tooltip": "failed",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: skipped",
+ "groups": [
+ {
+ "name": "review",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 1143232982857,
+ "name": "review",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.805Z",
+ "updated_at": "2018-10-31T16:41:40.569Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "review_stop",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 114921313182858,
+ "name": "review_stop",
+ "started": null,
+ "archived": false,
+ "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2018-10-31T16:35:23.840Z",
+ "updated_at": "2018-10-31T16:41:40.480Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858",
+ "illustration": {
+ "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "illustration": null,
+ "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy",
+ "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy"
+ }
+ ],
+ "artifacts": [],
+ "manual_actions": [
+ {
+ "name": "image:bootstrap",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:builder-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "image:nginx-onbuild",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play",
+ "playable": true,
+ "scheduled": false
+ },
+ {
+ "name": "review_stop",
+ "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play",
+ "playable": false,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "project": {
+ "id": 1794617,
+ "name": "GitLab Docs",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ },
+ "triggered": [
+ {
+ "id": 26,
+ "user": null,
+ "active": false,
+ "coverage": null,
+ "source": "push",
+ "created_at": "2019-01-06T17:48:37.599Z",
+ "updated_at": "2019-01-06T17:48:38.371Z",
+ "path": "/h5bp/html5-boilerplate/pipelines/26",
+ "flags": {
+ "latest": true,
+ "stuck": false,
+ "auto_devops": false,
+ "merge_request": false,
+ "yaml_errors": false,
+ "retryable": true,
+ "cancelable": false,
+ "failure_reason": false
+ },
+ "details": {
+ "status": {
+ "icon": "status_warning",
+ "text": "passed",
+ "label": "passed with warnings",
+ "group": "success-with-warnings",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "duration": null,
+ "finished_at": "2019-01-06T17:48:38.370Z",
+ "stages": [
+ {
+ "name": "build",
+ "title": "build: passed",
+ "groups": [
+ {
+ "name": "build:linux",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/526",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 526,
+ "name": "build:linux",
+ "started": "2019-01-06T08:48:20.236Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/526",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.806Z",
+ "updated_at": "2019-01-06T17:48:37.806Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/526",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/526/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "build:osx",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/527",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 527,
+ "name": "build:osx",
+ "started": "2019-01-06T07:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/527",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.846Z",
+ "updated_at": "2019-01-06T17:48:37.846Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/527",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/527/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#build",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#build",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build"
+ },
+ {
+ "name": "test",
+ "title": "test: passed with warnings",
+ "groups": [
+ {
+ "name": "jenkins",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": null,
+ "group": "success",
+ "tooltip": null,
+ "has_details": false,
+ "details_path": null,
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "jobs": [
+ {
+ "id": 546,
+ "name": "jenkins",
+ "started": "2019-01-06T11:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/546",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.359Z",
+ "updated_at": "2019-01-06T17:48:38.359Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": null,
+ "group": "success",
+ "tooltip": null,
+ "has_details": false,
+ "details_path": null,
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "rspec:linux",
+ "size": 3,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": false,
+ "details_path": null,
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "jobs": [
+ {
+ "id": 528,
+ "name": "rspec:linux 0 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/528",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.885Z",
+ "updated_at": "2019-01-06T17:48:37.885Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/528",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/528/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ },
+ {
+ "id": 529,
+ "name": "rspec:linux 1 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/529",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/529/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.907Z",
+ "updated_at": "2019-01-06T17:48:37.907Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/529",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/529/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ },
+ {
+ "id": 530,
+ "name": "rspec:linux 2 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/530",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/530/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.927Z",
+ "updated_at": "2019-01-06T17:48:37.927Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/530",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/530/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "rspec:osx",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/535",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 535,
+ "name": "rspec:osx",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/535",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.018Z",
+ "updated_at": "2019-01-06T17:48:38.018Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/535",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/535/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "rspec:windows",
+ "size": 3,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": false,
+ "details_path": null,
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "jobs": [
+ {
+ "id": 531,
+ "name": "rspec:windows 0 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/531",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/531/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.944Z",
+ "updated_at": "2019-01-06T17:48:37.944Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/531",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/531/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ },
+ {
+ "id": 532,
+ "name": "rspec:windows 1 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/532",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/532/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.962Z",
+ "updated_at": "2019-01-06T17:48:37.962Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/532",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/532/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ },
+ {
+ "id": 534,
+ "name": "rspec:windows 2 3",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/534",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/534/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:37.999Z",
+ "updated_at": "2019-01-06T17:48:37.999Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/534",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/534/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "spinach:linux",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/536",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 536,
+ "name": "spinach:linux",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/536",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.050Z",
+ "updated_at": "2019-01-06T17:48:38.050Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/536",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/536/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "spinach:osx",
+ "size": 1,
+ "status": {
+ "icon": "status_warning",
+ "text": "failed",
+ "label": "failed (allowed to fail)",
+ "group": "failed-with-warnings",
+ "tooltip": "failed - (unknown failure) (allowed to fail)",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/537",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 537,
+ "name": "spinach:osx",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/537",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.069Z",
+ "updated_at": "2019-01-06T17:48:38.069Z",
+ "status": {
+ "icon": "status_warning",
+ "text": "failed",
+ "label": "failed (allowed to fail)",
+ "group": "failed-with-warnings",
+ "tooltip": "failed - (unknown failure) (allowed to fail)",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/537",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/537/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "callout_message": "There is an unknown failure, please try again",
+ "recoverable": true
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_warning",
+ "text": "passed",
+ "label": "passed with warnings",
+ "group": "success-with-warnings",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#test",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#test",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test"
+ },
+ {
+ "name": "security",
+ "title": "security: passed",
+ "groups": [
+ {
+ "name": "container_scanning",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/541",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 541,
+ "name": "container_scanning",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/541",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.186Z",
+ "updated_at": "2019-01-06T17:48:38.186Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/541",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/541/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "dast",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/538",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 538,
+ "name": "dast",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/538",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.087Z",
+ "updated_at": "2019-01-06T17:48:38.087Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/538",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/538/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "dependency_scanning",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/540",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 540,
+ "name": "dependency_scanning",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/540",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.153Z",
+ "updated_at": "2019-01-06T17:48:38.153Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/540",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/540/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "sast",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/539",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 539,
+ "name": "sast",
+ "started": "2019-01-06T09:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/539",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.121Z",
+ "updated_at": "2019-01-06T17:48:38.121Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/539",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/539/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#security",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#security",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security"
+ },
+ {
+ "name": "deploy",
+ "title": "deploy: passed",
+ "groups": [
+ {
+ "name": "production",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/544",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 544,
+ "name": "production",
+ "started": null,
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/544",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.313Z",
+ "updated_at": "2019-01-06T17:48:38.313Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/544",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ },
+ {
+ "name": "staging",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/542",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ },
+ "jobs": [
+ {
+ "id": 542,
+ "name": "staging",
+ "started": "2019-01-06T11:48:20.237Z",
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/542",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.219Z",
+ "updated_at": "2019-01-06T17:48:38.219Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/542",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job does not have a trace."
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "retry",
+ "title": "Retry",
+ "path": "/h5bp/html5-boilerplate/-/jobs/542/retry",
+ "method": "post",
+ "button_title": "Retry this job"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "stop staging",
+ "size": 1,
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/543",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ },
+ "jobs": [
+ {
+ "id": 543,
+ "name": "stop staging",
+ "started": null,
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/543",
+ "playable": false,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.283Z",
+ "updated_at": "2019-01-06T17:48:38.283Z",
+ "status": {
+ "icon": "status_skipped",
+ "text": "skipped",
+ "label": "skipped",
+ "group": "skipped",
+ "tooltip": "skipped",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/543",
+ "illustration": {
+ "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
+ "size": "svg-430",
+ "title": "This job has been skipped"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png"
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#deploy",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#deploy",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy"
+ },
+ {
+ "name": "notify",
+ "title": "notify: passed",
+ "groups": [
+ {
+ "name": "slack",
+ "size": 1,
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "manual play action",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/545",
+ "illustration": {
+ "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ },
+ "jobs": [
+ {
+ "id": 545,
+ "name": "slack",
+ "started": null,
+ "archived": false,
+ "build_path": "/h5bp/html5-boilerplate/-/jobs/545",
+ "retry_path": "/h5bp/html5-boilerplate/-/jobs/545/retry",
+ "play_path": "/h5bp/html5-boilerplate/-/jobs/545/play",
+ "playable": true,
+ "scheduled": false,
+ "created_at": "2019-01-06T17:48:38.341Z",
+ "updated_at": "2019-01-06T17:48:38.341Z",
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "manual play action",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/-/jobs/545",
+ "illustration": {
+ "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg",
+ "size": "svg-394",
+ "title": "This job requires a manual action",
+ "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments"
+ },
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
+ "action": {
+ "icon": "play",
+ "title": "Play",
+ "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
+ "method": "post",
+ "button_title": "Trigger this manual action"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "status": {
+ "icon": "status_success",
+ "text": "passed",
+ "label": "passed",
+ "group": "success",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/h5bp/html5-boilerplate/pipelines/26#notify",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ },
+ "path": "/h5bp/html5-boilerplate/pipelines/26#notify",
+ "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify"
+ }
+ ],
+ "artifacts": [
+ {
+ "name": "build:linux",
+ "expired": null,
+ "expire_at": null,
+ "path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/download",
+ "browse_path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse"
+ },
+ {
+ "name": "build:osx",
+ "expired": null,
+ "expire_at": null,
+ "path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/download",
+ "browse_path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse"
+ }
+ ],
+ "manual_actions": [
+ {
+ "name": "stop staging",
+ "path": "/h5bp/html5-boilerplate/-/jobs/543/play",
+ "playable": false,
+ "scheduled": false
+ },
+ {
+ "name": "production",
+ "path": "/h5bp/html5-boilerplate/-/jobs/544/play",
+ "playable": false,
+ "scheduled": false
+ },
+ {
+ "name": "slack",
+ "path": "/h5bp/html5-boilerplate/-/jobs/545/play",
+ "playable": true,
+ "scheduled": false
+ }
+ ],
+ "scheduled_actions": []
+ },
+ "ref": {
+ "name": "master",
+ "path": "/h5bp/html5-boilerplate/commits/master",
+ "tag": false,
+ "branch": true,
+ "merge_request": false
+ },
+ "commit": {
+ "id": "bad98c453eab56d20057f3929989251d45cd1a8b",
+ "short_id": "bad98c45",
+ "title": "remove instances of shrink-to-fit=no (#2103)",
+ "created_at": "2018-12-17T20:52:18.000Z",
+ "parent_ids": ["49130f6cfe9ff1f749015d735649a2bc6f66cf3a"],
+ "message": "remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.",
+ "author_name": "Scott O'Hara",
+ "author_email": "scottaohara@users.noreply.github.com",
+ "authored_date": "2018-12-17T20:52:18.000Z",
+ "committer_name": "Rob Larsen",
+ "committer_email": "rob@drunkenfist.com",
+ "committed_date": "2018-12-17T20:52:18.000Z",
+ "author": null,
+ "author_gravatar_url": "https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80\u0026d=identicon",
+ "commit_url": "http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b",
+ "commit_path": "/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b"
+ },
+ "retry_path": "/h5bp/html5-boilerplate/pipelines/26/retry",
+ "triggered_by": {
+ "id": 4,
+ "user": null,
+ "active": false,
+ "coverage": null,
+ "source": "push",
+ "path": "/gitlab-org/gitlab-test/pipelines/4",
+ "details": {
+ "status": {
+ "icon": "status_warning",
+ "text": "passed",
+ "label": "passed with warnings",
+ "group": "success-with-warnings",
+ "tooltip": "passed",
+ "has_details": true,
+ "details_path": "/gitlab-org/gitlab-test/pipelines/4",
+ "illustration": null,
+ "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"
+ }
+ },
+ "project": {
+ "id": 1,
+ "name": "Gitlab Test",
+ "full_path": "/gitlab-org/gitlab-test",
+ "full_name": "Gitlab Org / Gitlab Test"
+ }
+ },
+ "triggered": [],
+ "project": {
+ "id": 1794617,
+ "name": "GitLab Docs",
+ "full_path": "/gitlab-com/gitlab-docs",
+ "full_name": "GitLab.com / GitLab Docs"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
new file mode 100644
index 00000000000..37c1e471415
--- /dev/null
+++ b/spec/frontend/pipelines/mock_data.js
@@ -0,0 +1,568 @@
+export const pipelineWithStages = {
+ id: 20333396,
+ user: {
+ id: 128633,
+ name: 'Rémy Coutable',
+ username: 'rymai',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/rymai',
+ path: '/rymai',
+ },
+ active: true,
+ coverage: '58.24',
+ source: 'push',
+ created_at: '2018-04-11T14:04:53.881Z',
+ updated_at: '2018-04-11T14:05:00.792Z',
+ path: '/gitlab-org/gitlab/pipelines/20333396',
+ flags: {
+ latest: true,
+ stuck: false,
+ auto_devops: false,
+ yaml_errors: false,
+ retryable: false,
+ cancelable: true,
+ failure_reason: false,
+ },
+ details: {
+ status: {
+ icon: 'status_running',
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico',
+ },
+ duration: null,
+ finished_at: null,
+ stages: [
+ {
+ name: 'build',
+ title: 'build: skipped',
+ status: {
+ icon: 'status_skipped',
+ text: 'skipped',
+ label: 'skipped',
+ group: 'skipped',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#build',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_skipped-a2eee568a5bffdb494050c7b62dde241de9189280836288ac8923d369f16222d.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#build',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=build',
+ },
+ {
+ name: 'prepare',
+ title: 'prepare: passed',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#prepare',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_success-26f59841becbef8c6fe414e9e74471d8bfd6a91b5855c19fe7f5923a40a7da47.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#prepare',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=prepare',
+ },
+ {
+ name: 'test',
+ title: 'test: running',
+ status: {
+ icon: 'status_running',
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#test',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#test',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=test',
+ },
+ {
+ name: 'post-test',
+ title: 'post-test: created',
+ status: {
+ icon: 'status_created',
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#post-test',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#post-test',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-test',
+ },
+ {
+ name: 'pages',
+ title: 'pages: created',
+ status: {
+ icon: 'status_created',
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#pages',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#pages',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=pages',
+ },
+ {
+ name: 'post-cleanup',
+ title: 'post-cleanup: created',
+ status: {
+ icon: 'status_created',
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ has_details: true,
+ details_path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup',
+ favicon:
+ 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
+ },
+ path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup',
+ dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-cleanup',
+ },
+ ],
+ artifacts: [
+ {
+ name: 'gitlab:assets:compile',
+ expired: false,
+ expire_at: '2018-05-12T14:22:54.730Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/browse',
+ },
+ {
+ name: 'rspec-mysql 12 28',
+ expired: false,
+ expire_at: '2018-05-12T14:22:45.136Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/browse',
+ },
+ {
+ name: 'rspec-mysql 6 28',
+ expired: false,
+ expire_at: '2018-05-12T14:22:41.523Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/browse',
+ },
+ {
+ name: 'rspec-pg geo 0 1',
+ expired: false,
+ expire_at: '2018-05-12T14:22:13.287Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/browse',
+ },
+ {
+ name: 'rspec-mysql 0 28',
+ expired: false,
+ expire_at: '2018-05-12T14:22:06.834Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/browse',
+ },
+ {
+ name: 'spinach-mysql 0 2',
+ expired: false,
+ expire_at: '2018-05-12T14:21:51.409Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/browse',
+ },
+ {
+ name: 'karma',
+ expired: false,
+ expire_at: '2018-05-12T14:21:20.934Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/browse',
+ },
+ {
+ name: 'spinach-pg 0 2',
+ expired: false,
+ expire_at: '2018-05-12T14:20:01.028Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/browse',
+ },
+ {
+ name: 'spinach-pg 1 2',
+ expired: false,
+ expire_at: '2018-05-12T14:19:04.336Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/browse',
+ },
+ {
+ name: 'sast',
+ expired: null,
+ expire_at: null,
+ path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/download',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/browse',
+ },
+ {
+ name: 'code_quality',
+ expired: false,
+ expire_at: '2018-04-18T14:16:24.484Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/browse',
+ },
+ {
+ name: 'cache gems',
+ expired: null,
+ expire_at: null,
+ path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/download',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/browse',
+ },
+ {
+ name: 'dependency_scanning',
+ expired: null,
+ expire_at: null,
+ path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/download',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/browse',
+ },
+ {
+ name: 'compile-assets',
+ expired: false,
+ expire_at: '2018-04-18T14:12:07.638Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/browse',
+ },
+ {
+ name: 'setup-test-env',
+ expired: false,
+ expire_at: '2018-04-18T14:10:27.024Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/browse',
+ },
+ {
+ name: 'retrieve-tests-metadata',
+ expired: false,
+ expire_at: '2018-05-12T14:06:35.926Z',
+ path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/download',
+ keep_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/keep',
+ browse_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/browse',
+ },
+ ],
+ manual_actions: [
+ {
+ name: 'package-and-qa',
+ path: '/gitlab-org/gitlab/-/jobs/62411330/play',
+ playable: true,
+ },
+ {
+ name: 'review-docs-deploy',
+ path: '/gitlab-org/gitlab/-/jobs/62411332/play',
+ playable: true,
+ },
+ ],
+ },
+ ref: {
+ name: 'master',
+ path: '/gitlab-org/gitlab/commits/master',
+ tag: false,
+ branch: true,
+ },
+ commit: {
+ id: 'e6a2885c503825792cb8a84a8731295e361bd059',
+ short_id: 'e6a2885c',
+ title: "Merge branch 'ce-to-ee-2018-04-11' into 'master'",
+ created_at: '2018-04-11T14:04:39.000Z',
+ parent_ids: [
+ '5d9b5118f6055f72cff1a82b88133609912f2c1d',
+ '6fdc6ee76a8062fe41b1a33f7c503334a6ebdc02',
+ ],
+ message:
+ "Merge branch 'ce-to-ee-2018-04-11' into 'master'\n\nCE upstream - 2018-04-11 12:26 UTC\n\nSee merge request gitlab-org/gitlab-ee!5326",
+ author_name: 'Rémy Coutable',
+ author_email: 'remy@rymai.me',
+ authored_date: '2018-04-11T14:04:39.000Z',
+ committer_name: 'Rémy Coutable',
+ committer_email: 'remy@rymai.me',
+ committed_date: '2018-04-11T14:04:39.000Z',
+ author: {
+ id: 128633,
+ name: 'Rémy Coutable',
+ username: 'rymai',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/rymai',
+ path: '/rymai',
+ },
+ author_gravatar_url:
+ 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
+ commit_url:
+ 'https://gitlab.com/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059',
+ commit_path: '/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059',
+ },
+ cancel_path: '/gitlab-org/gitlab/pipelines/20333396/cancel',
+ triggered_by: null,
+ triggered: [],
+};
+
+export const stageReply = {
+ name: 'deploy',
+ title: 'deploy: running',
+ latest_statuses: [
+ {
+ id: 928,
+ name: 'stop staging',
+ started: false,
+ build_path: '/twitter/flight/-/jobs/928',
+ cancel_path: '/twitter/flight/-/jobs/928/cancel',
+ playable: false,
+ created_at: '2018-04-04T20:02:02.728Z',
+ updated_at: '2018-04-04T20:02:02.766Z',
+ status: {
+ icon: 'status_pending',
+ text: 'pending',
+ label: 'pending',
+ group: 'pending',
+ tooltip: 'pending',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/928',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico',
+ action: {
+ icon: 'cancel',
+ title: 'Cancel',
+ path: '/twitter/flight/-/jobs/928/cancel',
+ method: 'post',
+ },
+ },
+ },
+ {
+ id: 926,
+ name: 'production',
+ started: false,
+ build_path: '/twitter/flight/-/jobs/926',
+ retry_path: '/twitter/flight/-/jobs/926/retry',
+ play_path: '/twitter/flight/-/jobs/926/play',
+ playable: true,
+ created_at: '2018-04-04T20:00:57.202Z',
+ updated_at: '2018-04-04T20:11:13.110Z',
+ status: {
+ icon: 'status_canceled',
+ text: 'canceled',
+ label: 'manual play action',
+ group: 'canceled',
+ tooltip: 'canceled',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/926',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico',
+ action: {
+ icon: 'play',
+ title: 'Play',
+ path: '/twitter/flight/-/jobs/926/play',
+ method: 'post',
+ },
+ },
+ },
+ {
+ id: 217,
+ name: 'staging',
+ started: '2018-03-07T08:41:46.234Z',
+ build_path: '/twitter/flight/-/jobs/217',
+ retry_path: '/twitter/flight/-/jobs/217/retry',
+ playable: false,
+ created_at: '2018-03-07T14:41:58.093Z',
+ updated_at: '2018-03-07T14:41:58.093Z',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/217',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
+ action: {
+ icon: 'retry',
+ title: 'Retry',
+ path: '/twitter/flight/-/jobs/217/retry',
+ method: 'post',
+ },
+ },
+ },
+ ],
+ status: {
+ icon: 'status_running',
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ tooltip: 'running',
+ has_details: true,
+ details_path: '/twitter/flight/pipelines/13#deploy',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico',
+ },
+ path: '/twitter/flight/pipelines/13#deploy',
+ dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy',
+};
+
+export const users = [
+ {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/root',
+ },
+ {
+ id: 10,
+ name: 'Angel Spinka',
+ username: 'shalonda',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/709df1b65ad06764ee2b0edf1b49fc27?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/shalonda',
+ },
+ {
+ id: 11,
+ name: 'Art Davis',
+ username: 'deja.green',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/bb56834c061522760e7a6dd7d431a306?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/deja.green',
+ },
+ {
+ id: 32,
+ name: 'Arnold Mante',
+ username: 'reported_user_10',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/ab558033a82466d7905179e837d7723a?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/reported_user_10',
+ },
+ {
+ id: 38,
+ name: 'Cher Wintheiser',
+ username: 'reported_user_16',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/2640356e8b5bc4314133090994ed162b?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/reported_user_16',
+ },
+ {
+ id: 39,
+ name: 'Bethel Wolf',
+ username: 'reported_user_17',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/4b948694fadba4b01e4acfc06b065e8e?s=80\u0026d=identicon',
+ web_url: 'http://192.168.1.22:3000/reported_user_17',
+ },
+];
+
+export const branches = [
+ {
+ name: 'branch-1',
+ commit: {
+ id: '21fb056cc47dcf706670e6de635b1b326490ebdc',
+ short_id: '21fb056c',
+ created_at: '2020-05-07T10:58:28.000-04:00',
+ parent_ids: null,
+ title: 'Add new file',
+ message: 'Add new file',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ authored_date: '2020-05-07T10:58:28.000-04:00',
+ committer_name: 'Administrator',
+ committer_email: 'admin@example.com',
+ committed_date: '2020-05-07T10:58:28.000-04:00',
+ web_url:
+ 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/21fb056cc47dcf706670e6de635b1b326490ebdc',
+ },
+ merged: false,
+ protected: false,
+ developers_can_push: false,
+ developers_can_merge: false,
+ can_push: true,
+ default: false,
+ web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-1',
+ },
+ {
+ name: 'branch-10',
+ commit: {
+ id: '66673b07efef254dab7d537f0433a40e61cf84fe',
+ short_id: '66673b07',
+ created_at: '2020-03-16T11:04:46.000-04:00',
+ parent_ids: null,
+ title: 'Update .gitlab-ci.yml',
+ message: 'Update .gitlab-ci.yml',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ authored_date: '2020-03-16T11:04:46.000-04:00',
+ committer_name: 'Administrator',
+ committer_email: 'admin@example.com',
+ committed_date: '2020-03-16T11:04:46.000-04:00',
+ web_url:
+ 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
+ },
+ merged: false,
+ protected: false,
+ developers_can_push: false,
+ developers_can_merge: false,
+ can_push: true,
+ default: false,
+ web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-10',
+ },
+ {
+ name: 'branch-11',
+ commit: {
+ id: '66673b07efef254dab7d537f0433a40e61cf84fe',
+ short_id: '66673b07',
+ created_at: '2020-03-16T11:04:46.000-04:00',
+ parent_ids: null,
+ title: 'Update .gitlab-ci.yml',
+ message: 'Update .gitlab-ci.yml',
+ author_name: 'Administrator',
+ author_email: 'admin@example.com',
+ authored_date: '2020-03-16T11:04:46.000-04:00',
+ committer_name: 'Administrator',
+ committer_email: 'admin@example.com',
+ committed_date: '2020-03-16T11:04:46.000-04:00',
+ web_url:
+ 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
+ },
+ merged: false,
+ protected: false,
+ developers_can_push: false,
+ developers_can_merge: false,
+ can_push: true,
+ default: false,
+ web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-11',
+ },
+];
+
+export const mockSearch = [
+ { type: 'username', value: { data: 'root', operator: '=' } },
+ { type: 'ref', value: { data: 'master', operator: '=' } },
+];
+
+export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
diff --git a/spec/frontend/pipelines/pipeline_details_mediator_spec.js b/spec/frontend/pipelines/pipeline_details_mediator_spec.js
new file mode 100644
index 00000000000..083e97666ed
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_details_mediator_spec.js
@@ -0,0 +1,36 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import PipelineMediator from '~/pipelines/pipeline_details_mediator';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('PipelineMdediator', () => {
+ let mediator;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mediator = new PipelineMediator({ endpoint: 'foo.json' });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should set defaults', () => {
+ expect(mediator.options).toEqual({ endpoint: 'foo.json' });
+ expect(mediator.state.isLoading).toEqual(false);
+ expect(mediator.store).toBeDefined();
+ expect(mediator.service).toBeDefined();
+ });
+
+ describe('request and store data', () => {
+ it('should store received data', () => {
+ mock.onGet('foo.json').reply(200, { id: '121123' });
+ mediator.fetchPipeline();
+
+ return waitForPromises().then(() => {
+ expect(mediator.store.state.pipeline).toEqual({ id: '121123' });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js
new file mode 100644
index 00000000000..5e8d21660de
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_actions_spec.js
@@ -0,0 +1,142 @@
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'spec/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import PipelinesActions from '~/pipelines/components/pipelines_actions.vue';
+import { GlDeprecatedButton } from '@gitlab/ui';
+import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('Pipelines Actions dropdown', () => {
+ let wrapper;
+ let mock;
+
+ const createComponent = (actions = []) => {
+ wrapper = shallowMount(PipelinesActions, {
+ propsData: {
+ actions,
+ },
+ });
+ };
+
+ const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedButton);
+ const findAllCountdowns = () => wrapper.findAll(GlCountdown);
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ mock.restore();
+ });
+
+ describe('manual actions', () => {
+ const mockActions = [
+ {
+ name: 'stop_review',
+ path: `${TEST_HOST}/root/review-app/builds/1893/play`,
+ },
+ {
+ name: 'foo',
+ path: `${TEST_HOST}/disabled/pipeline/action`,
+ playable: false,
+ },
+ ];
+
+ beforeEach(() => {
+ createComponent(mockActions);
+ });
+
+ it('renders a dropdown with the provided actions', () => {
+ expect(findAllDropdownItems()).toHaveLength(mockActions.length);
+ });
+
+ it("renders a disabled action when it's not playable", () => {
+ expect(
+ findAllDropdownItems()
+ .at(1)
+ .attributes('disabled'),
+ ).toBe('true');
+ });
+
+ describe('on click', () => {
+ it('makes a request and toggles the loading state', () => {
+ mock.onPost(mockActions.path).reply(200);
+
+ wrapper.find(GlDeprecatedButton).vm.$emit('click');
+
+ expect(wrapper.vm.isLoading).toBe(true);
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.isLoading).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('scheduled jobs', () => {
+ const scheduledJobAction = {
+ name: 'scheduled action',
+ path: `${TEST_HOST}/scheduled/job/action`,
+ playable: true,
+ scheduled_at: '2063-04-05T00:42:00Z',
+ };
+ const expiredJobAction = {
+ name: 'expired action',
+ path: `${TEST_HOST}/expired/job/action`,
+ playable: true,
+ scheduled_at: '2018-10-05T08:23:00Z',
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
+ createComponent([scheduledJobAction, expiredJobAction]);
+ });
+
+ it('makes post request after confirming', () => {
+ mock.onPost(scheduledJobAction.path).reply(200);
+ jest.spyOn(window, 'confirm').mockReturnValue(true);
+
+ findAllDropdownItems()
+ .at(0)
+ .vm.$emit('click');
+
+ expect(window.confirm).toHaveBeenCalled();
+
+ return waitForPromises().then(() => {
+ expect(mock.history.post.length).toBe(1);
+ });
+ });
+
+ it('does not make post request if confirmation is cancelled', () => {
+ mock.onPost(scheduledJobAction.path).reply(200);
+ jest.spyOn(window, 'confirm').mockReturnValue(false);
+
+ findAllDropdownItems()
+ .at(0)
+ .vm.$emit('click');
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(mock.history.post.length).toBe(0);
+ });
+
+ it('displays the remaining time in the dropdown', () => {
+ expect(
+ findAllCountdowns()
+ .at(0)
+ .props('endDateString'),
+ ).toBe(scheduledJobAction.scheduled_at);
+ });
+
+ it('displays 00:00:00 for expired jobs in the dropdown', () => {
+ expect(
+ findAllCountdowns()
+ .at(1)
+ .props('endDateString'),
+ ).toBe(expiredJobAction.scheduled_at);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js
new file mode 100644
index 00000000000..a93cc8a62ab
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js
@@ -0,0 +1,46 @@
+import { shallowMount } from '@vue/test-utils';
+import PipelineArtifacts from '~/pipelines/components/pipelines_artifacts.vue';
+import { GlLink } from '@gitlab/ui';
+
+describe('Pipelines Artifacts dropdown', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineArtifacts, {
+ propsData: {
+ artifacts: [
+ {
+ name: 'artifact',
+ path: '/download/path',
+ },
+ {
+ name: 'artifact two',
+ path: '/download/path-two',
+ },
+ ],
+ },
+ });
+ };
+
+ const findGlLink = () => wrapper.find(GlLink);
+ const findAllGlLinks = () => wrapper.find('.dropdown-menu').findAll(GlLink);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render a dropdown with all the provided artifacts', () => {
+ expect(findAllGlLinks()).toHaveLength(2);
+ });
+
+ it('should render a link with the provided path', () => {
+ expect(findGlLink().attributes('href')).toEqual('/download/path');
+
+ expect(findGlLink().text()).toContain('artifact');
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
new file mode 100644
index 00000000000..2ddd2116e2c
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -0,0 +1,710 @@
+import Api from '~/api';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import PipelinesComponent from '~/pipelines/components/pipelines.vue';
+import Store from '~/pipelines/stores/pipelines_store';
+import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data';
+import { RAW_TEXT_WARNING } from '~/pipelines/constants';
+import { GlFilteredSearch } from '@gitlab/ui';
+import createFlash from '~/flash';
+
+jest.mock('~/flash', () => jest.fn());
+
+describe('Pipelines', () => {
+ const jsonFixtureName = 'pipelines/pipelines.json';
+
+ preloadFixtures(jsonFixtureName);
+
+ let pipelines;
+ let wrapper;
+ let mock;
+
+ const paths = {
+ endpoint: 'twitter/flight/pipelines.json',
+ autoDevopsPath: '/help/topics/autodevops/index.md',
+ helpPagePath: '/help/ci/quick_start/README',
+ emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
+ errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
+ noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
+ ciLintPath: '/ci/lint',
+ resetCachePath: '/twitter/flight/settings/ci_cd/reset_cache',
+ newPipelinePath: '/twitter/flight/pipelines/new',
+ };
+
+ const noPermissions = {
+ endpoint: 'twitter/flight/pipelines.json',
+ autoDevopsPath: '/help/topics/autodevops/index.md',
+ helpPagePath: '/help/ci/quick_start/README',
+ emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
+ errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
+ noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
+ };
+
+ const defaultProps = {
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
+ };
+
+ const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
+
+ const createComponent = (props = defaultProps, methods) => {
+ wrapper = mount(PipelinesComponent, {
+ provide: { glFeatures: { filterPipelinesSearch: true } },
+ propsData: {
+ store: new Store(),
+ projectId: '21',
+ ...props,
+ },
+ methods: {
+ ...methods,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ pipelines = getJSONFixture(jsonFixtureName);
+
+ jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ describe('With permission', () => {
+ describe('With pipelines in main tab', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+ createComponent();
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('renders Run Pipeline link', () => {
+ expect(wrapper.find('.js-run-pipeline').attributes('href')).toBe(paths.newPipelinePath);
+ });
+
+ it('renders CI Lint link', () => {
+ expect(wrapper.find('.js-ci-lint').attributes('href')).toBe(paths.ciLintPath);
+ });
+
+ it('renders Clear Runner Cache button', () => {
+ expect(wrapper.find('.js-clear-cache').text()).toBe('Clear Runner Caches');
+ });
+
+ it('renders pipelines table', () => {
+ expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength(
+ pipelines.pipelines.length + 1,
+ );
+ });
+ });
+
+ describe('Without pipelines on main tab with CI', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, {
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ });
+
+ createComponent();
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('renders Run Pipeline link', () => {
+ expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath);
+ });
+
+ it('renders CI Lint link', () => {
+ expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath);
+ });
+
+ it('renders Clear Runner Cache button', () => {
+ expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches');
+ });
+
+ it('renders tab empty state', () => {
+ expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.');
+ });
+ });
+
+ describe('Without pipelines nor CI', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, {
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ });
+
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+
+ return waitForPromises();
+ });
+
+ it('renders empty state', () => {
+ expect(wrapper.find('.js-empty-state h4').text()).toEqual('Build with confidence');
+
+ expect(wrapper.find('.js-get-started-pipelines').attributes('href')).toEqual(
+ paths.helpPagePath,
+ );
+ });
+
+ it('does not render tabs nor buttons', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy();
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+ });
+
+ describe('When API returns error', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(500, {});
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('renders buttons', () => {
+ expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath);
+
+ expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath);
+ expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches');
+ });
+
+ it('renders error state', () => {
+ expect(wrapper.find('.empty-state').text()).toContain(
+ 'There was an error fetching the pipelines.',
+ );
+ });
+ });
+ });
+
+ describe('Without permission', () => {
+ describe('With pipelines in main tab', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('does not render buttons', () => {
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+
+ it('renders pipelines table', () => {
+ expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength(
+ pipelines.pipelines.length + 1,
+ );
+ });
+ });
+
+ describe('Without pipelines on main tab with CI', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, {
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ });
+
+ createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('does not render buttons', () => {
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+
+ it('renders tab empty state', () => {
+ expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.');
+ });
+ });
+
+ describe('Without pipelines nor CI', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, {
+ pipelines: [],
+ count: {
+ all: 0,
+ pending: 0,
+ running: 0,
+ finished: 0,
+ },
+ });
+
+ createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders empty state without button to set CI', () => {
+ expect(wrapper.find('.js-empty-state').text()).toEqual(
+ 'This project is not currently set up to run pipelines.',
+ );
+
+ expect(wrapper.find('.js-get-started-pipelines').exists()).toBeFalsy();
+ });
+
+ it('does not render tabs or buttons', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy();
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+ });
+
+ describe('When API returns error', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(500, {});
+
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ });
+
+ it('does not renders buttons', () => {
+ expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
+ expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
+ expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ });
+
+ it('renders error state', () => {
+ expect(wrapper.find('.empty-state').text()).toContain(
+ 'There was an error fetching the pipelines.',
+ );
+ });
+ });
+ });
+
+ describe('successful request', () => {
+ describe('with pipelines', () => {
+ beforeEach(() => {
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ createComponent();
+ return waitForPromises();
+ });
+
+ it('should render table', () => {
+ expect(wrapper.find('.table-holder').exists()).toBe(true);
+ expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength(
+ pipelines.pipelines.length + 1,
+ );
+ });
+
+ it('should render navigation tabs', () => {
+ expect(wrapper.find('.js-pipelines-tab-pending').text()).toContain('Pending');
+
+ expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+
+ expect(wrapper.find('.js-pipelines-tab-running').text()).toContain('Running');
+
+ expect(wrapper.find('.js-pipelines-tab-finished').text()).toContain('Finished');
+
+ expect(wrapper.find('.js-pipelines-tab-branches').text()).toContain('Branches');
+
+ expect(wrapper.find('.js-pipelines-tab-tags').text()).toContain('Tags');
+ });
+
+ it('should make an API request when using tabs', () => {
+ const updateContentMock = jest.fn(() => {});
+ createComponent(
+ { hasGitlabCi: true, canCreatePipeline: true, ...paths },
+ {
+ updateContent: updateContentMock,
+ },
+ );
+
+ return waitForPromises().then(() => {
+ wrapper.find('.js-pipelines-tab-finished').trigger('click');
+
+ expect(updateContentMock).toHaveBeenCalledWith({ scope: 'finished', page: '1' });
+ });
+ });
+
+ describe('with pagination', () => {
+ it('should make an API request when using pagination', () => {
+ const updateContentMock = jest.fn(() => {});
+ createComponent(
+ { hasGitlabCi: true, canCreatePipeline: true, ...paths },
+ {
+ updateContent: updateContentMock,
+ },
+ );
+
+ return waitForPromises()
+ .then(() => {
+ // Mock pagination
+ wrapper.vm.store.state.pageInfo = {
+ page: 1,
+ total: 10,
+ perPage: 2,
+ nextPage: 2,
+ totalPages: 5,
+ };
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ wrapper.find('.next-page-item').trigger('click');
+
+ expect(updateContentMock).toHaveBeenCalledWith({ scope: 'all', page: '2' });
+ });
+ });
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ jest.spyOn(window.history, 'pushState').mockImplementation(() => null);
+ });
+
+ describe('onChangeTab', () => {
+ it('should set page to 1', () => {
+ const updateContentMock = jest.fn(() => {});
+ createComponent(
+ { hasGitlabCi: true, canCreatePipeline: true, ...paths },
+ {
+ updateContent: updateContentMock,
+ },
+ );
+
+ wrapper.vm.onChangeTab('running');
+
+ expect(updateContentMock).toHaveBeenCalledWith({ scope: 'running', page: '1' });
+ });
+ });
+
+ describe('onChangePage', () => {
+ it('should update page and keep scope', () => {
+ const updateContentMock = jest.fn(() => {});
+ createComponent(
+ { hasGitlabCi: true, canCreatePipeline: true, ...paths },
+ {
+ updateContent: updateContentMock,
+ },
+ );
+
+ wrapper.vm.onChangePage(4);
+
+ expect(updateContentMock).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4' });
+ });
+ });
+ });
+
+ describe('computed properties', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('tabs', () => {
+ it('returns default tabs', () => {
+ expect(wrapper.vm.tabs).toEqual([
+ { name: 'All', scope: 'all', count: undefined, isActive: true },
+ { name: 'Pending', scope: 'pending', count: undefined, isActive: false },
+ { name: 'Running', scope: 'running', count: undefined, isActive: false },
+ { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
+ { name: 'Branches', scope: 'branches', isActive: false },
+ { name: 'Tags', scope: 'tags', isActive: false },
+ ]);
+ });
+ });
+
+ describe('emptyTabMessage', () => {
+ it('returns message with scope', () => {
+ wrapper.vm.scope = 'pending';
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pending pipelines.');
+ });
+ });
+
+ it('returns message without scope when scope is `all`', () => {
+ expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pipelines.');
+ });
+ });
+
+ describe('stateToRender', () => {
+ it('returns loading state when the app is loading', () => {
+ expect(wrapper.vm.stateToRender).toEqual('loading');
+ });
+
+ it('returns error state when app has error', () => {
+ wrapper.vm.hasError = true;
+ wrapper.vm.isLoading = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('error');
+ });
+ });
+
+ it('returns table list when app has pipelines', () => {
+ wrapper.vm.isLoading = false;
+ wrapper.vm.hasError = false;
+ wrapper.vm.state.pipelines = pipelines.pipelines;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('tableList');
+ });
+ });
+
+ it('returns empty tab when app does not have pipelines but project has pipelines', () => {
+ wrapper.vm.state.count.all = 10;
+ wrapper.vm.isLoading = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('emptyTab');
+ });
+ });
+
+ it('returns empty tab when project has CI', () => {
+ wrapper.vm.isLoading = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('emptyTab');
+ });
+ });
+
+ it('returns empty state when project does not have pipelines nor CI', () => {
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+
+ wrapper.vm.isLoading = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.stateToRender).toEqual('emptyState');
+ });
+ });
+ });
+
+ describe('shouldRenderTabs', () => {
+ it('returns true when state is loading & has already made the first request', () => {
+ wrapper.vm.isLoading = true;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ });
+ });
+
+ it('returns true when state is tableList & has already made the first request', () => {
+ wrapper.vm.isLoading = false;
+ wrapper.vm.state.pipelines = pipelines.pipelines;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ });
+ });
+
+ it('returns true when state is error & has already made the first request', () => {
+ wrapper.vm.isLoading = false;
+ wrapper.vm.hasError = true;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ });
+ });
+
+ it('returns true when state is empty tab & has already made the first request', () => {
+ wrapper.vm.isLoading = false;
+ wrapper.vm.state.count.all = 10;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ });
+ });
+
+ it('returns false when has not made first request', () => {
+ wrapper.vm.hasMadeRequest = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(false);
+ });
+ });
+
+ it('returns false when state is empty state', () => {
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+
+ wrapper.vm.isLoading = false;
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderTabs).toEqual(false);
+ });
+ });
+ });
+
+ describe('shouldRenderButtons', () => {
+ it('returns true when it has paths & has made the first request', () => {
+ wrapper.vm.hasMadeRequest = true;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderButtons).toEqual(true);
+ });
+ });
+
+ it('returns false when it has not made the first request', () => {
+ wrapper.vm.hasMadeRequest = false;
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shouldRenderButtons).toEqual(false);
+ });
+ });
+ });
+ });
+
+ describe('updates results when a staged is clicked', () => {
+ beforeEach(() => {
+ const copyPipeline = { ...pipelineWithStages };
+ copyPipeline.id += 1;
+ mock
+ .onGet('twitter/flight/pipelines.json')
+ .reply(
+ 200,
+ {
+ pipelines: [pipelineWithStages],
+ count: {
+ all: 1,
+ finished: 1,
+ pending: 0,
+ running: 0,
+ },
+ },
+ {
+ 'POLL-INTERVAL': 100,
+ },
+ )
+ .onGet(pipelineWithStages.details.stages[0].dropdown_path)
+ .reply(200, stageReply);
+
+ createComponent();
+ });
+
+ describe('when a request is being made', () => {
+ it('stops polling, cancels the request, & restarts polling', () => {
+ const stopMock = jest.spyOn(wrapper.vm.poll, 'stop');
+ const restartMock = jest.spyOn(wrapper.vm.poll, 'restart');
+ const cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel');
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ return waitForPromises()
+ .then(() => {
+ wrapper.vm.isMakingRequest = true;
+ wrapper.find('.js-builds-dropdown-button').trigger('click');
+ })
+ .then(() => {
+ expect(cancelMock).toHaveBeenCalled();
+ expect(stopMock).toHaveBeenCalled();
+ expect(restartMock).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when no request is being made', () => {
+ it('stops polling & restarts polling', () => {
+ const stopMock = jest.spyOn(wrapper.vm.poll, 'stop');
+ const restartMock = jest.spyOn(wrapper.vm.poll, 'restart');
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ return waitForPromises()
+ .then(() => {
+ wrapper.find('.js-builds-dropdown-button').trigger('click');
+ expect(stopMock).toHaveBeenCalled();
+ })
+ .then(() => {
+ expect(restartMock).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('Pipeline filters', () => {
+ let updateContentMock;
+
+ beforeEach(() => {
+ mock.onGet(paths.endpoint).reply(200, pipelines);
+ createComponent();
+
+ updateContentMock = jest.spyOn(wrapper.vm, 'updateContent');
+
+ return waitForPromises();
+ });
+
+ it('updates request data and query params on filter submit', () => {
+ const expectedQueryParams = { page: '1', scope: 'all', username: 'root', ref: 'master' };
+
+ findFilteredSearch().vm.$emit('submit', mockSearch);
+
+ expect(wrapper.vm.requestData).toEqual(expectedQueryParams);
+ expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams);
+ });
+
+ it('does not add query params if raw text search is used', () => {
+ const expectedQueryParams = { page: '1', scope: 'all' };
+
+ findFilteredSearch().vm.$emit('submit', ['rawText']);
+
+ expect(wrapper.vm.requestData).toEqual(expectedQueryParams);
+ expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams);
+ });
+
+ it('displays a warning message if raw text search is used', () => {
+ findFilteredSearch().vm.$emit('submit', ['rawText']);
+
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(RAW_TEXT_WARNING, 'warning');
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js
index c43210c5350..3d564c8758c 100644
--- a/spec/frontend/pipelines/pipelines_table_row_spec.js
+++ b/spec/frontend/pipelines/pipelines_table_row_spec.js
@@ -169,7 +169,7 @@ describe('Pipelines Table Row', () => {
};
beforeEach(() => {
- const withActions = Object.assign({}, pipeline);
+ const withActions = { ...pipeline };
withActions.details.scheduled_actions = [scheduledJobAction];
withActions.flags.cancelable = true;
withActions.flags.retryable = true;
diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js
new file mode 100644
index 00000000000..b0ab250dd16
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_table_spec.js
@@ -0,0 +1,66 @@
+import { mount } from '@vue/test-utils';
+import PipelinesTable from '~/pipelines/components/pipelines_table.vue';
+
+describe('Pipelines Table', () => {
+ let pipeline;
+ let wrapper;
+
+ const jsonFixtureName = 'pipelines/pipelines.json';
+
+ const defaultProps = {
+ pipelines: [],
+ autoDevopsHelpPath: 'foo',
+ viewType: 'root',
+ };
+
+ const createComponent = (props = defaultProps) => {
+ wrapper = mount(PipelinesTable, {
+ propsData: props,
+ });
+ };
+ const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row');
+
+ preloadFixtures(jsonFixtureName);
+
+ beforeEach(() => {
+ const { pipelines } = getJSONFixture(jsonFixtureName);
+ pipeline = pipelines.find(p => p.user !== null && p.commit !== null);
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('table', () => {
+ it('should render a table', () => {
+ expect(wrapper.classes()).toContain('ci-table');
+ });
+
+ it('should render table head with correct columns', () => {
+ expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status');
+
+ expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline');
+
+ expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit');
+
+ expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages');
+ });
+ });
+
+ describe('without data', () => {
+ it('should render an empty table', () => {
+ expect(findRows()).toHaveLength(0);
+ });
+ });
+
+ describe('with data', () => {
+ it('should render rows', () => {
+ createComponent({ pipelines: [pipeline], autoDevopsHelpPath: 'foo', viewType: 'root' });
+
+ expect(findRows()).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js
new file mode 100644
index 00000000000..6aa041bcb7f
--- /dev/null
+++ b/spec/frontend/pipelines/stage_spec.js
@@ -0,0 +1,156 @@
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import StageComponent from '~/pipelines/components/stage.vue';
+import eventHub from '~/pipelines/event_hub';
+import { stageReply } from './mock_data';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('Pipelines stage component', () => {
+ let wrapper;
+ let mock;
+
+ const defaultProps = {
+ stage: {
+ status: {
+ group: 'success',
+ icon: 'status_success',
+ title: 'success',
+ },
+ dropdown_path: 'path.json',
+ },
+ updateDropdown: false,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(StageComponent, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ mock.restore();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should render a dropdown with the status icon', () => {
+ expect(wrapper.attributes('class')).toEqual('dropdown');
+ expect(wrapper.find('svg').exists()).toBe(true);
+ expect(wrapper.find('button').attributes('data-toggle')).toEqual('dropdown');
+ });
+ });
+
+ describe('with successful request', () => {
+ beforeEach(() => {
+ mock.onGet('path.json').reply(200, stageReply);
+ createComponent();
+ });
+
+ it('should render the received data and emit `clickedDropdown` event', () => {
+ jest.spyOn(eventHub, '$emit');
+ wrapper.find('button').trigger('click');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain(
+ stageReply.latest_statuses[0].name,
+ );
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
+ });
+ });
+ });
+
+ describe('when request fails', () => {
+ beforeEach(() => {
+ mock.onGet('path.json').reply(500);
+ createComponent();
+ });
+
+ it('should close the dropdown', () => {
+ wrapper.setMethods({
+ closeDropdown: jest.fn(),
+ isDropdownOpen: jest.fn().mockReturnValue(false),
+ });
+
+ wrapper.find('button').trigger('click');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.closeDropdown).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('update endpoint correctly', () => {
+ beforeEach(() => {
+ const copyStage = { ...stageReply };
+ copyStage.latest_statuses[0].name = 'this is the updated content';
+ mock.onGet('bar.json').reply(200, copyStage);
+ createComponent({
+ stage: {
+ status: {
+ group: 'running',
+ icon: 'status_running',
+ title: 'running',
+ },
+ dropdown_path: 'bar.json',
+ },
+ });
+ });
+
+ it('should update the stage to request the new endpoint provided', () => {
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ wrapper.find('button').trigger('click');
+ return waitForPromises();
+ })
+ .then(() => {
+ expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain(
+ 'this is the updated content',
+ );
+ });
+ });
+ });
+
+ describe('pipelineActionRequestComplete', () => {
+ beforeEach(() => {
+ mock.onGet('path.json').reply(200, stageReply);
+
+ mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200);
+
+ createComponent({ type: 'PIPELINES_TABLE' });
+ });
+
+ describe('within pipeline table', () => {
+ it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', () => {
+ jest.spyOn(eventHub, '$emit');
+
+ wrapper.find('button').trigger('click');
+
+ return waitForPromises()
+ .then(() => {
+ wrapper.find('.js-ci-action').trigger('click');
+
+ return waitForPromises();
+ })
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable');
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/stores/pipeline_store_spec.js b/spec/frontend/pipelines/stores/pipeline_store_spec.js
new file mode 100644
index 00000000000..68d438109b3
--- /dev/null
+++ b/spec/frontend/pipelines/stores/pipeline_store_spec.js
@@ -0,0 +1,135 @@
+import PipelineStore from '~/pipelines/stores/pipeline_store';
+import LinkedPipelines from '../linked_pipelines_mock.json';
+
+describe('EE Pipeline store', () => {
+ let store;
+ let data;
+
+ beforeEach(() => {
+ store = new PipelineStore();
+ data = { ...LinkedPipelines };
+
+ store.storePipeline(data);
+ });
+
+ describe('storePipeline', () => {
+ describe('triggered_by', () => {
+ it('sets triggered_by as an array', () => {
+ expect(store.state.pipeline.triggered_by.length).toEqual(1);
+ });
+
+ it('adds isExpanding & isLoading keys set to false', () => {
+ expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
+ expect(store.state.pipeline.triggered_by[0].isLoading).toEqual(false);
+ });
+
+ it('parses nested triggered_by', () => {
+ expect(store.state.pipeline.triggered_by[0].triggered_by.length).toEqual(1);
+ expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
+ expect(store.state.pipeline.triggered_by[0].triggered_by[0].isLoading).toEqual(false);
+ });
+ });
+
+ describe('triggered', () => {
+ it('adds isExpanding & isLoading keys set to false for each triggered pipeline', () => {
+ store.state.pipeline.triggered.forEach(pipeline => {
+ expect(pipeline.isExpanded).toEqual(false);
+ expect(pipeline.isLoading).toEqual(false);
+ });
+ });
+
+ it('parses nested triggered pipelines', () => {
+ store.state.pipeline.triggered[1].triggered.forEach(pipeline => {
+ expect(pipeline.isExpanded).toEqual(false);
+ expect(pipeline.isLoading).toEqual(false);
+ });
+ });
+ });
+ });
+
+ describe('resetTriggeredByPipeline', () => {
+ it('closes the pipeline & nested ones', () => {
+ store.state.pipeline.triggered_by[0].isExpanded = true;
+ store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded = true;
+
+ store.resetTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
+
+ expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
+ expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
+ });
+ });
+
+ describe('openTriggeredByPipeline', () => {
+ it('opens the given pipeline', () => {
+ store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
+
+ expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(true);
+ });
+ });
+
+ describe('closeTriggeredByPipeline', () => {
+ it('closes the given pipeline', () => {
+ // open it first
+ store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
+
+ store.closeTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
+
+ expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
+ });
+ });
+
+ describe('resetTriggeredPipelines', () => {
+ it('closes the pipeline & nested ones', () => {
+ store.state.pipeline.triggered[0].isExpanded = true;
+ store.state.pipeline.triggered[0].triggered[0].isExpanded = true;
+
+ store.resetTriggeredPipelines(store.state.pipeline, store.state.pipeline.triggered[0]);
+
+ expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
+ expect(store.state.pipeline.triggered[0].triggered[0].isExpanded).toEqual(false);
+ });
+ });
+
+ describe('openTriggeredPipeline', () => {
+ it('opens the given pipeline', () => {
+ store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
+
+ expect(store.state.pipeline.triggered[0].isExpanded).toEqual(true);
+ });
+ });
+
+ describe('closeTriggeredPipeline', () => {
+ it('closes the given pipeline', () => {
+ // open it first
+ store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
+
+ store.closeTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
+
+ expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
+ });
+ });
+
+ describe('toggleLoading', () => {
+ it('toggles the isLoading property for the given pipeline', () => {
+ store.toggleLoading(store.state.pipeline.triggered[0]);
+
+ expect(store.state.pipeline.triggered[0].isLoading).toEqual(true);
+ });
+ });
+
+ describe('addExpandedPipelineToRequestData', () => {
+ it('pushes the given id to expandedPipelines array', () => {
+ store.addExpandedPipelineToRequestData('213231');
+
+ expect(store.state.expandedPipelines).toEqual(['213231']);
+ });
+ });
+
+ describe('removeExpandedPipelineToRequestData', () => {
+ it('pushes the given id to expandedPipelines array', () => {
+ store.removeExpandedPipelineToRequestData('213231');
+
+ expect(store.state.expandedPipelines).toEqual([]);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
index 9eaa563025d..a0eb93c4e6b 100644
--- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
@@ -20,7 +20,7 @@ describe('Mutations TestReports Store', () => {
describe('set endpoint', () => {
it('should set endpoint', () => {
- const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
+ const expectedState = { ...mockState, endpoint: 'foo' };
mutations[types.SET_ENDPOINT](mockState, 'foo');
expect(mockState.endpoint).toEqual(expectedState.endpoint);
@@ -47,14 +47,14 @@ describe('Mutations TestReports Store', () => {
describe('toggle loading', () => {
it('should set to true', () => {
- const expectedState = Object.assign({}, mockState, { isLoading: true });
+ const expectedState = { ...mockState, isLoading: true };
mutations[types.TOGGLE_LOADING](mockState);
expect(mockState.isLoading).toEqual(expectedState.isLoading);
});
it('should toggle back to false', () => {
- const expectedState = Object.assign({}, mockState, { isLoading: false });
+ const expectedState = { ...mockState, isLoading: false };
mockState.isLoading = true;
mutations[types.TOGGLE_LOADING](mockState);
diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js
index 160d93d2e6b..8f041e46472 100644
--- a/spec/frontend/pipelines/test_reports/test_summary_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js
@@ -82,17 +82,19 @@ describe('Test reports summary', () => {
describe('success percentage calculation', () => {
it.each`
- name | successCount | totalCount | result
- ${'displays 0 when there are no tests'} | ${0} | ${0} | ${'0'}
- ${'displays whole number when possible'} | ${10} | ${50} | ${'20'}
- ${'rounds to 0.01'} | ${1} | ${16604} | ${'0.01'}
- ${'correctly rounds to 50'} | ${8302} | ${16604} | ${'50'}
- ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${'99.99'}
- ${'correctly displays 100'} | ${16604} | ${16604} | ${'100'}
- `('$name', ({ successCount, totalCount, result }) => {
+ name | successCount | totalCount | skippedCount | result
+ ${'displays 0 when there are no tests'} | ${0} | ${0} | ${0} | ${'0'}
+ ${'displays whole number when possible'} | ${10} | ${50} | ${0} | ${'20'}
+ ${'excludes skipped tests from total'} | ${10} | ${50} | ${5} | ${'22.22'}
+ ${'rounds to 0.01'} | ${1} | ${16604} | ${0} | ${'0.01'}
+ ${'correctly rounds to 50'} | ${8302} | ${16604} | ${0} | ${'50'}
+ ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${0} | ${'99.99'}
+ ${'correctly displays 100'} | ${16604} | ${16604} | ${0} | ${'100'}
+ `('$name', ({ successCount, totalCount, skippedCount, result }) => {
createComponent({
report: {
success_count: successCount,
+ skipped_count: skippedCount,
total_count: totalCount,
},
});
diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
index 9146f301f66..b585536ae09 100644
--- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
@@ -37,11 +37,47 @@ describe('Test reports summary table', () => {
describe('when test reports are supplied', () => {
beforeEach(() => createComponent());
+ const findErrorIcon = () => wrapper.find({ ref: 'suiteErrorIcon' });
it('renders the correct number of rows', () => {
expect(noSuitesToShow().exists()).toBe(false);
expect(allSuitesRows().length).toBe(testReports.test_suites.length);
});
+
+ describe('when there is a suite error', () => {
+ beforeEach(() => {
+ createComponent({
+ test_suites: [
+ {
+ ...testReports.test_suites[0],
+ suite_error: 'Suite Error',
+ },
+ ],
+ });
+ });
+
+ it('renders error icon', () => {
+ expect(findErrorIcon().exists()).toBe(true);
+ expect(findErrorIcon().attributes('title')).toEqual('Suite Error');
+ });
+ });
+
+ describe('when there is not a suite error', () => {
+ beforeEach(() => {
+ createComponent({
+ test_suites: [
+ {
+ ...testReports.test_suites[0],
+ suite_error: null,
+ },
+ ],
+ });
+ });
+
+ it('does not render error icon', () => {
+ expect(findErrorIcon().exists()).toBe(false);
+ });
+ });
});
describe('when there are no test suites', () => {
diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js
new file mode 100644
index 00000000000..1bd16182d47
--- /dev/null
+++ b/spec/frontend/pipelines/time_ago_spec.js
@@ -0,0 +1,67 @@
+import { shallowMount } from '@vue/test-utils';
+import TimeAgo from '~/pipelines/components/time_ago.vue';
+
+describe('Timeago component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(TimeAgo, {
+ propsData: {
+ ...props,
+ },
+ data() {
+ return {
+ iconTimerSvg: `<svg></svg>`,
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('with duration', () => {
+ beforeEach(() => {
+ createComponent({ duration: 10, finishedTime: '' });
+ });
+
+ it('should render duration and timer svg', () => {
+ expect(wrapper.find('.duration').exists()).toBe(true);
+ expect(wrapper.find('.duration svg').exists()).toBe(true);
+ });
+ });
+
+ describe('without duration', () => {
+ beforeEach(() => {
+ createComponent({ duration: 0, finishedTime: '' });
+ });
+
+ it('should not render duration and timer svg', () => {
+ expect(wrapper.find('.duration').exists()).toBe(false);
+ });
+ });
+
+ describe('with finishedTime', () => {
+ beforeEach(() => {
+ createComponent({ duration: 0, finishedTime: '2017-04-26T12:40:23.277Z' });
+ });
+
+ it('should render time and calendar icon', () => {
+ expect(wrapper.find('.finished-at').exists()).toBe(true);
+ expect(wrapper.find('.finished-at i.fa-calendar').exists()).toBe(true);
+ expect(wrapper.find('.finished-at time').exists()).toBe(true);
+ });
+ });
+
+ describe('without finishedTime', () => {
+ beforeEach(() => {
+ createComponent({ duration: 0, finishedTime: '' });
+ });
+
+ it('should not render time and calendar icon', () => {
+ expect(wrapper.find('.finished-at').exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
new file mode 100644
index 00000000000..a6753600792
--- /dev/null
+++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js
@@ -0,0 +1,89 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PipelineBranchNameToken from '~/pipelines/components/tokens/pipeline_branch_name_token.vue';
+import { branches } from '../mock_data';
+
+describe('Pipeline Branch Name Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ const stubs = {
+ GlFilteredSearchToken: {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ },
+ };
+
+ const defaultProps = {
+ config: {
+ type: 'ref',
+ icon: 'branch',
+ title: 'Branch name',
+ dataType: 'ref',
+ unique: true,
+ branches,
+ projectId: '21',
+ },
+ value: {
+ data: '',
+ },
+ };
+
+ const createComponent = (options, data) => {
+ wrapper = shallowMount(PipelineBranchNameToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ describe('displays loading icon correctly', () => {
+ it('shows loading icon', () => {
+ createComponent({ stubs }, { loading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not show loading icon', () => {
+ createComponent({ stubs }, { loading: false });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('shows branches correctly', () => {
+ it('renders all trigger authors', () => {
+ createComponent({ stubs }, { branches, loading: false });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length);
+ });
+
+ it('renders only the branch searched for', () => {
+ const mockBranches = ['master'];
+ createComponent({ stubs }, { branches: mockBranches, loading: false });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
new file mode 100644
index 00000000000..00a9ff04e75
--- /dev/null
+++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js
@@ -0,0 +1,98 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PipelineTriggerAuthorToken from '~/pipelines/components/tokens/pipeline_trigger_author_token.vue';
+import { users } from '../mock_data';
+
+describe('Pipeline Trigger Author Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ const stubs = {
+ GlFilteredSearchToken: {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ },
+ };
+
+ const defaultProps = {
+ config: {
+ type: 'username',
+ icon: 'user',
+ title: 'Trigger author',
+ dataType: 'username',
+ unique: true,
+ triggerAuthors: users,
+ },
+ value: {
+ data: '',
+ },
+ };
+
+ const createComponent = (options, data) => {
+ wrapper = shallowMount(PipelineTriggerAuthorToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ describe('displays loading icon correctly', () => {
+ it('shows loading icon', () => {
+ createComponent({ stubs }, { loading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not show loading icon', () => {
+ createComponent({ stubs }, { loading: false });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('shows trigger authors correctly', () => {
+ beforeEach(() => {});
+
+ it('renders all trigger authors', () => {
+ createComponent({ stubs }, { users, loading: false });
+
+ // should have length of all users plus the static 'Any' option
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1);
+ });
+
+ it('renders only the trigger author searched for', () => {
+ createComponent(
+ { stubs },
+ {
+ users: [
+ { name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' },
+ ],
+ loading: false,
+ },
+ );
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(2);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines_spec.js b/spec/frontend/pipelines_spec.js
new file mode 100644
index 00000000000..6d4d634c575
--- /dev/null
+++ b/spec/frontend/pipelines_spec.js
@@ -0,0 +1,19 @@
+import Pipelines from '~/pipelines';
+
+describe('Pipelines', () => {
+ preloadFixtures('static/pipeline_graph.html');
+
+ beforeEach(() => {
+ loadFixtures('static/pipeline_graph.html');
+ });
+
+ it('should be defined', () => {
+ expect(Pipelines).toBeDefined();
+ });
+
+ it('should create a `Pipelines` instance without options', () => {
+ expect(() => {
+ new Pipelines(); // eslint-disable-line no-new
+ }).not.toThrow();
+ });
+});
diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
index 97b8f7bd913..1244d7342ad 100644
--- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import CustomMetrics from '~/prometheus_metrics/custom_metrics';
import axios from '~/lib/utils/axios_utils';
import PANEL_STATE from '~/prometheus_metrics/constants';
-import metrics from './mock_data';
+import { metrics1 as metrics } from './mock_data';
describe('PrometheusMetrics', () => {
const FIXTURE = 'services/prometheus/prometheus_service.html';
diff --git a/spec/frontend/prometheus_metrics/mock_data.js b/spec/frontend/prometheus_metrics/mock_data.js
index d5532537302..375447ac3be 100644
--- a/spec/frontend/prometheus_metrics/mock_data.js
+++ b/spec/frontend/prometheus_metrics/mock_data.js
@@ -1,4 +1,4 @@
-const metrics = [
+export const metrics1 = [
{
edit_path: '/root/prometheus-test/prometheus/metrics/3/edit',
id: 3,
@@ -19,4 +19,44 @@ const metrics = [
},
];
-export default metrics;
+export const metrics2 = [
+ {
+ group: 'Kubernetes',
+ priority: 1,
+ active_metrics: 4,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'HAProxy',
+ priority: 2,
+ active_metrics: 3,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'Apache',
+ priority: 3,
+ active_metrics: 5,
+ metrics_missing_requirements: 0,
+ },
+];
+
+export const missingVarMetrics = [
+ {
+ group: 'Kubernetes',
+ priority: 1,
+ active_metrics: 4,
+ metrics_missing_requirements: 0,
+ },
+ {
+ group: 'HAProxy',
+ priority: 2,
+ active_metrics: 3,
+ metrics_missing_requirements: 1,
+ },
+ {
+ group: 'Apache',
+ priority: 3,
+ active_metrics: 5,
+ metrics_missing_requirements: 3,
+ },
+];
diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
new file mode 100644
index 00000000000..437a2116f5c
--- /dev/null
+++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
@@ -0,0 +1,178 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
+import PANEL_STATE from '~/prometheus_metrics/constants';
+import { metrics2 as metrics, missingVarMetrics } from './mock_data';
+
+describe('PrometheusMetrics', () => {
+ const FIXTURE = 'services/prometheus/prometheus_service.html';
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ });
+
+ describe('constructor', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should initialize wrapper element refs on class object', () => {
+ expect(prometheusMetrics.$wrapper).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsPanel).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsCount).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsLoading).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsEmpty).toBeDefined();
+ expect(prometheusMetrics.$monitoredMetricsList).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarPanel).toBeDefined();
+ expect(prometheusMetrics.$panelToggle).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarMetricCount).toBeDefined();
+ expect(prometheusMetrics.$missingEnvVarMetricsList).toBeDefined();
+ });
+
+ it('should initialize metadata on class object', () => {
+ expect(prometheusMetrics.backOffRequestCounter).toEqual(0);
+ expect(prometheusMetrics.activeMetricsEndpoint).toContain('/test');
+ });
+ });
+
+ describe('showMonitoringMetricsPanelState', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show loading state when called with `loading`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LOADING);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
+ });
+
+ it('should show metrics list when called with `list`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.LIST);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
+ });
+
+ it('should show empty state when called with `empty`', () => {
+ prometheusMetrics.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeTruthy();
+ });
+ });
+
+ describe('populateActiveMetrics', () => {
+ let prometheusMetrics;
+
+ beforeEach(() => {
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+ });
+
+ it('should show monitored metrics list', () => {
+ prometheusMetrics.populateActiveMetrics(metrics);
+
+ const $metricsListLi = prometheusMetrics.$monitoredMetricsList.find('li');
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsList.hasClass('hidden')).toBeFalsy();
+
+ expect(prometheusMetrics.$monitoredMetricsCount.text()).toEqual(
+ '3 exporters with 12 metrics were found',
+ );
+
+ expect($metricsListLi.length).toEqual(metrics.length);
+ expect(
+ $metricsListLi
+ .first()
+ .find('.badge')
+ .text(),
+ ).toEqual(`${metrics[0].active_metrics}`);
+ });
+
+ it('should show missing environment variables list', () => {
+ prometheusMetrics.populateActiveMetrics(missingVarMetrics);
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$missingEnvVarPanel.hasClass('hidden')).toBeFalsy();
+
+ expect(prometheusMetrics.$missingEnvVarMetricCount.text()).toEqual('2');
+ expect(prometheusMetrics.$missingEnvVarPanel.find('li').length).toEqual(2);
+ expect(prometheusMetrics.$missingEnvVarPanel.find('.flash-container')).toBeDefined();
+ });
+ });
+
+ describe('loadActiveMetrics', () => {
+ let prometheusMetrics;
+ let mock;
+
+ function mockSuccess() {
+ mock.onGet(prometheusMetrics.activeMetricsEndpoint).reply(200, {
+ data: metrics,
+ success: true,
+ });
+ }
+
+ function mockError() {
+ mock.onGet(prometheusMetrics.activeMetricsEndpoint).networkError();
+ }
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'get');
+
+ prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should show loader animation while response is being loaded and hide it when request is complete', done => {
+ mockSuccess();
+
+ prometheusMetrics.loadActiveMetrics();
+
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeFalsy();
+ expect(axios.get).toHaveBeenCalledWith(prometheusMetrics.activeMetricsEndpoint);
+
+ setImmediate(() => {
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should show empty state if response failed to load', done => {
+ mockError();
+
+ prometheusMetrics.loadActiveMetrics();
+
+ setImmediate(() => {
+ expect(prometheusMetrics.$monitoredMetricsLoading.hasClass('hidden')).toBeTruthy();
+ expect(prometheusMetrics.$monitoredMetricsEmpty.hasClass('hidden')).toBeFalsy();
+ done();
+ });
+ });
+
+ it('should populate metrics list once response is loaded', done => {
+ jest.spyOn(prometheusMetrics, 'populateActiveMetrics').mockImplementation();
+ mockSuccess();
+
+ prometheusMetrics.loadActiveMetrics();
+
+ setImmediate(() => {
+ expect(prometheusMetrics.populateActiveMetrics).toHaveBeenCalledWith(metrics);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/registry/explorer/components/image_list_spec.js b/spec/frontend/registry/explorer/components/image_list_spec.js
new file mode 100644
index 00000000000..12f0fbe0c87
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/image_list_spec.js
@@ -0,0 +1,74 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlPagination } from '@gitlab/ui';
+import Component from '~/registry/explorer/components/image_list.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { RouterLink } from '../stubs';
+import { imagesListResponse, imagePagination } from '../mock_data';
+
+describe('Image List', () => {
+ let wrapper;
+
+ const firstElement = imagesListResponse.data[0];
+
+ const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]');
+ const findRowItems = () => wrapper.findAll('[data-testid="rowItem"]');
+ const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]');
+ const findClipboardButton = () => wrapper.find(ClipboardButton);
+ const findPagination = () => wrapper.find(GlPagination);
+
+ const mountComponent = () => {
+ wrapper = shallowMount(Component, {
+ stubs: {
+ RouterLink,
+ },
+ propsData: {
+ images: imagesListResponse.data,
+ pagination: imagePagination,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('contains one list element for each image', () => {
+ expect(findRowItems().length).toBe(imagesListResponse.data.length);
+ });
+
+ it('contains a link to the details page', () => {
+ const link = findDetailsLink();
+ expect(link.html()).toContain(firstElement.path);
+ expect(link.props('to').name).toBe('details');
+ });
+
+ it('contains a clipboard button', () => {
+ const button = findClipboardButton();
+ expect(button.exists()).toBe(true);
+ expect(button.props('text')).toBe(firstElement.location);
+ expect(button.props('title')).toBe(firstElement.location);
+ });
+
+ it('should be possible to delete a repo', () => {
+ const deleteBtn = findDeleteBtn();
+ expect(deleteBtn.exists()).toBe(true);
+ });
+
+ describe('pagination', () => {
+ it('exists', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('is wired to the correct pagination props', () => {
+ const pagination = findPagination();
+ expect(pagination.props('perPage')).toBe(imagePagination.perPage);
+ expect(pagination.props('totalItems')).toBe(imagePagination.total);
+ expect(pagination.props('value')).toBe(imagePagination.page);
+ });
+
+ it('emits a pageChange event when the page change', () => {
+ wrapper.setData({ currentPage: 2 });
+ expect(wrapper.emitted('pageChange')).toEqual([[2]]);
+ });
+ });
+});
diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js
index 2d8cd4e42bc..f6beccda9b1 100644
--- a/spec/frontend/registry/explorer/mock_data.js
+++ b/spec/frontend/registry/explorer/mock_data.js
@@ -87,3 +87,11 @@ export const tagsListResponse = {
],
headers,
};
+
+export const imagePagination = {
+ perPage: 10,
+ page: 1,
+ total: 14,
+ totalPages: 2,
+ nextPage: 2,
+};
diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js
index 15aa5008413..93098403a28 100644
--- a/spec/frontend/registry/explorer/pages/details_spec.js
+++ b/spec/frontend/registry/explorer/pages/details_spec.js
@@ -1,15 +1,21 @@
import { mount } from '@vue/test-utils';
-import { GlTable, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
+import { GlTable, GlPagination, GlSkeletonLoader, GlAlert, GlLink } from '@gitlab/ui';
import Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/pages/details.vue';
-import store from '~/registry/explorer/stores/';
-import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
+import { createStore } from '~/registry/explorer/stores/';
+import {
+ SET_MAIN_LOADING,
+ SET_INITIAL_STATE,
+ SET_TAGS_LIST_SUCCESS,
+ SET_TAGS_PAGINATION,
+} from '~/registry/explorer/stores/mutation_types/';
import {
DELETE_TAG_SUCCESS_MESSAGE,
DELETE_TAG_ERROR_MESSAGE,
DELETE_TAGS_SUCCESS_MESSAGE,
DELETE_TAGS_ERROR_MESSAGE,
+ ADMIN_GARBAGE_COLLECTION_TIP,
} from '~/registry/explorer/constants';
import { tagsListResponse } from '../mock_data';
import { GlModal } from '../stubs';
@@ -18,6 +24,7 @@ import { $toast } from '../../shared/mocks';
describe('Details Page', () => {
let wrapper;
let dispatchSpy;
+ let store;
const findDeleteModal = () => wrapper.find(GlModal);
const findPagination = () => wrapper.find(GlPagination);
@@ -30,6 +37,8 @@ describe('Details Page', () => {
const findAllCheckboxes = () => wrapper.findAll('.js-row-checkbox');
const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked'));
const findFirsTagColumn = () => wrapper.find('.js-tag-column');
+ const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]');
+ const findAlert = () => wrapper.find(GlAlert);
const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
@@ -55,13 +64,17 @@ describe('Details Page', () => {
};
beforeEach(() => {
+ store = createStore();
dispatchSpy = jest.spyOn(store, 'dispatch');
- store.dispatch('receiveTagsListSuccess', tagsListResponse);
+ dispatchSpy.mockResolvedValue();
+ store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data);
+ store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers);
jest.spyOn(Tracking, 'event');
});
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
describe('when isLoading is true', () => {
@@ -130,10 +143,6 @@ describe('Details Page', () => {
});
describe('row checkbox', () => {
- beforeEach(() => {
- mountComponent();
- });
-
it('if selected adds item to selectedItems', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
@@ -240,15 +249,24 @@ describe('Details Page', () => {
});
});
- describe('tag cell', () => {
+ describe('name cell', () => {
+ it('tag column has a tooltip with the tag name', () => {
+ mountComponent();
+ expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name);
+ });
+
describe('on desktop viewport', () => {
beforeEach(() => {
mountComponent();
});
- it('has class w-25', () => {
+ it('table header has class w-25', () => {
expect(findFirsTagColumn().classes()).toContain('w-25');
});
+
+ it('tag column has the mw-m class', () => {
+ expect(findFirstRowItem('rowName').classes()).toContain('mw-m');
+ });
});
describe('on mobile viewport', () => {
@@ -260,9 +278,28 @@ describe('Details Page', () => {
});
});
- it('does not has class w-25', () => {
+ it('table header does not have class w-25', () => {
expect(findFirsTagColumn().classes()).not.toContain('w-25');
});
+
+ it('tag column has the gl-justify-content-end class', () => {
+ expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end');
+ });
+ });
+ });
+
+ describe('last updated cell', () => {
+ let timeCell;
+
+ beforeEach(() => {
+ timeCell = findFirstRowItem('rowTime');
+ });
+
+ it('displays the time in string format', () => {
+ expect(timeCell.text()).toBe('2 years ago');
+ });
+ it('has a tooltip timestamp', () => {
+ expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000');
});
});
});
@@ -328,25 +365,9 @@ describe('Details Page', () => {
});
// itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
+ expect(wrapper.vm.selectedItems).toEqual([]);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
-
- it('show success toast on successful delete', () => {
- return wrapper.vm.handleSingleDelete(0).then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAG_SUCCESS_MESSAGE, {
- type: 'success',
- });
- });
- });
-
- it('show error toast on erred delete', () => {
- dispatchSpy.mockRejectedValue();
- return wrapper.vm.handleSingleDelete(0).then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAG_ERROR_MESSAGE, {
- type: 'error',
- });
- });
- });
});
describe('when multiple elements are selected', () => {
@@ -365,23 +386,6 @@ describe('Details Page', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
-
- it('show success toast on successful delete', () => {
- return wrapper.vm.handleMultipleDelete(0).then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAGS_SUCCESS_MESSAGE, {
- type: 'success',
- });
- });
- });
-
- it('show error toast on erred delete', () => {
- dispatchSpy.mockRejectedValue();
- return wrapper.vm.handleMultipleDelete(0).then(() => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_TAGS_ERROR_MESSAGE, {
- type: 'error',
- });
- });
- });
});
});
@@ -395,4 +399,108 @@ describe('Details Page', () => {
});
});
});
+
+ describe('Delete alert', () => {
+ const config = {
+ garbageCollectionHelpPagePath: 'foo',
+ };
+
+ describe('when the user is an admin', () => {
+ beforeEach(() => {
+ store.commit(SET_INITIAL_STATE, { ...config, isAdmin: true });
+ });
+
+ afterEach(() => {
+ store.commit(SET_INITIAL_STATE, config);
+ });
+
+ describe.each`
+ deleteType | successTitle | errorTitle
+ ${'handleSingleDelete'} | ${DELETE_TAG_SUCCESS_MESSAGE} | ${DELETE_TAG_ERROR_MESSAGE}
+ ${'handleMultipleDelete'} | ${DELETE_TAGS_SUCCESS_MESSAGE} | ${DELETE_TAGS_ERROR_MESSAGE}
+ `('behaves correctly on $deleteType', ({ deleteType, successTitle, errorTitle }) => {
+ describe('when delete is successful', () => {
+ beforeEach(() => {
+ dispatchSpy.mockResolvedValue();
+ mountComponent();
+ return wrapper.vm[deleteType]('foo');
+ });
+
+ it('alert exists', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('alert body contains admin tip', () => {
+ expect(
+ findAlert()
+ .text()
+ .replace(/\s\s+/gm, ' '),
+ ).toBe(ADMIN_GARBAGE_COLLECTION_TIP.replace(/%{\w+}/gm, ''));
+ });
+
+ it('alert body contains link', () => {
+ const alertLink = findAlert().find(GlLink);
+ expect(alertLink.exists()).toBe(true);
+ expect(alertLink.attributes('href')).toBe(config.garbageCollectionHelpPagePath);
+ });
+
+ it('alert title is appropriate', () => {
+ expect(findAlert().attributes('title')).toBe(successTitle);
+ });
+ });
+
+ describe('when delete is not successful', () => {
+ beforeEach(() => {
+ mountComponent();
+ dispatchSpy.mockRejectedValue();
+ return wrapper.vm[deleteType]('foo');
+ });
+
+ it('alert exist and text is appropriate', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(errorTitle);
+ });
+ });
+ });
+ });
+
+ describe.each`
+ deleteType | successTitle | errorTitle
+ ${'handleSingleDelete'} | ${DELETE_TAG_SUCCESS_MESSAGE} | ${DELETE_TAG_ERROR_MESSAGE}
+ ${'handleMultipleDelete'} | ${DELETE_TAGS_SUCCESS_MESSAGE} | ${DELETE_TAGS_ERROR_MESSAGE}
+ `(
+ 'when the user is not an admin alert behaves correctly on $deleteType',
+ ({ deleteType, successTitle, errorTitle }) => {
+ beforeEach(() => {
+ store.commit('SET_INITIAL_STATE', { ...config });
+ });
+
+ describe('when delete is successful', () => {
+ beforeEach(() => {
+ dispatchSpy.mockResolvedValue();
+ mountComponent();
+ return wrapper.vm[deleteType]('foo');
+ });
+
+ it('alert exist and text is appropriate', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(successTitle);
+ });
+ });
+
+ describe('when delete is not successful', () => {
+ beforeEach(() => {
+ mountComponent();
+ dispatchSpy.mockRejectedValue();
+ return wrapper.vm[deleteType]('foo');
+ });
+
+ it('alert exist and text is appropriate', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(errorTitle);
+ });
+ });
+ },
+ );
+ });
});
diff --git a/spec/frontend/registry/explorer/pages/index_spec.js b/spec/frontend/registry/explorer/pages/index_spec.js
index f52e7d67866..b558727ed5e 100644
--- a/spec/frontend/registry/explorer/pages/index_spec.js
+++ b/spec/frontend/registry/explorer/pages/index_spec.js
@@ -1,62 +1,26 @@
import { shallowMount } from '@vue/test-utils';
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import component from '~/registry/explorer/pages/index.vue';
import store from '~/registry/explorer/stores/';
describe('List Page', () => {
let wrapper;
- let dispatchSpy;
const findRouterView = () => wrapper.find({ ref: 'router-view' });
- const findAlert = () => wrapper.find(GlAlert);
- const findLink = () => wrapper.find(GlLink);
const mountComponent = () => {
wrapper = shallowMount(component, {
store,
stubs: {
RouterView: true,
- GlSprintf,
},
});
};
beforeEach(() => {
- dispatchSpy = jest.spyOn(store, 'dispatch');
mountComponent();
});
it('has a router view', () => {
expect(findRouterView().exists()).toBe(true);
});
-
- describe('garbageCollectionTip alert', () => {
- beforeEach(() => {
- store.dispatch('setInitialState', { isAdmin: true, garbageCollectionHelpPagePath: 'foo' });
- store.dispatch('setShowGarbageCollectionTip', true);
- });
-
- afterEach(() => {
- store.dispatch('setInitialState', {});
- store.dispatch('setShowGarbageCollectionTip', false);
- });
-
- it('is visible when the user is an admin and the user performed a delete action', () => {
- expect(findAlert().exists()).toBe(true);
- });
-
- it('on dismiss disappears ', () => {
- findAlert().vm.$emit('dismiss');
- expect(dispatchSpy).toHaveBeenCalledWith('setShowGarbageCollectionTip', false);
- return wrapper.vm.$nextTick().then(() => {
- expect(findAlert().exists()).toBe(false);
- });
- });
-
- it('contains a link to the docs', () => {
- const link = findLink();
- expect(link.exists()).toBe(true);
- expect(link.attributes('href')).toBe(store.state.config.garbageCollectionHelpPagePath);
- });
- });
});
diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js
index f69b849521d..97742b9e9b3 100644
--- a/spec/frontend/registry/explorer/pages/list_spec.js
+++ b/spec/frontend/registry/explorer/pages/list_spec.js
@@ -1,47 +1,53 @@
-import VueRouter from 'vue-router';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlPagination, GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui';
import Tracking from '~/tracking';
+import waitForPromises from 'helpers/wait_for_promises';
import component from '~/registry/explorer/pages/list.vue';
import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue';
import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue';
import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue';
import ProjectPolicyAlert from '~/registry/explorer/components/project_policy_alert.vue';
-import store from '~/registry/explorer/stores/';
-import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
+import ImageList from '~/registry/explorer/components/image_list.vue';
+import { createStore } from '~/registry/explorer/stores/';
+import {
+ SET_MAIN_LOADING,
+ SET_IMAGES_LIST_SUCCESS,
+ SET_PAGINATION,
+ SET_INITIAL_STATE,
+} from '~/registry/explorer/stores/mutation_types/';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
+ IMAGE_REPOSITORY_LIST_LABEL,
+ SEARCH_PLACEHOLDER_TEXT,
} from '~/registry/explorer/constants';
import { imagesListResponse } from '../mock_data';
import { GlModal, GlEmptyState } from '../stubs';
import { $toast } from '../../shared/mocks';
-const localVue = createLocalVue();
-localVue.use(VueRouter);
-
describe('List Page', () => {
let wrapper;
let dispatchSpy;
+ let store;
- const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' });
const findDeleteModal = () => wrapper.find(GlModal);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findImagesList = () => wrapper.find({ ref: 'imagesList' });
- const findRowItems = () => wrapper.findAll({ ref: 'rowItem' });
+
const findEmptyState = () => wrapper.find(GlEmptyState);
- const findDetailsLink = () => wrapper.find({ ref: 'detailsLink' });
- const findClipboardButton = () => wrapper.find({ ref: 'clipboardButton' });
- const findPagination = () => wrapper.find(GlPagination);
+
const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown);
const findProjectEmptyState = () => wrapper.find(ProjectEmptyState);
const findGroupEmptyState = () => wrapper.find(GroupEmptyState);
const findProjectPolicyAlert = () => wrapper.find(ProjectPolicyAlert);
const findDeleteAlert = () => wrapper.find(GlAlert);
+ const findImageList = () => wrapper.find(ImageList);
+ const findListHeader = () => wrapper.find('[data-testid="listHeader"]');
+ const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
+ const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
- beforeEach(() => {
+ const mountComponent = ({ mocks } = {}) => {
wrapper = shallowMount(component, {
- localVue,
store,
stubs: {
GlModal,
@@ -50,10 +56,20 @@ describe('List Page', () => {
},
mocks: {
$toast,
+ $route: {
+ name: 'foo',
+ },
+ ...mocks,
},
});
+ };
+
+ beforeEach(() => {
+ store = createStore();
dispatchSpy = jest.spyOn(store, 'dispatch');
- store.dispatch('receiveImagesListSuccess', imagesListResponse);
+ dispatchSpy.mockResolvedValue();
+ store.commit(SET_IMAGES_LIST_SUCCESS, imagesListResponse.data);
+ store.commit(SET_PAGINATION, imagesListResponse.headers);
});
afterEach(() => {
@@ -61,17 +77,38 @@ describe('List Page', () => {
});
describe('Expiration policy notification', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
it('shows up on project page', () => {
expect(findProjectPolicyAlert().exists()).toBe(true);
});
it('does show up on group page', () => {
- store.dispatch('setInitialState', { isGroupPage: true });
+ store.commit(SET_INITIAL_STATE, { isGroupPage: true });
return wrapper.vm.$nextTick().then(() => {
expect(findProjectPolicyAlert().exists()).toBe(false);
});
});
});
+ describe('API calls', () => {
+ it.each`
+ imageList | name | called
+ ${[]} | ${'foo'} | ${['requestImagesList']}
+ ${imagesListResponse.data} | ${undefined} | ${['requestImagesList']}
+ ${imagesListResponse.data} | ${'foo'} | ${undefined}
+ `(
+ 'with images equal $imageList and name $name dispatch calls $called',
+ ({ imageList, name, called }) => {
+ store.commit(SET_IMAGES_LIST_SUCCESS, imageList);
+ dispatchSpy.mockClear();
+ mountComponent({ mocks: { $route: { name } } });
+
+ expect(dispatchSpy.mock.calls[0]).toEqual(called);
+ },
+ );
+ });
+
describe('connection error', () => {
const config = {
characterError: true,
@@ -79,12 +116,13 @@ describe('List Page', () => {
helpPagePath: 'bar',
};
- beforeAll(() => {
- store.dispatch('setInitialState', config);
+ beforeEach(() => {
+ store.commit(SET_INITIAL_STATE, config);
+ mountComponent();
});
- afterAll(() => {
- store.dispatch('setInitialState', {});
+ afterEach(() => {
+ store.commit(SET_INITIAL_STATE, {});
});
it('should show an empty state', () => {
@@ -106,9 +144,12 @@ describe('List Page', () => {
});
describe('isLoading is true', () => {
- beforeAll(() => store.commit(SET_MAIN_LOADING, true));
+ beforeEach(() => {
+ store.commit(SET_MAIN_LOADING, true);
+ mountComponent();
+ });
- afterAll(() => store.commit(SET_MAIN_LOADING, false));
+ afterEach(() => store.commit(SET_MAIN_LOADING, false));
it('shows the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
@@ -125,7 +166,9 @@ describe('List Page', () => {
describe('list is empty', () => {
beforeEach(() => {
- store.dispatch('receiveImagesListSuccess', { data: [] });
+ store.commit(SET_IMAGES_LIST_SUCCESS, []);
+ mountComponent();
+ return waitForPromises();
});
it('quick start is not visible', () => {
@@ -137,12 +180,13 @@ describe('List Page', () => {
});
describe('is group page is true', () => {
- beforeAll(() => {
- store.dispatch('setInitialState', { isGroupPage: true });
+ beforeEach(() => {
+ store.commit(SET_INITIAL_STATE, { isGroupPage: true });
+ mountComponent();
});
- afterAll(() => {
- store.dispatch('setInitialState', { isGroupPage: undefined });
+ afterEach(() => {
+ store.commit(SET_INITIAL_STATE, { isGroupPage: undefined });
});
it('group empty state is visible', () => {
@@ -152,50 +196,39 @@ describe('List Page', () => {
it('quick start is not visible', () => {
expect(findQuickStartDropdown().exists()).toBe(false);
});
+
+ it('list header is not visible', () => {
+ expect(findListHeader().exists()).toBe(false);
+ });
});
});
describe('list is not empty', () => {
- it('quick start is visible', () => {
- expect(findQuickStartDropdown().exists()).toBe(true);
- });
-
- describe('listElement', () => {
- let listElements;
- let firstElement;
-
+ describe('unfiltered state', () => {
beforeEach(() => {
- listElements = findRowItems();
- [firstElement] = store.state.images;
+ mountComponent();
});
- it('contains one list element for each image', () => {
- expect(listElements.length).toBe(store.state.images.length);
+ it('quick start is visible', () => {
+ expect(findQuickStartDropdown().exists()).toBe(true);
});
- it('contains a link to the details page', () => {
- const link = findDetailsLink();
- expect(link.html()).toContain(firstElement.path);
- expect(link.props('to').name).toBe('details');
+ it('list component is visible', () => {
+ expect(findImageList().exists()).toBe(true);
});
- it('contains a clipboard button', () => {
- const button = findClipboardButton();
- expect(button.exists()).toBe(true);
- expect(button.props('text')).toBe(firstElement.location);
- expect(button.props('title')).toBe(firstElement.location);
+ it('list header is visible', () => {
+ const header = findListHeader();
+ expect(header.exists()).toBe(true);
+ expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL);
});
describe('delete image', () => {
- it('should be possible to delete a repo', () => {
- const deleteBtn = findDeleteBtn();
- expect(deleteBtn.exists()).toBe(true);
- });
-
+ const itemToDelete = { path: 'bar' };
it('should call deleteItem when confirming deletion', () => {
dispatchSpy.mockResolvedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
findDeleteModal().vm.$emit('ok');
expect(store.dispatch).toHaveBeenCalledWith(
'requestDeleteImage',
@@ -205,8 +238,8 @@ describe('List Page', () => {
it('should show a success alert when delete request is successful', () => {
dispatchSpy.mockResolvedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
return wrapper.vm.handleDeleteImage().then(() => {
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
@@ -218,8 +251,8 @@ describe('List Page', () => {
it('should show an error alert when delete request fails', () => {
dispatchSpy.mockRejectedValue();
- findDeleteBtn().vm.$emit('click');
- expect(wrapper.vm.itemToDelete).not.toEqual({});
+ findImageList().vm.$emit('delete', itemToDelete);
+ expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
return wrapper.vm.handleDeleteImage().then(() => {
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
@@ -229,71 +262,93 @@ describe('List Page', () => {
});
});
});
+ });
- describe('pagination', () => {
- it('exists', () => {
- expect(findPagination().exists()).toBe(true);
- });
+ describe('search', () => {
+ it('has a search box element', () => {
+ mountComponent();
+ const searchBox = findSearchBox();
+ expect(searchBox.exists()).toBe(true);
+ expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT);
+ });
- it('is wired to the correct pagination props', () => {
- const pagination = findPagination();
- expect(pagination.props('perPage')).toBe(store.state.pagination.perPage);
- expect(pagination.props('totalItems')).toBe(store.state.pagination.total);
- expect(pagination.props('value')).toBe(store.state.pagination.page);
+ it('performs a search', () => {
+ mountComponent();
+ findSearchBox().vm.$emit('submit', 'foo');
+ expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
+ name: 'foo',
});
+ });
- it('fetch the data from the API when the v-model changes', () => {
- dispatchSpy.mockReturnValue();
- wrapper.setData({ currentPage: 2 });
- return wrapper.vm.$nextTick().then(() => {
- expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { page: 2 });
- });
+ it('when search result is empty displays an empty search message', () => {
+ mountComponent();
+ store.commit(SET_IMAGES_LIST_SUCCESS, []);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findEmptySearchMessage().exists()).toBe(true);
});
});
});
- describe('modal', () => {
- it('exists', () => {
- expect(findDeleteModal().exists()).toBe(true);
- });
-
- it('contains a description with the path of the item to delete', () => {
- wrapper.setData({ itemToDelete: { path: 'foo' } });
- return wrapper.vm.$nextTick().then(() => {
- expect(findDeleteModal().html()).toContain('foo');
+ describe('pagination', () => {
+ it('pageChange event triggers the appropriate store function', () => {
+ mountComponent();
+ findImageList().vm.$emit('pageChange', 2);
+ expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
+ pagination: { page: 2 },
+ name: wrapper.vm.search,
});
});
});
+ });
- describe('tracking', () => {
- const testTrackingCall = action => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
- label: 'registry_repository_delete',
- });
- };
+ describe('modal', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- dispatchSpy.mockResolvedValue();
- });
+ it('exists', () => {
+ expect(findDeleteModal().exists()).toBe(true);
+ });
- it('send an event when delete button is clicked', () => {
- const deleteBtn = findDeleteBtn();
- deleteBtn.vm.$emit('click');
- testTrackingCall('click_button');
+ it('contains a description with the path of the item to delete', () => {
+ wrapper.setData({ itemToDelete: { path: 'foo' } });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findDeleteModal().html()).toContain('foo');
});
+ });
+ });
- it('send an event when cancel is pressed on modal', () => {
- const deleteModal = findDeleteModal();
- deleteModal.vm.$emit('cancel');
- testTrackingCall('cancel_delete');
- });
+ describe('tracking', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
- it('send an event when confirm is clicked on modal', () => {
- const deleteModal = findDeleteModal();
- deleteModal.vm.$emit('ok');
- testTrackingCall('confirm_delete');
+ const testTrackingCall = action => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
+ label: 'registry_repository_delete',
});
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ dispatchSpy.mockResolvedValue();
+ });
+
+ it('send an event when delete button is clicked', () => {
+ findImageList().vm.$emit('delete', {});
+ testTrackingCall('click_button');
+ });
+
+ it('send an event when cancel is pressed on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('cancel');
+ testTrackingCall('cancel_delete');
+ });
+
+ it('send an event when confirm is clicked on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('ok');
+ testTrackingCall('confirm_delete');
});
});
});
diff --git a/spec/frontend/registry/explorer/stores/actions_spec.js b/spec/frontend/registry/explorer/stores/actions_spec.js
index 58f61a0e8c2..15f9db90910 100644
--- a/spec/frontend/registry/explorer/stores/actions_spec.js
+++ b/spec/frontend/registry/explorer/stores/actions_spec.js
@@ -191,7 +191,10 @@ describe('Actions RegistryExplorer Store', () => {
{
tagsPagination: {},
},
- [{ type: types.SET_MAIN_LOADING, payload: true }],
+ [
+ { type: types.SET_MAIN_LOADING, payload: true },
+ { type: types.SET_MAIN_LOADING, payload: false },
+ ],
[
{
type: 'setShowGarbageCollectionTip',
@@ -220,8 +223,7 @@ describe('Actions RegistryExplorer Store', () => {
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
- done,
- );
+ ).catch(() => done());
});
});
@@ -241,7 +243,10 @@ describe('Actions RegistryExplorer Store', () => {
{
tagsPagination: {},
},
- [{ type: types.SET_MAIN_LOADING, payload: true }],
+ [
+ { type: types.SET_MAIN_LOADING, payload: true },
+ { type: types.SET_MAIN_LOADING, payload: false },
+ ],
[
{
type: 'setShowGarbageCollectionTip',
@@ -273,8 +278,7 @@ describe('Actions RegistryExplorer Store', () => {
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
- done,
- );
+ ).catch(() => done());
});
});
@@ -311,9 +315,7 @@ describe('Actions RegistryExplorer Store', () => {
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
- ).catch(() => {
- done();
- });
+ ).catch(() => done());
});
});
});
diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js
index 2c2c7587af9..0e178abfbed 100644
--- a/spec/frontend/registry/explorer/stubs.js
+++ b/spec/frontend/registry/explorer/stubs.js
@@ -9,3 +9,8 @@ export const GlEmptyState = {
template: '<div><slot name="description"></slot></div>',
name: 'GlEmptyStateSTub',
};
+
+export const RouterLink = {
+ template: `<div><slot></slot></div>`,
+ props: ['to'],
+};
diff --git a/spec/frontend/registry/settings/store/getters_spec.js b/spec/frontend/registry/settings/store/getters_spec.js
index 944057ebc9f..b781d09466c 100644
--- a/spec/frontend/registry/settings/store/getters_spec.js
+++ b/spec/frontend/registry/settings/store/getters_spec.js
@@ -4,9 +4,12 @@ import { formOptions } from '../../shared/mock_data';
describe('Getters registry settings store', () => {
const settings = {
+ enabled: true,
cadence: 'foo',
keep_n: 'bar',
older_than: 'baz',
+ name_regex: 'name-foo',
+ name_regex_keep: 'name-keep-bar',
};
describe.each`
@@ -29,6 +32,17 @@ describe('Getters registry settings store', () => {
});
});
+ describe('getSettings', () => {
+ it('returns the content of settings', () => {
+ const computedGetters = {
+ getCadence: settings.cadence,
+ getOlderThan: settings.older_than,
+ getKeepN: settings.keep_n,
+ };
+ expect(getters.getSettings({ settings }, computedGetters)).toEqual(settings);
+ });
+ });
+
describe('getIsEdited', () => {
it('returns false when original is equal to settings', () => {
const same = { foo: 'bar' };
diff --git a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
index 6e7bc0491ce..a9034b81d2f 100644
--- a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
+++ b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap
@@ -117,11 +117,11 @@ exports[`Expiration Policy Form renders 1`] = `
<gl-form-group-stub
id="expiration-policy-name-matching-group"
invalid-feedback="The value of this input should be less than 255 characters"
- label="Docker tags with names matching this regex pattern will expire:"
label-align="right"
label-cols="3"
label-for="expiration-policy-name-matching"
>
+
<gl-form-textarea-stub
disabled="true"
id="expiration-policy-name-matching"
@@ -130,5 +130,21 @@ exports[`Expiration Policy Form renders 1`] = `
value=""
/>
</gl-form-group-stub>
+ <gl-form-group-stub
+ id="expiration-policy-keep-name-group"
+ invalid-feedback="The value of this input should be less than 255 characters"
+ label-align="right"
+ label-cols="3"
+ label-for="expiration-policy-keep-name"
+ >
+
+ <gl-form-textarea-stub
+ disabled="true"
+ id="expiration-policy-keep-name"
+ placeholder=""
+ trim=""
+ value=""
+ />
+ </gl-form-group-stub>
</div>
`;
diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
index 3782bfeaac4..4825351a6d3 100644
--- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
+++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
@@ -40,12 +40,13 @@ describe('Expiration Policy Form', () => {
});
describe.each`
- elementName | modelName | value | disabledByToggle
- ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
- ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'}
- ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
- ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'}
- ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'}
+ elementName | modelName | value | disabledByToggle
+ ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
+ ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'}
+ ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
+ ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'}
+ ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'}
+ ${'keep-name'} | ${'name_regex_keep'} | ${'bar'} | ${'disabled'}
`(
`${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`,
({ elementName, modelName, value, disabledByToggle }) => {
@@ -118,21 +119,26 @@ describe('Expiration Policy Form', () => {
${'schedule'}
${'latest'}
${'name-matching'}
+ ${'keep-name'}
`(`${FORM_ELEMENTS_ID_PREFIX}-$elementName is disabled`, ({ elementName }) => {
expect(findFormElements(elementName).attributes('disabled')).toBe('true');
});
});
- describe('form validation', () => {
+ describe.each`
+ modelName | elementName | stateVariable
+ ${'name_regex'} | ${'name-matching'} | ${'nameRegexState'}
+ ${'name_regex_keep'} | ${'keep-name'} | ${'nameKeepRegexState'}
+ `('regex textarea validation', ({ modelName, elementName, stateVariable }) => {
describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => {
const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
beforeEach(() => {
- mountComponent({ value: { name_regex: invalidString } });
+ mountComponent({ value: { [modelName]: invalidString } });
});
- it('nameRegexState is false', () => {
- expect(wrapper.vm.nameRegexState).toBe(false);
+ it(`${stateVariable} is false`, () => {
+ expect(wrapper.vm.textAreaState[stateVariable]).toBe(false);
});
it('emit the @invalidated event', () => {
@@ -141,17 +147,20 @@ describe('Expiration Policy Form', () => {
});
it('if the user did not type validation is null', () => {
- mountComponent({ value: { name_regex: '' } });
+ mountComponent({ value: { [modelName]: '' } });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.nameRegexState).toBe(null);
+ expect(wrapper.vm.textAreaState[stateVariable]).toBe(null);
expect(wrapper.emitted('validated')).toBeTruthy();
});
});
it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => {
- mountComponent({ value: { name_regex: 'foo' } });
+ mountComponent({ value: { [modelName]: 'foo' } });
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.nameRegexState).toBe(true);
+ const formGroup = findFormGroup(elementName);
+ const formElement = findFormElements(elementName, formGroup);
+ expect(formGroup.attributes('state')).toBeTruthy();
+ expect(formElement.attributes('state')).toBeTruthy();
});
});
});
diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
new file mode 100644
index 00000000000..1b938c93df8
--- /dev/null
+++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
@@ -0,0 +1,94 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue';
+import createStore from '~/related_merge_requests/store/index';
+
+const FIXTURE_PATH = 'issues/related_merge_requests.json';
+const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests';
+const localVue = createLocalVue();
+
+describe('RelatedMergeRequests', () => {
+ let wrapper;
+ let mock;
+ let mockData;
+
+ beforeEach(done => {
+ loadFixtures(FIXTURE_PATH);
+ mockData = getJSONFixture(FIXTURE_PATH);
+
+ // put the fixture in DOM as the component expects
+ document.body.innerHTML = `<div id="js-issuable-app-initial-data">${JSON.stringify(
+ mockData,
+ )}</div>`;
+
+ mock = new MockAdapter(axios);
+ mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 });
+
+ wrapper = mount(localVue.extend(RelatedMergeRequests), {
+ localVue,
+ store: createStore(),
+ propsData: {
+ endpoint: API_ENDPOINT,
+ projectNamespace: 'gitlab-org',
+ projectPath: 'gitlab-ce',
+ },
+ });
+
+ setImmediate(done);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ describe('methods', () => {
+ describe('getAssignees', () => {
+ const assignees = [{ name: 'foo' }, { name: 'bar' }];
+
+ describe('when there is assignees array', () => {
+ it('should return assignees array', () => {
+ const mr = { assignees };
+
+ expect(wrapper.vm.getAssignees(mr)).toEqual(assignees);
+ });
+ });
+
+ it('should return an array with single assingee', () => {
+ const mr = { assignee: assignees[0] };
+
+ expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]);
+ });
+
+ it('should return empty array when assignee is not set', () => {
+ expect(wrapper.vm.getAssignees({})).toEqual([]);
+ expect(wrapper.vm.getAssignees({ assignee: null })).toEqual([]);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render related merge request items', () => {
+ expect(wrapper.find('.js-items-count').text()).toEqual('2');
+ expect(wrapper.findAll(RelatedIssuableItem).length).toEqual(2);
+
+ const props = wrapper
+ .findAll(RelatedIssuableItem)
+ .at(1)
+ .props();
+ const data = mockData[1];
+
+ expect(props.idKey).toEqual(data.id);
+ expect(props.pathIdSeparator).toEqual('!');
+ expect(props.pipelineStatus).toBe(data.head_pipeline.detailed_status);
+ expect(props.assignees).toEqual([data.assignee]);
+ expect(props.isMergeRequest).toBe(true);
+ expect(props.confidential).toEqual(false);
+ expect(props.title).toEqual(data.title);
+ expect(props.state).toEqual(data.state);
+ expect(props.createdAt).toEqual(data.created_at);
+ });
+ });
+});
diff --git a/spec/frontend/related_merge_requests/store/actions_spec.js b/spec/frontend/related_merge_requests/store/actions_spec.js
new file mode 100644
index 00000000000..26c5977cb5f
--- /dev/null
+++ b/spec/frontend/related_merge_requests/store/actions_spec.js
@@ -0,0 +1,111 @@
+import MockAdapter from 'axios-mock-adapter';
+import createFlash from '~/flash';
+import testAction from 'helpers/vuex_action_helper';
+import axios from '~/lib/utils/axios_utils';
+import * as types from '~/related_merge_requests/store/mutation_types';
+import * as actions from '~/related_merge_requests/store/actions';
+
+jest.mock('~/flash');
+
+describe('RelatedMergeRequest store actions', () => {
+ let state;
+ let mock;
+
+ beforeEach(() => {
+ state = {
+ apiEndpoint: '/api/related_merge_requests',
+ };
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setInitialState', () => {
+ it('commits types.SET_INITIAL_STATE with given props', done => {
+ const props = { a: 1, b: 2 };
+
+ testAction(
+ actions.setInitialState,
+ props,
+ {},
+ [{ type: types.SET_INITIAL_STATE, payload: props }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestData', () => {
+ it('commits types.REQUEST_DATA', done => {
+ testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done);
+ });
+ });
+
+ describe('receiveDataSuccess', () => {
+ it('commits types.RECEIVE_DATA_SUCCESS with data', done => {
+ const data = { a: 1, b: 2 };
+
+ testAction(
+ actions.receiveDataSuccess,
+ data,
+ {},
+ [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveDataError', () => {
+ it('commits types.RECEIVE_DATA_ERROR', done => {
+ testAction(
+ actions.receiveDataError,
+ null,
+ {},
+ [{ type: types.RECEIVE_DATA_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchMergeRequests', () => {
+ describe('for a successful request', () => {
+ it('should dispatch success action', done => {
+ const data = { a: 1 };
+ mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 });
+
+ testAction(
+ actions.fetchMergeRequests,
+ null,
+ state,
+ [],
+ [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }],
+ done,
+ );
+ });
+ });
+
+ describe('for a failing request', () => {
+ it('should dispatch error action', done => {
+ mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400);
+
+ testAction(
+ actions.fetchMergeRequests,
+ null,
+ state,
+ [],
+ [{ type: 'requestData' }, { type: 'receiveDataError' }],
+ () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(expect.stringMatching('Something went wrong'));
+
+ done();
+ },
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/related_merge_requests/store/mutations_spec.js b/spec/frontend/related_merge_requests/store/mutations_spec.js
new file mode 100644
index 00000000000..21b6e26376b
--- /dev/null
+++ b/spec/frontend/related_merge_requests/store/mutations_spec.js
@@ -0,0 +1,49 @@
+import mutations from '~/related_merge_requests/store/mutations';
+import * as types from '~/related_merge_requests/store/mutation_types';
+
+describe('RelatedMergeRequests Store Mutations', () => {
+ describe('SET_INITIAL_STATE', () => {
+ it('should set initial state according to given data', () => {
+ const apiEndpoint = '/api';
+ const state = {};
+
+ mutations[types.SET_INITIAL_STATE](state, { apiEndpoint });
+
+ expect(state.apiEndpoint).toEqual(apiEndpoint);
+ });
+ });
+
+ describe('REQUEST_DATA', () => {
+ it('should set loading flag', () => {
+ const state = {};
+
+ mutations[types.REQUEST_DATA](state);
+
+ expect(state.isFetchingMergeRequests).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_DATA_SUCCESS', () => {
+ it('should set loading flag and data', () => {
+ const state = {};
+ const mrs = [1, 2, 3];
+
+ mutations[types.RECEIVE_DATA_SUCCESS](state, { data: mrs, total: mrs.length });
+
+ expect(state.isFetchingMergeRequests).toEqual(false);
+ expect(state.mergeRequests).toEqual(mrs);
+ expect(state.totalCount).toEqual(mrs.length);
+ });
+ });
+
+ describe('RECEIVE_DATA_ERROR', () => {
+ it('should set loading and error flags', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_DATA_ERROR](state);
+
+ expect(state.isFetchingMergeRequests).toEqual(false);
+ expect(state.hasErrorFetchingMergeRequests).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/app_edit_spec.js b/spec/frontend/releases/components/app_edit_spec.js
index 09bafe4aa9b..4450b047acd 100644
--- a/spec/frontend/releases/components/app_edit_spec.js
+++ b/spec/frontend/releases/components/app_edit_spec.js
@@ -1,11 +1,13 @@
import Vuex from 'vuex';
import { mount } from '@vue/test-utils';
import ReleaseEditApp from '~/releases/components/app_edit.vue';
-import { release as originalRelease } from '../mock_data';
+import { release as originalRelease, milestones as originalMilestones } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
import { merge } from 'lodash';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
describe('Release edit component', () => {
let wrapper;
@@ -13,6 +15,7 @@ describe('Release edit component', () => {
let actions;
let getters;
let state;
+ let mock;
const factory = ({ featureFlags = {}, store: storeUpdates = {} } = {}) => {
state = {
@@ -20,6 +23,7 @@ describe('Release edit component', () => {
markdownDocsPath: 'path/to/markdown/docs',
updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
releasesPagePath: 'path/to/releases/page',
+ projectId: '8',
};
actions = {
@@ -62,8 +66,11 @@ describe('Release edit component', () => {
};
beforeEach(() => {
+ mock = new MockAdapter(axios);
gon.api_version = 'v4';
+ mock.onGet('/api/v4/projects/8/milestones').reply(200, originalMilestones);
+
release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
});
diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js
index c63637c4cae..b91cfb82b65 100644
--- a/spec/frontend/releases/components/release_block_footer_spec.js
+++ b/spec/frontend/releases/components/release_block_footer_spec.js
@@ -3,13 +3,17 @@ import { GlLink } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
import Icon from '~/vue_shared/components/icon.vue';
-import { release } from '../mock_data';
+import { release as originalRelease } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { cloneDeep } from 'lodash';
+
+const mockFutureDate = new Date(9999, 0, 0).toISOString();
+let mockIsFutureRelease = false;
jest.mock('~/vue_shared/mixins/timeago', () => ({
methods: {
timeFormatted() {
- return '7 fortnights ago';
+ return mockIsFutureRelease ? 'in 1 month' : '7 fortnights ago';
},
tooltipTitle() {
return 'February 30, 2401';
@@ -19,12 +23,12 @@ jest.mock('~/vue_shared/mixins/timeago', () => ({
describe('Release block footer', () => {
let wrapper;
- let releaseClone;
+ let release;
const factory = (props = {}) => {
wrapper = mount(ReleaseBlockFooter, {
propsData: {
- ...convertObjectPropsToCamelCase(releaseClone, { deep: true }),
+ ...convertObjectPropsToCamelCase(release, { deep: true }),
...props,
},
});
@@ -33,11 +37,13 @@ describe('Release block footer', () => {
};
beforeEach(() => {
- releaseClone = JSON.parse(JSON.stringify(release));
+ release = cloneDeep(originalRelease);
});
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
+ mockIsFutureRelease = false;
});
const commitInfoSection = () => wrapper.find('.js-commit-info');
@@ -60,8 +66,8 @@ describe('Release block footer', () => {
const commitLink = commitInfoSectionLink();
expect(commitLink.exists()).toBe(true);
- expect(commitLink.text()).toBe(releaseClone.commit.short_id);
- expect(commitLink.attributes('href')).toBe(releaseClone.commit_path);
+ expect(commitLink.text()).toBe(release.commit.short_id);
+ expect(commitLink.attributes('href')).toBe(release.commit_path);
});
it('renders the tag icon', () => {
@@ -75,28 +81,60 @@ describe('Release block footer', () => {
const commitLink = tagInfoSection().find(GlLink);
expect(commitLink.exists()).toBe(true);
- expect(commitLink.text()).toBe(releaseClone.tag_name);
- expect(commitLink.attributes('href')).toBe(releaseClone.tag_path);
+ expect(commitLink.text()).toBe(release.tag_name);
+ expect(commitLink.attributes('href')).toBe(release.tag_path);
});
it('renders the author and creation time info', () => {
expect(trimText(authorDateInfoSection().text())).toBe(
- `Created 7 fortnights ago by ${releaseClone.author.username}`,
+ `Created 7 fortnights ago by ${release.author.username}`,
);
});
+ describe('when the release date is in the past', () => {
+ it('prefixes the creation info with "Created"', () => {
+ expect(trimText(authorDateInfoSection().text())).toEqual(expect.stringMatching(/^Created/));
+ });
+ });
+
+ describe('renders the author and creation time info with future release date', () => {
+ beforeEach(() => {
+ mockIsFutureRelease = true;
+ factory({ releasedAt: mockFutureDate });
+ });
+
+ it('renders the release date without the author name', () => {
+ expect(trimText(authorDateInfoSection().text())).toBe(
+ `Will be created in 1 month by ${release.author.username}`,
+ );
+ });
+ });
+
+ describe('when the release date is in the future', () => {
+ beforeEach(() => {
+ mockIsFutureRelease = true;
+ factory({ releasedAt: mockFutureDate });
+ });
+
+ it('prefixes the creation info with "Will be created"', () => {
+ expect(trimText(authorDateInfoSection().text())).toEqual(
+ expect.stringMatching(/^Will be created/),
+ );
+ });
+ });
+
it("renders the author's avatar image", () => {
const avatarImg = authorDateInfoSection().find('img');
expect(avatarImg.exists()).toBe(true);
- expect(avatarImg.attributes('src')).toBe(releaseClone.author.avatar_url);
+ expect(avatarImg.attributes('src')).toBe(release.author.avatar_url);
});
it("renders a link to the author's profile", () => {
const authorLink = authorDateInfoSection().find(GlLink);
expect(authorLink.exists()).toBe(true);
- expect(authorLink.attributes('href')).toBe(releaseClone.author.web_url);
+ expect(authorLink.attributes('href')).toBe(release.author.web_url);
});
});
@@ -113,7 +151,7 @@ describe('Release block footer', () => {
it('renders the commit SHA as plain text (instead of a link)', () => {
expect(commitInfoSectionLink().exists()).toBe(false);
- expect(commitInfoSection().text()).toBe(releaseClone.commit.short_id);
+ expect(commitInfoSection().text()).toBe(release.commit.short_id);
});
});
@@ -130,7 +168,7 @@ describe('Release block footer', () => {
it('renders the tag name as plain text (instead of a link)', () => {
expect(tagInfoSectionLink().exists()).toBe(false);
- expect(tagInfoSection().text()).toBe(releaseClone.tag_name);
+ expect(tagInfoSection().text()).toBe(release.tag_name);
});
});
@@ -138,7 +176,18 @@ describe('Release block footer', () => {
beforeEach(() => factory({ author: undefined }));
it('renders the release date without the author name', () => {
- expect(trimText(authorDateInfoSection().text())).toBe('Created 7 fortnights ago');
+ expect(trimText(authorDateInfoSection().text())).toBe(`Created 7 fortnights ago`);
+ });
+ });
+
+ describe('future release without any author info', () => {
+ beforeEach(() => {
+ mockIsFutureRelease = true;
+ factory({ author: undefined, releasedAt: mockFutureDate });
+ });
+
+ it('renders the release date without the author name', () => {
+ expect(trimText(authorDateInfoSection().text())).toBe(`Will be created in 1 month`);
});
});
@@ -147,7 +196,7 @@ describe('Release block footer', () => {
it('renders the author name without the release date', () => {
expect(trimText(authorDateInfoSection().text())).toBe(
- `Created by ${releaseClone.author.username}`,
+ `Created by ${release.author.username}`,
);
});
});
diff --git a/spec/frontend/releases/components/release_block_metadata_spec.js b/spec/frontend/releases/components/release_block_metadata_spec.js
new file mode 100644
index 00000000000..cbe478bfa1f
--- /dev/null
+++ b/spec/frontend/releases/components/release_block_metadata_spec.js
@@ -0,0 +1,67 @@
+import { mount } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import ReleaseBlockMetadata from '~/releases/components/release_block_metadata.vue';
+import { release as originalRelease } from '../mock_data';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { cloneDeep } from 'lodash';
+
+const mockFutureDate = new Date(9999, 0, 0).toISOString();
+let mockIsFutureRelease = false;
+
+jest.mock('~/vue_shared/mixins/timeago', () => ({
+ methods: {
+ timeFormatted() {
+ return mockIsFutureRelease ? 'in 1 month' : '7 fortnights ago';
+ },
+ tooltipTitle() {
+ return 'February 30, 2401';
+ },
+ },
+}));
+
+describe('Release block metadata', () => {
+ let wrapper;
+ let release;
+
+ const factory = (releaseUpdates = {}) => {
+ wrapper = mount(ReleaseBlockMetadata, {
+ propsData: {
+ release: {
+ ...convertObjectPropsToCamelCase(release, { deep: true }),
+ ...releaseUpdates,
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ release = cloneDeep(originalRelease);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ mockIsFutureRelease = false;
+ });
+
+ const findReleaseDateInfo = () => wrapper.find('.js-release-date-info');
+
+ describe('with all props provided', () => {
+ beforeEach(() => factory());
+
+ it('renders the release time info', () => {
+ expect(trimText(findReleaseDateInfo().text())).toBe(`released 7 fortnights ago`);
+ });
+ });
+
+ describe('with a future release date', () => {
+ beforeEach(() => {
+ mockIsFutureRelease = true;
+ factory({ releasedAt: mockFutureDate });
+ });
+
+ it('renders the release date without the author name', () => {
+ expect(trimText(findReleaseDateInfo().text())).toBe(`will be released in 1 month`);
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js
index 0b65b6cab96..0e79c45b337 100644
--- a/spec/frontend/releases/components/release_block_milestone_info_spec.js
+++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import { GlProgressBar, GlLink, GlBadge, GlDeprecatedButton } from '@gitlab/ui';
+import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue';
import { milestones as originalMilestones } from '../mock_data';
@@ -106,7 +106,7 @@ describe('Release block milestone info', () => {
const clickShowMoreFewerButton = () => {
milestoneListContainer()
- .find(GlDeprecatedButton)
+ .find(GlButton)
.trigger('click');
return wrapper.vm.$nextTick();
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
index 9846fcb65eb..19119d99f3c 100644
--- a/spec/frontend/releases/components/release_block_spec.js
+++ b/spec/frontend/releases/components/release_block_spec.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import { mount } from '@vue/test-utils';
-import { first } from 'underscore';
import EvidenceBlock from '~/releases/components/evidence_block.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
@@ -80,11 +79,11 @@ describe('Release block', () => {
);
expect(wrapper.find('.js-sources-dropdown li a').attributes().href).toEqual(
- first(release.assets.sources).url,
+ release.assets.sources[0].url,
);
expect(wrapper.find('.js-sources-dropdown li a').text()).toContain(
- first(release.assets.sources).format,
+ release.assets.sources[0].format,
);
});
@@ -92,12 +91,10 @@ describe('Release block', () => {
expect(wrapper.findAll('.js-assets-list li').length).toEqual(release.assets.links.length);
expect(wrapper.find('.js-assets-list li a').attributes().href).toEqual(
- first(release.assets.links).directAssetUrl,
+ release.assets.links[0].directAssetUrl,
);
- expect(wrapper.find('.js-assets-list li a').text()).toContain(
- first(release.assets.links).name,
- );
+ expect(wrapper.find('.js-assets-list li a').text()).toContain(release.assets.links[0].name);
});
it('renders author avatar', () => {
@@ -264,7 +261,7 @@ describe('Release block', () => {
});
it('renders a link to the milestone with a tooltip', () => {
- const milestone = first(release.milestones);
+ const milestone = release.milestones[0];
const milestoneLink = wrapper.find('.js-milestone-link');
expect(milestoneLink.exists()).toBe(true);
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index 4a1790adb09..854f06821be 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -130,6 +130,15 @@ describe('Release detail actions', () => {
});
});
+ describe('updateReleaseMilestones', () => {
+ it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => {
+ const newReleaseMilestones = ['v0.0', 'v0.1'];
+ return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [
+ { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones },
+ ]);
+ });
+ });
+
describe('requestUpdateRelease', () => {
it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
testAction(actions.requestUpdateRelease, undefined, state, [
@@ -143,7 +152,7 @@ describe('Release detail actions', () => {
{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS },
]));
- describe('when the releaseShowPage feature flag is enabled', () => {
+ it('redirects to the releases page if releaseShowPage feature flag is enabled', () => {
const rootState = { featureFlags: { releaseShowPage: true } };
const updatedState = merge({}, state, {
releasesPagePath: 'path/to/releases/page',
@@ -248,6 +257,7 @@ describe('Release detail actions', () => {
{
name: state.release.name,
description: state.release.description,
+ milestones: state.release.milestones.map(milestone => milestone.title),
},
],
]);
diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
index cb5a1880b0c..f3f7ca797b4 100644
--- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
@@ -1,10 +1,3 @@
-/* eslint-disable jest/valid-describe */
-/*
- * ESLint disable directive ↑ can be removed once
- * https://github.com/jest-community/eslint-plugin-jest/issues/203
- * is resolved
- */
-
import createState from '~/releases/stores/modules/detail/state';
import mutations from '~/releases/stores/modules/detail/mutations';
import * as types from '~/releases/stores/modules/detail/mutation_types';
@@ -27,7 +20,7 @@ describe('Release detail mutations', () => {
release = convertObjectPropsToCamelCase(originalRelease);
});
- describe(types.REQUEST_RELEASE, () => {
+ describe(`${types.REQUEST_RELEASE}`, () => {
it('set state.isFetchingRelease to true', () => {
mutations[types.REQUEST_RELEASE](state);
@@ -35,7 +28,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.RECEIVE_RELEASE_SUCCESS, () => {
+ describe(`${types.RECEIVE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_RELEASE_SUCCESS](state, release);
@@ -49,7 +42,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.RECEIVE_RELEASE_ERROR, () => {
+ describe(`${types.RECEIVE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_RELEASE_ERROR](state, error);
@@ -62,7 +55,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.UPDATE_RELEASE_TITLE, () => {
+ describe(`${types.UPDATE_RELEASE_TITLE}`, () => {
it("updates the release's title", () => {
state.release = release;
const newTitle = 'The new release title';
@@ -72,7 +65,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.UPDATE_RELEASE_NOTES, () => {
+ describe(`${types.UPDATE_RELEASE_NOTES}`, () => {
it("updates the release's notes", () => {
state.release = release;
const newNotes = 'The new release notes';
@@ -82,7 +75,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.REQUEST_UPDATE_RELEASE, () => {
+ describe(`${types.REQUEST_UPDATE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_UPDATE_RELEASE](state);
@@ -90,7 +83,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.RECEIVE_UPDATE_RELEASE_SUCCESS, () => {
+ describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => {
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release);
@@ -100,7 +93,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.RECEIVE_UPDATE_RELEASE_ERROR, () => {
+ describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error);
@@ -111,7 +104,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.ADD_EMPTY_ASSET_LINK, () => {
+ describe(`${types.ADD_EMPTY_ASSET_LINK}`, () => {
it('adds a new, empty link object to the release', () => {
state.release = release;
@@ -130,7 +123,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.UPDATE_ASSET_LINK_URL, () => {
+ describe(`${types.UPDATE_ASSET_LINK_URL}`, () => {
it('updates an asset link with a new URL', () => {
state.release = release;
@@ -145,7 +138,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.UPDATE_ASSET_LINK_NAME, () => {
+ describe(`${types.UPDATE_ASSET_LINK_NAME}`, () => {
it('updates an asset link with a new name', () => {
state.release = release;
@@ -160,7 +153,7 @@ describe('Release detail mutations', () => {
});
});
- describe(types.REMOVE_ASSET_LINK, () => {
+ describe(`${types.REMOVE_ASSET_LINK}`, () => {
it('removes an asset link from the release', () => {
state.release = release;
diff --git a/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js
new file mode 100644
index 00000000000..a036588596a
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js
@@ -0,0 +1,126 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import GroupedAccessibilityReportsApp from '~/reports/accessibility_report/grouped_accessibility_reports_app.vue';
+import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue';
+import store from '~/reports/accessibility_report/store';
+import { mockReport } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Grouped accessibility reports app', () => {
+ const Component = localVue.extend(GroupedAccessibilityReportsApp);
+ let wrapper;
+ let mockStore;
+
+ const mountComponent = () => {
+ wrapper = mount(Component, {
+ store: mockStore,
+ localVue,
+ propsData: {
+ endpoint: 'endpoint.json',
+ },
+ methods: {
+ fetchReport: () => {},
+ },
+ });
+ };
+
+ const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]');
+
+ beforeEach(() => {
+ mockStore = store();
+ mountComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ mockStore.state.isLoading = true;
+ mountComponent();
+ });
+
+ it('renders loading state', () => {
+ expect(findHeader().text()).toEqual('Accessibility scanning results are being parsed');
+ });
+ });
+
+ describe('with error', () => {
+ beforeEach(() => {
+ mockStore.state.isLoading = false;
+ mockStore.state.hasError = true;
+ mountComponent();
+ });
+
+ it('renders error state', () => {
+ expect(findHeader().text()).toEqual('Accessibility scanning failed loading results');
+ });
+ });
+
+ describe('with a report', () => {
+ describe('with no issues', () => {
+ beforeEach(() => {
+ mockStore.state.report = {
+ summary: {
+ errored: 0,
+ },
+ };
+ });
+
+ it('renders no issues header', () => {
+ expect(findHeader().text()).toContain(
+ 'Accessibility scanning detected no issues for the source branch only',
+ );
+ });
+ });
+
+ describe('with one issue', () => {
+ beforeEach(() => {
+ mockStore.state.report = {
+ summary: {
+ errored: 1,
+ },
+ };
+ });
+
+ it('renders one issue header', () => {
+ expect(findHeader().text()).toContain(
+ 'Accessibility scanning detected 1 issue for the source branch only',
+ );
+ });
+ });
+
+ describe('with multiple issues', () => {
+ beforeEach(() => {
+ mockStore.state.report = {
+ summary: {
+ errored: 2,
+ },
+ };
+ });
+
+ it('renders multiple issues header', () => {
+ expect(findHeader().text()).toContain(
+ 'Accessibility scanning detected 2 issues for the source branch only',
+ );
+ });
+ });
+
+ describe('with issues to show', () => {
+ beforeEach(() => {
+ mockStore.state.report = mockReport;
+ });
+
+ it('renders custom accessibility issue body', () => {
+ const issueBody = wrapper.find(AccessibilityIssueBody);
+
+ expect(issueBody.props('issue').code).toBe(mockReport.new_errors[0].code);
+ expect(issueBody.props('issue').message).toBe(mockReport.new_errors[0].message);
+ expect(issueBody.props('isNew')).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/reports/accessibility_report/mock_data.js b/spec/frontend/reports/accessibility_report/mock_data.js
new file mode 100644
index 00000000000..f8e832c1ce5
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/mock_data.js
@@ -0,0 +1,55 @@
+export const mockReport = {
+ status: 'failed',
+ summary: {
+ total: 2,
+ resolved: 0,
+ errored: 2,
+ },
+ new_errors: [
+ {
+ code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail',
+ type: 'error',
+ typeCode: 1,
+ message:
+ 'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.84:1. Recommendation: change text colour to #767676.',
+ context: '<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>',
+ selector: '#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a',
+ runner: 'htmlcs',
+ runnerExtras: {},
+ },
+ ],
+ new_notes: [],
+ new_warnings: [],
+ resolved_errors: [
+ {
+ code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
+ type: 'error',
+ typeCode: 1,
+ message:
+ 'Anchor element found with a valid href attribute, but no link content has been supplied.',
+ context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>',
+ selector: '#main-nav > div:nth-child(1) > a',
+ runner: 'htmlcs',
+ runnerExtras: {},
+ },
+ ],
+ resolved_notes: [],
+ resolved_warnings: [],
+ existing_errors: [
+ {
+ code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
+ type: 'error',
+ typeCode: 1,
+ message:
+ 'Anchor element found with a valid href attribute, but no link content has been supplied.',
+ context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>',
+ selector: '#main-nav > div:nth-child(1) > a',
+ runner: 'htmlcs',
+ runnerExtras: {},
+ },
+ ],
+ existing_notes: [],
+ existing_warnings: [],
+};
+
+export default () => {};
diff --git a/spec/frontend/reports/accessibility_report/store/actions_spec.js b/spec/frontend/reports/accessibility_report/store/actions_spec.js
new file mode 100644
index 00000000000..129a5bade86
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/store/actions_spec.js
@@ -0,0 +1,121 @@
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import * as actions from '~/reports/accessibility_report/store/actions';
+import * as types from '~/reports/accessibility_report/store/mutation_types';
+import createStore from '~/reports/accessibility_report/store';
+import { TEST_HOST } from 'spec/test_constants';
+import testAction from 'helpers/vuex_action_helper';
+import { mockReport } from '../mock_data';
+
+describe('Accessibility Reports actions', () => {
+ let localState;
+ let localStore;
+
+ beforeEach(() => {
+ localStore = createStore();
+ localState = localStore.state;
+ });
+
+ describe('setEndpoints', () => {
+ it('should commit SET_ENDPOINTS mutation', done => {
+ const endpoint = 'endpoint.json';
+
+ testAction(
+ actions.setEndpoint,
+ endpoint,
+ localState,
+ [{ type: types.SET_ENDPOINT, payload: endpoint }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchReport', () => {
+ let mock;
+
+ beforeEach(() => {
+ localState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ actions.stopPolling();
+ actions.clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', done => {
+ const data = { report: { summary: {} } };
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, data);
+
+ testAction(
+ actions.fetchReport,
+ null,
+ localState,
+ [{ type: types.REQUEST_REPORT }],
+ [
+ {
+ payload: { status: 200, data },
+ type: 'receiveReportSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
+
+ testAction(
+ actions.fetchReport,
+ null,
+ localState,
+ [{ type: types.REQUEST_REPORT }],
+ [{ type: 'receiveReportError' }],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveReportSuccess', () => {
+ it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', done => {
+ testAction(
+ actions.receiveReportSuccess,
+ { status: 200, data: mockReport },
+ localState,
+ [{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }],
+ [{ type: 'stopPolling' }],
+ done,
+ );
+ });
+
+ it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => {
+ testAction(
+ actions.receiveReportSuccess,
+ { status: 204, data: mockReport },
+ localState,
+ [],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveReportError', () => {
+ it('should commit RECEIVE_REPORT_ERROR mutation', done => {
+ testAction(
+ actions.receiveReportError,
+ null,
+ localState,
+ [{ type: types.RECEIVE_REPORT_ERROR }],
+ [{ type: 'stopPolling' }],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/reports/accessibility_report/store/getters_spec.js b/spec/frontend/reports/accessibility_report/store/getters_spec.js
new file mode 100644
index 00000000000..d74c71cfa09
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/store/getters_spec.js
@@ -0,0 +1,149 @@
+import * as getters from '~/reports/accessibility_report/store/getters';
+import createStore from '~/reports/accessibility_report/store';
+import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '~/reports/constants';
+
+describe('Accessibility reports store getters', () => {
+ let localState;
+ let localStore;
+
+ beforeEach(() => {
+ localStore = createStore();
+ localState = localStore.state;
+ });
+
+ describe('summaryStatus', () => {
+ describe('when summary is loading', () => {
+ it('returns loading status', () => {
+ localState.isLoading = true;
+
+ expect(getters.summaryStatus(localState)).toEqual(LOADING);
+ });
+ });
+
+ describe('when summary has error', () => {
+ it('returns error status', () => {
+ localState.hasError = true;
+
+ expect(getters.summaryStatus(localState)).toEqual(ERROR);
+ });
+ });
+
+ describe('when summary has failed status', () => {
+ it('returns loading status', () => {
+ localState.status = STATUS_FAILED;
+
+ expect(getters.summaryStatus(localState)).toEqual(ERROR);
+ });
+ });
+
+ describe('when summary has successfully loaded', () => {
+ it('returns loading status', () => {
+ expect(getters.summaryStatus(localState)).toEqual(SUCCESS);
+ });
+ });
+ });
+
+ describe('groupedSummaryText', () => {
+ describe('when state is loading', () => {
+ it('returns the loading summary message', () => {
+ localState.isLoading = true;
+ const result = 'Accessibility scanning results are being parsed';
+
+ expect(getters.groupedSummaryText(localState)).toEqual(result);
+ });
+ });
+
+ describe('when state has error', () => {
+ it('returns the error summary message', () => {
+ localState.hasError = true;
+ const result = 'Accessibility scanning failed loading results';
+
+ expect(getters.groupedSummaryText(localState)).toEqual(result);
+ });
+ });
+
+ describe('when state has successfully loaded', () => {
+ describe('when report has errors', () => {
+ it('returns summary message containing number of errors', () => {
+ localState.report = {
+ summary: {
+ errored: 2,
+ },
+ };
+ const result = 'Accessibility scanning detected 2 issues for the source branch only';
+
+ expect(getters.groupedSummaryText(localState)).toEqual(result);
+ });
+ });
+
+ describe('when report has no errors', () => {
+ it('returns summary message containing no errors', () => {
+ localState.report = {
+ summary: {
+ errored: 0,
+ },
+ };
+ const result = 'Accessibility scanning detected no issues for the source branch only';
+
+ expect(getters.groupedSummaryText(localState)).toEqual(result);
+ });
+ });
+ });
+ });
+
+ describe('shouldRenderIssuesList', () => {
+ describe('when has issues to render', () => {
+ it('returns true', () => {
+ localState.report = {
+ existing_errors: [{ name: 'Issue' }],
+ };
+
+ expect(getters.shouldRenderIssuesList(localState)).toEqual(true);
+ });
+ });
+
+ describe('when does not have issues to render', () => {
+ it('returns false', () => {
+ localState.report = {
+ status: 'success',
+ summary: { errored: 0 },
+ };
+
+ expect(getters.shouldRenderIssuesList(localState)).toEqual(false);
+ });
+ });
+ });
+
+ describe('unresolvedIssues', () => {
+ it('returns the array unresolved errors', () => {
+ localState.report = {
+ existing_errors: [1],
+ };
+ const result = [1];
+
+ expect(getters.unresolvedIssues(localState)).toEqual(result);
+ });
+ });
+
+ describe('resolvedIssues', () => {
+ it('returns array of resolved errors', () => {
+ localState.report = {
+ resolved_errors: [1],
+ };
+ const result = [1];
+
+ expect(getters.resolvedIssues(localState)).toEqual(result);
+ });
+ });
+
+ describe('newIssues', () => {
+ it('returns array of new errors', () => {
+ localState.report = {
+ new_errors: [1],
+ };
+ const result = [1];
+
+ expect(getters.newIssues(localState)).toEqual(result);
+ });
+ });
+});
diff --git a/spec/frontend/reports/accessibility_report/store/mutations_spec.js b/spec/frontend/reports/accessibility_report/store/mutations_spec.js
new file mode 100644
index 00000000000..a4e9571b721
--- /dev/null
+++ b/spec/frontend/reports/accessibility_report/store/mutations_spec.js
@@ -0,0 +1,64 @@
+import mutations from '~/reports/accessibility_report/store/mutations';
+import createStore from '~/reports/accessibility_report/store';
+
+describe('Accessibility Reports mutations', () => {
+ let localState;
+ let localStore;
+
+ beforeEach(() => {
+ localStore = createStore();
+ localState = localStore.state;
+ });
+
+ describe('SET_ENDPOINT', () => {
+ it('sets endpoint to given value', () => {
+ const endpoint = 'endpoint.json';
+ mutations.SET_ENDPOINT(localState, endpoint);
+
+ expect(localState.endpoint).toEqual(endpoint);
+ });
+ });
+
+ describe('REQUEST_REPORT', () => {
+ it('sets isLoading to true', () => {
+ mutations.REQUEST_REPORT(localState);
+
+ expect(localState.isLoading).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_REPORT_SUCCESS', () => {
+ it('sets isLoading to false', () => {
+ mutations.RECEIVE_REPORT_SUCCESS(localState, {});
+
+ expect(localState.isLoading).toEqual(false);
+ });
+
+ it('sets hasError to false', () => {
+ mutations.RECEIVE_REPORT_SUCCESS(localState, {});
+
+ expect(localState.hasError).toEqual(false);
+ });
+
+ it('sets report to response report', () => {
+ const report = { data: 'testing' };
+ mutations.RECEIVE_REPORT_SUCCESS(localState, report);
+
+ expect(localState.report).toEqual(report);
+ });
+ });
+
+ describe('RECEIVE_REPORT_ERROR', () => {
+ it('sets isLoading to false', () => {
+ mutations.RECEIVE_REPORT_ERROR(localState);
+
+ expect(localState.isLoading).toEqual(false);
+ });
+
+ it('sets hasError to true', () => {
+ mutations.RECEIVE_REPORT_ERROR(localState);
+
+ expect(localState.hasError).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap b/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap
new file mode 100644
index 00000000000..c932379a253
--- /dev/null
+++ b/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Grouped Issues List renders a smart virtual list with the correct props 1`] = `
+Object {
+ "length": 4,
+ "remain": 20,
+ "rtag": "div",
+ "size": 32,
+ "wclass": "report-block-list",
+ "wtag": "ul",
+}
+`;
+
+exports[`Grouped Issues List with data renders a report item with the correct props 1`] = `
+Object {
+ "component": "TestIssueBody",
+ "isNew": false,
+ "issue": Object {
+ "name": "foo",
+ },
+ "showReportSectionStatusIcon": false,
+ "status": "none",
+ "statusIconSize": 24,
+}
+`;
diff --git a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap
new file mode 100644
index 00000000000..70e1ff01323
--- /dev/null
+++ b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`IssueStatusIcon renders "failed" state correctly 1`] = `
+<div
+ class="report-block-list-icon failed"
+>
+ <icon-stub
+ data-qa-selector="status_failed_icon"
+ name="status_failed_borderless"
+ size="24"
+ />
+</div>
+`;
+
+exports[`IssueStatusIcon renders "neutral" state correctly 1`] = `
+<div
+ class="report-block-list-icon neutral"
+>
+ <icon-stub
+ data-qa-selector="status_neutral_icon"
+ name="dash"
+ size="24"
+ />
+</div>
+`;
+
+exports[`IssueStatusIcon renders "success" state correctly 1`] = `
+<div
+ class="report-block-list-icon success"
+>
+ <icon-stub
+ data-qa-selector="status_success_icon"
+ name="status_success_borderless"
+ size="24"
+ />
+</div>
+`;
diff --git a/spec/frontend/reports/components/grouped_issues_list_spec.js b/spec/frontend/reports/components/grouped_issues_list_spec.js
new file mode 100644
index 00000000000..1f8f4a0e4c1
--- /dev/null
+++ b/spec/frontend/reports/components/grouped_issues_list_spec.js
@@ -0,0 +1,86 @@
+import { shallowMount } from '@vue/test-utils';
+import GroupedIssuesList from '~/reports/components/grouped_issues_list.vue';
+import ReportItem from '~/reports/components/report_item.vue';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+
+describe('Grouped Issues List', () => {
+ let wrapper;
+
+ const createComponent = ({ propsData = {}, stubs = {} } = {}) => {
+ wrapper = shallowMount(GroupedIssuesList, {
+ propsData,
+ stubs,
+ });
+ };
+
+ const findHeading = groupName => wrapper.find(`[data-testid="${groupName}Heading"`);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders a smart virtual list with the correct props', () => {
+ createComponent({
+ propsData: {
+ resolvedIssues: [{ name: 'foo' }],
+ unresolvedIssues: [{ name: 'bar' }],
+ },
+ stubs: {
+ SmartVirtualList,
+ },
+ });
+
+ expect(wrapper.find(SmartVirtualList).props()).toMatchSnapshot();
+ });
+
+ describe('without data', () => {
+ beforeEach(createComponent);
+
+ it.each(['unresolved', 'resolved'])('does not a render a header for %s issues', issueName => {
+ expect(findHeading(issueName).exists()).toBe(false);
+ });
+
+ it.each('resolved', 'unresolved')('does not render report items for %s issues', () => {
+ expect(wrapper.contains(ReportItem)).toBe(false);
+ });
+ });
+
+ describe('with data', () => {
+ it.each`
+ givenIssues | givenHeading | groupName
+ ${[{ name: 'foo issue' }]} | ${'Foo Heading'} | ${'resolved'}
+ ${[{ name: 'bar issue' }]} | ${'Bar Heading'} | ${'unresolved'}
+ `('renders the heading for $groupName issues', ({ givenIssues, givenHeading, groupName }) => {
+ createComponent({
+ propsData: { [`${groupName}Issues`]: givenIssues, [`${groupName}Heading`]: givenHeading },
+ });
+
+ expect(findHeading(groupName).text()).toBe(givenHeading);
+ });
+
+ it.each(['resolved', 'unresolved'])('renders all %s issues', issueName => {
+ const issues = [{ name: 'foo' }, { name: 'bar' }];
+
+ createComponent({
+ propsData: { [`${issueName}Issues`]: issues },
+ });
+
+ expect(wrapper.findAll(ReportItem)).toHaveLength(issues.length);
+ });
+
+ it('renders a report item with the correct props', () => {
+ createComponent({
+ propsData: {
+ resolvedIssues: [{ name: 'foo' }],
+ component: 'TestIssueBody',
+ },
+ stubs: {
+ ReportItem,
+ },
+ });
+
+ expect(wrapper.find(ReportItem).props()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js
new file mode 100644
index 00000000000..1a01db391da
--- /dev/null
+++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js
@@ -0,0 +1,260 @@
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import state from '~/reports/store/state';
+import component from '~/reports/components/grouped_test_reports_app.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+import { failedReport } from '../mock_data/mock_data';
+import newFailedTestReports from '../mock_data/new_failures_report.json';
+import newErrorsTestReports from '../mock_data/new_errors_report.json';
+import successTestReports from '../mock_data/no_failures_report.json';
+import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
+import resolvedFailures from '../mock_data/resolved_failures.json';
+
+describe('Grouped Test Reports App', () => {
+ let vm;
+ let mock;
+ const Component = Vue.extend(component);
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ vm.$store.replaceState(state());
+ vm.$destroy();
+ mock.restore();
+ });
+
+ describe('with success result', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, successTestReports, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders success summary text', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained no changed test results out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found no changed test results out of 8 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'java ant found no changed test results out of 3 total tests',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with 204 result', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(204, {}, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders success summary text', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary results are being parsed',
+ );
+
+ done();
+ });
+ });
+ });
+
+ describe('with new failed result', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, newFailedTestReports, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders failed summary text + new badge', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 2 failed out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain('rspec:pg found 2 failed out of 8 total tests');
+
+ expect(vm.$el.textContent).toContain('New');
+ expect(vm.$el.textContent).toContain(
+ 'java ant found no changed test results out of 3 total tests',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with new error result', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, newErrorsTestReports, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders error summary text + new badge', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 2 errors out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain('karma found 2 errors out of 3 total tests');
+
+ expect(vm.$el.textContent).toContain('New');
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found no changed test results out of 8 total tests',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with mixed results', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, mixedResultsTestReports, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders summary text', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 2 failed and 2 fixed test results out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found 1 failed and 2 fixed test results out of 8 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain('New');
+ expect(vm.$el.textContent).toContain(' java ant found 1 failed out of 3 total tests');
+ done();
+ });
+ });
+ });
+
+ describe('with resolved failures and resolved errors', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, resolvedFailures, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders summary text', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.gl-spinner')).toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary contained 4 fixed test results out of 11 total tests',
+ );
+
+ expect(vm.$el.textContent).toContain(
+ 'rspec:pg found 4 fixed test results out of 8 total tests',
+ );
+ done();
+ });
+ });
+
+ it('renders resolved failures', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
+ resolvedFailures.suites[0].resolved_failures[0].name,
+ );
+
+ expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
+ resolvedFailures.suites[0].resolved_failures[1].name,
+ );
+ done();
+ });
+ });
+
+ it('renders resolved errors', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
+ resolvedFailures.suites[0].resolved_errors[0].name,
+ );
+
+ expect(vm.$el.querySelector('.report-block-container').textContent).toContain(
+ resolvedFailures.suites[0].resolved_errors[1].name,
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with a report that failed to load', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, failedReport, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders an error status for the report', done => {
+ setImmediate(() => {
+ const { name } = failedReport.suites[0];
+
+ expect(vm.$el.querySelector('.report-block-list-issue').textContent).toContain(
+ `An error occurred while loading ${name} results`,
+ );
+ done();
+ });
+ });
+ });
+
+ describe('with error', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(500, {}, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders loading summary text with loading icon', done => {
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary failed loading results',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ mock.onGet('test_results.json').reply(200, {}, {});
+ vm = mountComponent(Component, {
+ endpoint: 'test_results.json',
+ });
+ });
+
+ it('renders loading summary text with loading icon', done => {
+ expect(vm.$el.querySelector('.gl-spinner')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+ 'Test summary results are being parsed',
+ );
+
+ setImmediate(() => {
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/reports/components/issue_status_icon_spec.js b/spec/frontend/reports/components/issue_status_icon_spec.js
new file mode 100644
index 00000000000..3a55ff0a9e3
--- /dev/null
+++ b/spec/frontend/reports/components/issue_status_icon_spec.js
@@ -0,0 +1,29 @@
+import { shallowMount } from '@vue/test-utils';
+import ReportItem from '~/reports/components/issue_status_icon.vue';
+import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
+
+describe('IssueStatusIcon', () => {
+ let wrapper;
+
+ const createComponent = ({ status }) => {
+ wrapper = shallowMount(ReportItem, {
+ propsData: {
+ status,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it.each([STATUS_SUCCESS, STATUS_NEUTRAL, STATUS_FAILED])(
+ 'renders "%s" state correctly',
+ status => {
+ createComponent({ status });
+
+ expect(wrapper.element).toMatchSnapshot();
+ },
+ );
+});
diff --git a/spec/frontend/reports/components/modal_open_name_spec.js b/spec/frontend/reports/components/modal_open_name_spec.js
new file mode 100644
index 00000000000..d59f3571c4b
--- /dev/null
+++ b/spec/frontend/reports/components/modal_open_name_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import component from '~/reports/components/modal_open_name.vue';
+
+Vue.use(Vuex);
+
+describe('Modal open name', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const store = new Vuex.Store({
+ actions: {
+ openModal: () => {},
+ },
+ state: {},
+ mutations: {},
+ });
+
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ store,
+ props: {
+ issue: {
+ title: 'Issue',
+ },
+ status: 'failed',
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders the issue name', () => {
+ expect(vm.$el.textContent.trim()).toEqual('Issue');
+ });
+
+ it('calls openModal actions when button is clicked', () => {
+ jest.spyOn(vm, 'openModal').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ expect(vm.openModal).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/reports/components/modal_spec.js b/spec/frontend/reports/components/modal_spec.js
new file mode 100644
index 00000000000..ff046e64b6e
--- /dev/null
+++ b/spec/frontend/reports/components/modal_spec.js
@@ -0,0 +1,54 @@
+import Vue from 'vue';
+import component from '~/reports/components/modal.vue';
+import state from '~/reports/store/state';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+
+describe('Grouped Test Reports Modal', () => {
+ const Component = Vue.extend(component);
+ const modalDataStructure = state().modal.data;
+
+ // populate data
+ modalDataStructure.execution_time.value = 0.009411;
+ modalDataStructure.system_output.value = 'Failure/Error: is_expected.to eq(3)\n\n';
+ modalDataStructure.class.value = 'link';
+
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ title: 'Test#sum when a is 1 and b is 2 returns summary',
+ modalData: modalDataStructure,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders code block', () => {
+ expect(vm.$el.querySelector('code').textContent).toEqual(
+ modalDataStructure.system_output.value,
+ );
+ });
+
+ it('renders link', () => {
+ expect(vm.$el.querySelector('.js-modal-link').getAttribute('href')).toEqual(
+ modalDataStructure.class.value,
+ );
+
+ expect(trimText(vm.$el.querySelector('.js-modal-link').textContent)).toEqual(
+ modalDataStructure.class.value,
+ );
+ });
+
+ it('renders seconds', () => {
+ expect(vm.$el.textContent).toContain(`${modalDataStructure.execution_time.value} s`);
+ });
+
+ it('render title', () => {
+ expect(trimText(vm.$el.querySelector('.modal-title').textContent)).toEqual(
+ 'Test#sum when a is 1 and b is 2 returns summary',
+ );
+ });
+});
diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js
new file mode 100644
index 00000000000..cb0cc025e80
--- /dev/null
+++ b/spec/frontend/reports/components/summary_row_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import component from '~/reports/components/summary_row.vue';
+
+describe('Summary row', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const props = {
+ summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability',
+ popoverOptions: {
+ title: 'Static Application Security Testing (SAST)',
+ content: '<a>Learn more about SAST</a>',
+ },
+ statusIcon: 'warning',
+ };
+
+ beforeEach(() => {
+ vm = mountComponent(Component, props);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders provided summary', () => {
+ expect(
+ vm.$el.querySelector('.report-block-list-issue-description-text').textContent.trim(),
+ ).toEqual(props.summary);
+ });
+
+ it('renders provided icon', () => {
+ expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain(
+ 'js-ci-status-icon-warning',
+ );
+ });
+});
diff --git a/spec/frontend/reports/components/test_issue_body_spec.js b/spec/frontend/reports/components/test_issue_body_spec.js
new file mode 100644
index 00000000000..ff81020a4eb
--- /dev/null
+++ b/spec/frontend/reports/components/test_issue_body_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+import component from '~/reports/components/test_issue_body.vue';
+import createStore from '~/reports/store';
+import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+import { issue } from '../mock_data/mock_data';
+
+describe('Test Issue body', () => {
+ let vm;
+ const Component = Vue.extend(component);
+ const store = createStore();
+
+ const commonProps = {
+ issue,
+ status: 'failed',
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('on click', () => {
+ it('calls openModal action', () => {
+ vm = mountComponentWithStore(Component, {
+ store,
+ props: commonProps,
+ });
+
+ jest.spyOn(vm, 'openModal').mockImplementation(() => {});
+
+ vm.$el.querySelector('button').click();
+
+ expect(vm.openModal).toHaveBeenCalledWith({
+ issue: commonProps.issue,
+ });
+ });
+ });
+
+ describe('is new', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ store,
+ props: { ...commonProps, isNew: true },
+ });
+ });
+
+ it('renders issue name', () => {
+ expect(vm.$el.textContent).toContain(commonProps.issue.name);
+ });
+
+ it('renders new badge', () => {
+ expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New');
+ });
+ });
+
+ describe('not new', () => {
+ beforeEach(() => {
+ vm = mountComponentWithStore(Component, {
+ store,
+ props: commonProps,
+ });
+ });
+
+ it('renders issue name', () => {
+ expect(vm.$el.textContent).toContain(commonProps.issue.name);
+ });
+
+ it('does not renders new badge', () => {
+ expect(vm.$el.querySelector('.badge')).toEqual(null);
+ });
+ });
+});
diff --git a/spec/frontend/reports/mock_data/mock_data.js b/spec/frontend/reports/mock_data/mock_data.js
new file mode 100644
index 00000000000..3caaab2fd79
--- /dev/null
+++ b/spec/frontend/reports/mock_data/mock_data.js
@@ -0,0 +1,24 @@
+export const issue = {
+ result: 'failure',
+ name: 'Test#sum when a is 1 and b is 2 returns summary',
+ execution_time: 0.009411,
+ system_output:
+ "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'",
+};
+
+export const failedReport = {
+ summary: { total: 11, resolved: 0, errored: 2, failed: 0 },
+ suites: [
+ {
+ name: 'rspec:pg',
+ status: 'error',
+ summary: { total: 0, resolved: 0, errored: 0, failed: 0 },
+ new_failures: [],
+ resolved_failures: [],
+ existing_failures: [],
+ new_errors: [],
+ resolved_errors: [],
+ existing_errors: [],
+ },
+ ],
+};
diff --git a/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json b/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json
new file mode 100644
index 00000000000..6141e5433a6
--- /dev/null
+++ b/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json
@@ -0,0 +1,55 @@
+{
+ "status": "failed",
+ "summary": { "total": 11, "resolved": 2, "errored": 0, "failed": 2 },
+ "suites": [
+ {
+ "name": "rspec:pg",
+ "status": "failed",
+ "summary": { "total": 8, "resolved": 2, "errored": 0, "failed": 1 },
+ "new_failures": [
+ {
+ "status": "failed",
+ "name": "Test#subtract when a is 2 and b is 1 returns correct result",
+ "execution_time": 0.00908,
+ "system_output": "Failure/Error: is_expected.to eq(1)\n\n expected: 1\n got: 3\n\n (compared using ==)\n./spec/test_spec.rb:43:in `block (4 levels) in <top (required)>'"
+ }
+ ],
+ "resolved_failures": [
+ {
+ "status": "success",
+ "name": "Test#sum when a is 1 and b is 2 returns summary",
+ "execution_time": 0.000318,
+ "system_output": null
+ },
+ {
+ "status": "success",
+ "name": "Test#sum when a is 100 and b is 200 returns summary",
+ "execution_time": 0.000074,
+ "system_output": null
+ }
+ ],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ },
+ {
+ "name": "java ant",
+ "status": "failed",
+ "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 1 },
+ "new_failures": [],
+ "resolved_failures": [],
+ "existing_failures": [
+ {
+ "status": "failed",
+ "name": "sumTest",
+ "execution_time": 0.004,
+ "system_output": "junit.framework.AssertionFailedError: expected:<3> but was:<-1>\n\tat CalculatorTest.sumTest(Unknown Source)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\n\tat java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\n"
+ }
+ ],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ }
+ ]
+}
diff --git a/spec/frontend/reports/mock_data/new_errors_report.json b/spec/frontend/reports/mock_data/new_errors_report.json
new file mode 100644
index 00000000000..cebf98fdb63
--- /dev/null
+++ b/spec/frontend/reports/mock_data/new_errors_report.json
@@ -0,0 +1,38 @@
+{
+ "summary": { "total": 11, "resolved": 0, "errored": 2, "failed": 0 },
+ "suites": [
+ {
+ "name": "rspec:pg",
+ "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ },
+ {
+ "name": "karma",
+ "summary": { "total": 3, "resolved": 0, "errored": 2, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [
+ {
+ "result": "error",
+ "name": "Test#sum when a is 1 and b is 2 returns summary",
+ "execution_time": 0.009411,
+ "system_output": "Failed: Error in render: 'TypeError: Cannot read property 'status' of undefined'"
+ },
+ {
+ "result": "error",
+ "name": "Test#sum when a is 100 and b is 200 returns summary",
+ "execution_time": 0.000162,
+ "system_output": "Failed: Error in render: 'TypeError: Cannot read property 'length' of undefined'"
+ }
+ ],
+ "resolved_errors": [],
+ "existing_errors": []
+ }
+ ]
+}
diff --git a/spec/frontend/reports/mock_data/new_failures_report.json b/spec/frontend/reports/mock_data/new_failures_report.json
new file mode 100644
index 00000000000..8b9c12c6271
--- /dev/null
+++ b/spec/frontend/reports/mock_data/new_failures_report.json
@@ -0,0 +1,38 @@
+{
+ "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 2 },
+ "suites": [
+ {
+ "name": "rspec:pg",
+ "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 2 },
+ "new_failures": [
+ {
+ "result": "failure",
+ "name": "Test#sum when a is 1 and b is 2 returns summary",
+ "execution_time": 0.009411,
+ "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'"
+ },
+ {
+ "result": "failure",
+ "name": "Test#sum when a is 100 and b is 200 returns summary",
+ "execution_time": 0.000162,
+ "system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'"
+ }
+ ],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ },
+ {
+ "name": "java ant",
+ "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ }
+ ]
+}
diff --git a/spec/frontend/reports/mock_data/no_failures_report.json b/spec/frontend/reports/mock_data/no_failures_report.json
new file mode 100644
index 00000000000..7da9e0c6211
--- /dev/null
+++ b/spec/frontend/reports/mock_data/no_failures_report.json
@@ -0,0 +1,28 @@
+{
+ "status": "success",
+ "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 0 },
+ "suites": [
+ {
+ "name": "rspec:pg",
+ "status": "success",
+ "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ },
+ {
+ "name": "java ant",
+ "status": "success",
+ "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ }
+ ]
+}
diff --git a/spec/frontend/reports/mock_data/resolved_failures.json b/spec/frontend/reports/mock_data/resolved_failures.json
new file mode 100644
index 00000000000..49de6aa840b
--- /dev/null
+++ b/spec/frontend/reports/mock_data/resolved_failures.json
@@ -0,0 +1,58 @@
+{
+ "status": "success",
+ "summary": { "total": 11, "resolved": 4, "errored": 0, "failed": 0 },
+ "suites": [
+ {
+ "name": "rspec:pg",
+ "status": "success",
+ "summary": { "total": 8, "resolved": 4, "errored": 0, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [
+ {
+ "status": "success",
+ "name": "Test#sum when a is 1 and b is 2 returns summary",
+ "execution_time": 0.000411,
+ "system_output": null,
+ "stack_trace": null
+ },
+ {
+ "status": "success",
+ "name": "Test#sum when a is 100 and b is 200 returns summary",
+ "execution_time": 7.6e-5,
+ "system_output": null,
+ "stack_trace": null
+ }
+ ],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [
+ {
+ "status": "success",
+ "name": "Test#sum when a is 4 and b is 4 returns summary",
+ "execution_time": 0.00342,
+ "system_output": null,
+ "stack_trace": null
+ },
+ {
+ "status": "success",
+ "name": "Test#sum when a is 40 and b is 400 returns summary",
+ "execution_time": 0.0000231,
+ "system_output": null,
+ "stack_trace": null
+ }
+ ],
+ "existing_errors": []
+ },
+ {
+ "name": "java ant",
+ "status": "success",
+ "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 },
+ "new_failures": [],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ }
+ ]
+}
diff --git a/spec/frontend/reports/store/actions_spec.js b/spec/frontend/reports/store/actions_spec.js
new file mode 100644
index 00000000000..3f189736922
--- /dev/null
+++ b/spec/frontend/reports/store/actions_spec.js
@@ -0,0 +1,171 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import {
+ setEndpoint,
+ requestReports,
+ fetchReports,
+ stopPolling,
+ clearEtagPoll,
+ receiveReportsSuccess,
+ receiveReportsError,
+ openModal,
+ setModalData,
+} from '~/reports/store/actions';
+import state from '~/reports/store/state';
+import * as types from '~/reports/store/mutation_types';
+
+describe('Reports Store Actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setEndpoint', () => {
+ it('should commit SET_ENDPOINT mutation', done => {
+ testAction(
+ setEndpoint,
+ 'endpoint.json',
+ mockedState,
+ [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestReports', () => {
+ it('should commit REQUEST_REPORTS mutation', done => {
+ testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], [], done);
+ });
+ });
+
+ describe('fetchReports', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches requestReports and receiveReportsSuccess ', done => {
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json`)
+ .replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] });
+
+ testAction(
+ fetchReports,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestReports',
+ },
+ {
+ payload: { data: { summary: {}, suites: [{ name: 'rspec' }] }, status: 200 },
+ type: 'receiveReportsSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
+ });
+
+ it('dispatches requestReports and receiveReportsError ', done => {
+ testAction(
+ fetchReports,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestReports',
+ },
+ {
+ type: 'receiveReportsError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveReportsSuccess', () => {
+ it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', done => {
+ testAction(
+ receiveReportsSuccess,
+ { data: { summary: {} }, status: 200 },
+ mockedState,
+ [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }],
+ [],
+ done,
+ );
+ });
+
+ it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => {
+ testAction(
+ receiveReportsSuccess,
+ { data: { summary: {} }, status: 204 },
+ mockedState,
+ [],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveReportsError', () => {
+ it('should commit RECEIVE_REPORTS_ERROR mutation', done => {
+ testAction(
+ receiveReportsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_REPORTS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('openModal', () => {
+ it('should dispatch setModalData', done => {
+ testAction(
+ openModal,
+ { name: 'foo' },
+ mockedState,
+ [],
+ [{ type: 'setModalData', payload: { name: 'foo' } }],
+ done,
+ );
+ });
+ });
+
+ describe('setModalData', () => {
+ it('should commit SET_ISSUE_MODAL_DATA', done => {
+ testAction(
+ setModalData,
+ { name: 'foo' },
+ mockedState,
+ [{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/reports/store/mutations_spec.js b/spec/frontend/reports/store/mutations_spec.js
new file mode 100644
index 00000000000..9446cd454ab
--- /dev/null
+++ b/spec/frontend/reports/store/mutations_spec.js
@@ -0,0 +1,126 @@
+import state from '~/reports/store/state';
+import mutations from '~/reports/store/mutations';
+import * as types from '~/reports/store/mutation_types';
+import { issue } from '../mock_data/mock_data';
+
+describe('Reports Store Mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_ENDPOINT', () => {
+ it('should set endpoint', () => {
+ mutations[types.SET_ENDPOINT](stateCopy, 'endpoint.json');
+
+ expect(stateCopy.endpoint).toEqual('endpoint.json');
+ });
+ });
+
+ describe('REQUEST_REPORTS', () => {
+ it('should set isLoading to true', () => {
+ mutations[types.REQUEST_REPORTS](stateCopy);
+
+ expect(stateCopy.isLoading).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_REPORTS_SUCCESS', () => {
+ const mockedResponse = {
+ summary: {
+ total: 14,
+ resolved: 0,
+ failed: 7,
+ },
+ suites: [
+ {
+ name: 'build:linux',
+ summary: {
+ total: 2,
+ resolved: 0,
+ failed: 1,
+ },
+ new_failures: [
+ {
+ name: 'StringHelper#concatenate when a is git and b is lab returns summary',
+ execution_time: 0.0092435,
+ system_output: "Failure/Error: is_expected.to eq('gitlab')",
+ },
+ ],
+ resolved_failures: [
+ {
+ name: 'StringHelper#concatenate when a is git and b is lab returns summary',
+ execution_time: 0.009235,
+ system_output: "Failure/Error: is_expected.to eq('gitlab')",
+ },
+ ],
+ existing_failures: [
+ {
+ name: 'StringHelper#concatenate when a is git and b is lab returns summary',
+ execution_time: 1232.08,
+ system_output: "Failure/Error: is_expected.to eq('gitlab')",
+ },
+ ],
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_REPORTS_SUCCESS](stateCopy, mockedResponse);
+ });
+
+ it('should reset isLoading', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should reset hasError', () => {
+ expect(stateCopy.hasError).toEqual(false);
+ });
+
+ it('should set summary counts', () => {
+ expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total);
+ expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved);
+ expect(stateCopy.summary.failed).toEqual(mockedResponse.summary.failed);
+ });
+
+ it('should set reports', () => {
+ expect(stateCopy.reports).toEqual(mockedResponse.suites);
+ });
+ });
+
+ describe('RECEIVE_REPORTS_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_REPORTS_ERROR](stateCopy);
+ });
+
+ it('should reset isLoading', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.hasError).toEqual(true);
+ });
+
+ it('should reset reports', () => {
+ expect(stateCopy.reports).toEqual([]);
+ });
+ });
+
+ describe('SET_ISSUE_MODAL_DATA', () => {
+ beforeEach(() => {
+ mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, {
+ issue,
+ });
+ });
+
+ it('should set modal title', () => {
+ expect(stateCopy.modal.title).toEqual(issue.name);
+ });
+
+ it('should set modal data', () => {
+ expect(stateCopy.modal.data.execution_time.value).toEqual(issue.execution_time);
+ expect(stateCopy.modal.data.system_output.value).toEqual(issue.system_output);
+ });
+ });
+});
diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
index 491fc20c40e..1dca65dd862 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -26,9 +26,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
class="commit-row-message item-title"
href="https://test.com/commit/123"
>
-
- Commit title
-
+ Commit title
</gl-link-stub>
<!---->
@@ -128,9 +126,7 @@ exports[`Repository last commit component renders the signature HTML as returned
class="commit-row-message item-title"
href="https://test.com/commit/123"
>
-
- Commit title
-
+ Commit title
</gl-link-stub>
<!---->
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index d2576ec26b7..a5bfeb08fe4 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -9,6 +9,7 @@ function createCommitData(data = {}) {
const defaultData = {
sha: '123456789',
title: 'Commit title',
+ titleHtml: 'Commit title',
message: 'Commit message',
webUrl: 'https://test.com/commit/123',
authoredDate: '2019-01-01',
diff --git a/spec/frontend/repository/utils/commit_spec.js b/spec/frontend/repository/utils/commit_spec.js
index e7cc28178bf..aaaa39f739f 100644
--- a/spec/frontend/repository/utils/commit_spec.js
+++ b/spec/frontend/repository/utils/commit_spec.js
@@ -8,6 +8,7 @@ const mockData = [
committed_date: '2019-01-01',
},
commit_path: `https://test.com`,
+ commit_title_html: 'testing message',
file_name: 'index.js',
type: 'blob',
},
@@ -24,6 +25,7 @@ describe('normalizeData', () => {
fileName: 'index.js',
filePath: '/index.js',
type: 'blob',
+ titleHtml: 'testing message',
__typename: 'LogTreeCommit',
},
]);
diff --git a/spec/frontend/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js
new file mode 100644
index 00000000000..2c5d91a45bc
--- /dev/null
+++ b/spec/frontend/settings_panels_spec.js
@@ -0,0 +1,45 @@
+import $ from 'jquery';
+import initSettingsPanels from '~/settings_panels';
+
+describe('Settings Panels', () => {
+ preloadFixtures('groups/edit.html');
+
+ beforeEach(() => {
+ loadFixtures('groups/edit.html');
+ });
+
+ describe('initSettingsPane', () => {
+ afterEach(() => {
+ window.location.hash = '';
+ });
+
+ it('should expand linked hash fragment panel', () => {
+ window.location.hash = '#js-general-settings';
+
+ const panel = document.querySelector('#js-general-settings');
+ // Our test environment automatically expands everything so we need to clear that out first
+ panel.classList.remove('expanded');
+
+ expect(panel.classList.contains('expanded')).toBe(false);
+
+ initSettingsPanels();
+
+ expect(panel.classList.contains('expanded')).toBe(true);
+ });
+ });
+
+ it('does not change the text content of triggers', () => {
+ const panel = document.querySelector('#js-general-settings');
+ const trigger = panel.querySelector('.js-settings-toggle-trigger-only');
+ const originalText = trigger.textContent;
+
+ initSettingsPanels();
+
+ expect(panel.classList.contains('expanded')).toBe(true);
+
+ $(trigger).click();
+
+ expect(panel.classList.contains('expanded')).toBe(false);
+ expect(trigger.textContent).toEqual(originalText);
+ });
+});
diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
index 1f93336e755..cf7832f3948 100644
--- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
+++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = false 1`] = `
+exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
>
@@ -52,7 +52,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and
</div>
`;
-exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = true 1`] = `
+exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
>
@@ -84,9 +84,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and
data-track-property="confidentiality"
href="#"
>
-
Edit
-
</a>
</div>
@@ -114,7 +112,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and
</div>
`;
-exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = false 1`] = `
+exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
>
@@ -166,7 +164,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = true and
</div>
`;
-exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = true 1`] = `
+exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
>
@@ -198,9 +196,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = true and
data-track-property="confidentiality"
href="#"
>
-
Edit
-
</a>
</div>
diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js
new file mode 100644
index 00000000000..1c62c52dc67
--- /dev/null
+++ b/spec/frontend/sidebar/assignees_realtime_spec.js
@@ -0,0 +1,102 @@
+import { shallowMount } from '@vue/test-utils';
+import ActionCable from '@rails/actioncable';
+import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import Mock from './mock_data';
+import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
+
+jest.mock('@rails/actioncable', () => {
+ const mockConsumer = {
+ subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) },
+ };
+ return {
+ createConsumer: jest.fn().mockReturnValue(mockConsumer),
+ };
+});
+
+describe('Assignees Realtime', () => {
+ let wrapper;
+ let mediator;
+
+ const createComponent = () => {
+ wrapper = shallowMount(AssigneesRealtime, {
+ propsData: {
+ issuableIid: '1',
+ mediator,
+ projectPath: 'path/to/project',
+ },
+ mocks: {
+ $apollo: {
+ query,
+ queries: {
+ project: {
+ refetch: jest.fn(),
+ },
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mediator = new SidebarMediator(Mock.mediator);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ SidebarMediator.singleton = null;
+ });
+
+ describe('when handleFetchResult is called from smart query', () => {
+ it('sets assignees to the store', () => {
+ const data = {
+ project: {
+ issue: {
+ assignees: {
+ nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }],
+ },
+ },
+ },
+ };
+ const expected = [{ id: 123, avatar_url: 'url', avatarUrl: 'url' }];
+ createComponent();
+
+ wrapper.vm.handleFetchResult({ data });
+
+ expect(mediator.store.assignees).toEqual(expected);
+ });
+ });
+
+ describe('when mounted', () => {
+ it('calls create subscription', () => {
+ const cable = ActionCable.createConsumer();
+
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(cable.subscriptions.create).toHaveBeenCalledTimes(1);
+ expect(cable.subscriptions.create).toHaveBeenCalledWith(
+ {
+ channel: 'IssuesChannel',
+ iid: wrapper.props('issuableIid'),
+ project_path: wrapper.props('projectPath'),
+ },
+ { received: wrapper.vm.received },
+ );
+ });
+ });
+ });
+
+ describe('when subscription is recieved', () => {
+ it('refetches the GraphQL project query', () => {
+ createComponent();
+
+ wrapper.vm.received({ event: 'updated' });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.$apollo.queries.project.refetch).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
new file mode 100644
index 00000000000..1f028f74423
--- /dev/null
+++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
@@ -0,0 +1,279 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
+
+describe('Issuable Time Tracker', () => {
+ let initialData;
+ let vm;
+
+ const initTimeTrackingComponent = ({
+ timeEstimate,
+ timeSpent,
+ timeEstimateHumanReadable,
+ timeSpentHumanReadable,
+ limitToHours,
+ }) => {
+ setFixtures(`
+ <div>
+ <div id="mock-container"></div>
+ </div>
+ `);
+
+ initialData = {
+ timeEstimate,
+ timeSpent,
+ humanTimeEstimate: timeEstimateHumanReadable,
+ humanTimeSpent: timeSpentHumanReadable,
+ limitToHours: Boolean(limitToHours),
+ rootPath: '/',
+ };
+
+ const TimeTrackingComponent = Vue.extend({
+ ...TimeTracker,
+ components: {
+ ...TimeTracker.components,
+ transition: {
+ // disable animations
+ render(h) {
+ return h('div', this.$slots.default);
+ },
+ },
+ },
+ });
+ vm = mountComponent(TimeTrackingComponent, initialData, '#mock-container');
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('Initialization', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 10000, // 2h 46m
+ timeSpent: 5000, // 1h 23m
+ timeEstimateHumanReadable: '2h 46m',
+ timeSpentHumanReadable: '1h 23m',
+ });
+ });
+
+ it('should return something defined', () => {
+ expect(vm).toBeDefined();
+ });
+
+ it('should correctly set timeEstimate', done => {
+ Vue.nextTick(() => {
+ expect(vm.timeEstimate).toBe(initialData.timeEstimate);
+ done();
+ });
+ });
+
+ it('should correctly set time_spent', done => {
+ Vue.nextTick(() => {
+ expect(vm.timeSpent).toBe(initialData.timeSpent);
+ done();
+ });
+ });
+ });
+
+ describe('Content Display', () => {
+ describe('Panes', () => {
+ describe('Comparison pane', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 100000, // 1d 3h
+ timeSpent: 5000, // 1h 23m
+ timeEstimateHumanReadable: '1d 3h',
+ timeSpentHumanReadable: '1h 23m',
+ });
+ });
+
+ it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', done => {
+ Vue.nextTick(() => {
+ expect(vm.showComparisonState).toBe(true);
+ const $comparisonPane = vm.$el.querySelector('.time-tracking-comparison-pane');
+
+ expect($comparisonPane).toBeVisible();
+ done();
+ });
+ });
+
+ it('should show full times when the sidebar is collapsed', done => {
+ Vue.nextTick(() => {
+ const timeTrackingText = vm.$el.querySelector('.time-tracking-collapsed-summary span')
+ .textContent;
+
+ expect(timeTrackingText.trim()).toBe('1h 23m / 1d 3h');
+ done();
+ });
+ });
+
+ describe('Remaining meter', () => {
+ it('should display the remaining meter with the correct width', done => {
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.time-tracking-comparison-pane .progress[value="5"]'),
+ ).not.toBeNull();
+ done();
+ });
+ });
+
+ it('should display the remaining meter with the correct background color when within estimate', done => {
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="primary"]'),
+ ).not.toBeNull();
+ done();
+ });
+ });
+
+ it('should display the remaining meter with the correct background color when over estimate', done => {
+ vm.timeEstimate = 10000; // 2h 46m
+ vm.timeSpent = 20000000; // 231 days
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]'),
+ ).not.toBeNull();
+ done();
+ });
+ });
+ });
+ });
+
+ describe('Comparison pane when limitToHours is true', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 100000, // 1d 3h
+ timeSpent: 5000, // 1h 23m
+ timeEstimateHumanReadable: '',
+ timeSpentHumanReadable: '',
+ limitToHours: true,
+ });
+ });
+
+ it('should show the correct tooltip text', done => {
+ Vue.nextTick(() => {
+ expect(vm.showComparisonState).toBe(true);
+ const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').dataset
+ .originalTitle;
+
+ expect($title).toBe('Time remaining: 26h 23m');
+ done();
+ });
+ });
+ });
+
+ describe('Estimate only pane', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 10000, // 2h 46m
+ timeSpent: 0,
+ timeEstimateHumanReadable: '2h 46m',
+ timeSpentHumanReadable: '',
+ });
+ });
+
+ it('should display the human readable version of time estimated', done => {
+ Vue.nextTick(() => {
+ const estimateText = vm.$el.querySelector('.time-tracking-estimate-only-pane')
+ .textContent;
+ const correctText = 'Estimated: 2h 46m';
+
+ expect(estimateText.trim()).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe('Spent only pane', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 0,
+ timeSpent: 5000, // 1h 23m
+ timeEstimateHumanReadable: '2h 46m',
+ timeSpentHumanReadable: '1h 23m',
+ });
+ });
+
+ it('should display the human readable version of time spent', done => {
+ Vue.nextTick(() => {
+ const spentText = vm.$el.querySelector('.time-tracking-spend-only-pane').textContent;
+ const correctText = 'Spent: 1h 23m';
+
+ expect(spentText).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe('No time tracking pane', () => {
+ beforeEach(() => {
+ initTimeTrackingComponent({
+ timeEstimate: 0,
+ timeSpent: 0,
+ timeEstimateHumanReadable: '',
+ timeSpentHumanReadable: '',
+ });
+ });
+
+ it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', done => {
+ Vue.nextTick(() => {
+ const $noTrackingPane = vm.$el.querySelector('.time-tracking-no-tracking-pane');
+ const noTrackingText = $noTrackingPane.textContent;
+ const correctText = 'No estimate or time spent';
+
+ expect(vm.showNoTimeTrackingState).toBe(true);
+ expect($noTrackingPane).toBeVisible();
+ expect(noTrackingText.trim()).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe('Help pane', () => {
+ const helpButton = () => vm.$el.querySelector('.help-button');
+ const closeHelpButton = () => vm.$el.querySelector('.close-help-button');
+ const helpPane = () => vm.$el.querySelector('.time-tracking-help-state');
+
+ beforeEach(() => {
+ initTimeTrackingComponent({ timeEstimate: 0, timeSpent: 0 });
+
+ return vm.$nextTick();
+ });
+
+ it('should not show the "Help" pane by default', () => {
+ expect(vm.showHelpState).toBe(false);
+ expect(helpPane()).toBeNull();
+ });
+
+ it('should show the "Help" pane when help button is clicked', () => {
+ helpButton().click();
+
+ return vm.$nextTick().then(() => {
+ expect(vm.showHelpState).toBe(true);
+
+ // let animations run
+ jest.advanceTimersByTime(500);
+
+ expect(helpPane()).toBeVisible();
+ });
+ });
+
+ it('should not show the "Help" pane when help button is clicked and then closed', done => {
+ helpButton().click();
+
+ Vue.nextTick()
+ .then(() => closeHelpButton().click())
+ .then(() => Vue.nextTick())
+ .then(() => {
+ expect(vm.showHelpState).toBe(false);
+ expect(helpPane()).toBeNull();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
new file mode 100644
index 00000000000..acdfb5139bf
--- /dev/null
+++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
@@ -0,0 +1,41 @@
+import { shallowMount } from '@vue/test-utils';
+import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
+
+describe('Edit Form Buttons', () => {
+ let wrapper;
+ const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]');
+
+ const createComponent = props => {
+ wrapper = shallowMount(EditFormButtons, {
+ propsData: {
+ updateConfidentialAttribute: () => {},
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when not confidential', () => {
+ it('renders Turn On in the ', () => {
+ createComponent({
+ isConfidential: false,
+ });
+
+ expect(findConfidentialToggle().text()).toBe('Turn On');
+ });
+ });
+
+ describe('when confidential', () => {
+ it('renders on or off text based on confidentiality', () => {
+ createComponent({
+ isConfidential: true,
+ });
+
+ expect(findConfidentialToggle().text()).toBe('Turn Off');
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js
new file mode 100644
index 00000000000..137019a1e1b
--- /dev/null
+++ b/spec/frontend/sidebar/confidential/edit_form_spec.js
@@ -0,0 +1,45 @@
+import { shallowMount } from '@vue/test-utils';
+import EditForm from '~/sidebar/components/confidential/edit_form.vue';
+
+describe('Edit Form Dropdown', () => {
+ let wrapper;
+ const toggleForm = () => {};
+ const updateConfidentialAttribute = () => {};
+
+ const createComponent = props => {
+ wrapper = shallowMount(EditForm, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when not confidential', () => {
+ it('renders "You are going to turn off the confidentiality." in the ', () => {
+ createComponent({
+ isConfidential: false,
+ toggleForm,
+ updateConfidentialAttribute,
+ });
+
+ expect(wrapper.find('p').text()).toContain('You are going to turn on the confidentiality.');
+ });
+ });
+
+ describe('when confidential', () => {
+ it('renders on or off text based on confidentiality', () => {
+ createComponent({
+ isConfidential: true,
+ toggleForm,
+ updateConfidentialAttribute,
+ });
+
+ expect(wrapper.find('p').text()).toContain('You are going to turn off the confidentiality.');
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/confidential_edit_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_buttons_spec.js
deleted file mode 100644
index 32da9f83112..00000000000
--- a/spec/frontend/sidebar/confidential_edit_buttons_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import Vue from 'vue';
-import editFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
-
-describe('Edit Form Buttons', () => {
- let vm1;
- let vm2;
-
- beforeEach(() => {
- const Component = Vue.extend(editFormButtons);
- const toggleForm = () => {};
- const updateConfidentialAttribute = () => {};
-
- vm1 = new Component({
- propsData: {
- isConfidential: true,
- toggleForm,
- updateConfidentialAttribute,
- },
- }).$mount();
-
- vm2 = new Component({
- propsData: {
- isConfidential: false,
- toggleForm,
- updateConfidentialAttribute,
- },
- }).$mount();
- });
-
- it('renders on or off text based on confidentiality', () => {
- expect(vm1.$el.innerHTML.includes('Turn Off')).toBe(true);
-
- expect(vm2.$el.innerHTML.includes('Turn On')).toBe(true);
- });
-});
diff --git a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js
deleted file mode 100644
index 369088cb258..00000000000
--- a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import Vue from 'vue';
-import editForm from '~/sidebar/components/confidential/edit_form.vue';
-
-describe('Edit Form Dropdown', () => {
- let vm1;
- let vm2;
-
- beforeEach(() => {
- const Component = Vue.extend(editForm);
- const toggleForm = () => {};
- const updateConfidentialAttribute = () => {};
-
- vm1 = new Component({
- propsData: {
- isConfidential: true,
- toggleForm,
- updateConfidentialAttribute,
- },
- }).$mount();
-
- vm2 = new Component({
- propsData: {
- isConfidential: false,
- toggleForm,
- updateConfidentialAttribute,
- },
- }).$mount();
- });
-
- it('renders on the appropriate warning text', () => {
- expect(vm1.$el.innerHTML.includes('You are going to turn off the confidentiality.')).toBe(true);
-
- expect(vm2.$el.innerHTML.includes('You are going to turn on the confidentiality.')).toBe(true);
- });
-});
diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
index 4853d9795b1..e7a64ec5ed9 100644
--- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
+++ b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
@@ -5,6 +5,7 @@ import EditForm from '~/sidebar/components/confidential/edit_form.vue';
import SidebarService from '~/sidebar/services/sidebar_service';
import createFlash from '~/flash';
import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue';
+import createStore from '~/notes/stores';
jest.mock('~/flash');
jest.mock('~/sidebar/services/sidebar_service');
@@ -31,8 +32,10 @@ describe('Confidential Issue Sidebar Block', () => {
};
const createComponent = propsData => {
+ const store = createStore();
const service = new SidebarService();
wrapper = shallowMount(ConfidentialIssueSidebar, {
+ store,
propsData: {
service,
...propsData,
@@ -49,29 +52,31 @@ describe('Confidential Issue Sidebar Block', () => {
});
it.each`
- isConfidential | isEditable
- ${false} | ${false}
- ${false} | ${true}
- ${true} | ${false}
- ${true} | ${true}
+ confidential | isEditable
+ ${false} | ${false}
+ ${false} | ${true}
+ ${true} | ${false}
+ ${true} | ${true}
`(
- 'renders for isConfidential = $isConfidential and isEditable = $isEditable',
- ({ isConfidential, isEditable }) => {
+ 'renders for confidential = $confidential and isEditable = $isEditable',
+ ({ confidential, isEditable }) => {
createComponent({
- isConfidential,
isEditable,
});
+ wrapper.vm.$store.state.noteableData.confidential = confidential;
- expect(wrapper.element).toMatchSnapshot();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
},
);
describe('if editable', () => {
beforeEach(() => {
createComponent({
- isConfidential: true,
isEditable: true,
});
+ wrapper.vm.$store.state.noteableData.confidential = true;
});
it('displays the edit form when editable', () => {
diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
new file mode 100644
index 00000000000..66f9237ce97
--- /dev/null
+++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
@@ -0,0 +1,31 @@
+import { shallowMount } from '@vue/test-utils';
+import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
+
+describe('EditFormButtons', () => {
+ let wrapper;
+
+ const mountComponent = propsData => shallowMount(EditFormButtons, { propsData });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays "Unlock" when locked', () => {
+ wrapper = mountComponent({
+ isLocked: true,
+ updateLockedAttribute: () => {},
+ });
+
+ expect(wrapper.text()).toContain('Unlock');
+ });
+
+ it('displays "Lock" when unlocked', () => {
+ wrapper = mountComponent({
+ isLocked: false,
+ updateLockedAttribute: () => {},
+ });
+
+ expect(wrapper.text()).toContain('Lock');
+ });
+});
diff --git a/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js
new file mode 100644
index 00000000000..00997326d87
--- /dev/null
+++ b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js
@@ -0,0 +1,99 @@
+import Vue from 'vue';
+import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
+import lockIssueSidebar from '~/sidebar/components/lock/lock_issue_sidebar.vue';
+
+describe('LockIssueSidebar', () => {
+ let vm1;
+ let vm2;
+
+ beforeEach(() => {
+ const Component = Vue.extend(lockIssueSidebar);
+
+ const mediator = {
+ service: {
+ update: Promise.resolve(true),
+ },
+
+ store: {
+ isLockDialogOpen: false,
+ },
+ };
+
+ vm1 = new Component({
+ propsData: {
+ isLocked: true,
+ isEditable: true,
+ mediator,
+ issuableType: 'issue',
+ },
+ }).$mount();
+
+ vm2 = new Component({
+ propsData: {
+ isLocked: false,
+ isEditable: false,
+ mediator,
+ issuableType: 'merge_request',
+ },
+ }).$mount();
+ });
+
+ it('shows if locked and/or editable', () => {
+ expect(vm1.$el.innerHTML.includes('Edit')).toBe(true);
+
+ expect(vm1.$el.innerHTML.includes('Locked')).toBe(true);
+
+ expect(vm2.$el.innerHTML.includes('Unlocked')).toBe(true);
+ });
+
+ it('displays the edit form when editable', done => {
+ expect(vm1.isLockDialogOpen).toBe(false);
+
+ vm1.$el.querySelector('.lock-edit').click();
+
+ expect(vm1.isLockDialogOpen).toBe(true);
+
+ vm1.$nextTick(() => {
+ expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true);
+
+ done();
+ });
+ });
+
+ it('tracks an event when "Edit" is clicked', () => {
+ const spy = mockTracking('_category_', vm1.$el, jest.spyOn);
+ triggerEvent('.lock-edit');
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
+ label: 'right_sidebar',
+ property: 'lock_issue',
+ });
+ });
+
+ it('displays the edit form when opened from collapsed state', done => {
+ expect(vm1.isLockDialogOpen).toBe(false);
+
+ vm1.$el.querySelector('.sidebar-collapsed-icon').click();
+
+ expect(vm1.isLockDialogOpen).toBe(true);
+
+ setImmediate(() => {
+ expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true);
+
+ done();
+ });
+ });
+
+ it('does not display the edit form when opened from collapsed state if not editable', done => {
+ expect(vm2.isLockDialogOpen).toBe(false);
+
+ vm2.$el.querySelector('.sidebar-collapsed-icon').click();
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm2.isLockDialogOpen).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js
new file mode 100644
index 00000000000..ebe94582588
--- /dev/null
+++ b/spec/frontend/sidebar/participants_spec.js
@@ -0,0 +1,206 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Participants from '~/sidebar/components/participants/participants.vue';
+
+const PARTICIPANT = {
+ id: 1,
+ state: 'active',
+ username: 'marcene',
+ name: 'Allie Will',
+ web_url: 'foo.com',
+ avatar_url: 'gravatar.com/avatar/xxx',
+};
+
+const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }];
+
+describe('Participants', () => {
+ let wrapper;
+
+ const getMoreParticipantsButton = () => wrapper.find('button');
+
+ const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]');
+
+ const mountComponent = propsData =>
+ shallowMount(Participants, {
+ propsData,
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('collapsed sidebar state', () => {
+ it('shows loading spinner when loading', () => {
+ wrapper = mountComponent({
+ loading: true,
+ });
+
+ expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ });
+
+ it('does not show loading spinner not loading', () => {
+ wrapper = mountComponent({
+ loading: false,
+ });
+
+ expect(wrapper.contains(GlLoadingIcon)).toBe(false);
+ });
+
+ it('shows participant count when given', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ });
+
+ expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`);
+ });
+
+ it('shows full participant count when there are hidden participants', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 1,
+ });
+
+ expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`);
+ });
+ });
+
+ describe('expanded sidebar state', () => {
+ it('shows loading spinner when loading', () => {
+ wrapper = mountComponent({
+ loading: true,
+ });
+
+ expect(wrapper.contains(GlLoadingIcon)).toBe(true);
+ });
+
+ it('when only showing visible participants, shows an avatar only for each participant under the limit', () => {
+ const numberOfLessParticipants = 2;
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants,
+ });
+
+ wrapper.setData({
+ isShowingMoreParticipants: false,
+ });
+
+ return Vue.nextTick().then(() => {
+ expect(wrapper.findAll('.participants-author')).toHaveLength(numberOfLessParticipants);
+ });
+ });
+
+ it('when only showing all participants, each has an avatar', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ wrapper.setData({
+ isShowingMoreParticipants: true,
+ });
+
+ return Vue.nextTick().then(() => {
+ expect(wrapper.findAll('.participants-author')).toHaveLength(PARTICIPANT_LIST.length);
+ });
+ });
+
+ it('does not have more participants link when they can all be shown', () => {
+ const numberOfLessParticipants = 100;
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants,
+ });
+
+ expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants);
+ expect(getMoreParticipantsButton().exists()).toBe(false);
+ });
+
+ it('when too many participants, has more participants link to show more', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ wrapper.setData({
+ isShowingMoreParticipants: false,
+ });
+
+ return Vue.nextTick().then(() => {
+ expect(getMoreParticipantsButton().text()).toBe('+ 1 more');
+ });
+ });
+
+ it('when too many participants and already showing them, has more participants link to show less', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ wrapper.setData({
+ isShowingMoreParticipants: true,
+ });
+
+ return Vue.nextTick().then(() => {
+ expect(getMoreParticipantsButton().text()).toBe('- show less');
+ });
+ });
+
+ it('clicking more participants link emits event', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ expect(wrapper.vm.isShowingMoreParticipants).toBe(false);
+
+ getMoreParticipantsButton().trigger('click');
+
+ expect(wrapper.vm.isShowingMoreParticipants).toBe(true);
+ });
+
+ it('clicking on participants icon emits `toggleSidebar` event', () => {
+ wrapper = mountComponent({
+ loading: false,
+ participants: PARTICIPANT_LIST,
+ numberOfLessParticipants: 2,
+ });
+
+ const spy = jest.spyOn(wrapper.vm, '$emit');
+
+ wrapper.find('.sidebar-collapsed-icon').trigger('click');
+
+ return Vue.nextTick(() => {
+ expect(spy).toHaveBeenCalledWith('toggleSidebar');
+
+ spy.mockRestore();
+ });
+ });
+ });
+
+ describe('when not showing participants label', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ participants: PARTICIPANT_LIST,
+ showParticipantLabel: false,
+ });
+ });
+
+ it('does not show sidebar collapsed icon', () => {
+ expect(wrapper.contains('.sidebar-collapsed-icon')).toBe(false);
+ });
+
+ it('does not show participants label title', () => {
+ expect(wrapper.contains('.title')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js
index c1876066a21..88e2d2c9514 100644
--- a/spec/frontend/sidebar/sidebar_assignees_spec.js
+++ b/spec/frontend/sidebar/sidebar_assignees_spec.js
@@ -3,6 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import axios from 'axios';
import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.vue';
import Assigness from '~/sidebar/components/assignees/assignees.vue';
+import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
@@ -12,12 +13,19 @@ describe('sidebar assignees', () => {
let wrapper;
let mediator;
let axiosMock;
-
- const createComponent = () => {
+ const createComponent = (realTimeIssueSidebar = false, props) => {
wrapper = shallowMount(SidebarAssignees, {
propsData: {
+ issuableIid: '1',
mediator,
field: '',
+ projectPath: 'projectPath',
+ ...props,
+ },
+ provide: {
+ glFeatures: {
+ realTimeIssueSidebar,
+ },
},
// Attaching to document is required because this component emits something from the parent element :/
attachToDocument: true,
@@ -30,8 +38,6 @@ describe('sidebar assignees', () => {
jest.spyOn(mediator, 'saveAssignees');
jest.spyOn(mediator, 'assignYourself');
-
- createComponent();
});
afterEach(() => {
@@ -45,6 +51,8 @@ describe('sidebar assignees', () => {
});
it('calls the mediator when saves the assignees', () => {
+ createComponent();
+
expect(mediator.saveAssignees).not.toHaveBeenCalled();
wrapper.vm.saveAssignees();
@@ -53,6 +61,8 @@ describe('sidebar assignees', () => {
});
it('calls the mediator when "assignSelf" method is called', () => {
+ createComponent();
+
expect(mediator.assignYourself).not.toHaveBeenCalled();
expect(mediator.store.assignees.length).toBe(0);
@@ -63,6 +73,8 @@ describe('sidebar assignees', () => {
});
it('hides assignees until fetched', () => {
+ createComponent();
+
expect(wrapper.find(Assigness).exists()).toBe(false);
wrapper.vm.store.isFetching.assignees = false;
@@ -71,4 +83,30 @@ describe('sidebar assignees', () => {
expect(wrapper.find(Assigness).exists()).toBe(true);
});
});
+
+ describe('when realTimeIssueSidebar is turned on', () => {
+ describe('when issuableType is issue', () => {
+ it('finds AssigneesRealtime componeont', () => {
+ createComponent(true);
+
+ expect(wrapper.find(AssigneesRealtime).exists()).toBe(true);
+ });
+ });
+
+ describe('when issuableType is MR', () => {
+ it('does not find AssigneesRealtime componeont', () => {
+ createComponent(true, { issuableType: 'MR' });
+
+ expect(wrapper.find(AssigneesRealtime).exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when realTimeIssueSidebar is turned off', () => {
+ it('does not find AssigneesRealtime', () => {
+ createComponent(false, { issuableType: 'issue' });
+
+ expect(wrapper.find(AssigneesRealtime).exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js
new file mode 100644
index 00000000000..0892d452966
--- /dev/null
+++ b/spec/frontend/sidebar/sidebar_mediator_spec.js
@@ -0,0 +1,135 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import * as urlUtility from '~/lib/utils/url_utility';
+import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import Mock from './mock_data';
+
+describe('Sidebar mediator', () => {
+ const { mediator: mediatorMockData } = Mock;
+ let mock;
+ let mediator;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mediator = new SidebarMediator(mediatorMockData);
+ });
+
+ afterEach(() => {
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+ mock.restore();
+ });
+
+ it('assigns yourself ', () => {
+ mediator.assignYourself();
+
+ expect(mediator.store.currentUser).toEqual(mediatorMockData.currentUser);
+ expect(mediator.store.assignees[0]).toEqual(mediatorMockData.currentUser);
+ });
+
+ it('saves assignees', () => {
+ mock.onPut(mediatorMockData.endpoint).reply(200, {});
+
+ return mediator.saveAssignees('issue[assignee_ids]').then(resp => {
+ expect(resp.status).toEqual(200);
+ });
+ });
+
+ it('fetches the data', () => {
+ const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
+ mock.onGet(mediatorMockData.endpoint).reply(200, mockData);
+
+ const mockGraphQlData = Mock.graphQlResponseData;
+ const graphQlSpy = jest.spyOn(gqClient, 'query').mockReturnValue({
+ data: mockGraphQlData,
+ });
+ const spy = jest.spyOn(mediator, 'processFetchedData').mockReturnValue(Promise.resolve());
+
+ return mediator.fetch().then(() => {
+ expect(spy).toHaveBeenCalledWith(mockData, mockGraphQlData);
+
+ spy.mockRestore();
+ graphQlSpy.mockRestore();
+ });
+ });
+
+ it('processes fetched data', () => {
+ const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
+ mediator.processFetchedData(mockData);
+
+ expect(mediator.store.assignees).toEqual(mockData.assignees);
+ expect(mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate);
+ expect(mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent);
+ expect(mediator.store.participants).toEqual(mockData.participants);
+ expect(mediator.store.subscribed).toEqual(mockData.subscribed);
+ expect(mediator.store.timeEstimate).toEqual(mockData.time_estimate);
+ expect(mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent);
+ });
+
+ it('sets moveToProjectId', () => {
+ const projectId = 7;
+ const spy = jest.spyOn(mediator.store, 'setMoveToProjectId').mockReturnValue(Promise.resolve());
+
+ mediator.setMoveToProjectId(projectId);
+
+ expect(spy).toHaveBeenCalledWith(projectId);
+
+ spy.mockRestore();
+ });
+
+ it('fetches autocomplete projects', () => {
+ const searchTerm = 'foo';
+ mock.onGet(mediatorMockData.projectsAutocompleteEndpoint).reply(200, {});
+ const getterSpy = jest
+ .spyOn(mediator.service, 'getProjectsAutocomplete')
+ .mockReturnValue(Promise.resolve({ data: {} }));
+ const setterSpy = jest
+ .spyOn(mediator.store, 'setAutocompleteProjects')
+ .mockReturnValue(Promise.resolve());
+
+ return mediator.fetchAutocompleteProjects(searchTerm).then(() => {
+ expect(getterSpy).toHaveBeenCalledWith(searchTerm);
+ expect(setterSpy).toHaveBeenCalled();
+
+ getterSpy.mockRestore();
+ setterSpy.mockRestore();
+ });
+ });
+
+ it('moves issue', () => {
+ const mockData = Mock.responseMap.POST[mediatorMockData.moveIssueEndpoint];
+ const moveToProjectId = 7;
+ mock.onPost(mediatorMockData.moveIssueEndpoint).reply(200, mockData);
+ mediator.store.setMoveToProjectId(moveToProjectId);
+ const moveIssueSpy = jest
+ .spyOn(mediator.service, 'moveIssue')
+ .mockReturnValue(Promise.resolve({ data: { web_url: mockData.web_url } }));
+ const urlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
+
+ return mediator.moveIssue().then(() => {
+ expect(moveIssueSpy).toHaveBeenCalledWith(moveToProjectId);
+ expect(urlSpy).toHaveBeenCalledWith(mockData.web_url);
+
+ moveIssueSpy.mockRestore();
+ urlSpy.mockRestore();
+ });
+ });
+
+ it('toggle subscription', () => {
+ mediator.store.setSubscribedState(false);
+ mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {});
+ const spy = jest
+ .spyOn(mediator.service, 'toggleSubscription')
+ .mockReturnValue(Promise.resolve());
+
+ return mediator.toggleSubscription().then(() => {
+ expect(spy).toHaveBeenCalled();
+ expect(mediator.store.subscribed).toEqual(true);
+
+ spy.mockRestore();
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js
new file mode 100644
index 00000000000..db0d3e06272
--- /dev/null
+++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js
@@ -0,0 +1,167 @@
+import $ from 'jquery';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue';
+import Mock from './mock_data';
+
+describe('SidebarMoveIssue', () => {
+ let mock;
+ const test = {};
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ const mockData = Mock.responseMap.GET['/autocomplete/projects?project_id=15'];
+ mock.onGet('/autocomplete/projects?project_id=15').reply(200, mockData);
+ test.mediator = new SidebarMediator(Mock.mediator);
+ test.$content = $(`
+ <div class="dropdown">
+ <div class="js-toggle"></div>
+ <div class="dropdown-menu">
+ <div class="dropdown-content"></div>
+ </div>
+ <div class="js-confirm-button"></div>
+ </div>
+ `);
+ test.$toggleButton = test.$content.find('.js-toggle');
+ test.$confirmButton = test.$content.find('.js-confirm-button');
+
+ test.sidebarMoveIssue = new SidebarMoveIssue(
+ test.mediator,
+ test.$toggleButton,
+ test.$confirmButton,
+ );
+ test.sidebarMoveIssue.init();
+ });
+
+ afterEach(() => {
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+
+ test.sidebarMoveIssue.destroy();
+ mock.restore();
+ });
+
+ describe('init', () => {
+ it('should initialize the dropdown and listeners', () => {
+ jest.spyOn(test.sidebarMoveIssue, 'initDropdown').mockImplementation(() => {});
+ jest.spyOn(test.sidebarMoveIssue, 'addEventListeners').mockImplementation(() => {});
+
+ test.sidebarMoveIssue.init();
+
+ expect(test.sidebarMoveIssue.initDropdown).toHaveBeenCalled();
+ expect(test.sidebarMoveIssue.addEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ describe('destroy', () => {
+ it('should remove the listeners', () => {
+ jest.spyOn(test.sidebarMoveIssue, 'removeEventListeners').mockImplementation(() => {});
+
+ test.sidebarMoveIssue.destroy();
+
+ expect(test.sidebarMoveIssue.removeEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ describe('initDropdown', () => {
+ it('should initialize the gl_dropdown', () => {
+ jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {});
+
+ test.sidebarMoveIssue.initDropdown();
+
+ expect($.fn.glDropdown).toHaveBeenCalled();
+ });
+
+ it('escapes html from project name', done => {
+ test.$toggleButton.dropdown('toggle');
+
+ setImmediate(() => {
+ expect(test.$content.find('.js-move-issue-dropdown-item')[1].innerHTML.trim()).toEqual(
+ '&lt;img src=x onerror=alert(document.domain)&gt; foo / bar',
+ );
+ done();
+ });
+ });
+ });
+
+ describe('onConfirmClicked', () => {
+ it('should move the issue with valid project ID', () => {
+ jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.resolve());
+ test.mediator.setMoveToProjectId(7);
+
+ test.sidebarMoveIssue.onConfirmClicked();
+
+ expect(test.mediator.moveIssue).toHaveBeenCalled();
+ expect(test.$confirmButton.prop('disabled')).toBeTruthy();
+ expect(test.$confirmButton.hasClass('is-loading')).toBe(true);
+ });
+
+ it('should remove loading state from confirm button on failure', done => {
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+ jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.reject());
+ test.mediator.setMoveToProjectId(7);
+
+ test.sidebarMoveIssue.onConfirmClicked();
+
+ expect(test.mediator.moveIssue).toHaveBeenCalled();
+ // Wait for the move issue request to fail
+ setImmediate(() => {
+ expect(window.Flash).toHaveBeenCalled();
+ expect(test.$confirmButton.prop('disabled')).toBeFalsy();
+ expect(test.$confirmButton.hasClass('is-loading')).toBe(false);
+ done();
+ });
+ });
+
+ it('should not move the issue with id=0', () => {
+ jest.spyOn(test.mediator, 'moveIssue').mockImplementation(() => {});
+ test.mediator.setMoveToProjectId(0);
+
+ test.sidebarMoveIssue.onConfirmClicked();
+
+ expect(test.mediator.moveIssue).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should set moveToProjectId on dropdown item "No project" click', done => {
+ jest.spyOn(test.mediator, 'setMoveToProjectId').mockImplementation(() => {});
+
+ // Open the dropdown
+ test.$toggleButton.dropdown('toggle');
+
+ // Wait for the autocomplete request to finish
+ setImmediate(() => {
+ test.$content
+ .find('.js-move-issue-dropdown-item')
+ .eq(0)
+ .trigger('click');
+
+ expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(0);
+ expect(test.$confirmButton.prop('disabled')).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should set moveToProjectId on dropdown item click', done => {
+ jest.spyOn(test.mediator, 'setMoveToProjectId').mockImplementation(() => {});
+
+ // Open the dropdown
+ test.$toggleButton.dropdown('toggle');
+
+ // Wait for the autocomplete request to finish
+ setImmediate(() => {
+ test.$content
+ .find('.js-move-issue-dropdown-item')
+ .eq(1)
+ .trigger('click');
+
+ expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(20);
+ expect(test.$confirmButton.attr('disabled')).toBe(undefined);
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_subscriptions_spec.js b/spec/frontend/sidebar/sidebar_subscriptions_spec.js
new file mode 100644
index 00000000000..18aaeabe3dd
--- /dev/null
+++ b/spec/frontend/sidebar/sidebar_subscriptions_spec.js
@@ -0,0 +1,36 @@
+import { shallowMount } from '@vue/test-utils';
+import SidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import Mock from './mock_data';
+
+describe('Sidebar Subscriptions', () => {
+ let wrapper;
+ let mediator;
+
+ beforeEach(() => {
+ mediator = new SidebarMediator(Mock.mediator);
+ wrapper = shallowMount(SidebarSubscriptions, {
+ propsData: {
+ mediator,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+ });
+
+ it('calls the mediator toggleSubscription on event', () => {
+ const spy = jest.spyOn(mediator, 'toggleSubscription').mockReturnValue(Promise.resolve());
+
+ wrapper.vm.onToggleSubscription();
+
+ expect(spy).toHaveBeenCalled();
+ spy.mockRestore();
+ });
+});
diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js
new file mode 100644
index 00000000000..cce35666985
--- /dev/null
+++ b/spec/frontend/sidebar/subscriptions_spec.js
@@ -0,0 +1,106 @@
+import { shallowMount } from '@vue/test-utils';
+import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
+import eventHub from '~/sidebar/event_hub';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+
+describe('Subscriptions', () => {
+ let wrapper;
+
+ const findToggleButton = () => wrapper.find(ToggleButton);
+
+ const mountComponent = propsData =>
+ shallowMount(Subscriptions, {
+ propsData,
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('shows loading spinner when loading', () => {
+ wrapper = mountComponent({
+ loading: true,
+ subscribed: undefined,
+ });
+
+ expect(findToggleButton().attributes('isloading')).toBe('true');
+ });
+
+ it('is toggled "off" when currently not subscribed', () => {
+ wrapper = mountComponent({
+ subscribed: false,
+ });
+
+ expect(findToggleButton().attributes('value')).toBeFalsy();
+ });
+
+ it('is toggled "on" when currently subscribed', () => {
+ wrapper = mountComponent({
+ subscribed: true,
+ });
+
+ expect(findToggleButton().attributes('value')).toBe('true');
+ });
+
+ it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => {
+ const id = 42;
+ wrapper = mountComponent({ subscribed: true, id });
+ const eventHubSpy = jest.spyOn(eventHub, '$emit');
+ const wrapperEmitSpy = jest.spyOn(wrapper.vm, '$emit');
+
+ wrapper.vm.toggleSubscription();
+
+ expect(eventHubSpy).toHaveBeenCalledWith('toggleSubscription', id);
+ expect(wrapperEmitSpy).toHaveBeenCalledWith('toggleSubscription', id);
+ eventHubSpy.mockRestore();
+ wrapperEmitSpy.mockRestore();
+ });
+
+ it('tracks the event when toggled', () => {
+ wrapper = mountComponent({ subscribed: true });
+
+ const wrapperTrackSpy = jest.spyOn(wrapper.vm, 'track');
+
+ wrapper.vm.toggleSubscription();
+
+ expect(wrapperTrackSpy).toHaveBeenCalledWith('toggle_button', {
+ property: 'notifications',
+ value: 0,
+ });
+ wrapperTrackSpy.mockRestore();
+ });
+
+ it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => {
+ wrapper = mountComponent({ subscribed: true });
+ const spy = jest.spyOn(wrapper.vm, '$emit');
+
+ wrapper.vm.onClickCollapsedIcon();
+
+ expect(spy).toHaveBeenCalledWith('toggleSidebar');
+ spy.mockRestore();
+ });
+
+ describe('given project emails are disabled', () => {
+ const subscribeDisabledDescription = 'Notifications have been disabled';
+
+ beforeEach(() => {
+ wrapper = mountComponent({
+ subscribed: false,
+ projectEmailsDisabled: true,
+ subscribeDisabledDescription,
+ });
+ });
+
+ it('sets the correct display text', () => {
+ expect(wrapper.find('.issuable-header-text').text()).toContain(subscribeDisabledDescription);
+ expect(wrapper.find({ ref: 'tooltip' }).attributes('data-original-title')).toBe(
+ subscribeDisabledDescription,
+ );
+ });
+
+ it('does not render the toggle button', () => {
+ expect(wrapper.contains('.js-issuable-subscribe-button')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/smart_interval_spec.js b/spec/frontend/smart_interval_spec.js
index b32ac99e4e4..1a2fd7ff8f1 100644
--- a/spec/frontend/smart_interval_spec.js
+++ b/spec/frontend/smart_interval_spec.js
@@ -3,8 +3,6 @@ import { assignIn } from 'lodash';
import waitForPromises from 'helpers/wait_for_promises';
import SmartInterval from '~/smart_interval';
-jest.useFakeTimers();
-
let interval;
describe('SmartInterval', () => {
diff --git a/spec/frontend/snippet/snippet_bundle_spec.js b/spec/frontend/snippet/snippet_bundle_spec.js
index 12d20d5cd85..38d05243c65 100644
--- a/spec/frontend/snippet/snippet_bundle_spec.js
+++ b/spec/frontend/snippet/snippet_bundle_spec.js
@@ -1,94 +1,85 @@
import Editor from '~/editor/editor_lite';
-import { initEditor } from '~/snippet/snippet_bundle';
+import initEditor from '~/snippet/snippet_bundle';
import { setHTMLFixture } from 'helpers/fixtures';
jest.mock('~/editor/editor_lite', () => jest.fn());
describe('Snippet editor', () => {
- describe('Monaco editor for Snippets', () => {
- let oldGon;
- let editorEl;
- let contentEl;
- let fileNameEl;
- let form;
-
- const mockName = 'foo.bar';
- const mockContent = 'Foo Bar';
- const updatedMockContent = 'New Foo Bar';
-
- const mockEditor = {
- createInstance: jest.fn(),
- updateModelLanguage: jest.fn(),
- getValue: jest.fn().mockReturnValueOnce(updatedMockContent),
- };
- Editor.mockImplementation(() => mockEditor);
-
- function setUpFixture(name, content) {
- setHTMLFixture(`
- <div class="snippet-form-holder">
- <form>
- <input class="js-snippet-file-name" type="text" value="${name}">
- <input class="snippet-file-content" type="hidden" value="${content}">
- <pre id="editor"></pre>
- </form>
- </div>
- `);
- }
-
- function bootstrap(name = '', content = '') {
- setUpFixture(name, content);
- editorEl = document.getElementById('editor');
- contentEl = document.querySelector('.snippet-file-content');
- fileNameEl = document.querySelector('.js-snippet-file-name');
- form = document.querySelector('.snippet-form-holder form');
-
- initEditor();
- }
-
- function createEvent(name) {
- return new Event(name, {
- view: window,
- bubbles: true,
- cancelable: true,
- });
- }
-
- beforeEach(() => {
- oldGon = window.gon;
- window.gon = { features: { monacoSnippets: true } };
- bootstrap(mockName, mockContent);
+ let editorEl;
+ let contentEl;
+ let fileNameEl;
+ let form;
+
+ const mockName = 'foo.bar';
+ const mockContent = 'Foo Bar';
+ const updatedMockContent = 'New Foo Bar';
+
+ const mockEditor = {
+ createInstance: jest.fn(),
+ updateModelLanguage: jest.fn(),
+ getValue: jest.fn().mockReturnValueOnce(updatedMockContent),
+ };
+ Editor.mockImplementation(() => mockEditor);
+
+ function setUpFixture(name, content) {
+ setHTMLFixture(`
+ <div class="snippet-form-holder">
+ <form>
+ <input class="js-snippet-file-name" type="text" value="${name}">
+ <input class="snippet-file-content" type="hidden" value="${content}">
+ <pre id="editor"></pre>
+ </form>
+ </div>
+ `);
+ }
+
+ function bootstrap(name = '', content = '') {
+ setUpFixture(name, content);
+ editorEl = document.getElementById('editor');
+ contentEl = document.querySelector('.snippet-file-content');
+ fileNameEl = document.querySelector('.js-snippet-file-name');
+ form = document.querySelector('.snippet-form-holder form');
+
+ initEditor();
+ }
+
+ function createEvent(name) {
+ return new Event(name, {
+ view: window,
+ bubbles: true,
+ cancelable: true,
});
+ }
- afterEach(() => {
- window.gon = oldGon;
- });
+ beforeEach(() => {
+ bootstrap(mockName, mockContent);
+ });
- it('correctly initializes Editor', () => {
- expect(mockEditor.createInstance).toHaveBeenCalledWith({
- el: editorEl,
- blobPath: mockName,
- blobContent: mockContent,
- });
+ it('correctly initializes Editor', () => {
+ expect(mockEditor.createInstance).toHaveBeenCalledWith({
+ el: editorEl,
+ blobPath: mockName,
+ blobContent: mockContent,
});
+ });
- it('listens to file name changes and updates syntax highlighting of code', () => {
- expect(mockEditor.updateModelLanguage).not.toHaveBeenCalled();
+ it('listens to file name changes and updates syntax highlighting of code', () => {
+ expect(mockEditor.updateModelLanguage).not.toHaveBeenCalled();
- const event = createEvent('change');
+ const event = createEvent('change');
- fileNameEl.value = updatedMockContent;
- fileNameEl.dispatchEvent(event);
+ fileNameEl.value = updatedMockContent;
+ fileNameEl.dispatchEvent(event);
- expect(mockEditor.updateModelLanguage).toHaveBeenCalledWith(updatedMockContent);
- });
+ expect(mockEditor.updateModelLanguage).toHaveBeenCalledWith(updatedMockContent);
+ });
- it('listens to form submit event and populates the hidden field with most recent version of the content', () => {
- expect(contentEl.value).toBe(mockContent);
+ it('listens to form submit event and populates the hidden field with most recent version of the content', () => {
+ expect(contentEl.value).toBe(mockContent);
- const event = createEvent('submit');
+ const event = createEvent('submit');
- form.dispatchEvent(event);
- expect(contentEl.value).toBe(updatedMockContent);
- });
+ form.dispatchEvent(event);
+ expect(contentEl.value).toBe(updatedMockContent);
});
});
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
index b1bbe2a9710..301ec5652a9 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
@@ -12,6 +12,7 @@ exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = `
class="file-holder snippet"
>
<blob-header-edit-stub
+ data-qa-selector="snippet_file_name"
value="lorem.txt"
/>
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
index 334ceaa064f..9fd4cba5b87 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
@@ -35,8 +35,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
>
<textarea
aria-label="Description"
- class="note-textarea js-gfm-input js-autosize markdown-area
- qa-description-textarea"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-qa-selector="snippet_description_field"
data-supports-quick-actions="false"
dir="auto"
placeholder="Write a comment or drag your files here…"
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap
new file mode 100644
index 00000000000..9ebc4e81baf
--- /dev/null
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Snippet Description component matches the snapshot 1`] = `
+<markdown-field-view-stub
+ class="snippet-description"
+ data-qa-selector="snippet_description_field"
+>
+ <div
+ class="md js-snippet-description"
+ >
+ <h2>
+ The property of Thor
+ </h2>
+ </div>
+</markdown-field-view-stub>
+`;
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index 21a4ccf5a74..ba62a0a92ca 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -100,6 +100,7 @@ describe('Snippet Edit app', () => {
});
const findSubmitButton = () => wrapper.find('[type=submit]');
+ const findCancellButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
describe('rendering', () => {
it('renders loader while the query is in flight', () => {
@@ -148,6 +149,21 @@ describe('Snippet Edit app', () => {
expect(isBtnDisabled).toBe(expectation);
},
);
+
+ it.each`
+ isNew | status | expectation
+ ${true} | ${`new`} | ${`/snippets`}
+ ${false} | ${`existing`} | ${newlyEditedSnippetUrl}
+ `('sets correct href for the cancel button on a $status snippet', ({ isNew, expectation }) => {
+ createComponent({
+ data: {
+ snippet: { webUrl: newlyEditedSnippetUrl },
+ newSnippet: isNew,
+ },
+ });
+
+ expect(findCancellButton().attributes('href')).toBe(expectation);
+ });
});
describe('functionality', () => {
diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js
index 1f6038bc7f0..d06489cffa9 100644
--- a/spec/frontend/snippets/components/snippet_blob_view_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js
@@ -3,6 +3,7 @@ import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import BlobContent from '~/blob/components/blob_content.vue';
+import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from '~/blob/components/constants';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import {
SNIPPET_VISIBILITY_PRIVATE,
@@ -29,6 +30,8 @@ describe('Blob Embeddable', () => {
queries: {
blobContent: {
loading: contentLoading,
+ refetch: jest.fn(),
+ skip: true,
},
},
};
@@ -84,9 +87,7 @@ describe('Blob Embeddable', () => {
});
it('sets rich viewer correctly', () => {
- const data = Object.assign({}, dataMock, {
- activeViewerType: RichViewerMock.type,
- });
+ const data = { ...dataMock, activeViewerType: RichViewerMock.type };
createComponent({}, data);
expect(wrapper.find(RichViewer).exists()).toBe(true);
});
@@ -145,4 +146,35 @@ describe('Blob Embeddable', () => {
});
});
});
+
+ describe('functionality', () => {
+ describe('render error', () => {
+ const findContentEl = () => wrapper.find(BlobContent);
+
+ it('correctly sets blob on the blob-content-error component', () => {
+ createComponent();
+ expect(findContentEl().props('blob')).toEqual(BlobMock);
+ });
+
+ it(`refetches blob content on ${BLOB_RENDER_EVENT_LOAD} event`, () => {
+ createComponent();
+
+ expect(wrapper.vm.$apollo.queries.blobContent.refetch).not.toHaveBeenCalled();
+ findContentEl().vm.$emit(BLOB_RENDER_EVENT_LOAD);
+ expect(wrapper.vm.$apollo.queries.blobContent.refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it(`sets '${SimpleViewerMock.type}' as active on ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => {
+ createComponent(
+ {},
+ {
+ activeViewerType: RichViewerMock.type,
+ },
+ );
+
+ findContentEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE);
+ expect(wrapper.vm.activeViewerType).toEqual(SimpleViewerMock.type);
+ });
+ });
+ });
});
diff --git a/spec/frontend/snippets/components/snippet_description_view_spec.js b/spec/frontend/snippets/components/snippet_description_view_spec.js
new file mode 100644
index 00000000000..46467ef311e
--- /dev/null
+++ b/spec/frontend/snippets/components/snippet_description_view_spec.js
@@ -0,0 +1,27 @@
+import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
+import { shallowMount } from '@vue/test-utils';
+
+describe('Snippet Description component', () => {
+ let wrapper;
+ const description = '<h2>The property of Thor</h2>';
+
+ function createComponent() {
+ wrapper = shallowMount(SnippetDescription, {
+ propsData: {
+ description,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('matches the snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js
index 16a66c70d6a..5230910b6f5 100644
--- a/spec/frontend/snippets/components/snippet_header_spec.js
+++ b/spec/frontend/snippets/components/snippet_header_spec.js
@@ -7,26 +7,27 @@ import { shallowMount } from '@vue/test-utils';
describe('Snippet header component', () => {
let wrapper;
const snippet = {
- snippet: {
- id: 'gid://gitlab/PersonalSnippet/50',
- title: 'The property of Thor',
- visibilityLevel: 'private',
- webUrl: 'http://personal.dev.null/42',
- userPermissions: {
- adminSnippet: true,
- updateSnippet: true,
- reportSnippet: false,
- },
- project: null,
- author: {
- name: 'Thor Odinson',
- },
+ id: 'gid://gitlab/PersonalSnippet/50',
+ title: 'The property of Thor',
+ visibilityLevel: 'private',
+ webUrl: 'http://personal.dev.null/42',
+ userPermissions: {
+ adminSnippet: true,
+ updateSnippet: true,
+ reportSnippet: false,
+ },
+ project: null,
+ author: {
+ name: 'Thor Odinson',
+ },
+ blob: {
+ binary: false,
},
};
const mutationVariables = {
mutation: DeleteSnippetMutation,
variables: {
- id: snippet.snippet.id,
+ id: snippet.id,
},
};
const errorMsg = 'Foo bar';
@@ -46,10 +47,12 @@ describe('Snippet header component', () => {
loading = false,
permissions = {},
mutationRes = mutationTypes.RESOLVE,
+ snippetProps = {},
} = {}) {
- const defaultProps = Object.assign({}, snippet);
+ // const defaultProps = Object.assign({}, snippet, snippetProps);
+ const defaultProps = Object.assign(snippet, snippetProps);
if (permissions) {
- Object.assign(defaultProps.snippet.userPermissions, {
+ Object.assign(defaultProps.userPermissions, {
...permissions,
});
}
@@ -65,7 +68,9 @@ describe('Snippet header component', () => {
wrapper = shallowMount(SnippetHeader, {
mocks: { $apollo },
propsData: {
- ...defaultProps,
+ snippet: {
+ ...defaultProps,
+ },
},
stubs: {
ApolloMutation,
@@ -126,6 +131,17 @@ describe('Snippet header component', () => {
expect(wrapper.find(GlModal).exists()).toBe(true);
});
+ it('renders Edit button as disabled for binary snippets', () => {
+ createComponent({
+ snippetProps: {
+ blob: {
+ binary: true,
+ },
+ },
+ });
+ expect(wrapper.find('[href*="edit"]').props('disabled')).toBe(true);
+ });
+
describe('Delete mutation', () => {
const { location } = window;
@@ -156,14 +172,34 @@ describe('Snippet header component', () => {
});
});
- it('closes modal and redirects to snippets listing in case of successful mutation', () => {
- createComponent();
- wrapper.vm.closeDeleteModal = jest.fn();
+ describe('in case of successful mutation, closes modal and redirects to correct listing', () => {
+ const createDeleteSnippet = (snippetProps = {}) => {
+ createComponent({
+ snippetProps,
+ });
+ wrapper.vm.closeDeleteModal = jest.fn();
- wrapper.vm.deleteSnippet();
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
- expect(window.location.pathname).toEqual('dashboard/snippets');
+ wrapper.vm.deleteSnippet();
+ return wrapper.vm.$nextTick();
+ };
+
+ it('redirects to dashboard/snippets for personal snippet', () => {
+ return createDeleteSnippet().then(() => {
+ expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
+ expect(window.location.pathname).toBe('dashboard/snippets');
+ });
+ });
+
+ it('redirects to project snippets for project snippet', () => {
+ const fullPath = 'foo/bar';
+ return createDeleteSnippet({
+ project: {
+ fullPath,
+ },
+ }).then(() => {
+ expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
+ expect(window.location.pathname).toBe(`${fullPath}/snippets`);
+ });
});
});
});
diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js
index b49b2008610..88261a75f6c 100644
--- a/spec/frontend/snippets/components/snippet_title_spec.js
+++ b/spec/frontend/snippets/components/snippet_title_spec.js
@@ -1,4 +1,5 @@
import SnippetTitle from '~/snippets/components/snippet_title.vue';
+import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
@@ -16,7 +17,7 @@ describe('Snippet header component', () => {
};
function createComponent({ props = snippet } = {}) {
- const defaultProps = Object.assign({}, props);
+ const defaultProps = { ...props };
wrapper = shallowMount(SnippetTitle, {
propsData: {
@@ -36,8 +37,9 @@ describe('Snippet header component', () => {
it('renders snippets title and description', () => {
createComponent();
+
expect(wrapper.text().trim()).toContain(title);
- expect(wrapper.find('.js-snippet-description').element.innerHTML).toBe(descriptionHtml);
+ expect(wrapper.find(SnippetDescription).props('description')).toBe(descriptionHtml);
});
it('does not render recent changes time stamp if there were no updates', () => {
diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js
new file mode 100644
index 00000000000..bfe41f65d6e
--- /dev/null
+++ b/spec/frontend/static_site_editor/components/edit_area_spec.js
@@ -0,0 +1,76 @@
+import { shallowMount } from '@vue/test-utils';
+
+import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
+
+import EditArea from '~/static_site_editor/components/edit_area.vue';
+import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
+import EditHeader from '~/static_site_editor/components/edit_header.vue';
+
+import { sourceContentTitle as title, sourceContent as content, returnUrl } from '../mock_data';
+
+describe('~/static_site_editor/components/edit_area.vue', () => {
+ let wrapper;
+ const savingChanges = true;
+ const newContent = `new ${content}`;
+
+ const buildWrapper = (propsData = {}) => {
+ wrapper = shallowMount(EditArea, {
+ propsData: {
+ title,
+ content,
+ returnUrl,
+ savingChanges,
+ ...propsData,
+ },
+ });
+ };
+
+ const findEditHeader = () => wrapper.find(EditHeader);
+ const findRichContentEditor = () => wrapper.find(RichContentEditor);
+ const findPublishToolbar = () => wrapper.find(PublishToolbar);
+
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders edit header', () => {
+ expect(findEditHeader().exists()).toBe(true);
+ expect(findEditHeader().props('title')).toBe(title);
+ });
+
+ it('renders rich content editor', () => {
+ expect(findRichContentEditor().exists()).toBe(true);
+ expect(findRichContentEditor().props('value')).toBe(content);
+ });
+
+ it('renders publish toolbar', () => {
+ expect(findPublishToolbar().exists()).toBe(true);
+ expect(findPublishToolbar().props('returnUrl')).toBe(returnUrl);
+ expect(findPublishToolbar().props('savingChanges')).toBe(savingChanges);
+ expect(findPublishToolbar().props('saveable')).toBe(false);
+ });
+
+ describe('when content changes', () => {
+ beforeEach(() => {
+ findRichContentEditor().vm.$emit('input', newContent);
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('sets publish toolbar as saveable when content changes', () => {
+ expect(findPublishToolbar().props('saveable')).toBe(true);
+ });
+
+ it('sets publish toolbar as not saveable when content changes are rollback', () => {
+ findRichContentEditor().vm.$emit('input', content);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findPublishToolbar().props('saveable')).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js
index 82eb12d4c4d..5428ed23266 100644
--- a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js
+++ b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
@@ -19,7 +19,6 @@ describe('Static Site Editor Toolbar', () => {
const findReturnUrlLink = () => wrapper.find({ ref: 'returnUrlLink' });
const findSaveChangesButton = () => wrapper.find(GlButton);
- const findLoadingIndicator = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
buildWrapper();
@@ -37,8 +36,8 @@ describe('Static Site Editor Toolbar', () => {
expect(findSaveChangesButton().attributes('disabled')).toBe('true');
});
- it('does not display saving changes indicator', () => {
- expect(findLoadingIndicator().classes()).toContain('invisible');
+ it('does not render the Submit Changes button with a loader', () => {
+ expect(findSaveChangesButton().props('loading')).toBe(false);
});
it('does not render returnUrl link', () => {
@@ -62,15 +61,11 @@ describe('Static Site Editor Toolbar', () => {
describe('when saving changes', () => {
beforeEach(() => {
- buildWrapper({ saveable: true, savingChanges: true });
+ buildWrapper({ savingChanges: true });
});
- it('disables Submit Changes button', () => {
- expect(findSaveChangesButton().attributes('disabled')).toBe('true');
- });
-
- it('displays saving changes indicator', () => {
- expect(findLoadingIndicator().classes()).not.toContain('invisible');
+ it('renders the Submit Changes button with a loading indicator', () => {
+ expect(findSaveChangesButton().props('loading')).toBe(true);
});
});
diff --git a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js b/spec/frontend/static_site_editor/components/saved_changes_message_spec.js
index 659e9be59d2..a63c3a83395 100644
--- a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js
+++ b/spec/frontend/static_site_editor/components/saved_changes_message_spec.js
@@ -46,14 +46,11 @@ describe('~/static_site_editor/components/saved_changes_message.vue', () => {
${'branch'} | ${findBranchLink} | ${props.branch}
${'commit'} | ${findCommitLink} | ${props.commit}
${'merge request'} | ${findMergeRequestLink} | ${props.mergeRequest}
- `('renders $desc link', ({ desc, findEl, prop }) => {
+ `('renders $desc link', ({ findEl, prop }) => {
const el = findEl();
expect(el.exists()).toBe(true);
expect(el.text()).toBe(prop.label);
-
- if (desc !== 'branch') {
- expect(el.attributes('href')).toBe(prop.url);
- }
+ expect(el.attributes('href')).toBe(prop.url);
});
});
diff --git a/spec/frontend/static_site_editor/components/static_site_editor_spec.js b/spec/frontend/static_site_editor/components/static_site_editor_spec.js
deleted file mode 100644
index 5d4e3758557..00000000000
--- a/spec/frontend/static_site_editor/components/static_site_editor_spec.js
+++ /dev/null
@@ -1,247 +0,0 @@
-import Vuex from 'vuex';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlSkeletonLoader } from '@gitlab/ui';
-
-import createState from '~/static_site_editor/store/state';
-
-import StaticSiteEditor from '~/static_site_editor/components/static_site_editor.vue';
-import EditArea from '~/static_site_editor/components/edit_area.vue';
-import EditHeader from '~/static_site_editor/components/edit_header.vue';
-import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue';
-import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
-import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue';
-import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue';
-
-import {
- returnUrl,
- sourceContent,
- sourceContentTitle,
- savedContentMeta,
- submitChangesError,
-} from '../mock_data';
-
-const localVue = createLocalVue();
-
-localVue.use(Vuex);
-
-describe('StaticSiteEditor', () => {
- let wrapper;
- let store;
- let loadContentActionMock;
- let setContentActionMock;
- let submitChangesActionMock;
- let dismissSubmitChangesErrorActionMock;
-
- const buildStore = ({ initialState, getters } = {}) => {
- loadContentActionMock = jest.fn();
- setContentActionMock = jest.fn();
- submitChangesActionMock = jest.fn();
- dismissSubmitChangesErrorActionMock = jest.fn();
-
- store = new Vuex.Store({
- state: createState({
- isSupportedContent: true,
- ...initialState,
- }),
- getters: {
- contentChanged: () => false,
- ...getters,
- },
- actions: {
- loadContent: loadContentActionMock,
- setContent: setContentActionMock,
- submitChanges: submitChangesActionMock,
- dismissSubmitChangesError: dismissSubmitChangesErrorActionMock,
- },
- });
- };
- const buildContentLoadedStore = ({ initialState, getters } = {}) => {
- buildStore({
- initialState: {
- isContentLoaded: true,
- ...initialState,
- },
- getters: {
- ...getters,
- },
- });
- };
-
- const buildWrapper = () => {
- wrapper = shallowMount(StaticSiteEditor, {
- localVue,
- store,
- });
- };
-
- const findEditArea = () => wrapper.find(EditArea);
- const findEditHeader = () => wrapper.find(EditHeader);
- const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage);
- const findPublishToolbar = () => wrapper.find(PublishToolbar);
- const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
- const findSubmitChangesError = () => wrapper.find(SubmitChangesError);
- const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage);
-
- beforeEach(() => {
- buildStore();
- buildWrapper();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders the saved changes message when changes are submitted successfully', () => {
- buildStore({ initialState: { returnUrl, savedContentMeta } });
- buildWrapper();
-
- expect(findSavedChangesMessage().exists()).toBe(true);
- expect(findSavedChangesMessage().props()).toEqual({
- returnUrl,
- ...savedContentMeta,
- });
- });
-
- describe('when content is not loaded', () => {
- it('does not render edit area', () => {
- expect(findEditArea().exists()).toBe(false);
- });
-
- it('does not render edit header', () => {
- expect(findEditHeader().exists()).toBe(false);
- });
-
- it('does not render toolbar', () => {
- expect(findPublishToolbar().exists()).toBe(false);
- });
-
- it('does not render saved changes message', () => {
- expect(findSavedChangesMessage().exists()).toBe(false);
- });
- });
-
- describe('when content is loaded', () => {
- const content = sourceContent;
- const title = sourceContentTitle;
-
- beforeEach(() => {
- buildContentLoadedStore({ initialState: { content, title } });
- buildWrapper();
- });
-
- it('renders the edit area', () => {
- expect(findEditArea().exists()).toBe(true);
- });
-
- it('renders the edit header', () => {
- expect(findEditHeader().exists()).toBe(true);
- });
-
- it('does not render skeleton loader', () => {
- expect(findSkeletonLoader().exists()).toBe(false);
- });
-
- it('passes page content to edit area', () => {
- expect(findEditArea().props('value')).toBe(content);
- });
-
- it('passes page title to edit header', () => {
- expect(findEditHeader().props('title')).toBe(title);
- });
-
- it('renders toolbar', () => {
- expect(findPublishToolbar().exists()).toBe(true);
- });
- });
-
- it('sets toolbar as saveable when content changes', () => {
- buildContentLoadedStore({
- getters: {
- contentChanged: () => true,
- },
- });
- buildWrapper();
-
- expect(findPublishToolbar().props('saveable')).toBe(true);
- });
-
- it('displays skeleton loader when loading content', () => {
- buildStore({ initialState: { isLoadingContent: true } });
- buildWrapper();
-
- expect(findSkeletonLoader().exists()).toBe(true);
- });
-
- it('does not display submit changes error when an error does not exist', () => {
- buildContentLoadedStore();
- buildWrapper();
-
- expect(findSubmitChangesError().exists()).toBe(false);
- });
-
- it('sets toolbar as saving when saving changes', () => {
- buildContentLoadedStore({
- initialState: {
- isSavingChanges: true,
- },
- });
- buildWrapper();
-
- expect(findPublishToolbar().props('savingChanges')).toBe(true);
- });
-
- it('displays invalid content message when content is not supported', () => {
- buildStore({ initialState: { isSupportedContent: false } });
- buildWrapper();
-
- expect(findInvalidContentMessage().exists()).toBe(true);
- });
-
- describe('when submitting changes fail', () => {
- beforeEach(() => {
- buildContentLoadedStore({
- initialState: {
- submitChangesError,
- },
- });
- buildWrapper();
- });
-
- it('displays submit changes error message', () => {
- expect(findSubmitChangesError().exists()).toBe(true);
- });
-
- it('dispatches submitChanges action when error message emits retry event', () => {
- findSubmitChangesError().vm.$emit('retry');
-
- expect(submitChangesActionMock).toHaveBeenCalled();
- });
-
- it('dispatches dismissSubmitChangesError action when error message emits dismiss event', () => {
- findSubmitChangesError().vm.$emit('dismiss');
-
- expect(dismissSubmitChangesErrorActionMock).toHaveBeenCalled();
- });
- });
-
- it('dispatches load content action', () => {
- expect(loadContentActionMock).toHaveBeenCalled();
- });
-
- it('dispatches setContent action when edit area emits input event', () => {
- buildContentLoadedStore();
- buildWrapper();
-
- findEditArea().vm.$emit('input', sourceContent);
-
- expect(setContentActionMock).toHaveBeenCalledWith(expect.anything(), sourceContent, undefined);
- });
-
- it('dispatches submitChanges action when toolbar emits submit event', () => {
- buildContentLoadedStore();
- buildWrapper();
- findPublishToolbar().vm.$emit('submit');
-
- expect(submitChangesActionMock).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js
new file mode 100644
index 00000000000..8504d09e0f1
--- /dev/null
+++ b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js
@@ -0,0 +1,25 @@
+import fileResolver from '~/static_site_editor/graphql/resolvers/file';
+import loadSourceContent from '~/static_site_editor/services/load_source_content';
+
+import {
+ projectId,
+ sourcePath,
+ sourceContentTitle as title,
+ sourceContent as content,
+} from '../../mock_data';
+
+jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn());
+
+describe('static_site_editor/graphql/resolvers/file', () => {
+ it('returns file content and title when fetching file successfully', () => {
+ loadSourceContent.mockResolvedValueOnce({ title, content });
+
+ return fileResolver({ fullPath: projectId }, { path: sourcePath }).then(file => {
+ expect(file).toEqual({
+ __typename: 'File',
+ title,
+ content,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js
new file mode 100644
index 00000000000..515b5394594
--- /dev/null
+++ b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js
@@ -0,0 +1,37 @@
+import savedContentMetaQuery from '~/static_site_editor/graphql/queries/saved_content_meta.query.graphql';
+import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
+import submitContentChangesResolver from '~/static_site_editor/graphql/resolvers/submit_content_changes';
+
+import {
+ projectId as project,
+ sourcePath,
+ username,
+ sourceContent as content,
+ savedContentMeta,
+} from '../../mock_data';
+
+jest.mock('~/static_site_editor/services/submit_content_changes', () => jest.fn());
+
+describe('static_site_editor/graphql/resolvers/submit_content_changes', () => {
+ it('writes savedContentMeta query with the data returned by the submitContentChanges service', () => {
+ const cache = { writeQuery: jest.fn() };
+
+ submitContentChanges.mockResolvedValueOnce(savedContentMeta);
+
+ return submitContentChangesResolver(
+ {},
+ { input: { path: sourcePath, project, sourcePath, content, username } },
+ { cache },
+ ).then(() => {
+ expect(cache.writeQuery).toHaveBeenCalledWith({
+ query: savedContentMetaQuery,
+ data: {
+ savedContentMeta: {
+ __typename: 'SavedContentMeta',
+ ...savedContentMeta,
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js
index 962047e6dd2..371695e913e 100644
--- a/spec/frontend/static_site_editor/mock_data.js
+++ b/spec/frontend/static_site_editor/mock_data.js
@@ -34,6 +34,9 @@ export const savedContentMeta = {
};
export const submitChangesError = 'Could not save changes';
+export const commitBranchResponse = {
+ web_url: '/tree/root-master-patch-88195',
+};
export const commitMultipleResponse = {
short_id: 'ed899a2f4b5',
web_url: '/commit/ed899a2f4b5',
@@ -42,3 +45,5 @@ export const createMergeRequestResponse = {
iid: '123',
web_url: '/merge_requests/123',
};
+
+export const trackingCategory = 'projects:static_site_editor:show';
diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js
new file mode 100644
index 00000000000..8c9c54f593e
--- /dev/null
+++ b/spec/frontend/static_site_editor/pages/home_spec.js
@@ -0,0 +1,211 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Home from '~/static_site_editor/pages/home.vue';
+import SkeletonLoader from '~/static_site_editor/components/skeleton_loader.vue';
+import EditArea from '~/static_site_editor/components/edit_area.vue';
+import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue';
+import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue';
+import submitContentChangesMutation from '~/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql';
+import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants';
+
+import {
+ projectId as project,
+ returnUrl,
+ sourceContent as content,
+ sourceContentTitle as title,
+ sourcePath,
+ username,
+ savedContentMeta,
+ submitChangesError,
+} from '../mock_data';
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+
+describe('static_site_editor/pages/home', () => {
+ let wrapper;
+ let store;
+ let $apollo;
+ let $router;
+ let mutateMock;
+
+ const buildApollo = (queries = {}) => {
+ mutateMock = jest.fn();
+
+ $apollo = {
+ queries: {
+ sourceContent: {
+ loading: false,
+ },
+ ...queries,
+ },
+ mutate: mutateMock,
+ };
+ };
+
+ const buildRouter = () => {
+ $router = {
+ push: jest.fn(),
+ };
+ };
+
+ const buildWrapper = (data = {}) => {
+ wrapper = shallowMount(Home, {
+ localVue,
+ store,
+ mocks: {
+ $apollo,
+ $router,
+ },
+ data() {
+ return {
+ appData: { isSupportedContent: true, returnUrl, project, username, sourcePath },
+ sourceContent: { title, content },
+ ...data,
+ };
+ },
+ });
+ };
+
+ const findEditArea = () => wrapper.find(EditArea);
+ const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage);
+ const findSkeletonLoader = () => wrapper.find(SkeletonLoader);
+ const findSubmitChangesError = () => wrapper.find(SubmitChangesError);
+
+ beforeEach(() => {
+ buildApollo();
+ buildRouter();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ $apollo = null;
+ });
+
+ describe('when content is loaded', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders edit area', () => {
+ expect(findEditArea().exists()).toBe(true);
+ });
+
+ it('provides source content, returnUrl, and isSavingChanges to the edit area', () => {
+ expect(findEditArea().props()).toMatchObject({
+ title,
+ content,
+ returnUrl,
+ savingChanges: false,
+ });
+ });
+ });
+
+ it('does not render edit area when content is not loaded', () => {
+ buildWrapper({ sourceContent: null });
+
+ expect(findEditArea().exists()).toBe(false);
+ });
+
+ it('renders skeleton loader when content is not loading', () => {
+ buildApollo({
+ sourceContent: {
+ loading: true,
+ },
+ });
+ buildWrapper();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('does not render skeleton loader when content is not loading', () => {
+ buildApollo({
+ sourceContent: {
+ loading: false,
+ },
+ });
+ buildWrapper();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('displays invalid content message when content is not supported', () => {
+ buildWrapper({ appData: { isSupportedContent: false } });
+
+ expect(findInvalidContentMessage().exists()).toBe(true);
+ });
+
+ it('does not display invalid content message when content is supported', () => {
+ buildWrapper({ appData: { isSupportedContent: true } });
+
+ expect(findInvalidContentMessage().exists()).toBe(false);
+ });
+
+ describe('when submitting changes fails', () => {
+ beforeEach(() => {
+ mutateMock.mockRejectedValue(new Error(submitChangesError));
+
+ buildWrapper();
+ findEditArea().vm.$emit('submit', { content });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays submit changes error message', () => {
+ expect(findSubmitChangesError().exists()).toBe(true);
+ });
+
+ it('retries submitting changes when retry button is clicked', () => {
+ findSubmitChangesError().vm.$emit('retry');
+
+ expect(mutateMock).toHaveBeenCalled();
+ });
+
+ it('hides submit changes error message when dismiss button is clicked', () => {
+ findSubmitChangesError().vm.$emit('dismiss');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findSubmitChangesError().exists()).toBe(false);
+ });
+ });
+ });
+
+ it('does not display submit changes error when an error does not exist', () => {
+ buildWrapper();
+
+ expect(findSubmitChangesError().exists()).toBe(false);
+ });
+
+ describe('when submitting changes succeeds', () => {
+ const newContent = `new ${content}`;
+
+ beforeEach(() => {
+ mutateMock.mockResolvedValueOnce({ data: { submitContentChanges: savedContentMeta } });
+
+ buildWrapper();
+ findEditArea().vm.$emit('submit', { content: newContent });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('dispatches submitContentChanges mutation', () => {
+ expect(mutateMock).toHaveBeenCalledWith({
+ mutation: submitContentChangesMutation,
+ variables: {
+ input: {
+ content: newContent,
+ project,
+ sourcePath,
+ username,
+ },
+ },
+ });
+ });
+
+ it('transitions to the SUCCESS route', () => {
+ expect($router.push).toHaveBeenCalledWith(SUCCESS_ROUTE);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js
new file mode 100644
index 00000000000..d62b67bfa83
--- /dev/null
+++ b/spec/frontend/static_site_editor/pages/success_spec.js
@@ -0,0 +1,78 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Success from '~/static_site_editor/pages/success.vue';
+import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue';
+import { savedContentMeta, returnUrl } from '../mock_data';
+import { HOME_ROUTE } from '~/static_site_editor/router/constants';
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+
+describe('static_site_editor/pages/success', () => {
+ let wrapper;
+ let store;
+ let router;
+
+ const buildRouter = () => {
+ router = {
+ push: jest.fn(),
+ };
+ };
+
+ const buildWrapper = (data = {}) => {
+ wrapper = shallowMount(Success, {
+ localVue,
+ store,
+ mocks: {
+ $router: router,
+ },
+ data() {
+ return {
+ savedContentMeta,
+ appData: {
+ returnUrl,
+ },
+ ...data,
+ };
+ },
+ });
+ };
+
+ const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage);
+
+ beforeEach(() => {
+ buildRouter();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders saved changes message', () => {
+ buildWrapper();
+
+ expect(findSavedChangesMessage().exists()).toBe(true);
+ });
+
+ it('passes returnUrl to the saved changes message', () => {
+ buildWrapper();
+
+ expect(findSavedChangesMessage().props('returnUrl')).toBe(returnUrl);
+ });
+
+ it('passes saved content metadata to the saved changes message', () => {
+ buildWrapper();
+
+ expect(findSavedChangesMessage().props('branch')).toBe(savedContentMeta.branch);
+ expect(findSavedChangesMessage().props('commit')).toBe(savedContentMeta.commit);
+ expect(findSavedChangesMessage().props('mergeRequest')).toBe(savedContentMeta.mergeRequest);
+ });
+
+ it('redirects to the HOME route when content has not been submitted', () => {
+ buildWrapper({ savedContentMeta: null });
+
+ expect(router.push).toHaveBeenCalledWith(HOME_ROUTE);
+ });
+});
diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
index 9a0bd88b57d..a1e9ff4ec4c 100644
--- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
+++ b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
@@ -1,11 +1,13 @@
import Api from '~/api';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import {
DEFAULT_TARGET_BRANCH,
SUBMIT_CHANGES_BRANCH_ERROR,
SUBMIT_CHANGES_COMMIT_ERROR,
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
+ TRACKING_ACTION_CREATE_COMMIT,
} from '~/static_site_editor/constants';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
@@ -13,10 +15,12 @@ import submitContentChanges from '~/static_site_editor/services/submit_content_c
import {
username,
projectId,
+ commitBranchResponse,
commitMultipleResponse,
createMergeRequestResponse,
sourcePath,
sourceContent as content,
+ trackingCategory,
} from '../mock_data';
jest.mock('~/static_site_editor/services/generate_branch_name');
@@ -24,15 +28,26 @@ jest.mock('~/static_site_editor/services/generate_branch_name');
describe('submitContentChanges', () => {
const mergeRequestTitle = `Update ${sourcePath} file`;
const branch = 'branch-name';
+ let trackingSpy;
+ let origPage;
beforeEach(() => {
- jest.spyOn(Api, 'createBranch').mockResolvedValue();
+ jest.spyOn(Api, 'createBranch').mockResolvedValue({ data: commitBranchResponse });
jest.spyOn(Api, 'commitMultiple').mockResolvedValue({ data: commitMultipleResponse });
jest
.spyOn(Api, 'createProjectMergeRequest')
.mockResolvedValue({ data: createMergeRequestResponse });
generateBranchName.mockReturnValue(branch);
+
+ origPage = document.body.dataset.page;
+ document.body.dataset.page = trackingCategory;
+ trackingSpy = mockTracking(document.body.dataset.page, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ document.body.dataset.page = origPage;
+ unmockTracking();
});
it('creates a branch named after the username and target branch', () => {
@@ -47,7 +62,7 @@ describe('submitContentChanges', () => {
it('notifies error when branch could not be created', () => {
Api.createBranch.mockRejectedValueOnce();
- expect(submitContentChanges({ username, projectId })).rejects.toThrow(
+ return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_BRANCH_ERROR,
);
});
@@ -68,10 +83,19 @@ describe('submitContentChanges', () => {
});
});
+ it('sends the correct tracking event when committing content changes', () => {
+ return submitContentChanges({ username, projectId, sourcePath, content }).then(() => {
+ expect(trackingSpy).toHaveBeenCalledWith(
+ document.body.dataset.page,
+ TRACKING_ACTION_CREATE_COMMIT,
+ );
+ });
+ });
+
it('notifies error when content could not be committed', () => {
Api.commitMultiple.mockRejectedValueOnce();
- expect(submitContentChanges({ username, projectId })).rejects.toThrow(
+ return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_COMMIT_ERROR,
);
});
@@ -92,7 +116,7 @@ describe('submitContentChanges', () => {
it('notifies error when merge request could not be created', () => {
Api.createProjectMergeRequest.mockRejectedValueOnce();
- expect(submitContentChanges({ username, projectId })).rejects.toThrow(
+ return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
);
});
diff --git a/spec/frontend/static_site_editor/store/actions_spec.js b/spec/frontend/static_site_editor/store/actions_spec.js
deleted file mode 100644
index 6b0b77f59b7..00000000000
--- a/spec/frontend/static_site_editor/store/actions_spec.js
+++ /dev/null
@@ -1,152 +0,0 @@
-import testAction from 'helpers/vuex_action_helper';
-import createState from '~/static_site_editor/store/state';
-import * as actions from '~/static_site_editor/store/actions';
-import * as mutationTypes from '~/static_site_editor/store/mutation_types';
-import loadSourceContent from '~/static_site_editor/services/load_source_content';
-import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
-
-import createFlash from '~/flash';
-
-import {
- username,
- projectId,
- sourcePath,
- sourceContentTitle as title,
- sourceContent as content,
- savedContentMeta,
- submitChangesError,
-} from '../mock_data';
-
-jest.mock('~/flash');
-jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn());
-jest.mock('~/static_site_editor/services/submit_content_changes', () => jest.fn());
-
-describe('Static Site Editor Store actions', () => {
- let state;
-
- beforeEach(() => {
- state = createState({
- projectId,
- sourcePath,
- });
- });
-
- describe('loadContent', () => {
- describe('on success', () => {
- const payload = { title, content };
-
- beforeEach(() => {
- loadSourceContent.mockResolvedValueOnce(payload);
- });
-
- it('commits receiveContentSuccess', () => {
- testAction(
- actions.loadContent,
- null,
- state,
- [
- { type: mutationTypes.LOAD_CONTENT },
- { type: mutationTypes.RECEIVE_CONTENT_SUCCESS, payload },
- ],
- [],
- );
-
- expect(loadSourceContent).toHaveBeenCalledWith({ projectId, sourcePath });
- });
- });
-
- describe('on error', () => {
- const expectedMutations = [
- { type: mutationTypes.LOAD_CONTENT },
- { type: mutationTypes.RECEIVE_CONTENT_ERROR },
- ];
-
- beforeEach(() => {
- loadSourceContent.mockRejectedValueOnce();
- });
-
- it('commits receiveContentError', () => {
- testAction(actions.loadContent, null, state, expectedMutations);
- });
-
- it('displays flash communicating error', () => {
- return testAction(actions.loadContent, null, state, expectedMutations).then(() => {
- expect(createFlash).toHaveBeenCalledWith(
- 'An error ocurred while loading your content. Please try again.',
- );
- });
- });
- });
- });
-
- describe('setContent', () => {
- it('commits setContent mutation', () => {
- testAction(actions.setContent, content, state, [
- {
- type: mutationTypes.SET_CONTENT,
- payload: content,
- },
- ]);
- });
- });
-
- describe('submitChanges', () => {
- describe('on success', () => {
- beforeEach(() => {
- state = createState({
- projectId,
- content,
- username,
- sourcePath,
- });
- submitContentChanges.mockResolvedValueOnce(savedContentMeta);
- });
-
- it('commits submitChangesSuccess mutation', () => {
- testAction(
- actions.submitChanges,
- null,
- state,
- [
- { type: mutationTypes.SUBMIT_CHANGES },
- { type: mutationTypes.SUBMIT_CHANGES_SUCCESS, payload: savedContentMeta },
- ],
- [],
- );
-
- expect(submitContentChanges).toHaveBeenCalledWith({
- username,
- projectId,
- content,
- sourcePath,
- });
- });
- });
-
- describe('on error', () => {
- const error = new Error(submitChangesError);
- const expectedMutations = [
- { type: mutationTypes.SUBMIT_CHANGES },
- { type: mutationTypes.SUBMIT_CHANGES_ERROR, payload: error.message },
- ];
-
- beforeEach(() => {
- submitContentChanges.mockRejectedValueOnce(error);
- });
-
- it('dispatches receiveContentError', () => {
- testAction(actions.submitChanges, null, state, expectedMutations);
- });
- });
- });
-
- describe('dismissSubmitChangesError', () => {
- it('commits dismissSubmitChangesError', () => {
- testAction(actions.dismissSubmitChangesError, null, state, [
- {
- type: mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR,
- },
- ]);
- });
- });
-});
diff --git a/spec/frontend/static_site_editor/store/getters_spec.js b/spec/frontend/static_site_editor/store/getters_spec.js
deleted file mode 100644
index 5793e344784..00000000000
--- a/spec/frontend/static_site_editor/store/getters_spec.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import createState from '~/static_site_editor/store/state';
-import { contentChanged } from '~/static_site_editor/store/getters';
-import { sourceContent as content } from '../mock_data';
-
-describe('Static Site Editor Store getters', () => {
- describe('contentChanged', () => {
- it('returns true when content and originalContent are different', () => {
- const state = createState({ content, originalContent: 'something else' });
-
- expect(contentChanged(state)).toBe(true);
- });
-
- it('returns false when content and originalContent are the same', () => {
- const state = createState({ content, originalContent: content });
-
- expect(contentChanged(state)).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/static_site_editor/store/mutations_spec.js b/spec/frontend/static_site_editor/store/mutations_spec.js
deleted file mode 100644
index 2441f317d90..00000000000
--- a/spec/frontend/static_site_editor/store/mutations_spec.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import createState from '~/static_site_editor/store/state';
-import mutations from '~/static_site_editor/store/mutations';
-import * as types from '~/static_site_editor/store/mutation_types';
-import {
- sourceContentTitle as title,
- sourceContent as content,
- savedContentMeta,
- submitChangesError,
-} from '../mock_data';
-
-describe('Static Site Editor Store mutations', () => {
- let state;
- const contentLoadedPayload = { title, content };
-
- beforeEach(() => {
- state = createState();
- });
-
- it.each`
- mutation | stateProperty | payload | expectedValue
- ${types.LOAD_CONTENT} | ${'isLoadingContent'} | ${undefined} | ${true}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'isLoadingContent'} | ${contentLoadedPayload} | ${false}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'isContentLoaded'} | ${contentLoadedPayload} | ${true}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'title'} | ${contentLoadedPayload} | ${title}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'content'} | ${contentLoadedPayload} | ${content}
- ${types.RECEIVE_CONTENT_SUCCESS} | ${'originalContent'} | ${contentLoadedPayload} | ${content}
- ${types.RECEIVE_CONTENT_ERROR} | ${'isLoadingContent'} | ${undefined} | ${false}
- ${types.SET_CONTENT} | ${'content'} | ${content} | ${content}
- ${types.SUBMIT_CHANGES} | ${'isSavingChanges'} | ${undefined} | ${true}
- ${types.SUBMIT_CHANGES_SUCCESS} | ${'savedContentMeta'} | ${savedContentMeta} | ${savedContentMeta}
- ${types.SUBMIT_CHANGES_SUCCESS} | ${'isSavingChanges'} | ${savedContentMeta} | ${false}
- ${types.SUBMIT_CHANGES_ERROR} | ${'isSavingChanges'} | ${undefined} | ${false}
- ${types.SUBMIT_CHANGES_ERROR} | ${'submitChangesError'} | ${submitChangesError} | ${submitChangesError}
- ${types.DISMISS_SUBMIT_CHANGES_ERROR} | ${'submitChangesError'} | ${undefined} | ${''}
- `(
- '$mutation sets $stateProperty to $expectedValue',
- ({ mutation, stateProperty, payload, expectedValue }) => {
- mutations[mutation](state, payload);
- expect(state[stateProperty]).toBe(expectedValue);
- },
- );
-
- it(`${types.SUBMIT_CHANGES_SUCCESS} sets originalContent to content current value`, () => {
- const editedContent = `${content} plus something else`;
-
- state = createState({
- originalContent: content,
- content: editedContent,
- });
- mutations[types.SUBMIT_CHANGES_SUCCESS](state);
-
- expect(state.originalContent).toBe(state.content);
- });
-});
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
index 30a8e138df2..08a26d46618 100644
--- a/spec/frontend/tracking_spec.js
+++ b/spec/frontend/tracking_spec.js
@@ -4,6 +4,7 @@ import Tracking, { initUserTracking } from '~/tracking';
describe('Tracking', () => {
let snowplowSpy;
let bindDocumentSpy;
+ let trackLoadEventsSpy;
beforeEach(() => {
window.snowplow = window.snowplow || (() => {});
@@ -18,6 +19,7 @@ describe('Tracking', () => {
describe('initUserTracking', () => {
beforeEach(() => {
bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null);
+ trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null);
});
it('calls through to get a new tracker with the expected options', () => {
@@ -44,10 +46,11 @@ describe('Tracking', () => {
expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking');
expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking');
- window.snowplowOptions = Object.assign({}, window.snowplowOptions, {
+ window.snowplowOptions = {
+ ...window.snowplowOptions,
formTracking: true,
linkClickTracking: true,
- });
+ };
initUserTracking();
expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking');
@@ -58,6 +61,11 @@ describe('Tracking', () => {
initUserTracking();
expect(bindDocumentSpy).toHaveBeenCalled();
});
+
+ it('tracks page loaded events', () => {
+ initUserTracking();
+ expect(trackLoadEventsSpy).toHaveBeenCalled();
+ });
});
describe('.event', () => {
@@ -127,6 +135,7 @@ describe('Tracking', () => {
<input type="checkbox" data-track-event="toggle_checkbox" value="_value_" checked/>
<input class="dropdown" data-track-event="toggle_dropdown"/>
<div data-track-event="nested_event"><span class="nested"></span></div>
+ <input data-track-eventbogus="click_bogusinput" data-track-label="_label_" value="_value_"/>
`);
});
@@ -139,6 +148,12 @@ describe('Tracking', () => {
});
});
+ it('does not bind to clicks on elements without [data-track-event]', () => {
+ trigger('[data-track-eventbogus="click_bogusinput"]');
+
+ expect(eventSpy).not.toHaveBeenCalled();
+ });
+
it('allows value override with the data-track-value attribute', () => {
trigger('[data-track-event="click_input2"]');
@@ -178,6 +193,44 @@ describe('Tracking', () => {
});
});
+ describe('tracking page loaded events', () => {
+ let eventSpy;
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ setHTMLFixture(`
+ <input data-track-event="render" data-track-label="label1" value="_value_" data-track-property="_property_"/>
+ <span data-track-event="render" data-track-label="label2" data-track-value="_value_">
+ Something
+ </span>
+ <input data-track-event="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/>
+ `);
+ Tracking.trackLoadEvents('_category_'); // only happens once
+ });
+
+ it('sends tracking events when [data-track-event="render"] is on an element', () => {
+ expect(eventSpy.mock.calls).toEqual([
+ [
+ '_category_',
+ 'render',
+ {
+ label: 'label1',
+ value: '_value_',
+ property: '_property_',
+ },
+ ],
+ [
+ '_category_',
+ 'render',
+ {
+ label: 'label2',
+ value: '_value_',
+ },
+ ],
+ ]);
+ });
+ });
+
describe('tracking mixin', () => {
describe('trackingOptions', () => {
it('return the options defined on initialisation', () => {
diff --git a/spec/frontend/users_select/utils_spec.js b/spec/frontend/users_select/utils_spec.js
new file mode 100644
index 00000000000..a09935d8a04
--- /dev/null
+++ b/spec/frontend/users_select/utils_spec.js
@@ -0,0 +1,33 @@
+import $ from 'jquery';
+import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from '~/users_select/utils';
+
+const options = {
+ fooBar: 'baz',
+ activeUserId: 1,
+};
+
+describe('getAjaxUsersSelectOptions', () => {
+ it('returns options built from select data attributes', () => {
+ const $select = $('<select />', { 'data-foo-bar': 'baz', 'data-user-id': 1 });
+
+ expect(
+ getAjaxUsersSelectOptions($select, { fooBar: 'fooBar', activeUserId: 'user-id' }),
+ ).toEqual(options);
+ });
+});
+
+describe('getAjaxUsersSelectParams', () => {
+ it('returns query parameters built from provided options', () => {
+ expect(
+ getAjaxUsersSelectParams(options, {
+ foo_bar: 'fooBar',
+ active_user_id: 'activeUserId',
+ non_existent_key: 'nonExistentKey',
+ }),
+ ).toEqual({
+ foo_bar: 'baz',
+ active_user_id: 1,
+ non_existent_key: null,
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
index a7ecb863cfb..8a604355625 100644
--- a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
@@ -61,7 +61,7 @@ describe('Merge Request Collapsible Extension', () => {
describe('while loading', () => {
beforeEach(() => {
- mountComponent(Object.assign({}, data, { isLoading: true }));
+ mountComponent({ ...data, isLoading: true });
});
it('renders the buttons disabled', () => {
@@ -86,7 +86,7 @@ describe('Merge Request Collapsible Extension', () => {
describe('with error', () => {
beforeEach(() => {
- mountComponent(Object.assign({}, data, { hasError: true }));
+ mountComponent({ ...data, hasError: true });
});
it('does not render the buttons', () => {
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
new file mode 100644
index 00000000000..5f3a8654990
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
@@ -0,0 +1,100 @@
+import { mount } from '@vue/test-utils';
+import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue';
+import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue';
+import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue';
+import { mockStore } from '../mock_data';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+
+describe('MrWidgetPipelineContainer', () => {
+ let wrapper;
+ let mock;
+
+ const factory = (props = {}) => {
+ wrapper = mount(MrWidgetPipelineContainer, {
+ propsData: {
+ mr: { ...mockStore },
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet().reply(200, {});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when pre merge', () => {
+ beforeEach(() => {
+ factory();
+ });
+
+ it('renders pipeline', () => {
+ expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true);
+ expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({
+ pipeline: mockStore.pipeline,
+ pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
+ ciStatus: mockStore.ciStatus,
+ hasCi: mockStore.hasCI,
+ sourceBranch: mockStore.sourceBranch,
+ sourceBranchLink: mockStore.sourceBranchLink,
+ });
+ });
+
+ it('renders deployments', () => {
+ const expectedProps = mockStore.deployments.map(dep =>
+ expect.objectContaining({
+ deployment: dep,
+ showMetrics: false,
+ }),
+ );
+
+ const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment');
+
+ expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps);
+ });
+ });
+
+ describe('when post merge', () => {
+ beforeEach(() => {
+ factory({
+ isPostMerge: true,
+ });
+ });
+
+ it('renders pipeline', () => {
+ expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true);
+ expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({
+ pipeline: mockStore.mergePipeline,
+ pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
+ ciStatus: mockStore.ciStatus,
+ hasCi: mockStore.hasCI,
+ sourceBranch: mockStore.targetBranch,
+ sourceBranchLink: mockStore.targetBranch,
+ });
+ });
+
+ it('renders deployments', () => {
+ const expectedProps = mockStore.postMergeDeployments.map(dep =>
+ expect.objectContaining({
+ deployment: dep,
+ showMetrics: true,
+ }),
+ );
+
+ const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment');
+
+ expect(deployments.wrappers.map(x => x.props())).toEqual(expectedProps);
+ });
+ });
+
+ describe('with artifacts path', () => {
+ it('renders the artifacts app', () => {
+ expect(wrapper.find(ArtifactsApp).isVisible()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js
index 1951b56587a..91e95b2bdb1 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js
@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import MrWidgetTerraformPlan from '~/vue_merge_request_widget/components/mr_widget_terraform_plan.vue';
+import Poll from '~/lib/utils/poll';
const plan = {
create: 10,
@@ -57,11 +58,23 @@ describe('MrWidgetTerraformPlan', () => {
});
describe('successful poll', () => {
+ let pollRequest;
+ let pollStop;
+
beforeEach(() => {
+ pollRequest = jest.spyOn(Poll.prototype, 'makeRequest');
+ pollStop = jest.spyOn(Poll.prototype, 'stop');
+
mockPollingApi(200, { 'tfplan.json': plan }, {});
+
return mountWrapper();
});
+ afterEach(() => {
+ pollRequest.mockRestore();
+ pollStop.mockRestore();
+ });
+
it('content change text', () => {
expect(wrapper.find(GlSprintf).exists()).toBe(true);
});
@@ -69,6 +82,11 @@ describe('MrWidgetTerraformPlan', () => {
it('renders button when url is found', () => {
expect(wrapper.find('a').text()).toContain('View full log');
});
+
+ it('does not make additional requests after poll is successful', () => {
+ expect(pollRequest).toHaveBeenCalledTimes(1);
+ expect(pollStop).toHaveBeenCalledTimes(1);
+ });
});
describe('polling fails', () => {
diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js
new file mode 100644
index 00000000000..026ea0e4d0a
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js
@@ -0,0 +1,165 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import {
+ setEndpoint,
+ requestArtifacts,
+ clearEtagPoll,
+ stopPolling,
+ fetchArtifacts,
+ receiveArtifactsSuccess,
+ receiveArtifactsError,
+} from '~/vue_merge_request_widget/stores/artifacts_list/actions';
+import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
+import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types';
+
+describe('Artifacts App Store Actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setEndpoint', () => {
+ it('should commit SET_ENDPOINT mutation', done => {
+ testAction(
+ setEndpoint,
+ 'endpoint.json',
+ mockedState,
+ [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestArtifacts', () => {
+ it('should commit REQUEST_ARTIFACTS mutation', done => {
+ testAction(
+ requestArtifacts,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_ARTIFACTS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchArtifacts', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches requestArtifacts and receiveArtifactsSuccess ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [
+ {
+ text: 'result.txt',
+ url: 'asda',
+ job_name: 'generate-artifact',
+ job_path: 'asda',
+ },
+ ]);
+
+ testAction(
+ fetchArtifacts,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestArtifacts',
+ },
+ {
+ payload: {
+ data: [
+ {
+ text: 'result.txt',
+ url: 'asda',
+ job_name: 'generate-artifact',
+ job_path: 'asda',
+ },
+ ],
+ status: 200,
+ },
+ type: 'receiveArtifactsSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
+ });
+
+ it('dispatches requestArtifacts and receiveArtifactsError ', done => {
+ testAction(
+ fetchArtifacts,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestArtifacts',
+ },
+ {
+ type: 'receiveArtifactsError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveArtifactsSuccess', () => {
+ it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', done => {
+ testAction(
+ receiveArtifactsSuccess,
+ { data: { summary: {} }, status: 200 },
+ mockedState,
+ [{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }],
+ [],
+ done,
+ );
+ });
+
+ it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', done => {
+ testAction(
+ receiveArtifactsSuccess,
+ { data: { summary: {} }, status: 204 },
+ mockedState,
+ [],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveArtifactsError', () => {
+ it('should commit RECEIVE_ARTIFACTS_ERROR mutation', done => {
+ testAction(
+ receiveArtifactsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_ARTIFACTS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js
index 0f5d47b3bfe..e54cd345a37 100644
--- a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js
+++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js
@@ -35,10 +35,12 @@ describe('getStateKey', () => {
expect(bound()).toEqual('autoMergeEnabled');
+ context.canMerge = true;
context.isSHAMismatch = true;
expect(bound()).toEqual('shaMismatch');
+ context.canMerge = false;
context.isPipelineBlocked = true;
expect(bound()).toEqual('pipelineBlocked');
@@ -100,4 +102,26 @@ describe('getStateKey', () => {
expect(bound()).toEqual('rebase');
});
+
+ it.each`
+ canMerge | isSHAMismatch | stateKey
+ ${true} | ${true} | ${'shaMismatch'}
+ ${false} | ${true} | ${'notAllowedToMerge'}
+ ${false} | ${false} | ${'notAllowedToMerge'}
+ `(
+ 'returns $stateKey when canMerge is $canMerge and isSHAMismatch is $isSHAMismatch',
+ ({ canMerge, isSHAMismatch, stateKey }) => {
+ const bound = getStateKey.bind(
+ {
+ canMerge,
+ isSHAMismatch,
+ },
+ {
+ commits_count: 2,
+ },
+ );
+
+ expect(bound()).toEqual(stateKey);
+ },
+ );
});
diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
new file mode 100644
index 00000000000..48326eda404
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
@@ -0,0 +1,112 @@
+import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
+import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
+import mockData from '../mock_data';
+
+describe('MergeRequestStore', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new MergeRequestStore(mockData);
+ });
+
+ describe('setData', () => {
+ it('should set isSHAMismatch when the diff SHA changes', () => {
+ store.setData({ ...mockData, diff_head_sha: 'a-different-string' });
+
+ expect(store.isSHAMismatch).toBe(true);
+ });
+
+ it('should not set isSHAMismatch when other data changes', () => {
+ store.setData({ ...mockData, work_in_progress: !mockData.work_in_progress });
+
+ expect(store.isSHAMismatch).toBe(false);
+ });
+
+ it('should update cached sha after rebasing', () => {
+ store.setData({ ...mockData, diff_head_sha: 'abc123' }, true);
+
+ expect(store.isSHAMismatch).toBe(false);
+ expect(store.sha).toBe('abc123');
+ });
+
+ describe('isPipelinePassing', () => {
+ it('is true when the CI status is `success`', () => {
+ store.setData({ ...mockData, ci_status: 'success' });
+
+ expect(store.isPipelinePassing).toBe(true);
+ });
+
+ it('is true when the CI status is `success-with-warnings`', () => {
+ store.setData({ ...mockData, ci_status: 'success-with-warnings' });
+
+ expect(store.isPipelinePassing).toBe(true);
+ });
+
+ it('is false when the CI status is `failed`', () => {
+ store.setData({ ...mockData, ci_status: 'failed' });
+
+ expect(store.isPipelinePassing).toBe(false);
+ });
+
+ it('is false when the CI status is anything except `success`', () => {
+ store.setData({ ...mockData, ci_status: 'foobarbaz' });
+
+ expect(store.isPipelinePassing).toBe(false);
+ });
+ });
+
+ describe('isPipelineSkipped', () => {
+ it('should set isPipelineSkipped=true when the CI status is `skipped`', () => {
+ store.setData({ ...mockData, ci_status: 'skipped' });
+
+ expect(store.isPipelineSkipped).toBe(true);
+ });
+
+ it('should set isPipelineSkipped=false when the CI status is anything except `skipped`', () => {
+ store.setData({ ...mockData, ci_status: 'foobarbaz' });
+
+ expect(store.isPipelineSkipped).toBe(false);
+ });
+ });
+
+ describe('isNothingToMergeState', () => {
+ it('returns true when nothingToMerge', () => {
+ store.state = stateKey.nothingToMerge;
+
+ expect(store.isNothingToMergeState).toBe(true);
+ });
+
+ it('returns false when not nothingToMerge', () => {
+ store.state = 'state';
+
+ expect(store.isNothingToMergeState).toBe(false);
+ });
+ });
+ });
+
+ describe('setPaths', () => {
+ it('should set the add ci config path', () => {
+ store.setData({ ...mockData });
+
+ expect(store.mergeRequestAddCiConfigPath).toBe('/group2/project2/new/pipeline');
+ });
+
+ it('should set humanAccess=Maintainer when user has that role', () => {
+ store.setData({ ...mockData });
+
+ expect(store.humanAccess).toBe('Maintainer');
+ });
+
+ it('should set pipelinesEmptySvgPath', () => {
+ store.setData({ ...mockData });
+
+ expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg');
+ });
+
+ it('should set newPipelinePath', () => {
+ store.setData({ ...mockData });
+
+ expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
index 4cd03a690e9..408f9d57147 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
@@ -24,12 +24,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
<b-input-group-stub
tag="div"
>
- <b-input-group-prepend-stub
- tag="div"
- >
-
- <!---->
- </b-input-group-prepend-stub>
+ <!---->
<b-form-input-stub
class="gl-form-input"
@@ -44,18 +39,14 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
>
<gl-button-stub
category="tertiary"
+ class="d-inline-flex"
data-clipboard-text="ssh://foo.bar"
- icon=""
+ data-qa-selector="copy_ssh_url_button"
+ icon="copy-to-clipboard"
size="medium"
title="Copy URL"
variant="default"
- >
- <gl-icon-stub
- name="copy-to-clipboard"
- size="16"
- title="Copy URL"
- />
- </gl-button-stub>
+ />
</b-input-group-append-stub>
</b-input-group-stub>
</div>
@@ -74,12 +65,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
<b-input-group-stub
tag="div"
>
- <b-input-group-prepend-stub
- tag="div"
- >
-
- <!---->
- </b-input-group-prepend-stub>
+ <!---->
<b-form-input-stub
class="gl-form-input"
@@ -94,18 +80,14 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
>
<gl-button-stub
category="tertiary"
+ class="d-inline-flex"
data-clipboard-text="http://foo.bar"
- icon=""
+ data-qa-selector="copy_http_url_button"
+ icon="copy-to-clipboard"
size="medium"
title="Copy URL"
variant="default"
- >
- <gl-icon-stub
- name="copy-to-clipboard"
- size="16"
- title="Copy URL"
- />
- </gl-button-stub>
+ />
</b-input-group-append-stub>
</b-input-group-stub>
</div>
diff --git a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
index 5347d1efc48..db174346729 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
@@ -1,16 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Code Block matches snapshot 1`] = `
+exports[`Code Block with default props renders correctly 1`] = `
<pre
class="code-block rounded"
>
-
<code
class="d-block"
>
test-code
</code>
-
+</pre>
+`;
+exports[`Code Block with maxHeight set to "200px" renders correctly 1`] = `
+<pre
+ class="code-block rounded"
+ style="max-height: 200px; overflow-y: auto;"
+>
+ <code
+ class="d-block"
+ >
+ test-code
+ </code>
</pre>
`;
diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap
index 72370cb5b52..1d8e04b83a3 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap
@@ -1,6 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Identicon matches snapshot 1`] = `
+exports[`Identicon entity id is a GraphQL id matches snapshot 1`] = `
+<div
+ class="avatar identicon s40 bg2"
+>
+
+ E
+
+</div>
+`;
+
+exports[`Identicon entity id is a number matches snapshot 1`] = `
<div
class="avatar identicon s40 bg2"
>
diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js
index bb3e60ab9e2..0abb72ace2e 100644
--- a/spec/frontend/vue_shared/components/awards_list_spec.js
+++ b/spec/frontend/vue_shared/components/awards_list_spec.js
@@ -210,4 +210,46 @@ describe('vue_shared/components/awards_list', () => {
expect(buttons.wrappers.every(x => x.classes('disabled'))).toBe(true);
});
});
+
+ describe('with default awards', () => {
+ beforeEach(() => {
+ createComponent({
+ awards: [createAward(EMOJI_SMILE, USERS.marie), createAward(EMOJI_100, USERS.marie)],
+ canAwardEmoji: true,
+ currentUserId: USERS.root.id,
+ // Let's assert that it puts thumbsup and thumbsdown in the right order still
+ defaultAwards: [EMOJI_THUMBSDOWN, EMOJI_100, EMOJI_THUMBSUP],
+ });
+ });
+
+ it('shows awards in correct order', () => {
+ expect(findAwardsData()).toEqual([
+ {
+ classes: ['btn', 'award-control'],
+ count: 0,
+ html: matchingEmojiTag(EMOJI_THUMBSUP),
+ title: '',
+ },
+ {
+ classes: ['btn', 'award-control'],
+ count: 0,
+ html: matchingEmojiTag(EMOJI_THUMBSDOWN),
+ title: '',
+ },
+ // We expect the EMOJI_100 before the EMOJI_SMILE because it was given as a defaultAward
+ {
+ classes: ['btn', 'award-control'],
+ count: 1,
+ html: matchingEmojiTag(EMOJI_100),
+ title: 'Marie',
+ },
+ {
+ classes: ['btn', 'award-control'],
+ count: 1,
+ html: matchingEmojiTag(EMOJI_SMILE),
+ title: 'Marie',
+ },
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap
index 87f2a8f9eff..4909d2d4226 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap
+++ b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap
@@ -2,7 +2,8 @@
exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = `
<div
- class="file-content code js-syntax-highlight qa-file-content"
+ class="file-content code js-syntax-highlight"
+ data-qa-selector="file_content"
>
<div
class="line-numbers"
diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
index ce3f289eb6e..5cf42ecdc1d 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
@@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue';
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
import { handleBlobRichViewer } from '~/blob/viewer';
jest.mock('~/blob/viewer');
@@ -33,4 +34,8 @@ describe('Blob Rich Viewer component', () => {
it('queries for advanced viewer', () => {
expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType);
});
+
+ it('is using Markdown View Field', () => {
+ expect(wrapper.contains(MarkdownFieldView)).toBe(true);
+ });
});
diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js
new file mode 100644
index 00000000000..f656bb0b60d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js
@@ -0,0 +1,100 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import ciBadge from '~/vue_shared/components/ci_badge_link.vue';
+
+describe('CI Badge Link Component', () => {
+ let CIBadge;
+ let vm;
+
+ const statuses = {
+ canceled: {
+ text: 'canceled',
+ label: 'canceled',
+ group: 'canceled',
+ icon: 'status_canceled',
+ details_path: 'status/canceled',
+ },
+ created: {
+ text: 'created',
+ label: 'created',
+ group: 'created',
+ icon: 'status_created',
+ details_path: 'status/created',
+ },
+ failed: {
+ text: 'failed',
+ label: 'failed',
+ group: 'failed',
+ icon: 'status_failed',
+ details_path: 'status/failed',
+ },
+ manual: {
+ text: 'manual',
+ label: 'manual action',
+ group: 'manual',
+ icon: 'status_manual',
+ details_path: 'status/manual',
+ },
+ pending: {
+ text: 'pending',
+ label: 'pending',
+ group: 'pending',
+ icon: 'status_pending',
+ details_path: 'status/pending',
+ },
+ running: {
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ icon: 'status_running',
+ details_path: 'status/running',
+ },
+ skipped: {
+ text: 'skipped',
+ label: 'skipped',
+ group: 'skipped',
+ icon: 'status_skipped',
+ details_path: 'status/skipped',
+ },
+ success_warining: {
+ text: 'passed',
+ label: 'passed',
+ group: 'success-with-warnings',
+ icon: 'status_warning',
+ details_path: 'status/warning',
+ },
+ success: {
+ text: 'passed',
+ label: 'passed',
+ group: 'passed',
+ icon: 'status_success',
+ details_path: 'status/passed',
+ },
+ };
+
+ beforeEach(() => {
+ CIBadge = Vue.extend(ciBadge);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render each status badge', () => {
+ Object.keys(statuses).map(status => {
+ vm = mountComponent(CIBadge, { status: statuses[status] });
+
+ expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path);
+ expect(vm.$el.textContent.trim()).toEqual(statuses[status].text);
+ expect(vm.$el.getAttribute('class')).toContain(`ci-status ci-${statuses[status].group}`);
+ expect(vm.$el.querySelector('svg')).toBeDefined();
+ return vm;
+ });
+ });
+
+ it('should not render label', () => {
+ vm = mountComponent(CIBadge, { status: statuses.canceled, showText: false });
+
+ expect(vm.$el.textContent.trim()).toEqual('');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon_spec.js
new file mode 100644
index 00000000000..63afe631063
--- /dev/null
+++ b/spec/frontend/vue_shared/components/ci_icon_spec.js
@@ -0,0 +1,122 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import ciIcon from '~/vue_shared/components/ci_icon.vue';
+
+describe('CI Icon component', () => {
+ const Component = Vue.extend(ciIcon);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render a span element with an svg', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_success',
+ },
+ });
+
+ expect(vm.$el.tagName).toEqual('SPAN');
+ expect(vm.$el.querySelector('span > svg')).toBeDefined();
+ });
+
+ it('should render a success status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_success',
+ group: 'success',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-success')).toEqual(true);
+ });
+
+ it('should render a failed status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_failed',
+ group: 'failed',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-failed')).toEqual(true);
+ });
+
+ it('should render success with warnings status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_warning',
+ group: 'warning',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-warning')).toEqual(true);
+ });
+
+ it('should render pending status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_pending',
+ group: 'pending',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-pending')).toEqual(true);
+ });
+
+ it('should render running status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_running',
+ group: 'running',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-running')).toEqual(true);
+ });
+
+ it('should render created status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_created',
+ group: 'created',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-created')).toEqual(true);
+ });
+
+ it('should render skipped status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_skipped',
+ group: 'skipped',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-skipped')).toEqual(true);
+ });
+
+ it('should render canceled status', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_canceled',
+ group: 'canceled',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-canceled')).toEqual(true);
+ });
+
+ it('should render status for manual action', () => {
+ vm = mountComponent(Component, {
+ status: {
+ icon: 'status_manual',
+ group: 'manual',
+ },
+ });
+
+ expect(vm.$el.classList.contains('ci-status-icon-manual')).toEqual(true);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/code_block_spec.js b/spec/frontend/vue_shared/components/code_block_spec.js
index 0d21dd94f7c..60b0b0b566b 100644
--- a/spec/frontend/vue_shared/components/code_block_spec.js
+++ b/spec/frontend/vue_shared/components/code_block_spec.js
@@ -4,10 +4,15 @@ import CodeBlock from '~/vue_shared/components/code_block.vue';
describe('Code Block', () => {
let wrapper;
- const createComponent = () => {
+ const defaultProps = {
+ code: 'test-code',
+ };
+
+ const createComponent = (props = {}) => {
wrapper = shallowMount(CodeBlock, {
propsData: {
- code: 'test-code',
+ ...defaultProps,
+ ...props,
},
});
};
@@ -17,9 +22,23 @@ describe('Code Block', () => {
wrapper = null;
});
- it('matches snapshot', () => {
- createComponent();
+ describe('with default props', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- expect(wrapper.element).toMatchSnapshot();
+ it('renders correctly', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('with maxHeight set to "200px"', () => {
+ beforeEach(() => {
+ createComponent({ maxHeight: '200px' });
+ });
+
+ it('renders correctly', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js
new file mode 100644
index 00000000000..16e7e4dd5cc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js
@@ -0,0 +1,21 @@
+import { mount } from '@vue/test-utils';
+import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
+import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
+import '~/behaviors/markdown/render_gfm';
+
+describe('ContentViewer', () => {
+ let wrapper;
+
+ it.each`
+ path | type | selector | viewer
+ ${GREEN_BOX_IMAGE_URL} | ${'image'} | ${'img'} | ${'<image-viewer>'}
+ ${'myfile.md'} | ${'markdown'} | ${'.md-previewer'} | ${'<markdown-viewer>'}
+ ${'myfile.abc'} | ${undefined} | ${'[download]'} | ${'<download-viewer>'}
+ `('renders $viewer when file type="$type"', ({ path, type, selector }) => {
+ wrapper = mount(ContentViewer, {
+ propsData: { path, fileSize: 1024, type },
+ });
+
+ expect(wrapper.find(selector).element).toExist();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js b/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js
new file mode 100644
index 00000000000..facdaa86f84
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js
@@ -0,0 +1,20 @@
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
+
+describe('viewerInformationForPath', () => {
+ it.each`
+ path | type
+ ${'p/somefile.jpg'} | ${'image'}
+ ${'p/somefile.jpeg'} | ${'image'}
+ ${'p/somefile.bmp'} | ${'image'}
+ ${'p/somefile.ico'} | ${'image'}
+ ${'p/somefile.png'} | ${'image'}
+ ${'p/somefile.gif'} | ${'image'}
+ ${'p/somefile.md'} | ${'markdown'}
+ ${'p/md'} | ${undefined}
+ ${'p/png'} | ${undefined}
+ ${'p/md.png/a'} | ${undefined}
+ ${'p/some-file.php'} | ${undefined}
+ `('when path=$path, type=$type', ({ path, type }) => {
+ expect(viewerInformationForPath(path)?.id).toBe(type);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js
new file mode 100644
index 00000000000..b83602e7bfc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js
@@ -0,0 +1,28 @@
+import { mount } from '@vue/test-utils';
+import DownloadViewer from '~/vue_shared/components/content_viewer/viewers/download_viewer.vue';
+
+describe('DownloadViewer', () => {
+ let wrapper;
+
+ it.each`
+ path | filePath | fileSize | renderedName | renderedSize
+ ${'somepath/test.abc'} | ${undefined} | ${1024} | ${'test.abc'} | ${'1.00 KiB'}
+ ${'somepath/test.abc'} | ${undefined} | ${null} | ${'test.abc'} | ${''}
+ ${'data:application/unknown;base64,U0VMRUNU'} | ${'somepath/test.abc'} | ${2048} | ${'test.abc'} | ${'2.00 KiB'}
+ `(
+ 'renders the file name as "$renderedName" and shows size as "$renderedSize"',
+ ({ path, filePath, fileSize, renderedName, renderedSize }) => {
+ wrapper = mount(DownloadViewer, {
+ propsData: { path, filePath, fileSize },
+ });
+
+ const renderedFileInfo = wrapper.find('.file-info').text();
+
+ expect(renderedFileInfo).toContain(renderedName);
+ expect(renderedFileInfo).toContain(renderedSize);
+
+ expect(wrapper.find('.btn.btn-default').text()).toContain('Download');
+ expect(wrapper.find('.btn.btn-default').element).toHaveAttr('download', 'test.abc');
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js
index ef785b9f0f5..31e843297fa 100644
--- a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js
@@ -1,45 +1,36 @@
-import { shallowMount } from '@vue/test-utils';
-
+import { mount } from '@vue/test-utils';
import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
import ImageViewer from '~/vue_shared/components/content_viewer/viewers/image_viewer.vue';
describe('Image Viewer', () => {
- const requiredProps = {
- path: GREEN_BOX_IMAGE_URL,
- renderInfo: true,
- };
let wrapper;
- let imageInfo;
-
- function createElement({ props, includeRequired = true } = {}) {
- const data = includeRequired ? { ...requiredProps, ...props } : { ...props };
- wrapper = shallowMount(ImageViewer, {
- propsData: data,
+ it('renders image preview', () => {
+ wrapper = mount(ImageViewer, {
+ propsData: { path: GREEN_BOX_IMAGE_URL, fileSize: 1024 },
});
- imageInfo = wrapper.find('.image-info');
- }
-
- describe('file sizes', () => {
- it('should show the humanized file size when `renderInfo` is true and there is size info', () => {
- createElement({ props: { fileSize: 1024 } });
-
- expect(imageInfo.text()).toContain('1.00 KiB');
- });
-
- it('should not show the humanized file size when `renderInfo` is true and there is no size', () => {
- const FILESIZE_RE = /\d+(\.\d+)?\s*([KMGTP]i)*B/;
- createElement({ props: { fileSize: 0 } });
-
- // It shouldn't show any filesize info
- expect(imageInfo.text()).not.toMatch(FILESIZE_RE);
- });
-
- it('should not show any image information when `renderInfo` is false', () => {
- createElement({ props: { renderInfo: false } });
+ expect(wrapper.find('img').element).toHaveAttr('src', GREEN_BOX_IMAGE_URL);
+ });
- expect(imageInfo.exists()).toBe(false);
- });
+ describe('file sizes', () => {
+ it.each`
+ fileSize | renderInfo | elementExists | humanizedFileSize
+ ${1024} | ${true} | ${true} | ${'1.00 KiB'}
+ ${0} | ${true} | ${true} | ${''}
+ ${1024} | ${false} | ${false} | ${undefined}
+ `(
+ 'shows file size as "$humanizedFileSize", if fileSize=$fileSize and renderInfo=$renderInfo',
+ ({ fileSize, renderInfo, elementExists, humanizedFileSize }) => {
+ wrapper = mount(ImageViewer, {
+ propsData: { path: GREEN_BOX_IMAGE_URL, fileSize, renderInfo },
+ });
+
+ const imageInfo = wrapper.find('.image-info');
+
+ expect(imageInfo.exists()).toBe(elementExists);
+ expect(imageInfo.element?.textContent.trim()).toBe(humanizedFileSize);
+ },
+ );
});
});
diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js
new file mode 100644
index 00000000000..8d3fcdd48d2
--- /dev/null
+++ b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js
@@ -0,0 +1,114 @@
+import $ from 'jquery';
+import axios from '~/lib/utils/axios_utils';
+import MockAdapter from 'axios-mock-adapter';
+import { mount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import MarkdownViewer from '~/vue_shared/components/content_viewer/viewers/markdown_viewer.vue';
+
+describe('MarkdownViewer', () => {
+ let wrapper;
+ let mock;
+
+ const createComponent = props => {
+ wrapper = mount(MarkdownViewer, {
+ propsData: {
+ ...props,
+ path: 'test.md',
+ content: '* Test',
+ projectPath: 'testproject',
+ type: 'markdown',
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ jest.spyOn(axios, 'post');
+ jest.spyOn($.fn, 'renderGFM');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, {
+ body: '<b>testing</b> {{gl_md_img_1}}',
+ });
+ });
+
+ it('renders an animation container while the markdown is loading', () => {
+ createComponent();
+
+ expect(wrapper.find('.animation-container')).toExist();
+ });
+
+ it('renders markdown preview preview renders and loads rendered markdown from server', () => {
+ createComponent();
+
+ return waitForPromises().then(() => {
+ expect(wrapper.find('.md-previewer').text()).toContain('testing');
+ });
+ });
+
+ it('receives the filePath and commitSha as a parameters and passes them on to the server', () => {
+ createComponent({ filePath: 'foo/test.md', commitSha: 'abcdef' });
+
+ expect(axios.post).toHaveBeenCalledWith(
+ `${gon.relative_url_root}/testproject/preview_markdown`,
+ { path: 'foo/test.md', text: '* Test', ref: 'abcdef' },
+ expect.any(Object),
+ );
+ });
+
+ it.each`
+ imgSrc | imgAlt
+ ${'data:image/jpeg;base64,AAAAAA+/'} | ${'my image title'}
+ ${'data:image/jpeg;base64,AAAAAA+/'} | ${'"somebody\'s image" &'}
+ ${'hack" onclick=alert(0)'} | ${'hack" onclick=alert(0)'}
+ ${'hack\\" onclick=alert(0)'} | ${'hack\\" onclick=alert(0)'}
+ ${"hack' onclick=alert(0)"} | ${"hack' onclick=alert(0)"}
+ ${"hack'><script>alert(0)</script>"} | ${"hack'><script>alert(0)</script>"}
+ `(
+ 'transforms template tags with base64 encoded images available locally',
+ ({ imgSrc, imgAlt }) => {
+ createComponent({
+ images: {
+ '{{gl_md_img_1}}': {
+ src: imgSrc,
+ alt: imgAlt,
+ title: imgAlt,
+ },
+ },
+ });
+
+ return waitForPromises().then(() => {
+ const img = wrapper.find('.md-previewer img').element;
+
+ // if the values are the same as the input, it means
+ // they were escaped correctly
+ expect(img).toHaveAttr('src', imgSrc);
+ expect(img).toHaveAttr('alt', imgAlt);
+ expect(img).toHaveAttr('title', imgAlt);
+ });
+ },
+ );
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(500, {
+ body: 'Internal Server Error',
+ });
+ });
+ it('renders an error message if loading the markdown preview fails', () => {
+ createComponent();
+
+ return waitForPromises().then(() => {
+ expect(wrapper.find('.md-previewer').text()).toContain('error');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js
index 3a75ab2d127..98962918b49 100644
--- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js
+++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js
@@ -56,13 +56,8 @@ describe('date time picker lib', () => {
describe('stringToISODate', () => {
['', 'null', undefined, 'abc'].forEach(input => {
- it(`throws error for invalid input like ${input}`, done => {
- try {
- dateTimePickerLib.stringToISODate(input);
- } catch (e) {
- expect(e).toBeDefined();
- done();
- }
+ it(`throws error for invalid input like ${input}`, () => {
+ expect(() => dateTimePickerLib.stringToISODate(input)).toThrow();
});
});
[
diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js
new file mode 100644
index 00000000000..636508be6b6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js
@@ -0,0 +1,98 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
+import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
+
+describe('DiffViewer', () => {
+ const requiredProps = {
+ diffMode: 'replaced',
+ diffViewerMode: 'image',
+ newPath: GREEN_BOX_IMAGE_URL,
+ newSha: 'ABC',
+ oldPath: RED_BOX_IMAGE_URL,
+ oldSha: 'DEF',
+ };
+ let vm;
+
+ function createComponent(props) {
+ const DiffViewer = Vue.extend(diffViewer);
+
+ vm = mountComponent(DiffViewer, props);
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders image diff', done => {
+ window.gon = {
+ relative_url_root: '',
+ };
+
+ createComponent({ ...requiredProps, projectPath: '' });
+
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(
+ `//-/raw/DEF/${RED_BOX_IMAGE_URL}`,
+ );
+
+ expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(
+ `//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`,
+ );
+
+ done();
+ });
+ });
+
+ it('renders fallback download diff display', done => {
+ createComponent({
+ ...requiredProps,
+ diffViewerMode: 'added',
+ newPath: 'test.abc',
+ oldPath: 'testold.abc',
+ });
+
+ setImmediate(() => {
+ expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain(
+ 'testold.abc',
+ );
+
+ expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain(
+ 'Download',
+ );
+
+ expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc');
+ expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain(
+ 'Download',
+ );
+
+ done();
+ });
+ });
+
+ it('renders renamed component', () => {
+ createComponent({
+ ...requiredProps,
+ diffMode: 'renamed',
+ diffViewerMode: 'renamed',
+ newPath: 'test.abc',
+ oldPath: 'testold.abc',
+ });
+
+ expect(vm.$el.textContent).toContain('File moved');
+ });
+
+ it('renders mode changed component', () => {
+ createComponent({
+ ...requiredProps,
+ diffMode: 'mode_changed',
+ newPath: 'test.abc',
+ oldPath: 'testold.abc',
+ aMode: '123',
+ bMode: '321',
+ });
+
+ expect(vm.$el.textContent).toContain('File mode changed from 123 to 321');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
new file mode 100644
index 00000000000..892a96b76fd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
@@ -0,0 +1,81 @@
+import Vue from 'vue';
+
+import { mountComponentWithSlots } from 'helpers/vue_mount_component_helper';
+import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue';
+
+const defaultLabel = 'Select';
+const customLabel = 'Select project';
+
+const createComponent = (props, slots = {}) => {
+ const Component = Vue.extend(dropdownButtonComponent);
+
+ return mountComponentWithSlots(Component, { props, slots });
+};
+
+describe('DropdownButtonComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('dropdownToggleText', () => {
+ it('returns default toggle text', () => {
+ expect(vm.toggleText).toBe(defaultLabel);
+ });
+
+ it('returns custom toggle text when provided via props', () => {
+ const vmEmptyLabels = createComponent({ toggleText: customLabel });
+
+ expect(vmEmptyLabels.toggleText).toBe(customLabel);
+ vmEmptyLabels.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element of type `button`', () => {
+ expect(vm.$el.nodeName).toBe('BUTTON');
+ });
+
+ it('renders component container element with required data attributes', () => {
+ expect(vm.$el.dataset.abilityName).toBe(vm.abilityName);
+ expect(vm.$el.dataset.fieldName).toBe(vm.fieldName);
+ expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath);
+ expect(vm.$el.dataset.labels).toBe(vm.labelsPath);
+ expect(vm.$el.dataset.namespacePath).toBe(vm.namespace);
+ expect(vm.$el.dataset.showAny).not.toBeDefined();
+ });
+
+ it('renders dropdown toggle text element', () => {
+ const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text');
+
+ expect(dropdownToggleTextEl).not.toBeNull();
+ expect(dropdownToggleTextEl.innerText.trim()).toBe(defaultLabel);
+ });
+
+ it('renders dropdown button icon', () => {
+ const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa');
+
+ expect(dropdownIconEl).not.toBeNull();
+ expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
+ });
+
+ it('renders slot, if default slot exists', () => {
+ vm = createComponent(
+ {},
+ {
+ default: ['Lorem Ipsum Dolar'],
+ },
+ );
+
+ expect(vm.$el.querySelector('.dropdown-toggle-text')).toBeNull();
+ expect(vm.$el).toHaveText('Lorem Ipsum Dolar');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js
new file mode 100644
index 00000000000..30b8e869aab
--- /dev/null
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
+
+import { mockLabels } from './mock_data';
+
+const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => {
+ const Component = Vue.extend(dropdownHiddenInputComponent);
+
+ return mountComponent(Component, {
+ name,
+ value,
+ });
+};
+
+describe('DropdownHiddenInputComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('renders input element of type `hidden`', () => {
+ expect(vm.$el.nodeName).toBe('INPUT');
+ expect(vm.$el.getAttribute('type')).toBe('hidden');
+ expect(vm.$el.getAttribute('name')).toBe(vm.name);
+ expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/dropdown/mock_data.js b/spec/frontend/vue_shared/components/dropdown/mock_data.js
new file mode 100644
index 00000000000..b09d42da401
--- /dev/null
+++ b/spec/frontend/vue_shared/components/dropdown/mock_data.js
@@ -0,0 +1,11 @@
+export const mockLabels = [
+ {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ text_color: '#FFFFFF',
+ },
+];
+
+export default mockLabels;
diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js
new file mode 100644
index 00000000000..63f2614106d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js
@@ -0,0 +1,140 @@
+import Vue from 'vue';
+import { file } from 'jest/ide/helpers';
+import ItemComponent from '~/vue_shared/components/file_finder/item.vue';
+import createComponent from 'helpers/vue_mount_component_helper';
+
+describe('File finder item spec', () => {
+ const Component = Vue.extend(ItemComponent);
+ let vm;
+ let localFile;
+
+ beforeEach(() => {
+ localFile = {
+ ...file(),
+ name: 'test file',
+ path: 'test/file',
+ };
+
+ vm = createComponent(Component, {
+ file: localFile,
+ focused: true,
+ searchText: '',
+ index: 0,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders file name & path', () => {
+ expect(vm.$el.textContent).toContain('test file');
+ expect(vm.$el.textContent).toContain('test/file');
+ });
+
+ describe('focused', () => {
+ it('adds is-focused class', () => {
+ expect(vm.$el.classList).toContain('is-focused');
+ });
+
+ it('does not have is-focused class when not focused', done => {
+ vm.focused = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.classList).not.toContain('is-focused');
+
+ done();
+ });
+ });
+ });
+
+ describe('changed file icon', () => {
+ it('does not render when not a changed or temp file', () => {
+ expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null);
+ });
+
+ it('renders when a changed file', done => {
+ vm.file.changed = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('renders when a temp file', done => {
+ vm.file.tempFile = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
+
+ done();
+ });
+ });
+ });
+
+ it('emits event when clicked', () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('click', vm.file);
+ });
+
+ describe('path', () => {
+ let el;
+
+ beforeEach(done => {
+ vm.searchText = 'file';
+
+ el = vm.$el.querySelector('.diff-changed-file-path');
+
+ vm.$nextTick(done);
+ });
+
+ it('highlights text', () => {
+ expect(el.querySelectorAll('.highlighted').length).toBe(4);
+ });
+
+ it('adds ellipsis to long text', done => {
+ vm.file.path = new Array(70)
+ .fill()
+ .map((_, i) => `${i}-`)
+ .join('');
+
+ vm.$nextTick(() => {
+ expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`);
+ done();
+ });
+ });
+ });
+
+ describe('name', () => {
+ let el;
+
+ beforeEach(done => {
+ vm.searchText = 'file';
+
+ el = vm.$el.querySelector('.diff-changed-file-name');
+
+ vm.$nextTick(done);
+ });
+
+ it('highlights text', () => {
+ expect(el.querySelectorAll('.highlighted').length).toBe(4);
+ });
+
+ it('does not add ellipsis to long text', done => {
+ vm.file.name = new Array(70)
+ .fill()
+ .map((_, i) => `${i}-`)
+ .join('');
+
+ vm.$nextTick(() => {
+ expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js
index 732491378fa..46df2d2aaf1 100644
--- a/spec/frontend/vue_shared/components/file_row_spec.js
+++ b/spec/frontend/vue_shared/components/file_row_spec.js
@@ -91,9 +91,7 @@ describe('File row component', () => {
jest.spyOn(wrapper.vm, 'scrollIntoView');
wrapper.setProps({
- file: Object.assign({}, wrapper.props('file'), {
- active: true,
- }),
+ file: { ...wrapper.props('file'), active: true },
});
return nextTick().then(() => {
@@ -125,9 +123,7 @@ describe('File row component', () => {
it('matches the current route against encoded file URL', () => {
const fileName = 'with space';
- const rowFile = Object.assign({}, file(fileName), {
- url: `/${fileName}`,
- });
+ const rowFile = { ...file(fileName), url: `/${fileName}` };
const routerPath = `/project/${escapeFileUrl(fileName)}`;
createComponent(
{
diff --git a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js
new file mode 100644
index 00000000000..87cafa0bb8c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js
@@ -0,0 +1,190 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import component from '~/vue_shared/components/filtered_search_dropdown.vue';
+
+describe('Filtered search dropdown', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('with an empty array of items', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ items: [],
+ filterKey: '',
+ });
+ });
+
+ it('renders empty list', () => {
+ expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0);
+ });
+
+ it('renders filter input', () => {
+ expect(vm.$el.querySelector('.js-filtered-dropdown-input')).not.toBeNull();
+ });
+ });
+
+ describe('when visible numbers is less than the items length', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }],
+ visibleItems: 2,
+ filterKey: 'title',
+ });
+ });
+
+ it('it renders only the maximum number provided', () => {
+ expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2);
+ });
+ });
+
+ describe('when visible number is bigger than the items length', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }],
+ filterKey: 'title',
+ });
+ });
+
+ it('it renders the full list of items the maximum number provided', () => {
+ expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(3);
+ });
+ });
+
+ describe('while filtering', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ items: [
+ { title: 'One' },
+ { title: 'Two/three' },
+ { title: 'Three four' },
+ { title: 'Five' },
+ ],
+ filterKey: 'title',
+ });
+ });
+
+ it('updates the results to match the typed value', done => {
+ vm.$el.querySelector('.js-filtered-dropdown-input').value = 'three';
+ vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2);
+ done();
+ });
+ });
+
+ describe('when no value matches the typed one', () => {
+ it('does not render any result', done => {
+ vm.$el.querySelector('.js-filtered-dropdown-input').value = 'six';
+ vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0);
+ done();
+ });
+ });
+ });
+ });
+
+ describe('with create mode enabled', () => {
+ describe('when there are no matches', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ items: [
+ { title: 'One' },
+ { title: 'Two/three' },
+ { title: 'Three four' },
+ { title: 'Five' },
+ ],
+ filterKey: 'title',
+ showCreateMode: true,
+ });
+
+ vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven';
+ vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
+ });
+
+ it('renders a create button', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.js-dropdown-create-button')).not.toBeNull();
+ done();
+ });
+ });
+
+ it('renders computed button text', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.js-dropdown-create-button').textContent.trim()).toEqual(
+ 'Create eleven',
+ );
+ done();
+ });
+ });
+
+ describe('on click create button', () => {
+ it('emits createItem event with the filter', done => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ vm.$nextTick(() => {
+ vm.$el.querySelector('.js-dropdown-create-button').click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('createItem', 'eleven');
+ done();
+ });
+ });
+ });
+ });
+
+ describe('when there are matches', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ items: [
+ { title: 'One' },
+ { title: 'Two/three' },
+ { title: 'Three four' },
+ { title: 'Five' },
+ ],
+ filterKey: 'title',
+ showCreateMode: true,
+ });
+
+ vm.$el.querySelector('.js-filtered-dropdown-input').value = 'one';
+ vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
+ });
+
+ it('does not render a create button', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull();
+ done();
+ });
+ });
+ });
+ });
+
+ describe('with create mode disabled', () => {
+ describe('when there are no matches', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ items: [
+ { title: 'One' },
+ { title: 'Two/three' },
+ { title: 'Three four' },
+ { title: 'Five' },
+ ],
+ filterKey: 'title',
+ });
+
+ vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven';
+ vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
+ });
+
+ it('does not render a create button', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull();
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js
new file mode 100644
index 00000000000..365c9fad478
--- /dev/null
+++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js
@@ -0,0 +1,83 @@
+import mountComponent from 'helpers/vue_mount_component_helper';
+import Vue from 'vue';
+import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+
+describe('GlCountdown', () => {
+ const Component = Vue.extend(GlCountdown);
+ let vm;
+ let now = '2000-01-01T00:00:00Z';
+
+ beforeEach(() => {
+ jest.spyOn(Date, 'now').mockImplementation(() => new Date(now).getTime());
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ jest.clearAllTimers();
+ });
+
+ describe('when there is time remaining', () => {
+ beforeEach(done => {
+ vm = mountComponent(Component, {
+ endDateString: '2000-01-01T01:02:03Z',
+ });
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays remaining time', () => {
+ expect(vm.$el.textContent).toContain('01:02:03');
+ });
+
+ it('updates remaining time', done => {
+ now = '2000-01-01T00:00:01Z';
+ jest.advanceTimersByTime(1000);
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.textContent).toContain('01:02:02');
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('when there is no time remaining', () => {
+ beforeEach(done => {
+ vm = mountComponent(Component, {
+ endDateString: '1900-01-01T00:00:00Z',
+ });
+
+ Vue.nextTick()
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays 00:00:00', () => {
+ expect(vm.$el.textContent).toContain('00:00:00');
+ });
+ });
+
+ describe('when an invalid date is passed', () => {
+ beforeEach(() => {
+ Vue.config.warnHandler = jest.fn();
+ });
+
+ afterEach(() => {
+ Vue.config.warnHandler = null;
+ });
+
+ it('throws a validation error', () => {
+ vm = mountComponent(Component, {
+ endDateString: 'this is invalid',
+ });
+
+ expect(Vue.config.warnHandler).toHaveBeenCalledTimes(1);
+ const [errorMessage] = Vue.config.warnHandler.mock.calls[0];
+
+ expect(errorMessage).toMatch(/^Invalid prop: .* "endDateString"/);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js
new file mode 100644
index 00000000000..216563165d6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper';
+import headerCi from '~/vue_shared/components/header_ci_component.vue';
+
+describe('Header CI Component', () => {
+ let HeaderCi;
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ HeaderCi = Vue.extend(headerCi);
+ props = {
+ status: {
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ details_path: 'path',
+ },
+ itemName: 'job',
+ itemId: 123,
+ time: '2017-05-08T14:57:39.781Z',
+ user: {
+ web_url: 'path',
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatar_url: 'link',
+ },
+ hasSidebarButton: true,
+ };
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const findActionButtons = () => vm.$el.querySelector('.header-action-buttons');
+
+ describe('render', () => {
+ beforeEach(() => {
+ vm = mountComponent(HeaderCi, props);
+ });
+
+ it('should render status badge', () => {
+ expect(vm.$el.querySelector('.ci-failed')).toBeDefined();
+ expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined();
+ expect(vm.$el.querySelector('.ci-failed').getAttribute('href')).toEqual(
+ props.status.details_path,
+ );
+ });
+
+ it('should render item name and id', () => {
+ expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123');
+ });
+
+ it('should render timeago date', () => {
+ expect(vm.$el.querySelector('time')).toBeDefined();
+ });
+
+ it('should render user icon and name', () => {
+ expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name);
+ });
+
+ it('should render sidebar toggle button', () => {
+ expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
+ });
+
+ it('should not render header action buttons when empty', () => {
+ expect(findActionButtons()).toBeNull();
+ });
+ });
+
+ describe('slot', () => {
+ it('should render header action buttons', () => {
+ vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } });
+
+ const buttons = findActionButtons();
+
+ expect(buttons).not.toBeNull();
+ expect(buttons.textContent).toEqual('Test Actions');
+ });
+ });
+
+ describe('shouldRenderTriggeredLabel', () => {
+ it('should rendered created keyword when the shouldRenderTriggeredLabel is false', () => {
+ vm = mountComponent(HeaderCi, { ...props, shouldRenderTriggeredLabel: false });
+
+ expect(vm.$el.textContent).toContain('created');
+ expect(vm.$el.textContent).not.toContain('triggered');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js
index 5e8b013d480..53a55dcd6bd 100644
--- a/spec/frontend/vue_shared/components/identicon_spec.js
+++ b/spec/frontend/vue_shared/components/identicon_spec.js
@@ -4,12 +4,17 @@ import IdenticonComponent from '~/vue_shared/components/identicon.vue';
describe('Identicon', () => {
let wrapper;
- const createComponent = () => {
+ const defaultProps = {
+ entityId: 1,
+ entityName: 'entity-name',
+ sizeClass: 's40',
+ };
+
+ const createComponent = (props = {}) => {
wrapper = shallowMount(IdenticonComponent, {
propsData: {
- entityId: 1,
- entityName: 'entity-name',
- sizeClass: 's40',
+ ...defaultProps,
+ ...props,
},
});
};
@@ -19,15 +24,27 @@ describe('Identicon', () => {
wrapper = null;
});
- it('matches snapshot', () => {
- createComponent();
+ describe('entity id is a number', () => {
+ beforeEach(createComponent);
+
+ it('matches snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
- expect(wrapper.element).toMatchSnapshot();
+ it('adds a correct class to identicon', () => {
+ expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
+ });
});
- it('adds a correct class to identicon', () => {
- createComponent();
+ describe('entity id is a GraphQL id', () => {
+ beforeEach(() => createComponent({ entityId: 'gid://gitlab/Project/8' }));
+
+ it('matches snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
- expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
+ it('adds a correct class to identicon', () => {
+ expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
index 4c654e01f74..90c3fe54901 100644
--- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
+++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
@@ -36,9 +36,7 @@ describe('IssueMilestoneComponent', () => {
describe('isMilestoneStarted', () => {
it('should return `false` when milestoneStart prop is not defined', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '',
- }),
+ milestone: { ...mockMilestone, start_date: '' },
});
expect(wrapper.vm.isMilestoneStarted).toBe(false);
@@ -46,9 +44,7 @@ describe('IssueMilestoneComponent', () => {
it('should return `true` when milestone start date is past current date', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '1990-07-22',
- }),
+ milestone: { ...mockMilestone, start_date: '1990-07-22' },
});
expect(wrapper.vm.isMilestoneStarted).toBe(true);
@@ -58,9 +54,7 @@ describe('IssueMilestoneComponent', () => {
describe('isMilestonePastDue', () => {
it('should return `false` when milestoneDue prop is not defined', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- due_date: '',
- }),
+ milestone: { ...mockMilestone, due_date: '' },
});
expect(wrapper.vm.isMilestonePastDue).toBe(false);
@@ -68,9 +62,7 @@ describe('IssueMilestoneComponent', () => {
it('should return `true` when milestone due is past current date', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- due_date: '1990-07-22',
- }),
+ milestone: { ...mockMilestone, due_date: '1990-07-22' },
});
expect(wrapper.vm.isMilestonePastDue).toBe(true);
@@ -84,9 +76,7 @@ describe('IssueMilestoneComponent', () => {
it('returns string containing absolute milestone start date when due date is not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- due_date: '',
- }),
+ milestone: { ...mockMilestone, due_date: '' },
});
expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)');
@@ -94,10 +84,7 @@ describe('IssueMilestoneComponent', () => {
it('returns empty string when both milestone start and due dates are not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '',
- due_date: '',
- }),
+ milestone: { ...mockMilestone, start_date: '', due_date: '' },
});
expect(wrapper.vm.milestoneDatesAbsolute).toBe('');
@@ -107,9 +94,7 @@ describe('IssueMilestoneComponent', () => {
describe('milestoneDatesHuman', () => {
it('returns string containing milestone due date when date is yet to be due', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- due_date: `${new Date().getFullYear() + 10}-01-01`,
- }),
+ milestone: { ...mockMilestone, due_date: `${new Date().getFullYear() + 10}-01-01` },
});
expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining');
@@ -117,10 +102,7 @@ describe('IssueMilestoneComponent', () => {
it('returns string containing milestone start date when date has already started and due date is not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '1990-07-22',
- due_date: '',
- }),
+ milestone: { ...mockMilestone, start_date: '1990-07-22', due_date: '' },
});
expect(wrapper.vm.milestoneDatesHuman).toContain('Started');
@@ -128,10 +110,11 @@ describe('IssueMilestoneComponent', () => {
it('returns string containing milestone start date when date is yet to start and due date is not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
+ milestone: {
+ ...mockMilestone,
start_date: `${new Date().getFullYear() + 10}-01-01`,
due_date: '',
- }),
+ },
});
expect(wrapper.vm.milestoneDatesHuman).toContain('Starts');
@@ -139,10 +122,7 @@ describe('IssueMilestoneComponent', () => {
it('returns empty string when milestone start and due dates are not present', () => {
wrapper.setProps({
- milestone: Object.assign({}, mockMilestone, {
- start_date: '',
- due_date: '',
- }),
+ milestone: { ...mockMilestone, start_date: '', due_date: '' },
});
expect(wrapper.vm.milestoneDatesHuman).toBe('');
diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
index f7b1f041ef2..dd24ecf707d 100644
--- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
+++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
@@ -2,10 +2,7 @@ import Vue from 'vue';
import { mount } from '@vue/test-utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
-import {
- defaultAssignees,
- defaultMilestone,
-} from '../../../../javascripts/vue_shared/components/issue/related_issuable_mock_data';
+import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data';
describe('RelatedIssuableItem', () => {
let wrapper;
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index 46e269e5071..54ce1f47e28 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -9,9 +9,9 @@ const markdownPreviewPath = `${TEST_HOST}/preview`;
const markdownDocsPath = `${TEST_HOST}/docs`;
function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) {
- expect(writeLink.element.parentNode.classList.contains('active')).toEqual(isWrite);
- expect(previewLink.element.parentNode.classList.contains('active')).toEqual(!isWrite);
- expect(wrapper.find('.md-preview-holder').element.style.display).toEqual(isWrite ? 'none' : '');
+ expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite);
+ expect(previewLink.element.parentNode.classList.contains('active')).toBe(!isWrite);
+ expect(wrapper.find('.md-preview-holder').element.style.display).toBe(isWrite ? 'none' : '');
}
function createComponent() {
@@ -67,6 +67,10 @@ describe('Markdown field component', () => {
let previewLink;
let writeLink;
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
it('renders textarea inside backdrop', () => {
wrapper = createComponent();
expect(wrapper.find('.zen-backdrop textarea').element).not.toBeNull();
@@ -92,32 +96,24 @@ describe('Markdown field component', () => {
previewLink = getPreviewLink(wrapper);
previewLink.trigger('click');
- wrapper.vm.$nextTick(() => {
+ return wrapper.vm.$nextTick(() => {
expect(wrapper.find('.md-preview-holder').element.textContent.trim()).toContain(
'Loading…',
);
});
});
- it('renders markdown preview', () => {
+ it('renders markdown preview and GFM', () => {
wrapper = createComponent();
- previewLink = getPreviewLink(wrapper);
- previewLink.trigger('click');
+ const renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
- setTimeout(() => {
- expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML);
- });
- });
-
- it('renders GFM with jQuery', () => {
- wrapper = createComponent();
previewLink = getPreviewLink(wrapper);
- jest.spyOn($.fn, 'renderGFM');
previewLink.trigger('click');
return axios.waitFor(markdownPreviewPath).then(() => {
expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML);
+ expect(renderGFMSpy).toHaveBeenCalled();
});
});
@@ -176,7 +172,7 @@ describe('Markdown field component', () => {
const markdownButton = getMarkdownButton(wrapper);
markdownButton.trigger('click');
- wrapper.vm.$nextTick(() => {
+ return wrapper.vm.$nextTick(() => {
expect(textarea.value).toContain('**testing**');
});
});
@@ -188,8 +184,8 @@ describe('Markdown field component', () => {
const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5];
markdownButton.trigger('click');
- wrapper.vm.$nextTick(() => {
- expect(textarea.value).toContain('* testing');
+ return wrapper.vm.$nextTick(() => {
+ expect(textarea.value).toContain('* testing');
});
});
@@ -200,7 +196,7 @@ describe('Markdown field component', () => {
const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5];
markdownButton.trigger('click');
- wrapper.vm.$nextTick(() => {
+ return wrapper.vm.$nextTick(() => {
expect(textarea.value).toContain('* testing\n* 123');
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/field_view_spec.js b/spec/frontend/vue_shared/components/markdown/field_view_spec.js
new file mode 100644
index 00000000000..80cf1f655c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/field_view_spec.js
@@ -0,0 +1,26 @@
+import $ from 'jquery';
+import { shallowMount } from '@vue/test-utils';
+
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
+
+describe('Markdown Field View component', () => {
+ let renderGFMSpy;
+ let wrapper;
+
+ function createComponent() {
+ wrapper = shallowMount(MarkdownFieldView);
+ }
+
+ beforeEach(() => {
+ renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('processes rendering with GFM', () => {
+ expect(renderGFMSpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
new file mode 100644
index 00000000000..34ccdf38b00
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js
@@ -0,0 +1,102 @@
+import Vue from 'vue';
+import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue';
+
+const MOCK_DATA = {
+ suggestions: [
+ {
+ id: 1,
+ appliable: true,
+ applied: false,
+ current_user: {
+ can_apply: true,
+ },
+ diff_lines: [
+ {
+ can_receive_suggestion: false,
+ line_code: null,
+ meta_data: null,
+ new_line: null,
+ old_line: 5,
+ rich_text: '-test',
+ text: '-test',
+ type: 'old',
+ },
+ {
+ can_receive_suggestion: true,
+ line_code: null,
+ meta_data: null,
+ new_line: 5,
+ old_line: null,
+ rich_text: '+new test',
+ text: '+new test',
+ type: 'new',
+ },
+ ],
+ },
+ ],
+ noteHtml: `
+ <div class="suggestion">
+ <div class="line">-oldtest</div>
+ </div>
+ <div class="suggestion">
+ <div class="line">+newtest</div>
+ </div>
+ `,
+ isApplied: false,
+ helpPagePath: 'path_to_docs',
+};
+
+describe('Suggestion component', () => {
+ let vm;
+ let diffTable;
+
+ beforeEach(done => {
+ const Component = Vue.extend(SuggestionsComponent);
+
+ vm = new Component({
+ propsData: MOCK_DATA,
+ }).$mount();
+
+ diffTable = vm.generateDiff(0).$mount().$el;
+
+ jest.spyOn(vm, 'renderSuggestions').mockImplementation(() => {});
+ vm.renderSuggestions();
+ Vue.nextTick(done);
+ });
+
+ describe('mounted', () => {
+ it('renders a flash container', () => {
+ expect(vm.$el.querySelector('.js-suggestions-flash')).not.toBeNull();
+ });
+
+ it('renders a container for suggestions', () => {
+ expect(vm.$refs.container).not.toBeNull();
+ });
+
+ it('renders suggestions', () => {
+ expect(vm.renderSuggestions).toHaveBeenCalled();
+ expect(vm.$el.innerHTML.includes('oldtest')).toBe(true);
+ expect(vm.$el.innerHTML.includes('newtest')).toBe(true);
+ });
+ });
+
+ describe('generateDiff', () => {
+ it('generates a diff table', () => {
+ expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull();
+ });
+
+ it('generates a diff table that contains contents the suggested lines', () => {
+ MOCK_DATA.suggestions[0].diff_lines.forEach(line => {
+ const text = line.text.substring(1);
+
+ expect(diffTable.innerHTML.includes(text)).toBe(true);
+ });
+ });
+
+ it('generates a diff table with the correct line number for each suggested line', () => {
+ const lines = diffTable.querySelectorAll('.old_line');
+
+ expect(parseInt([...lines][0].innerHTML, 10)).toBe(5);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
new file mode 100644
index 00000000000..e7c31014bfc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import toolbar from '~/vue_shared/components/markdown/toolbar.vue';
+
+describe('toolbar', () => {
+ let vm;
+ const Toolbar = Vue.extend(toolbar);
+ const props = {
+ markdownDocsPath: '',
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('user can attach file', () => {
+ beforeEach(() => {
+ vm = mountComponent(Toolbar, props);
+ });
+
+ it('should render uploading-container', () => {
+ expect(vm.$el.querySelector('.uploading-container')).not.toBeNull();
+ });
+ });
+
+ describe('user cannot attach file', () => {
+ beforeEach(() => {
+ vm = mountComponent(Toolbar, { ...props, canAttachFile: false });
+ });
+
+ it('should not render uploading-container', () => {
+ expect(vm.$el.querySelector('.uploading-container')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/navigation_tabs_spec.js b/spec/frontend/vue_shared/components/navigation_tabs_spec.js
new file mode 100644
index 00000000000..561456d614e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/navigation_tabs_spec.js
@@ -0,0 +1,64 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import navigationTabs from '~/vue_shared/components/navigation_tabs.vue';
+
+describe('navigation tabs component', () => {
+ let vm;
+ let Component;
+ let data;
+
+ beforeEach(() => {
+ data = [
+ {
+ name: 'All',
+ scope: 'all',
+ count: 1,
+ isActive: true,
+ },
+ {
+ name: 'Pending',
+ scope: 'pending',
+ count: 0,
+ isActive: false,
+ },
+ {
+ name: 'Running',
+ scope: 'running',
+ isActive: false,
+ },
+ ];
+
+ Component = Vue.extend(navigationTabs);
+ vm = mountComponent(Component, { tabs: data, scope: 'pipelines' });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render tabs', () => {
+ expect(vm.$el.querySelectorAll('li').length).toEqual(data.length);
+ });
+
+ it('should render active tab', () => {
+ expect(vm.$el.querySelector('.active .js-pipelines-tab-all')).toBeDefined();
+ });
+
+ it('should render badge', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-all .badge').textContent.trim()).toEqual('1');
+ expect(vm.$el.querySelector('.js-pipelines-tab-pending .badge').textContent.trim()).toEqual(
+ '0',
+ );
+ });
+
+ it('should not render badge', () => {
+ expect(vm.$el.querySelector('.js-pipelines-tab-running .badge')).toEqual(null);
+ });
+
+ it('should trigger onTabClick', () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ vm.$el.querySelector('.js-pipelines-tab-pending').click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('onChangeTab', 'pending');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/pikaday_spec.js b/spec/frontend/vue_shared/components/pikaday_spec.js
new file mode 100644
index 00000000000..867bf88ff50
--- /dev/null
+++ b/spec/frontend/vue_shared/components/pikaday_spec.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import datePicker from '~/vue_shared/components/pikaday.vue';
+
+describe('datePicker', () => {
+ let vm;
+ beforeEach(() => {
+ const DatePicker = Vue.extend(datePicker);
+ vm = mountComponent(DatePicker, {
+ label: 'label',
+ });
+ });
+
+ it('should render label text', () => {
+ expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label');
+ });
+
+ it('should show calendar', () => {
+ expect(vm.$el.querySelector('.pika-single')).toBeDefined();
+ });
+
+ it('should toggle when dropdown is clicked', () => {
+ const hidePicker = jest.fn();
+ vm.$on('hidePicker', hidePicker);
+
+ vm.$el.querySelector('.dropdown-menu-toggle').click();
+
+ expect(hidePicker).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js
new file mode 100644
index 00000000000..090f8b69213
--- /dev/null
+++ b/spec/frontend/vue_shared/components/project_avatar/default_spec.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import { projectData } from 'jest/ide/mock_data';
+import { TEST_HOST } from 'spec/test_constants';
+import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
+import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue';
+
+describe('ProjectAvatarDefault component', () => {
+ const Component = Vue.extend(ProjectAvatarDefault);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ project: projectData,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders identicon if project has no avatar_url', done => {
+ const expectedText = getFirstCharacterCapitalized(projectData.name);
+
+ vm.project = {
+ ...vm.project,
+ avatar_url: null,
+ };
+
+ vm.$nextTick()
+ .then(() => {
+ const identiconEl = vm.$el.querySelector('.identicon');
+
+ expect(identiconEl).not.toBe(null);
+ expect(identiconEl.textContent.trim()).toEqual(expectedText);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders avatar image if project has avatar_url', done => {
+ const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`;
+
+ vm.project = {
+ ...vm.project,
+ avatar_url: avatarUrl,
+ };
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.avatar')).not.toBeNull();
+ expect(vm.$el.querySelector('.identicon')).toBeNull();
+ expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
new file mode 100644
index 00000000000..eb1d9e93634
--- /dev/null
+++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
@@ -0,0 +1,109 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
+
+const localVue = createLocalVue();
+
+describe('ProjectListItem component', () => {
+ const Component = localVue.extend(ProjectListItem);
+ let wrapper;
+ let vm;
+ let options;
+
+ const project = getJSONFixture('static/projects.json')[0];
+
+ beforeEach(() => {
+ options = {
+ propsData: {
+ project,
+ selected: false,
+ },
+ localVue,
+ };
+ });
+
+ afterEach(() => {
+ wrapper.vm.$destroy();
+ });
+
+ it('does not render a check mark icon if selected === false', () => {
+ wrapper = shallowMount(Component, options);
+
+ expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true);
+ });
+
+ it('renders a check mark icon if selected === true', () => {
+ options.propsData.selected = true;
+
+ wrapper = shallowMount(Component, options);
+
+ expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true);
+ });
+
+ it(`emits a "clicked" event when clicked`, () => {
+ wrapper = shallowMount(Component, options);
+ ({ vm } = wrapper);
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ wrapper.vm.onClick();
+
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('click');
+ });
+
+ it(`renders the project avatar`, () => {
+ wrapper = shallowMount(Component, options);
+
+ expect(wrapper.contains('.js-project-avatar')).toBe(true);
+ });
+
+ it(`renders a simple namespace name with a trailing slash`, () => {
+ options.propsData.project.name_with_namespace = 'a / b';
+
+ wrapper = shallowMount(Component, options);
+ const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
+
+ expect(renderedNamespace).toBe('a /');
+ });
+
+ it(`renders a properly truncated namespace with a trailing slash`, () => {
+ options.propsData.project.name_with_namespace = 'a / b / c / d / e / f';
+
+ wrapper = shallowMount(Component, options);
+ const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
+
+ expect(renderedNamespace).toBe('a / ... / e /');
+ });
+
+ it(`renders the project name`, () => {
+ options.propsData.project.name = 'my-test-project';
+
+ wrapper = shallowMount(Component, options);
+ const renderedName = trimText(wrapper.find('.js-project-name').text());
+
+ expect(renderedName).toBe('my-test-project');
+ });
+
+ it(`renders the project name with highlighting in the case of a search query match`, () => {
+ options.propsData.project.name = 'my-test-project';
+ options.propsData.matcher = 'pro';
+
+ wrapper = shallowMount(Component, options);
+ const renderedName = trimText(wrapper.find('.js-project-name').html());
+ const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject';
+
+ expect(renderedName).toContain(expected);
+ });
+
+ it('prevents search query and project name XSS', () => {
+ const alertSpy = jest.spyOn(window, 'alert');
+ options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject";
+ options.propsData.matcher = "pro<script>alert('XSS');</script>";
+
+ wrapper = shallowMount(Component, options);
+ const renderedName = trimText(wrapper.find('.js-project-name').html());
+ const expected = 'my-xss-project';
+
+ expect(renderedName).toContain(expected);
+ expect(alertSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
new file mode 100644
index 00000000000..29bced394dc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import { head } from 'lodash';
+
+import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
+import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
+
+const localVue = createLocalVue();
+
+describe('ProjectSelector component', () => {
+ let wrapper;
+ let vm;
+ const allProjects = getJSONFixture('static/projects.json');
+ const searchResults = allProjects.slice(0, 5);
+ let selected = [];
+ selected = selected.concat(allProjects.slice(0, 3)).concat(allProjects.slice(5, 8));
+
+ const findSearchInput = () => wrapper.find(GlSearchBoxByType).find('input');
+
+ beforeEach(() => {
+ wrapper = mount(Vue.extend(ProjectSelector), {
+ localVue,
+ propsData: {
+ projectSearchResults: searchResults,
+ selectedProjects: selected,
+ showNoResultsMessage: false,
+ showMinimumSearchQueryMessage: false,
+ showLoadingIndicator: false,
+ showSearchErrorMessage: false,
+ },
+ attachToDocument: true,
+ });
+
+ ({ vm } = wrapper);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders the search results', () => {
+ expect(wrapper.findAll('.js-project-list-item').length).toBe(5);
+ });
+
+ it(`triggers a search when the search input value changes`, () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ const query = 'my test query!';
+ const searchInput = findSearchInput();
+
+ searchInput.setValue(query);
+ searchInput.trigger('input');
+
+ expect(vm.$emit).toHaveBeenCalledWith('searched', query);
+ });
+
+ it(`includes a placeholder in the search box`, () => {
+ const searchInput = findSearchInput();
+
+ expect(searchInput.attributes('placeholder')).toBe('Search your projects');
+ });
+
+ it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached');
+
+ expect(vm.$emit).toHaveBeenCalledWith('bottomReached');
+ });
+
+ it(`triggers a "projectClicked" event when a project is clicked`, () => {
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ wrapper.find(ProjectListItem).vm.$emit('click', head(searchResults));
+
+ expect(vm.$emit).toHaveBeenCalledWith('projectClicked', head(searchResults));
+ });
+
+ it(`shows a "no results" message if showNoResultsMessage === true`, () => {
+ wrapper.setProps({ showNoResultsMessage: true });
+
+ return vm.$nextTick().then(() => {
+ const noResultsEl = wrapper.find('.js-no-results-message');
+
+ expect(noResultsEl.exists()).toBe(true);
+ expect(trimText(noResultsEl.text())).toEqual('Sorry, no projects matched your search');
+ });
+ });
+
+ it(`shows a "minimum search query" message if showMinimumSearchQueryMessage === true`, () => {
+ wrapper.setProps({ showMinimumSearchQueryMessage: true });
+
+ return vm.$nextTick().then(() => {
+ const minimumSearchEl = wrapper.find('.js-minimum-search-query-message');
+
+ expect(minimumSearchEl.exists()).toBe(true);
+ expect(trimText(minimumSearchEl.text())).toEqual('Enter at least three characters to search');
+ });
+ });
+
+ it(`shows a error message if showSearchErrorMessage === true`, () => {
+ wrapper.setProps({ showSearchErrorMessage: true });
+
+ return vm.$nextTick().then(() => {
+ const errorMessageEl = wrapper.find('.js-search-error-message');
+
+ expect(errorMessageEl.exists()).toBe(true);
+ expect(trimText(errorMessageEl.text())).toEqual(
+ 'Something went wrong, unable to search projects',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
new file mode 100644
index 00000000000..549d89171c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
+import {
+ EDITOR_OPTIONS,
+ EDITOR_TYPES,
+ EDITOR_HEIGHT,
+ EDITOR_PREVIEW_STYLE,
+} from '~/vue_shared/components/rich_content_editor/constants';
+
+describe('Rich Content Editor', () => {
+ let wrapper;
+
+ const value = '## Some Markdown';
+ const findEditor = () => wrapper.find({ ref: 'editor' });
+
+ beforeEach(() => {
+ wrapper = shallowMount(RichContentEditor, {
+ propsData: { value },
+ });
+ });
+
+ describe('when content is loaded', () => {
+ it('renders an editor', () => {
+ expect(findEditor().exists()).toBe(true);
+ });
+
+ it('renders the correct content', () => {
+ expect(findEditor().props().initialValue).toBe(value);
+ });
+
+ it('provides the correct editor options', () => {
+ expect(findEditor().props().options).toEqual(EDITOR_OPTIONS);
+ });
+
+ it('has the correct preview style', () => {
+ expect(findEditor().props().previewStyle).toBe(EDITOR_PREVIEW_STYLE);
+ });
+
+ it('has the correct initial edit type', () => {
+ expect(findEditor().props().initialEditType).toBe(EDITOR_TYPES.wysiwyg);
+ });
+
+ it('has the correct height', () => {
+ expect(findEditor().props().height).toBe(EDITOR_HEIGHT);
+ });
+ });
+
+ describe('when content is changed', () => {
+ it('emits an input event with the changed content', () => {
+ const changedMarkdown = '## Changed Markdown';
+ const getMarkdownMock = jest.fn().mockReturnValueOnce(changedMarkdown);
+
+ findEditor().setMethods({ invoke: getMarkdownMock });
+ findEditor().vm.$emit('change');
+
+ expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js
new file mode 100644
index 00000000000..8545c43dc1e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js
@@ -0,0 +1,44 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ToolbarItem from '~/vue_shared/components/rich_content_editor/toolbar_item.vue';
+
+describe('Toolbar Item', () => {
+ let wrapper;
+
+ const findIcon = () => wrapper.find(GlIcon);
+ const findButton = () => wrapper.find('button');
+
+ const buildWrapper = propsData => {
+ wrapper = shallowMount(ToolbarItem, { propsData });
+ };
+
+ describe.each`
+ icon
+ ${'heading'}
+ ${'bold'}
+ ${'italic'}
+ ${'strikethrough'}
+ ${'quote'}
+ ${'link'}
+ ${'doc-code'}
+ ${'list-bulleted'}
+ ${'list-numbered'}
+ ${'list-task'}
+ ${'list-indent'}
+ ${'list-outdent'}
+ ${'dash'}
+ ${'table'}
+ ${'code'}
+ `('toolbar item component', ({ icon }) => {
+ beforeEach(() => buildWrapper({ icon }));
+
+ it('renders a toolbar button', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ it(`renders the ${icon} icon`, () => {
+ expect(findIcon().exists()).toBe(true);
+ expect(findIcon().props().name).toBe(icon);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js
new file mode 100644
index 00000000000..7605cc6a22c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js
@@ -0,0 +1,29 @@
+import { generateToolbarItem } from '~/vue_shared/components/rich_content_editor/toolbar_service';
+
+describe('Toolbar Service', () => {
+ const config = {
+ icon: 'bold',
+ command: 'some-command',
+ tooltip: 'Some Tooltip',
+ event: 'some-event',
+ };
+ const generatedItem = generateToolbarItem(config);
+
+ it('generates the correct command', () => {
+ expect(generatedItem.options.command).toBe(config.command);
+ });
+
+ it('generates the correct tooltip', () => {
+ expect(generatedItem.options.tooltip).toBe(config.tooltip);
+ });
+
+ it('generates the correct event', () => {
+ expect(generatedItem.options.event).toBe(config.event);
+ });
+
+ it('generates a divider when isDivider is set to true', () => {
+ const isDivider = true;
+
+ expect(generateToolbarItem({ isDivider })).toBe('divider');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
index d90fafb6bf7..9db86fa775f 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
@@ -4,10 +4,7 @@ import { shallowMount } from '@vue/test-utils';
import LabelsSelect from '~/labels_select';
import BaseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue';
-import {
- mockConfig,
- mockLabels,
-} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockConfig, mockLabels } from './mock_data';
const createComponent = (config = mockConfig) =>
shallowMount(BaseComponent, {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
index e2e11c94c0d..d02d924bd2b 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
@@ -3,16 +3,14 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue';
-import {
- mockConfig,
- mockLabels,
-} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockConfig, mockLabels } from './mock_data';
-const componentConfig = Object.assign({}, mockConfig, {
+const componentConfig = {
+ ...mockConfig,
fieldName: 'label_id[]',
labels: mockLabels,
showExtraOptions: false,
-});
+};
const createComponent = (config = componentConfig) => {
const Component = Vue.extend(dropdownButtonComponent);
@@ -34,7 +32,7 @@ describe('DropdownButtonComponent', () => {
describe('computed', () => {
describe('dropdownToggleText', () => {
it('returns text as `Label` when `labels` prop is empty array', () => {
- const mockEmptyLabels = Object.assign({}, componentConfig, { labels: [] });
+ const mockEmptyLabels = { ...componentConfig, labels: [] };
const vmEmptyLabels = createComponent(mockEmptyLabels);
expect(vmEmptyLabels.dropdownToggleText).toBe('Label');
@@ -42,9 +40,7 @@ describe('DropdownButtonComponent', () => {
});
it('returns first label name with remaining label count when `labels` prop has more than one item', () => {
- const mockMoreLabels = Object.assign({}, componentConfig, {
- labels: mockLabels.concat(mockLabels),
- });
+ const mockMoreLabels = { ...componentConfig, labels: mockLabels.concat(mockLabels) };
const vmMoreLabels = createComponent(mockMoreLabels);
expect(vmMoreLabels.dropdownToggleText).toBe(
@@ -54,9 +50,7 @@ describe('DropdownButtonComponent', () => {
});
it('returns first label name when `labels` prop has only one item present', () => {
- const singleLabel = Object.assign({}, componentConfig, {
- labels: [mockLabels[0]],
- });
+ const singleLabel = { ...componentConfig, labels: [mockLabels[0]] };
const vmSingleLabel = createComponent(singleLabel);
expect(vmSingleLabel.dropdownToggleText).toBe(mockLabels[0].title);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
index d0299523137..edec3b138b3 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue';
-import { mockSuggestedColors } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockSuggestedColors } from './mock_data';
const createComponent = headerTitle => {
const Component = Vue.extend(dropdownCreateLabelComponent);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
index 784bbaf8e6a..7e9e242a4f5 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue';
-import { mockConfig } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockConfig } from './mock_data';
const createComponent = (
labelsWebUrl = mockConfig.labelsWebUrl,
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
index 887c04268d1..e09f0006359 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
-import { mockLabels } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockLabels } from './mock_data';
const createComponent = (labels = mockLabels) => {
const Component = Vue.extend(dropdownValueCollapsedComponent);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
index 06355c0dd65..c33cffb421d 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
@@ -2,10 +2,7 @@ import { mount } from '@vue/test-utils';
import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue';
import { GlLabel } from '@gitlab/ui';
-import {
- mockConfig,
- mockLabels,
-} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data';
+import { mockConfig, mockLabels } from './mock_data';
const createComponent = (
labels = mockLabels,
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js
new file mode 100644
index 00000000000..6564c012e67
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js
@@ -0,0 +1,57 @@
+export const mockLabels = [
+ {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ text_color: '#FFFFFF',
+ },
+ {
+ id: 27,
+ title: 'Foo::Bar',
+ description: 'Foobar',
+ color: '#0033CC',
+ text_color: '#FFFFFF',
+ },
+];
+
+export const mockSuggestedColors = [
+ '#0033CC',
+ '#428BCA',
+ '#44AD8E',
+ '#A8D695',
+ '#5CB85C',
+ '#69D100',
+ '#004E00',
+ '#34495E',
+ '#7F8C8D',
+ '#A295D6',
+ '#5843AD',
+ '#8E44AD',
+ '#FFECDB',
+ '#AD4363',
+ '#D10069',
+ '#CC0033',
+ '#FF0000',
+ '#D9534F',
+ '#D1D100',
+ '#F0AD4E',
+ '#AD8D43',
+];
+
+export const mockConfig = {
+ showCreate: true,
+ isProject: true,
+ abilityName: 'issue',
+ context: {
+ labels: mockLabels,
+ },
+ namespace: 'gitlab-org',
+ updatePath: '/gitlab-org/my-project/issue/1',
+ labelsPath: '/gitlab-org/my-project/-/labels.json',
+ labelsWebUrl: '/gitlab-org/my-project/-/labels',
+ labelFilterBasePath: '/gitlab-org/my-project/issues',
+ canEdit: true,
+ suggestedColors: mockSuggestedColors,
+ emptyValueText: 'None',
+};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
index e2d31a41e82..214eb239432 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
@@ -33,9 +33,32 @@ describe('DropdownButton', () => {
wrapper.destroy();
});
+ describe('methods', () => {
+ describe('handleButtonClick', () => {
+ it('calls action `toggleDropdownContents` and stops event propagation when `state.variant` is "standalone"', () => {
+ const event = {
+ stopPropagation: jest.fn(),
+ };
+ wrapper = createComponent({
+ ...mockConfig,
+ variant: 'standalone',
+ });
+
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents');
+
+ wrapper.vm.handleButtonClick(event);
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+
+ wrapper.destroy();
+ });
+ });
+ });
+
describe('template', () => {
it('renders component container element', () => {
- expect(wrapper.is('gl-deprecated-button-stub')).toBe(true);
+ expect(wrapper.is('gl-button-stub')).toBe(true);
});
it('renders button text element', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
index d7ca7ce30a9..04320a72be6 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
@@ -1,7 +1,7 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlDeprecatedButton, GlIcon, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue';
import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
@@ -127,12 +127,12 @@ describe('DropdownContentsCreateView', () => {
it('renders dropdown back button element', () => {
const backBtnEl = wrapper
.find('.dropdown-title')
- .findAll(GlDeprecatedButton)
+ .findAll(GlButton)
.at(0);
expect(backBtnEl.exists()).toBe(true);
expect(backBtnEl.attributes('aria-label')).toBe('Go back');
- expect(backBtnEl.find(GlIcon).props('name')).toBe('arrow-left');
+ expect(backBtnEl.props('icon')).toBe('arrow-left');
});
it('renders dropdown title element', () => {
@@ -145,12 +145,12 @@ describe('DropdownContentsCreateView', () => {
it('renders dropdown close button element', () => {
const closeBtnEl = wrapper
.find('.dropdown-title')
- .findAll(GlDeprecatedButton)
+ .findAll(GlButton)
.at(1);
expect(closeBtnEl.exists()).toBe(true);
expect(closeBtnEl.attributes('aria-label')).toBe('Close');
- expect(closeBtnEl.find(GlIcon).props('name')).toBe('close');
+ expect(closeBtnEl.props('icon')).toBe('close');
});
it('renders label title input element', () => {
@@ -192,7 +192,7 @@ describe('DropdownContentsCreateView', () => {
it('renders create button element', () => {
const createBtnEl = wrapper
.find('.dropdown-actions')
- .findAll(GlDeprecatedButton)
+ .findAll(GlButton)
.at(0);
expect(createBtnEl.exists()).toBe(true);
@@ -213,7 +213,7 @@ describe('DropdownContentsCreateView', () => {
it('renders cancel button element', () => {
const cancelBtnEl = wrapper
.find('.dropdown-actions')
- .findAll(GlDeprecatedButton)
+ .findAll(GlButton)
.at(1);
expect(cancelBtnEl.exists()).toBe(true);
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
index 3e6dbdb7ecb..74c769f86a3 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
@@ -1,9 +1,10 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlDeprecatedButton, GlLoadingIcon, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue';
import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state';
import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations';
@@ -41,13 +42,19 @@ const createComponent = (initialState = mockConfig) => {
describe('DropdownContentsLabelsView', () => {
let wrapper;
+ let wrapperStandalone;
beforeEach(() => {
wrapper = createComponent();
+ wrapperStandalone = createComponent({
+ ...mockConfig,
+ variant: 'standalone',
+ });
});
afterEach(() => {
wrapper.destroy();
+ wrapperStandalone.destroy();
});
describe('computed', () => {
@@ -72,16 +79,6 @@ describe('DropdownContentsLabelsView', () => {
});
describe('methods', () => {
- describe('getDropdownLabelBoxStyle', () => {
- it('returns an object containing `backgroundColor` based on provided `label` param', () => {
- expect(wrapper.vm.getDropdownLabelBoxStyle(mockRegularLabel)).toEqual(
- expect.objectContaining({
- backgroundColor: mockRegularLabel.color,
- }),
- );
- });
- });
-
describe('isLabelSelected', () => {
it('returns true when provided `label` param is one of the selected labels', () => {
expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
@@ -165,13 +162,24 @@ describe('DropdownContentsLabelsView', () => {
});
describe('handleLabelClick', () => {
- it('calls action `updateSelectedLabels` with provided `label` param', () => {
+ beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ });
+ it('calls action `updateSelectedLabels` with provided `label` param', () => {
wrapper.vm.handleLabelClick(mockRegularLabel);
expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
});
+
+ it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents');
+ wrapper.vm.$store.state.allowMultiselect = false;
+
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
});
});
@@ -198,12 +206,15 @@ describe('DropdownContentsLabelsView', () => {
expect(titleEl.text()).toBe('Assign labels');
});
+ it('does not render dropdown title element when `state.variant` is "standalone"', () => {
+ expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false);
+ });
+
it('renders dropdown close button element', () => {
- const closeButtonEl = wrapper.find('.dropdown-title').find(GlDeprecatedButton);
+ const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton);
expect(closeButtonEl.exists()).toBe(true);
- expect(closeButtonEl.find(GlIcon).exists()).toBe(true);
- expect(closeButtonEl.find(GlIcon).props('name')).toBe('close');
+ expect(closeButtonEl.props('icon')).toBe('close');
});
it('renders label search input element', () => {
@@ -214,16 +225,7 @@ describe('DropdownContentsLabelsView', () => {
});
it('renders label elements for all labels', () => {
- const labelsEl = wrapper.findAll('.dropdown-content li');
- const labelItemEl = labelsEl.at(0).find(GlLink);
-
- expect(labelsEl.length).toBe(mockLabels.length);
- expect(labelItemEl.exists()).toBe(true);
- expect(labelItemEl.find(GlIcon).props('name')).toBe('mobile-issue-close');
- expect(labelItemEl.find('.dropdown-label-box').attributes('style')).toBe(
- 'background-color: rgb(186, 218, 85);',
- );
- expect(labelItemEl.find(GlLink).text()).toContain(mockLabels[0].title);
+ expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
});
it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => {
@@ -233,9 +235,9 @@ describe('DropdownContentsLabelsView', () => {
return wrapper.vm.$nextTick(() => {
const labelsEl = wrapper.findAll('.dropdown-content li');
- const labelItemEl = labelsEl.at(0).find(GlLink);
+ const labelItemEl = labelsEl.at(0).find(LabelItem);
- expect(labelItemEl.attributes('class')).toContain('is-focused');
+ expect(labelItemEl.props('highlight')).toBe(true);
});
});
@@ -247,19 +249,42 @@ describe('DropdownContentsLabelsView', () => {
return wrapper.vm.$nextTick(() => {
const noMatchEl = wrapper.find('.dropdown-content li');
- expect(noMatchEl.exists()).toBe(true);
+ expect(noMatchEl.isVisible()).toBe(true);
expect(noMatchEl.text()).toContain('No matching results');
});
});
it('renders footer list items', () => {
- const createLabelBtn = wrapper.find('.dropdown-footer').find(GlDeprecatedButton);
- const manageLabelsLink = wrapper.find('.dropdown-footer').find(GlLink);
-
- expect(createLabelBtn.exists()).toBe(true);
- expect(createLabelBtn.text()).toBe('Create label');
+ const createLabelLink = wrapper
+ .find('.dropdown-footer')
+ .findAll(GlLink)
+ .at(0);
+ const manageLabelsLink = wrapper
+ .find('.dropdown-footer')
+ .findAll(GlLink)
+ .at(1);
+
+ expect(createLabelLink.exists()).toBe(true);
+ expect(createLabelLink.text()).toBe('Create label');
expect(manageLabelsLink.exists()).toBe(true);
expect(manageLabelsLink.text()).toBe('Manage labels');
});
+
+ it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => {
+ wrapper.vm.$store.state.allowLabelCreate = false;
+
+ return wrapper.vm.$nextTick(() => {
+ const createLabelLink = wrapper
+ .find('.dropdown-footer')
+ .findAll(GlLink)
+ .at(0);
+
+ expect(createLabelLink.text()).not.toBe('Create label');
+ });
+ });
+
+ it('does not render footer list items when `state.variant` is "standalone"', () => {
+ expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js
new file mode 100644
index 00000000000..401d208da5c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js
@@ -0,0 +1,111 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { GlIcon, GlLink } from '@gitlab/ui';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue';
+import { mockRegularLabel } from './mock_data';
+
+const createComponent = ({ label = mockRegularLabel, highlight = true } = {}) =>
+ shallowMount(LabelItem, {
+ propsData: {
+ label,
+ highlight,
+ },
+ });
+
+describe('LabelItem', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ describe('labelBoxStyle', () => {
+ it('returns an object containing `backgroundColor` based on `label` prop', () => {
+ expect(wrapper.vm.labelBoxStyle).toEqual(
+ expect.objectContaining({
+ backgroundColor: mockRegularLabel.color,
+ }),
+ );
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('handleClick', () => {
+ it('sets value of `isSet` data prop to opposite of its current value', () => {
+ wrapper.setData({
+ isSet: true,
+ });
+
+ wrapper.vm.handleClick();
+ expect(wrapper.vm.isSet).toBe(false);
+ wrapper.vm.handleClick();
+ expect(wrapper.vm.isSet).toBe(true);
+ });
+
+ it('emits event `clickLabel` on component with `label` prop as param', () => {
+ wrapper.vm.handleClick();
+
+ expect(wrapper.emitted('clickLabel')).toBeTruthy();
+ expect(wrapper.emitted('clickLabel')[0]).toEqual([mockRegularLabel]);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders gl-link component', () => {
+ expect(wrapper.find(GlLink).exists()).toBe(true);
+ });
+
+ it('renders gl-link component with class `is-focused` when `highlight` prop is true', () => {
+ wrapper.setProps({
+ highlight: true,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(GlLink).classes()).toContain('is-focused');
+ });
+ });
+
+ it('renders visible gl-icon component when `isSet` prop is true', () => {
+ wrapper.setData({
+ isSet: true,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const iconEl = wrapper.find(GlIcon);
+
+ expect(iconEl.isVisible()).toBe(true);
+ expect(iconEl.props('name')).toBe('mobile-issue-close');
+ });
+ });
+
+ it('renders visible span element as placeholder instead of gl-icon when `isSet` prop is false', () => {
+ wrapper.setData({
+ isSet: false,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const placeholderEl = wrapper.find('[data-testid="no-icon"]');
+
+ expect(placeholderEl.isVisible()).toBe(true);
+ });
+ });
+
+ it('renders label color element', () => {
+ const colorEl = wrapper.find('[data-testid="label-color-box"]');
+
+ expect(colorEl.exists()).toBe(true);
+ expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);');
+ });
+
+ it('renders label title', () => {
+ expect(wrapper.text()).toContain(mockRegularLabel.title);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
index 126fd5438c4..ee4e9090e5d 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
@@ -89,6 +89,19 @@ describe('LabelsSelectRoot', () => {
expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
});
+ it('renders component root element with CSS class `is-standalone` when `state.variant` is "standalone"', () => {
+ const wrapperStandalone = createComponent({
+ ...mockConfig,
+ variant: 'standalone',
+ });
+
+ return wrapperStandalone.vm.$nextTick(() => {
+ expect(wrapperStandalone.classes()).toContain('is-standalone');
+
+ wrapperStandalone.destroy();
+ });
+ });
+
it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => {
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
});
@@ -101,13 +114,16 @@ describe('LabelsSelectRoot', () => {
const wrapperDropdownValue = createComponent(mockConfig, {
default: 'None',
});
+ wrapperDropdownValue.vm.$store.state.showDropdownButton = false;
- const valueComp = wrapperDropdownValue.find(DropdownValue);
+ return wrapperDropdownValue.vm.$nextTick(() => {
+ const valueComp = wrapperDropdownValue.find(DropdownValue);
- expect(valueComp.exists()).toBe(true);
- expect(valueComp.text()).toBe('None');
+ expect(valueComp.exists()).toBe(true);
+ expect(valueComp.text()).toBe('None');
- wrapperDropdownValue.destroy();
+ wrapperDropdownValue.destroy();
+ });
});
it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
index a863cddbaee..e1008d13fc2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
@@ -30,15 +30,16 @@ export const mockConfig = {
allowLabelEdit: true,
allowLabelCreate: true,
allowScopedLabels: true,
+ allowMultiselect: true,
labelsListTitle: 'Assign labels',
labelsCreateTitle: 'Create label',
+ variant: 'sidebar',
dropdownOnly: false,
selectedLabels: [mockRegularLabel, mockScopedLabel],
labelsSelectInProgress: false,
labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
labelsManagePath: '/gitlab-org/my-project/-/labels',
labelsFilterBasePath: '/gitlab-org/my-project/issues',
- scopedLabelsDocumentationPath: '/help/user/project/labels.md#scoped-labels-premium',
};
export const mockSuggestedColors = {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
index 6e2363ba96f..072d8fe2fe2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
@@ -15,7 +15,7 @@ describe('LabelsSelect Actions', () => {
};
beforeEach(() => {
- state = Object.assign({}, defaultState());
+ state = { ...defaultState() };
});
describe('setInitialState', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
index bfceaa0828b..b866117efcf 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
@@ -5,19 +5,25 @@ describe('LabelsSelect Getters', () => {
it('returns string "Label" when state.labels has no selected labels', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
- expect(getters.dropdownButtonText({ labels })).toBe('Label');
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Label',
+ );
});
it('returns label title when state.labels has only 1 label', () => {
const labels = [{ id: 1, title: 'Foobar', set: true }];
- expect(getters.dropdownButtonText({ labels })).toBe('Foobar');
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foobar',
+ );
});
it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
const labels = [{ id: 1, title: 'Foo', set: true }, { id: 2, title: 'Bar', set: true }];
- expect(getters.dropdownButtonText({ labels })).toBe('Foo +1 more');
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foo +1 more',
+ );
});
});
@@ -28,4 +34,16 @@ describe('LabelsSelect Getters', () => {
expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
});
});
+
+ describe('isDropdownVariantSidebar', () => {
+ it('returns `true` when `state.variant` is "sidebar"', () => {
+ expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true);
+ });
+ });
+
+ describe('isDropdownVariantStandalone', () => {
+ it('returns `true` when `state.variant` is "standalone"', () => {
+ expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
index f6ca98fcc71..8081806e314 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
@@ -29,6 +29,7 @@ describe('LabelsSelect Mutations', () => {
const state = {
dropdownOnly: false,
showDropdownButton: false,
+ variant: 'sidebar',
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
@@ -155,11 +156,11 @@ describe('LabelsSelect Mutations', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
- const updatedLabelIds = [2, 4];
+ const updatedLabelIds = [2];
const state = {
labels,
};
- mutations[types.UPDATE_SELECTED_LABELS](state, { labels });
+ mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] });
state.labels.forEach(label => {
if (updatedLabelIds.includes(label.id)) {
diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
new file mode 100644
index 00000000000..bc86ee5a0c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
@@ -0,0 +1,104 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue';
+
+const createComponent = config => {
+ const Component = Vue.extend(stackedProgressBarComponent);
+ const defaultConfig = {
+ successLabel: 'Synced',
+ failureLabel: 'Failed',
+ neutralLabel: 'Out of sync',
+ successCount: 25,
+ failureCount: 10,
+ totalCount: 5000,
+ ...config,
+ };
+
+ return mountComponent(Component, defaultConfig);
+};
+
+describe('StackedProgressBarComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('neutralCount', () => {
+ it('returns neutralCount based on totalCount, successCount and failureCount', () => {
+ expect(vm.neutralCount).toBe(4965); // 5000 - 25 - 10
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('getPercent', () => {
+ it('returns percentage from provided count based on `totalCount`', () => {
+ expect(vm.getPercent(500)).toBe(10);
+ });
+
+ it('returns percentage with decimal place from provided count based on `totalCount`', () => {
+ expect(vm.getPercent(67)).toBe(1.3);
+ });
+
+ it('returns percentage as `< 1` from provided count based on `totalCount` when evaluated value is less than 1', () => {
+ expect(vm.getPercent(10)).toBe('< 1');
+ });
+
+ it('returns 0 if totalCount is falsy', () => {
+ vm = createComponent({ totalCount: 0 });
+
+ expect(vm.getPercent(100)).toBe(0);
+ });
+ });
+
+ describe('barStyle', () => {
+ it('returns style string based on percentage provided', () => {
+ expect(vm.barStyle(50)).toBe('width: 50%;');
+ });
+ });
+
+ describe('getTooltip', () => {
+ describe('when hideTooltips is false', () => {
+ it('returns label string based on label and count provided', () => {
+ expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10');
+ });
+ });
+
+ describe('when hideTooltips is true', () => {
+ beforeEach(() => {
+ vm = createComponent({ hideTooltips: true });
+ });
+
+ it('returns an empty string', () => {
+ expect(vm.getTooltip('Synced', 10)).toBe('');
+ });
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders container element', () => {
+ expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy();
+ });
+
+ it('renders empty state when count is unavailable', () => {
+ const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 });
+
+ expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0);
+ vmX.$destroy();
+ });
+
+ it('renders bar elements when count is available', () => {
+ expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0);
+ expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0);
+ expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/tabs/tab_spec.js b/spec/frontend/vue_shared/components/tabs/tab_spec.js
new file mode 100644
index 00000000000..8cf07a9177c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/tabs/tab_spec.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import Tab from '~/vue_shared/components/tabs/tab.vue';
+
+describe('Tab component', () => {
+ const Component = Vue.extend(Tab);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component);
+ });
+
+ it('sets localActive to equal active', done => {
+ vm.active = true;
+
+ vm.$nextTick(() => {
+ expect(vm.localActive).toBe(true);
+
+ done();
+ });
+ });
+
+ it('sets active class', done => {
+ vm.active = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.classList).toContain('active');
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/tabs/tabs_spec.js b/spec/frontend/vue_shared/components/tabs/tabs_spec.js
new file mode 100644
index 00000000000..49d92094b34
--- /dev/null
+++ b/spec/frontend/vue_shared/components/tabs/tabs_spec.js
@@ -0,0 +1,61 @@
+import Vue from 'vue';
+import Tabs from '~/vue_shared/components/tabs/tabs';
+import Tab from '~/vue_shared/components/tabs/tab.vue';
+
+describe('Tabs component', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = new Vue({
+ components: {
+ Tabs,
+ Tab,
+ },
+ render(h) {
+ return h('div', [
+ h('tabs', [
+ h('tab', { attrs: { title: 'Testing', active: true } }, 'First tab'),
+ h('tab', [h('template', { slot: 'title' }, 'Test slot'), 'Second tab']),
+ ]),
+ ]);
+ },
+ }).$mount();
+
+ return vm.$nextTick();
+ });
+
+ describe('tab links', () => {
+ it('renders links for tabs', () => {
+ expect(vm.$el.querySelectorAll('a').length).toBe(2);
+ });
+
+ it('renders link titles from props', () => {
+ expect(vm.$el.querySelector('a').textContent).toContain('Testing');
+ });
+
+ it('renders link titles from slot', () => {
+ expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot');
+ });
+
+ it('renders active class', () => {
+ expect(vm.$el.querySelector('a').classList).toContain('active');
+ });
+
+ it('updates active class on click', () => {
+ vm.$el.querySelectorAll('a')[1].click();
+
+ return vm.$nextTick(() => {
+ expect(vm.$el.querySelector('a').classList).not.toContain('active');
+ expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active');
+ });
+ });
+ });
+
+ describe('content', () => {
+ it('renders content panes', () => {
+ expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2);
+ expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab');
+ expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js
new file mode 100644
index 00000000000..83bbb37a89a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/toggle_button_spec.js
@@ -0,0 +1,101 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import toggleButton from '~/vue_shared/components/toggle_button.vue';
+
+describe('Toggle Button', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(toggleButton);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('render output', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ value: true,
+ name: 'foo',
+ });
+ });
+
+ it('renders input with provided name', () => {
+ expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo');
+ });
+
+ it('renders input with provided value', () => {
+ expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true');
+ });
+
+ it('renders input status icon', () => {
+ expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1);
+ expect(vm.$el.querySelectorAll('svg.s16.toggle-icon-svg').length).toEqual(1);
+ });
+ });
+
+ describe('is-checked', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ value: true,
+ });
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ it('renders is checked class', () => {
+ expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true);
+ });
+
+ it('sets aria-label representing toggle state', () => {
+ vm.value = true;
+
+ expect(vm.ariaLabel).toEqual('Toggle Status: ON');
+
+ vm.value = false;
+
+ expect(vm.ariaLabel).toEqual('Toggle Status: OFF');
+ });
+
+ it('emits change event when clicked', () => {
+ vm.$el.querySelector('button').click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('change', false);
+ });
+ });
+
+ describe('is-disabled', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ value: true,
+ disabledInput: true,
+ });
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ it('renders disabled button', () => {
+ expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true);
+ });
+
+ it('does not emit change event when clicked', () => {
+ vm.$el.querySelector('button').click();
+
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('is-loading', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ value: true,
+ isLoading: true,
+ });
+ });
+
+ it('renders loading class', () => {
+ expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/wikis_spec.js b/spec/frontend/wikis_spec.js
index 1d17c8b0777..e5d869840aa 100644
--- a/spec/frontend/wikis_spec.js
+++ b/spec/frontend/wikis_spec.js
@@ -14,6 +14,7 @@ describe('Wikis', () => {
<option value="asciidoc">AsciiDoc</option>
<option value="org">Org</option>
</select>
+ <textarea id="wiki_content"></textarea>
<code class="js-markup-link-example">{Link title}[link:page-slug]</code>
</form>
`;
@@ -24,6 +25,10 @@ describe('Wikis', () => {
let changeFormatSelect;
let linkExample;
+ const findBeforeUnloadWarning = () => window.onbeforeunload?.();
+ const findContent = () => document.getElementById('wiki_content');
+ const findForm = () => document.querySelector('.wiki-form');
+
describe('when the wiki page is being created', () => {
const formHtmlFixture = editFormHtmlFixture({ newPage: true });
@@ -94,6 +99,27 @@ describe('Wikis', () => {
expect(linkExample.innerHTML).toBe(text);
});
+
+ it('starts with no unload warning', () => {
+ expect(findBeforeUnloadWarning()).toBeUndefined();
+ });
+
+ describe('when wiki content is updated', () => {
+ beforeEach(() => {
+ const content = findContent();
+ content.value = 'Lorem ipsum dolar sit!';
+ content.dispatchEvent(new Event('input'));
+ });
+
+ it('sets before unload warning', () => {
+ expect(findBeforeUnloadWarning()).toBe('');
+ });
+
+ it('when form submitted, unsets before unload warning', () => {
+ findForm().dispatchEvent(new Event('submit'));
+ expect(findBeforeUnloadWarning()).toBeUndefined();
+ });
+ });
});
});
});