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:
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/alert_handler_spec.js65
-rw-r--r--spec/frontend/alert_management/components/alert_details_spec.js98
-rw-r--r--spec/frontend/alert_management/components/alert_management_table_spec.js30
-rw-r--r--spec/frontend/alert_management/components/alert_summary_row_spec.js40
-rw-r--r--spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js4
-rw-r--r--spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js13
-rw-r--r--spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js6
-rw-r--r--spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap14
-rw-r--r--spec/frontend/analytics/instance_statistics/components/app_spec.js24
-rw-r--r--spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js54
-rw-r--r--spec/frontend/analytics/instance_statistics/mock_data.js4
-rw-r--r--spec/frontend/analytics/shared/components/metric_card_spec.js129
-rw-r--r--spec/frontend/api_spec.js40
-rw-r--r--spec/frontend/awards_handler_spec.js24
-rw-r--r--spec/frontend/batch_comments/components/preview_item_spec.js16
-rw-r--r--spec/frontend/batch_comments/components/publish_button_spec.js11
-rw-r--r--spec/frontend/batch_comments/components/publish_dropdown_spec.js87
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js90
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js52
-rw-r--r--spec/frontend/behaviors/load_startup_css_spec.js44
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap2
-rw-r--r--spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js67
-rw-r--r--spec/frontend/boards/board_blank_state_spec.js95
-rw-r--r--spec/frontend/boards/boards_store_spec.js19
-rw-r--r--spec/frontend/boards/components/board_configuration_options_spec.js59
-rw-r--r--spec/frontend/boards/components/board_content_spec.js3
-rw-r--r--spec/frontend/boards/components/sidebar/board_editable_item_spec.js14
-rw-r--r--spec/frontend/boards/stores/actions_spec.js74
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js70
-rw-r--r--spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js102
-rw-r--r--spec/frontend/ci_settings_pipeline_triggers/mock_data.js30
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js24
-rw-r--r--spec/frontend/clusters/components/fluentd_output_settings_spec.js4
-rw-r--r--spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js10
-rw-r--r--spec/frontend/clusters/components/knative_domain_editor_spec.js4
-rw-r--r--spec/frontend/clusters/services/crossplane_provider_stack_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js18
-rw-r--r--spec/frontend/clusters_list/components/node_error_help_text_spec.js33
-rw-r--r--spec/frontend/clusters_list/mock_data.js10
-rw-r--r--spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap1
-rw-r--r--spec/frontend/commit/pipelines/pipelines_spec.js82
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js8
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js1
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/actions_spec.js32
-rw-r--r--spec/frontend/design_management/components/design_todo_button_spec.js2
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap2
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap6
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap9
-rw-r--r--spec/frontend/design_management/components/toolbar/design_navigation_spec.js4
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap3
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js1
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap11
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap1
-rw-r--r--spec/frontend/design_management/pages/index_spec.js56
-rw-r--r--spec/frontend/design_management/router_spec.js2
-rw-r--r--spec/frontend/design_management/utils/design_management_utils_spec.js4
-rw-r--r--spec/frontend/diff_comments_store_spec.js136
-rw-r--r--spec/frontend/diffs/components/app_spec.js4
-rw-r--r--spec/frontend/diffs/components/collapsed_files_warning_spec.js2
-rw-r--r--spec/frontend/diffs/components/commit_item_spec.js6
-rw-r--r--spec/frontend/diffs/components/diff_file_header_spec.js53
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js26
-rw-r--r--spec/frontend/diffs/components/diff_row_utils_spec.js203
-rw-r--r--spec/frontend/diffs/components/diff_table_cell_spec.js279
-rw-r--r--spec/frontend/diffs/components/edit_button_spec.js75
-rw-r--r--spec/frontend/diffs/mock_data/diff_file.js2
-rw-r--r--spec/frontend/diffs/mock_data/diff_file_unreadable.js2
-rw-r--r--spec/frontend/diffs/store/actions_spec.js8
-rw-r--r--spec/frontend/diffs/store/getters_spec.js90
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js8
-rw-r--r--spec/frontend/emoji/emoji_spec.js41
-rw-r--r--spec/frontend/environment.js10
-rw-r--r--spec/frontend/environments/environments_app_spec.js12
-rw-r--r--spec/frontend/environments/folder/environments_folder_view_spec.js5
-rw-r--r--spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js12
-rw-r--r--spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js159
-rw-r--r--spec/frontend/feature_flags/components/edit_feature_flag_spec.js197
-rw-r--r--spec/frontend/feature_flags/components/environments_dropdown_spec.js145
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_spec.js343
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_tab_spec.js168
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_table_spec.js262
-rw-r--r--spec/frontend/feature_flags/components/form_spec.js485
-rw-r--r--spec/frontend/feature_flags/components/new_environments_dropdown_spec.js103
-rw-r--r--spec/frontend/feature_flags/components/new_feature_flag_spec.js145
-rw-r--r--spec/frontend/feature_flags/components/strategy_spec.js320
-rw-r--r--spec/frontend/feature_flags/components/user_lists_table_spec.js98
-rw-r--r--spec/frontend/feature_flags/mock_data.js109
-rw-r--r--spec/frontend/feature_flags/store/edit/actions_spec.js334
-rw-r--r--spec/frontend/feature_flags/store/edit/mutations_spec.js150
-rw-r--r--spec/frontend/feature_flags/store/helpers_spec.js514
-rw-r--r--spec/frontend/feature_flags/store/index/actions_spec.js605
-rw-r--r--spec/frontend/feature_flags/store/index/mutations_spec.js332
-rw-r--r--spec/frontend/feature_flags/store/new/actions_spec.js223
-rw-r--r--spec/frontend/feature_flags/store/new/mutations_spec.js65
-rw-r--r--spec/frontend/fixtures/releases.rb136
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js219
-rw-r--r--spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap22
-rw-r--r--spec/frontend/groups/components/item_actions_spec.js125
-rw-r--r--spec/frontend/groups/components/item_caret_spec.js58
-rw-r--r--spec/frontend/groups/components/item_stats_spec.js131
-rw-r--r--spec/frontend/groups/components/item_stats_value_spec.js111
-rw-r--r--spec/frontend/groups/components/item_type_icon_spec.js80
-rw-r--r--spec/frontend/groups/members/index_spec.js10
-rw-r--r--spec/frontend/helpers/experimentation_helper.js14
-rw-r--r--spec/frontend/helpers/keep_alive_component_helper.js29
-rw-r--r--spec/frontend/helpers/keep_alive_component_helper_spec.js32
-rw-r--r--spec/frontend/helpers/local_storage_helper.js2
-rw-r--r--spec/frontend/helpers/local_storage_helper_spec.js4
-rw-r--r--spec/frontend/helpers/vue_test_utils_helper.js7
-rw-r--r--spec/frontend/helpers/wait_for_text.js3
-rw-r--r--spec/frontend/ide/components/commit_sidebar/actions_spec.js4
-rw-r--r--spec/frontend/ide/components/ide_review_spec.js78
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js81
-rw-r--r--spec/frontend/ide/components/ide_tree_list_spec.js8
-rw-r--r--spec/frontend/ide/components/ide_tree_spec.js41
-rw-r--r--spec/frontend/ide/components/repo_commit_section_spec.js21
-rw-r--r--spec/frontend/incidents/components/incidents_list_spec.js211
-rw-r--r--spec/frontend/incidents/mocks/incidents_filter.json14
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap27
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap1
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap54
-rw-r--r--spec/frontend/integrations/edit/components/confirmation_modal_spec.js51
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js23
-rw-r--r--spec/frontend/integrations/edit/mock_data.js1
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js115
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js58
-rw-r--r--spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js5
-rw-r--r--spec/frontend/issuable/related_issues/components/issue_token_spec.js257
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_block_spec.js5
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_list_spec.js5
-rw-r--r--spec/frontend/issuable_create/components/issuable_form_spec.js1
-rw-r--r--spec/frontend/issue_show/components/incidents/highlight_bar_spec.js4
-rw-r--r--spec/frontend/issue_show/components/incidents/incident_tabs_spec.js2
-rw-r--r--spec/frontend/issue_show/issue_spec.js8
-rw-r--r--spec/frontend/jobs/store/utils_spec.js8
-rw-r--r--spec/frontend/lib/dompurify_spec.js98
-rw-r--r--spec/frontend/lib/utils/axios_startup_calls_spec.js49
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js23
-rw-r--r--spec/frontend/lib/utils/experimentation_spec.js20
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js57
-rw-r--r--spec/frontend/merge_request_spec.js63
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap181
-rw-r--r--spec/frontend/monitoring/components/group_empty_state_spec.js33
-rw-r--r--spec/frontend/notes/components/discussion_counter_spec.js28
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js20
-rw-r--r--spec/frontend/notes/components/sort_discussion_spec.js12
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap214
-rw-r--r--spec/frontend/packages/details/store/getters_spec.js2
-rw-r--r--spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap756
-rw-r--r--spec/frontend/packages/list/components/packages_list_app_spec.js1
-rw-r--r--spec/frontend/packages/list/components/packages_title_spec.js71
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap26
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap3
-rw-r--r--spec/frontend/packages/shared/components/package_list_row_spec.js8
-rw-r--r--spec/frontend/packages/shared/components/package_path_spec.js86
-rw-r--r--spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap3
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js21
-rw-r--r--spec/frontend/pipeline_new/mock_data.js6
-rw-r--r--spec/frontend/pipelines/components/dag/dag_spec.js9
-rw-r--r--spec/frontend/pipelines/graph/graph_component_spec.js102
-rw-r--r--spec/frontend/pipelines/header_component_spec.js177
-rw-r--r--spec/frontend/pipelines/legacy_header_component_spec.js116
-rw-r--r--spec/frontend/pipelines/mock_data.js78
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js383
-rw-r--r--spec/frontend/pipelines/test_reports/mock_data.js16
-rw-r--r--spec/frontend/pipelines/test_reports/test_suite_table_spec.js21
-rw-r--r--spec/frontend/project_find_file_spec.js5
-rw-r--r--spec/frontend/projects/commit_box/info/load_branches_spec.js68
-rw-r--r--spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap1
-rw-r--r--spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap3
-rw-r--r--spec/frontend/projects/settings/access_dropdown_spec.js21
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js4
-rw-r--r--spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js71
-rw-r--r--spec/frontend/registry/explorer/components/list_page/registry_header_spec.js90
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js65
-rw-r--r--spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap7
-rw-r--r--spec/frontend/registry/settings/components/registry_settings_app_spec.js88
-rw-r--r--spec/frontend/registry/settings/components/settings_form_spec.js293
-rw-r--r--spec/frontend/registry/settings/graphql/cache_updated_spec.js56
-rw-r--r--spec/frontend/registry/settings/mock_data.js32
-rw-r--r--spec/frontend/registry/settings/store/actions_spec.js90
-rw-r--r--spec/frontend/registry/settings/store/getters_spec.js72
-rw-r--r--spec/frontend/registry/settings/store/mutations_spec.js80
-rw-r--r--spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap101
-rw-r--r--spec/frontend/registry/shared/components/expiration_policy_fields_spec.js20
-rw-r--r--spec/frontend/registry/shared/stubs.js11
-rw-r--r--spec/frontend/registry/shared/utils_spec.js37
-rw-r--r--spec/frontend/related_merge_requests/components/related_merge_requests_spec.js5
-rw-r--r--spec/frontend/releases/__snapshots__/util_spec.js.snap110
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js5
-rw-r--r--spec/frontend/releases/components/app_index_spec.js63
-rw-r--r--spec/frontend/releases/components/app_show_spec.js8
-rw-r--r--spec/frontend/releases/components/asset_links_form_spec.js18
-rw-r--r--spec/frontend/releases/components/evidence_block_spec.js8
-rw-r--r--spec/frontend/releases/components/release_block_assets_spec.js10
-rw-r--r--spec/frontend/releases/components/release_block_footer_spec.js4
-rw-r--r--spec/frontend/releases/components/release_block_header_spec.js6
-rw-r--r--spec/frontend/releases/components/release_block_metadata_spec.js4
-rw-r--r--spec/frontend/releases/components/release_block_milestone_info_spec.js26
-rw-r--r--spec/frontend/releases/components/release_block_spec.js8
-rw-r--r--spec/frontend/releases/components/release_skeleton_loader_spec.js15
-rw-r--r--spec/frontend/releases/components/releases_pagination_graphql_spec.js10
-rw-r--r--spec/frontend/releases/components/releases_pagination_rest_spec.js8
-rw-r--r--spec/frontend/releases/mock_data.js335
-rw-r--r--spec/frontend/releases/stores/getters_spec.js22
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js13
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js25
-rw-r--r--spec/frontend/releases/stores/modules/list/actions_spec.js334
-rw-r--r--spec/frontend/releases/stores/modules/list/mutations_spec.js44
-rw-r--r--spec/frontend/releases/util_spec.js6
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap58
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js2
-rw-r--r--spec/frontend/repository/log_tree_spec.js8
-rw-r--r--spec/frontend/right_sidebar_spec.js13
-rw-r--r--spec/frontend/search/components/dropdown_filter_spec.js (renamed from spec/frontend/search/components/state_filter_spec.js)34
-rw-r--r--spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap12
-rw-r--r--spec/frontend/self_monitor/components/self_monitor_form_spec.js4
-rw-r--r--spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap2
-rw-r--r--spec/frontend/serverless/components/missing_prometheus_spec.js4
-rw-r--r--spec/frontend/sidebar/confidential/edit_form_buttons_spec.js15
-rw-r--r--spec/frontend/sidebar/lock/edit_form_buttons_spec.js14
-rw-r--r--spec/frontend/sidebar/reviewer_title_spec.js116
-rw-r--r--spec/frontend/sidebar/reviewers_spec.js169
-rw-r--r--spec/frontend/sidebar/sidebar_labels_spec.js2
-rw-r--r--spec/frontend/snippet/snippet_edit_spec.js1
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap1
-rw-r--r--spec/frontend/snippets/components/edit_spec.js8
-rw-r--r--spec/frontend/snippets/components/snippet_blob_view_spec.js6
-rw-r--r--spec/frontend/static_site_editor/mock_data.js5
-rw-r--r--spec/frontend/static_site_editor/pages/home_spec.js51
-rw-r--r--spec/frontend/static_site_editor/pages/success_spec.js110
-rw-r--r--spec/frontend/static_site_editor/services/front_matterify_spec.js47
-rw-r--r--spec/frontend/static_site_editor/services/submit_content_changes_spec.js53
-rw-r--r--spec/frontend/static_site_editor/services/templater_spec.js8
-rw-r--r--spec/frontend/test_setup.js3
-rw-r--r--spec/frontend/user_lists/components/add_user_modal_spec.js50
-rw-r--r--spec/frontend/user_lists/components/edit_user_list_spec.js150
-rw-r--r--spec/frontend/user_lists/components/new_user_list_spec.js93
-rw-r--r--spec/frontend/user_lists/components/user_list_form_spec.js40
-rw-r--r--spec/frontend/user_lists/components/user_list_spec.js196
-rw-r--r--spec/frontend/user_lists/store/edit/actions_spec.js121
-rw-r--r--spec/frontend/user_lists/store/edit/mutations_spec.js61
-rw-r--r--spec/frontend/user_lists/store/new/actions_spec.js69
-rw-r--r--spec/frontend/user_lists/store/new/mutations_spec.js38
-rw-r--r--spec/frontend/user_lists/store/show/actions_spec.js117
-rw-r--r--spec/frontend/user_lists/store/show/mutations_spec.js86
-rw-r--r--spec/frontend/user_lists/store/utils_spec.js23
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js22
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js14
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js15
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js5
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_spec.js5
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/alert_details_table_spec.js (renamed from spec/frontend/vue_shared/components/alert_detail_table_spec.js)29
-rw-r--r--spec/frontend/vue_shared/components/clipboard_button_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/confirm_modal_spec.js16
-rw-r--r--spec/frontend/vue_shared/components/local_storage_sync_spec.js122
-rw-r--r--spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js46
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js85
-rw-r--r--spec/frontend/vue_shared/components/members/mock_data.js61
-rw-r--r--spec/frontend/vue_shared/components/members/table/created_at_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/members/table/expires_at_spec.js86
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_avatar_spec.js39
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_source_spec.js71
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js130
-rw-r--r--spec/frontend/vue_shared/components/members/table/members_table_spec.js104
-rw-r--r--spec/frontend/vue_shared/components/members/utils_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap5
-rw-r--r--spec/frontend/vue_shared/components/registry/title_area_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js27
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js7
-rw-r--r--spec/frontend/vue_shared/components/todo_button_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js1
-rw-r--r--spec/frontend/vue_shared/droplab_dropdown_button_spec.js132
-rw-r--r--spec/frontend/whats_new/components/app_spec.js38
-rw-r--r--spec/frontend/whats_new/store/actions_spec.js7
285 files changed, 13284 insertions, 4369 deletions
diff --git a/spec/frontend/alert_handler_spec.js b/spec/frontend/alert_handler_spec.js
index ba2f4f24aa5..0cee28112a8 100644
--- a/spec/frontend/alert_handler_spec.js
+++ b/spec/frontend/alert_handler_spec.js
@@ -2,18 +2,26 @@ import { setHTMLFixture } from 'helpers/fixtures';
import initAlertHandler from '~/alert_handler';
describe('Alert Handler', () => {
- const ALERT_SELECTOR = 'gl-alert';
- const CLOSE_SELECTOR = 'gl-alert-dismiss';
- const ALERT_HTML = `<div class="${ALERT_SELECTOR}"><button class="${CLOSE_SELECTOR}">Dismiss</button></div>`;
+ const ALERT_CLASS = 'gl-alert';
+ const BANNER_CLASS = 'gl-banner';
+ const DISMISS_CLASS = 'gl-alert-dismiss';
+ const DISMISS_LABEL = 'Dismiss';
- const findFirstAlert = () => document.querySelector(`.${ALERT_SELECTOR}`);
- const findAllAlerts = () => document.querySelectorAll(`.${ALERT_SELECTOR}`);
- const findFirstCloseButton = () => document.querySelector(`.${CLOSE_SELECTOR}`);
+ const generateHtml = parentClass =>
+ `<div class="${parentClass}">
+ <button aria-label="${DISMISS_LABEL}">Dismiss</button>
+ </div>`;
+
+ const findFirstAlert = () => document.querySelector(`.${ALERT_CLASS}`);
+ const findFirstBanner = () => document.querySelector(`.${BANNER_CLASS}`);
+ const findAllAlerts = () => document.querySelectorAll(`.${ALERT_CLASS}`);
+ const findFirstDismissButton = () => document.querySelector(`[aria-label="${DISMISS_LABEL}"]`);
+ const findFirstDismissButtonByClass = () => document.querySelector(`.${DISMISS_CLASS}`);
describe('initAlertHandler', () => {
describe('with one alert', () => {
beforeEach(() => {
- setHTMLFixture(ALERT_HTML);
+ setHTMLFixture(generateHtml(ALERT_CLASS));
initAlertHandler();
});
@@ -22,14 +30,14 @@ describe('Alert Handler', () => {
});
it('should dismiss the alert on click', () => {
- findFirstCloseButton().click();
+ findFirstDismissButton().click();
expect(findFirstAlert()).not.toExist();
});
});
describe('with two alerts', () => {
beforeEach(() => {
- setHTMLFixture(ALERT_HTML + ALERT_HTML);
+ setHTMLFixture(generateHtml(ALERT_CLASS) + generateHtml(ALERT_CLASS));
initAlertHandler();
});
@@ -38,9 +46,46 @@ describe('Alert Handler', () => {
});
it('should dismiss only one alert on click', () => {
- findFirstCloseButton().click();
+ findFirstDismissButton().click();
expect(findAllAlerts()).toHaveLength(1);
});
});
+
+ describe('with a dismissible banner', () => {
+ beforeEach(() => {
+ setHTMLFixture(generateHtml(BANNER_CLASS));
+ initAlertHandler();
+ });
+
+ it('should render the banner', () => {
+ expect(findFirstBanner()).toExist();
+ });
+
+ it('should dismiss the banner on click', () => {
+ findFirstDismissButton().click();
+ expect(findFirstBanner()).not.toExist();
+ });
+ });
+
+ // Dismiss buttons *should* have the correct aria labels, but some of them won't
+ // because legacy code isn't always a11y compliant.
+ // This tests that the fallback for the incorrectly labelled buttons works.
+ describe('with a mislabelled dismiss button', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div class="${ALERT_CLASS}">
+ <button class="${DISMISS_CLASS}">Dismiss</button>
+ </div>`);
+ initAlertHandler();
+ });
+
+ it('should render the banner', () => {
+ expect(findFirstAlert()).toExist();
+ });
+
+ it('should dismiss the banner on click', () => {
+ findFirstDismissButtonByClass().click();
+ expect(findFirstAlert()).not.toExist();
+ });
+ });
});
});
diff --git a/spec/frontend/alert_management/components/alert_details_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js
index 8aa26dbca3b..910bb31b573 100644
--- a/spec/frontend/alert_management/components/alert_details_spec.js
+++ b/spec/frontend/alert_management/components/alert_details_spec.js
@@ -2,8 +2,10 @@ import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import AlertDetails from '~/alert_management/components/alert_details.vue';
+import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue';
import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import {
@@ -24,31 +26,36 @@ describe('AlertDetails', () => {
const $router = { replace: jest.fn() };
function mountComponent({ data, loading = false, mountMethod = shallowMount, stubs = {} } = {}) {
- wrapper = mountMethod(AlertDetails, {
- provide: {
- alertId: 'alertId',
- projectPath,
- projectIssuesPath,
- projectId,
- },
- data() {
- return { alert: { ...mockAlert }, sidebarStatus: false, ...data };
- },
- mocks: {
- $apollo: {
- mutate: jest.fn(),
- queries: {
- alert: {
- loading,
+ wrapper = extendedWrapper(
+ mountMethod(AlertDetails, {
+ provide: {
+ alertId: 'alertId',
+ projectPath,
+ projectIssuesPath,
+ projectId,
+ },
+ data() {
+ return { alert: { ...mockAlert }, sidebarStatus: false, ...data };
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ queries: {
+ alert: {
+ loading,
+ },
+ sidebarStatus: {},
},
- sidebarStatus: {},
},
+ $router,
+ $route: { params: {} },
},
- $router,
- $route: { params: {} },
- },
- stubs,
- });
+ stubs: {
+ ...stubs,
+ AlertSummaryRow,
+ },
+ }),
+ );
}
beforeEach(() => {
@@ -62,9 +69,10 @@ describe('AlertDetails', () => {
mock.restore();
});
- const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
- const findViewIncidentBtn = () => wrapper.find('[data-testid="viewIncidentBtn"]');
- const findIncidentCreationAlert = () => wrapper.find('[data-testid="incidentCreationError"]');
+ const findCreateIncidentBtn = () => wrapper.findByTestId('createIncidentBtn');
+ const findViewIncidentBtn = () => wrapper.findByTestId('viewIncidentBtn');
+ const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError');
+ const findEnvironmentLink = () => wrapper.findByTestId('environmentUrl');
const findDetailsTable = () => wrapper.find(AlertDetailsTable);
describe('Alert details', () => {
@@ -74,7 +82,7 @@ describe('AlertDetails', () => {
});
it('shows an empty state', () => {
- expect(wrapper.find('[data-testid="alertDetailsTabs"]').exists()).toBe(false);
+ expect(wrapper.findByTestId('alertDetailsTabs').exists()).toBe(false);
});
});
@@ -84,28 +92,26 @@ describe('AlertDetails', () => {
});
it('renders a tab with overview information', () => {
- expect(wrapper.find('[data-testid="overview"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('overview').exists()).toBe(true);
});
it('renders a tab with an activity feed', () => {
- expect(wrapper.find('[data-testid="activity"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('activity').exists()).toBe(true);
});
it('renders severity', () => {
- expect(wrapper.find('[data-testid="severity"]').text()).toBe(
+ expect(wrapper.findByTestId('severity').text()).toBe(
ALERTS_SEVERITY_LABELS[mockAlert.severity],
);
});
it('renders a title', () => {
- expect(wrapper.find('[data-testid="title"]').text()).toBe(mockAlert.title);
+ expect(wrapper.findByTestId('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,
- );
+ expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true);
+ expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt);
});
});
@@ -114,6 +120,8 @@ describe('AlertDetails', () => {
field | data | isShown
${'eventCount'} | ${1} | ${true}
${'eventCount'} | ${undefined} | ${false}
+ ${'environment'} | ${undefined} | ${false}
+ ${'environment'} | ${'Production'} | ${true}
${'monitoringTool'} | ${'New Relic'} | ${true}
${'monitoringTool'} | ${undefined} | ${false}
${'service'} | ${'Prometheus'} | ${true}
@@ -126,15 +134,29 @@ describe('AlertDetails', () => {
});
it(`${field} is ${isShown ? 'displayed' : 'hidden'} correctly`, () => {
+ const element = wrapper.findByTestId(field);
if (isShown) {
- expect(wrapper.find(`[data-testid="${field}"]`).text()).toBe(data.toString());
+ expect(element.text()).toContain(data.toString());
} else {
- expect(wrapper.find(`[data-testid="${field}"]`).exists()).toBe(false);
+ expect(wrapper.findByTestId(field).exists()).toBe(false);
}
});
});
});
+ describe('environment URL fields', () => {
+ it('should show the environment URL when available', () => {
+ const environment = 'Production';
+ const environmentUrl = 'fake/url';
+ mountComponent({
+ data: { alert: { ...mockAlert, environment, environmentUrl } },
+ });
+
+ expect(findEnvironmentLink().text()).toBe(environment);
+ expect(findEnvironmentLink().attributes('href')).toBe(environmentUrl);
+ });
+ });
+
describe('Create incident from alert', () => {
it('should display "View incident" button that links the incident page when incident exists', () => {
const issueIid = '3';
@@ -222,7 +244,7 @@ describe('AlertDetails', () => {
mountComponent({
data: { errored: true, sidebarErrorMessage: '<span data-testid="htmlError" />' },
});
- expect(wrapper.find('[data-testid="htmlError"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('htmlError').exists()).toBe(true);
});
it('does not display an error when dismissed', () => {
@@ -232,7 +254,7 @@ describe('AlertDetails', () => {
});
describe('header', () => {
- const findHeader = () => wrapper.find('[data-testid="alert-header"]');
+ const findHeader = () => wrapper.findByTestId('alert-header');
const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } };
describe('individual header fields', () => {
diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js
index bcad415eb19..3aa67614369 100644
--- a/spec/frontend/alert_management/components/alert_management_table_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_table_spec.js
@@ -3,8 +3,8 @@ import {
GlTable,
GlAlert,
GlLoadingIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlTabs,
GlTab,
@@ -34,12 +34,12 @@ describe('AlertManagementTable', () => {
const findAlerts = () => wrapper.findAll('table tbody tr');
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
- const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown);
+ const findStatusDropdown = () => wrapper.find(GlDropdown);
const findStatusFilterTabs = () => wrapper.findAll(GlTab);
const findStatusTabs = () => wrapper.find(GlTabs);
const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
const findDateFields = () => wrapper.findAll(TimeAgo);
- const findFirstStatusOption = () => findStatusDropdown().find(GlDeprecatedDropdownItem);
+ const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
const findPagination = () => wrapper.find(GlPagination);
const findSearch = () => wrapper.find(GlSearchBoxByType);
const findSeverityColumnHeader = () =>
@@ -295,10 +295,30 @@ describe('AlertManagementTable', () => {
loading: false,
});
+ expect(visitUrl).not.toHaveBeenCalled();
+
findAlerts()
.at(0)
.trigger('click');
- expect(visitUrl).toHaveBeenCalledWith('/1527542/details');
+ expect(visitUrl).toHaveBeenCalledWith('/1527542/details', false);
+ });
+
+ it('navigates to the detail page in new tab when alert row is clicked with the metaKey', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ loading: false,
+ });
+
+ expect(visitUrl).not.toHaveBeenCalled();
+
+ findAlerts()
+ .at(0)
+ .trigger('click', {
+ metaKey: true,
+ });
+
+ expect(visitUrl).toHaveBeenCalledWith('/1527542/details', true);
});
describe('alert issue links', () => {
diff --git a/spec/frontend/alert_management/components/alert_summary_row_spec.js b/spec/frontend/alert_management/components/alert_summary_row_spec.js
new file mode 100644
index 00000000000..47c715c089a
--- /dev/null
+++ b/spec/frontend/alert_management/components/alert_summary_row_spec.js
@@ -0,0 +1,40 @@
+import { shallowMount } from '@vue/test-utils';
+import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue';
+
+const label = 'a label';
+const value = 'a value';
+
+describe('AlertSummaryRow', () => {
+ let wrapper;
+
+ function mountComponent({ mountMethod = shallowMount, props, defaultSlot } = {}) {
+ wrapper = mountMethod(AlertSummaryRow, {
+ propsData: props,
+ scopedSlots: {
+ default: defaultSlot,
+ },
+ });
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('Alert Summary Row', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: {
+ label,
+ },
+ defaultSlot: `<span class="value">${value}</span>`,
+ });
+ });
+
+ it('should display a label and a value', () => {
+ expect(wrapper.text()).toBe(`${label} ${value}`);
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
index 4c9db02eff4..1d87301aac9 100644
--- a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
+++ b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import SidebarAssignee from '~/alert_management/components/sidebar/sidebar_assignee.vue';
import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue';
import AlertSetAssignees from '~/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql';
@@ -106,7 +106,7 @@ describe('Alert Details Sidebar Assignees', () => {
it('renders a unassigned option', async () => {
wrapper.setData({ isDropdownSearching: false });
await wrapper.vm.$nextTick();
- expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned');
+ expect(wrapper.find(GlDropdownItem).text()).toBe('Unassigned');
});
it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
diff --git a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
index a8fe40687e1..e144d473c12 100644
--- a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
+++ b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { trackAlertStatusUpdateOptions } from '~/alert_management/constants';
import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue';
import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
@@ -10,9 +10,10 @@ const mockAlert = mockAlerts[0];
describe('Alert Details Sidebar Status', () => {
let wrapper;
- const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown);
- const findStatusDropdownItem = () => wrapper.find(GlDeprecatedDropdownItem);
+ const findStatusDropdown = () => wrapper.find(GlDropdown);
+ const findStatusDropdownItem = () => wrapper.find(GlDropdownItem);
const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const findStatusDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]');
function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) {
wrapper = mount(AlertSidebarStatus, {
@@ -56,11 +57,7 @@ describe('Alert Details Sidebar Status', () => {
});
it('displays the dropdown status header', () => {
- expect(
- findStatusDropdown()
- .find('.dropdown-title')
- .exists(),
- ).toBe(true);
+ expect(findStatusDropdownHeader().exists()).toBe(true);
});
describe('updating the alert status', () => {
diff --git a/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js b/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js
index 8dd663e55d9..65cfc600d76 100644
--- a/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js
+++ b/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import SystemNote from '~/alert_management/components/system_notes/system_note.vue';
import mockAlerts from '../../mocks/alerts.json';
@@ -19,6 +20,7 @@ describe('Alert Details System Note', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
+ wrapper = null;
}
});
@@ -29,10 +31,10 @@ describe('Alert Details System Note', () => {
it('renders the correct system note', () => {
const noteId = wrapper.find('.note-wrapper').attributes('id');
- const iconRoute = wrapper.find('use').attributes('href');
+ const iconName = wrapper.find(GlIcon).attributes('name');
expect(noteId).toBe('note_1628');
- expect(iconRoute.includes('user')).toBe(true);
+ expect(iconName).toBe(mockAlert.notes.nodes[0].systemNoteIconName);
});
});
});
diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
index 16e92bf505a..545be94dcaa 100644
--- a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
+++ b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
@@ -26,7 +26,7 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
</gl-form-group-stub>
<gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\" label-class=\\"label-bold\\">
<gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub>
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
<gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.
</gl-modal-stub>
@@ -34,16 +34,14 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
<gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\" label-class=\\"label-bold\\">
<gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub>
</gl-form-group-stub>
- <div class=\\"gl-display-flex gl-justify-content-end\\">
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub>
- </div>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub>
<div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\">
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">
- Cancel
- </gl-button-stub>
- <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">
+ <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">
Save changes
</gl-button-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">
+ Cancel
+ </gl-button-stub>
</div>
</gl-form-stub>
</div>"
diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js
new file mode 100644
index 00000000000..242621dc40c
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js
@@ -0,0 +1,24 @@
+import { shallowMount } from '@vue/test-utils';
+import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue';
+import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
+
+describe('InstanceStatisticsApp', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(InstanceStatisticsApp);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays the instance counts component', () => {
+ expect(wrapper.find(InstanceCounts).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js
new file mode 100644
index 00000000000..2274f4c3fde
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js
@@ -0,0 +1,54 @@
+import { shallowMount } from '@vue/test-utils';
+import InstanceCounts from '~/analytics/instance_statistics/components/instance_counts.vue';
+import MetricCard from '~/analytics/shared/components/metric_card.vue';
+import countsMockData from '../mock_data';
+
+describe('InstanceCounts', () => {
+ let wrapper;
+
+ const createComponent = ({ loading = false, data = {} } = {}) => {
+ const $apollo = {
+ queries: {
+ counts: {
+ loading,
+ },
+ },
+ };
+
+ wrapper = shallowMount(InstanceCounts, {
+ mocks: { $apollo },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findMetricCard = () => wrapper.find(MetricCard);
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ createComponent({ loading: true });
+ });
+
+ it('displays the metric card with isLoading=true', () => {
+ expect(findMetricCard().props('isLoading')).toBe(true);
+ });
+ });
+
+ describe('with data', () => {
+ beforeEach(() => {
+ createComponent({ data: { counts: countsMockData } });
+ });
+
+ it('passes the counts data to the metric card', () => {
+ expect(findMetricCard().props('metrics')).toEqual(countsMockData);
+ });
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/instance_statistics/mock_data.js
new file mode 100644
index 00000000000..9fabf3a4c65
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/mock_data.js
@@ -0,0 +1,4 @@
+export default [
+ { key: 'projects', value: 10, label: 'Projects' },
+ { key: 'groups', value: 20, label: 'Group' },
+];
diff --git a/spec/frontend/analytics/shared/components/metric_card_spec.js b/spec/frontend/analytics/shared/components/metric_card_spec.js
new file mode 100644
index 00000000000..e89d499ed9b
--- /dev/null
+++ b/spec/frontend/analytics/shared/components/metric_card_spec.js
@@ -0,0 +1,129 @@
+import { mount } from '@vue/test-utils';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import MetricCard from '~/analytics/shared/components/metric_card.vue';
+
+const metrics = [
+ { key: 'first_metric', value: 10, label: 'First metric', unit: 'days', link: 'some_link' },
+ { key: 'second_metric', value: 20, label: 'Yet another metric' },
+ { key: 'third_metric', value: null, label: 'Null metric without value', unit: 'parsecs' },
+ { key: 'fourth_metric', value: '-', label: 'Metric without value', unit: 'parsecs' },
+];
+
+const defaultProps = {
+ title: 'My fancy title',
+ isLoading: false,
+ metrics,
+};
+
+describe('MetricCard', () => {
+ let wrapper;
+
+ const factory = (props = defaultProps) => {
+ wrapper = mount(MetricCard, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findTitle = () => wrapper.find({ ref: 'title' });
+ const findLoadingIndicator = () => wrapper.find(GlSkeletonLoading);
+ const findMetricsWrapper = () => wrapper.find({ ref: 'metricsWrapper' });
+ const findMetricItem = () => wrapper.findAll({ ref: 'metricItem' });
+ const findTooltip = () => wrapper.find('[data-testid="tooltip"]');
+
+ describe('template', () => {
+ it('renders the title', () => {
+ factory();
+
+ expect(findTitle().text()).toContain('My fancy title');
+ });
+
+ describe('when isLoading is true', () => {
+ beforeEach(() => {
+ factory({ isLoading: true });
+ });
+
+ it('displays a loading indicator', () => {
+ expect(findLoadingIndicator().exists()).toBe(true);
+ });
+
+ it('does not display the metrics container', () => {
+ expect(findMetricsWrapper().exists()).toBe(false);
+ });
+ });
+
+ describe('when isLoading is false', () => {
+ beforeEach(() => {
+ factory({ isLoading: false });
+ });
+
+ it('does not display a loading indicator', () => {
+ expect(findLoadingIndicator().exists()).toBe(false);
+ });
+
+ it('displays the metrics container', () => {
+ expect(findMetricsWrapper().exists()).toBe(true);
+ });
+
+ it('renders two metrics', () => {
+ expect(findMetricItem()).toHaveLength(metrics.length);
+ });
+
+ describe('with tooltip text', () => {
+ const tooltipText = 'This is a tooltip';
+ const tooltipMetric = {
+ key: 'fifth_metric',
+ value: '-',
+ label: 'Metric with tooltip',
+ unit: 'parsecs',
+ tooltipText,
+ };
+
+ beforeEach(() => {
+ factory({
+ isLoading: false,
+ metrics: [tooltipMetric],
+ });
+ });
+
+ it('will render a tooltip', () => {
+ const tt = getBinding(findTooltip().element, 'gl-tooltip');
+ expect(tt.value.title).toEqual(tooltipText);
+ });
+ });
+
+ describe.each`
+ columnIndex | label | value | unit | link
+ ${0} | ${'First metric'} | ${10} | ${' days'} | ${'some_link'}
+ ${1} | ${'Yet another metric'} | ${20} | ${''} | ${null}
+ ${2} | ${'Null metric without value'} | ${'-'} | ${''} | ${null}
+ ${3} | ${'Metric without value'} | ${'-'} | ${''} | ${null}
+ `('metric columns', ({ columnIndex, label, value, unit, link }) => {
+ it(`renders ${value}${unit} ${label} with URL ${link}`, () => {
+ const allMetricItems = findMetricItem();
+ const metricItem = allMetricItems.at(columnIndex);
+ const text = metricItem.text();
+
+ expect(text).toContain(`${value}${unit}`);
+ expect(text).toContain(label);
+
+ if (link) {
+ expect(metricItem.find('a').attributes('href')).toBe(link);
+ } else {
+ expect(metricItem.find('a').exists()).toBe(false);
+ }
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 3ae0d06162d..f7c6290ce1c 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -1152,4 +1152,44 @@ describe('Api', () => {
});
});
});
+
+ describe('trackRedisHllUserEvent', () => {
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/usage_data/increment_unique_users`;
+
+ const event = 'dummy_event';
+ const postData = { event };
+ const headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ describe('when usage data increment unique users is called with feature flag disabled', () => {
+ beforeEach(() => {
+ gon.features = { ...gon.features, usageDataApi: false };
+ });
+
+ it('returns null', () => {
+ jest.spyOn(axios, 'post');
+ mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true);
+
+ expect(axios.post).toHaveBeenCalledTimes(0);
+ expect(Api.trackRedisHllUserEvent(event)).toEqual(null);
+ });
+ });
+
+ describe('when usage data increment unique users is called', () => {
+ beforeEach(() => {
+ gon.features = { ...gon.features, usageDataApi: true };
+ });
+
+ it('resolves the Promise', () => {
+ jest.spyOn(axios, 'post');
+ mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true);
+
+ return Api.trackRedisHllUserEvent(event).then(({ data }) => {
+ expect(data).toEqual(true);
+ expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers });
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js
index f0ed18248f0..7fd6a9e7b87 100644
--- a/spec/frontend/awards_handler_spec.js
+++ b/spec/frontend/awards_handler_spec.js
@@ -309,6 +309,30 @@ describe('AwardsHandler', () => {
expect($('[data-name=alien]').is(':visible')).toBe(true);
expect($('.js-emoji-menu-search').val()).toBe('');
});
+
+ it('should fuzzy filter the emoji', async () => {
+ await openAndWaitForEmojiMenu();
+
+ awardsHandler.searchEmojis('sgls');
+
+ expect($('[data-name=angel]').is(':visible')).toBe(false);
+ expect($('[data-name=anger]').is(':visible')).toBe(false);
+ expect($('[data-name=sunglasses]').is(':visible')).toBe(true);
+ });
+
+ it('should filter by emoji description', async () => {
+ await openAndWaitForEmojiMenu();
+
+ awardsHandler.searchEmojis('baby');
+ expect($('[data-name=angel]').is(':visible')).toBe(true);
+ });
+
+ it('should filter by emoji unicode value', async () => {
+ await openAndWaitForEmojiMenu();
+
+ awardsHandler.searchEmojis('👼');
+ expect($('[data-name=angel]').is(':visible')).toBe(true);
+ });
});
describe('emoji menu', () => {
diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js
index 2b63ece28ba..8ddad3dacfe 100644
--- a/spec/frontend/batch_comments/components/preview_item_spec.js
+++ b/spec/frontend/batch_comments/components/preview_item_spec.js
@@ -43,22 +43,6 @@ describe('Batch comments draft preview item component', () => {
);
});
- it('adds is last class', () => {
- createComponent(true);
-
- expect(vm.$el.classList).toContain('is-last');
- });
-
- it('scrolls to draft on click', () => {
- createComponent();
-
- jest.spyOn(vm.$store, 'dispatch').mockImplementation();
-
- vm.$el.click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/scrollToDraft', vm.draft);
- });
-
describe('for file', () => {
it('renders file path', () => {
createComponent(false, { file_path: 'index.js', file_hash: 'abc', position: {} });
diff --git a/spec/frontend/batch_comments/components/publish_button_spec.js b/spec/frontend/batch_comments/components/publish_button_spec.js
index 4362f62c7f8..4032713150c 100644
--- a/spec/frontend/batch_comments/components/publish_button_spec.js
+++ b/spec/frontend/batch_comments/components/publish_button_spec.js
@@ -29,17 +29,6 @@ describe('Batch comments publish button component', () => {
expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/publishReview', undefined);
});
- it('dispatches toggleReviewDropdown when shouldPublish is false on click', () => {
- vm.shouldPublish = false;
-
- vm.$el.click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith(
- 'batchComments/toggleReviewDropdown',
- undefined,
- );
- });
-
it('sets loading when isPublishing is true', done => {
vm.$store.state.batchComments.isPublishing = true;
diff --git a/spec/frontend/batch_comments/components/publish_dropdown_spec.js b/spec/frontend/batch_comments/components/publish_dropdown_spec.js
index fb3c532174d..f235867f002 100644
--- a/spec/frontend/batch_comments/components/publish_dropdown_spec.js
+++ b/spec/frontend/batch_comments/components/publish_dropdown_spec.js
@@ -1,96 +1,39 @@
-import Vue from 'vue';
-import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue';
import { createStore } from '~/mr_notes/stores';
import '~/behaviors/markdown/render_gfm';
import { createDraft } from '../mock_data';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('Batch comments publish dropdown component', () => {
- let vm;
- let Component;
+ let wrapper;
- function createComponent(extendStore = () => {}) {
+ function createComponent() {
const store = createStore();
store.state.batchComments.drafts.push(createDraft(), { ...createDraft(), id: 2 });
- extendStore(store);
-
- vm = mountComponentWithStore(Component, { store });
+ wrapper = shallowMount(PreviewDropdown, {
+ store,
+ });
}
- beforeAll(() => {
- Component = Vue.extend(PreviewDropdown);
- });
-
afterEach(() => {
- vm.$destroy();
- });
-
- it('toggles dropdown when clicking button', done => {
- createComponent();
-
- jest.spyOn(vm.$store, 'dispatch');
-
- vm.$el.querySelector('.review-preview-dropdown-toggle').click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith(
- 'batchComments/toggleReviewDropdown',
- expect.anything(),
- );
-
- setImmediate(() => {
- expect(vm.$el.classList).toContain('show');
-
- done();
- });
- });
-
- it('toggles dropdown when clicking body', () => {
- createComponent();
-
- vm.$store.state.batchComments.showPreviewDropdown = true;
-
- jest.spyOn(vm.$store, 'dispatch').mockImplementation();
-
- document.body.click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith(
- 'batchComments/toggleReviewDropdown',
- undefined,
- );
+ wrapper.destroy();
});
it('renders list of drafts', () => {
- createComponent(store => {
- Object.assign(store.state.notes, {
- isNotesFetched: true,
- });
- });
-
- expect(vm.$el.querySelectorAll('.dropdown-content li').length).toBe(2);
- });
-
- it('adds is-last class to last item', () => {
- createComponent(store => {
- Object.assign(store.state.notes, {
- isNotesFetched: true,
- });
- });
-
- expect(vm.$el.querySelectorAll('.dropdown-content li')[1].querySelector('.is-last')).not.toBe(
- null,
- );
- });
-
- it('renders draft count in dropdown title', () => {
createComponent();
- expect(vm.$el.querySelector('.dropdown-title').textContent).toContain('2 pending comments');
+ expect(wrapper.findAll(GlDropdownItem).length).toBe(2);
});
- it('renders publish button in footer', () => {
+ it('renders draft count in dropdown title', () => {
createComponent();
- expect(vm.$el.querySelector('.dropdown-footer .js-publish-draft-button')).not.toBe(null);
+ expect(wrapper.find(GlDropdown).props('headerText')).toEqual('2 pending comments');
});
});
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
index a6942115649..e66f36aa3a2 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
@@ -199,42 +199,6 @@ describe('Batch comments store actions', () => {
});
});
- describe('discardReview', () => {
- it('commits mutations', done => {
- const getters = {
- getNotesData: { draftsDiscardPath: TEST_HOST },
- };
- const commit = jest.fn();
- mock.onAny().reply(200);
-
- actions
- .discardReview({ getters, commit })
- .then(() => {
- expect(commit.mock.calls[0]).toEqual(['REQUEST_DISCARD_REVIEW']);
- expect(commit.mock.calls[1]).toEqual(['RECEIVE_DISCARD_REVIEW_SUCCESS']);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('commits error mutations', done => {
- const getters = {
- getNotesData: { draftsDiscardPath: TEST_HOST },
- };
- const commit = jest.fn();
- mock.onAny().reply(500);
-
- actions
- .discardReview({ getters, commit })
- .then(() => {
- expect(commit.mock.calls[0]).toEqual(['REQUEST_DISCARD_REVIEW']);
- expect(commit.mock.calls[1]).toEqual(['RECEIVE_DISCARD_REVIEW_ERROR']);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
describe('updateDraft', () => {
let getters;
@@ -284,56 +248,6 @@ describe('Batch comments store actions', () => {
});
});
- describe('toggleReviewDropdown', () => {
- it('dispatches openReviewDropdown', done => {
- testAction(
- actions.toggleReviewDropdown,
- null,
- { showPreviewDropdown: false },
- [],
- [{ type: 'openReviewDropdown' }],
- done,
- );
- });
-
- it('dispatches closeReviewDropdown when showPreviewDropdown is true', done => {
- testAction(
- actions.toggleReviewDropdown,
- null,
- { showPreviewDropdown: true },
- [],
- [{ type: 'closeReviewDropdown' }],
- done,
- );
- });
- });
-
- describe('openReviewDropdown', () => {
- it('commits OPEN_REVIEW_DROPDOWN', done => {
- testAction(
- actions.openReviewDropdown,
- null,
- null,
- [{ type: 'OPEN_REVIEW_DROPDOWN' }],
- [],
- done,
- );
- });
- });
-
- describe('closeReviewDropdown', () => {
- it('commits CLOSE_REVIEW_DROPDOWN', done => {
- testAction(
- actions.closeReviewDropdown,
- null,
- null,
- [{ type: 'CLOSE_REVIEW_DROPDOWN' }],
- [],
- done,
- );
- });
- });
-
describe('expandAllDiscussions', () => {
it('dispatches expandDiscussion for all drafts', done => {
const state = {
@@ -383,9 +297,7 @@ describe('Batch comments store actions', () => {
actions.scrollToDraft({ dispatch, rootGetters }, draft);
- expect(dispatch.mock.calls[0]).toEqual(['closeReviewDropdown']);
-
- expect(dispatch.mock.calls[1]).toEqual([
+ expect(dispatch.mock.calls[0]).toEqual([
'expandDiscussion',
{ discussionId: '1' },
{ root: true },
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
index a86726269ef..1406f66fd10 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
@@ -89,42 +89,6 @@ describe('Batch comments mutations', () => {
});
});
- describe(types.REQUEST_DISCARD_REVIEW, () => {
- it('sets isDiscarding to true', () => {
- mutations[types.REQUEST_DISCARD_REVIEW](state);
-
- expect(state.isDiscarding).toBe(true);
- });
- });
-
- describe(types.RECEIVE_DISCARD_REVIEW_SUCCESS, () => {
- it('emptys drafts array', () => {
- state.drafts.push('test');
-
- mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state);
-
- expect(state.drafts).toEqual([]);
- });
-
- it('sets isDiscarding to false', () => {
- state.isDiscarding = true;
-
- mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state);
-
- expect(state.isDiscarding).toBe(false);
- });
- });
-
- describe(types.RECEIVE_DISCARD_REVIEW_ERROR, () => {
- it('updates isDiscarding to false', () => {
- state.isDiscarding = true;
-
- mutations[types.RECEIVE_DISCARD_REVIEW_ERROR](state);
-
- expect(state.isDiscarding).toBe(false);
- });
- });
-
describe(types.RECEIVE_DRAFT_UPDATE_SUCCESS, () => {
it('updates draft in store', () => {
state.drafts.push({ id: 1 });
@@ -140,20 +104,4 @@ describe('Batch comments mutations', () => {
]);
});
});
-
- describe(types.OPEN_REVIEW_DROPDOWN, () => {
- it('sets showPreviewDropdown to true', () => {
- mutations[types.OPEN_REVIEW_DROPDOWN](state);
-
- expect(state.showPreviewDropdown).toBe(true);
- });
- });
-
- describe(types.CLOSE_REVIEW_DROPDOWN, () => {
- it('sets showPreviewDropdown to false', () => {
- mutations[types.CLOSE_REVIEW_DROPDOWN](state);
-
- expect(state.showPreviewDropdown).toBe(false);
- });
- });
});
diff --git a/spec/frontend/behaviors/load_startup_css_spec.js b/spec/frontend/behaviors/load_startup_css_spec.js
new file mode 100644
index 00000000000..81222ac5aaa
--- /dev/null
+++ b/spec/frontend/behaviors/load_startup_css_spec.js
@@ -0,0 +1,44 @@
+import { setHTMLFixture } from 'helpers/fixtures';
+import { loadStartupCSS } from '~/behaviors/load_startup_css';
+
+describe('behaviors/load_startup_css', () => {
+ let loadListener;
+
+ const setupListeners = () => {
+ document
+ .querySelectorAll('link')
+ .forEach(x => x.addEventListener('load', () => loadListener(x)));
+ };
+
+ beforeEach(() => {
+ loadListener = jest.fn();
+
+ setHTMLFixture(`
+ <meta charset="utf-8" />
+ <link media="print" src="./lorem-print.css" />
+ <link media="print" src="./ipsum-print.css" />
+ <link media="all" src="./dolar-all.css" />
+ `);
+
+ setupListeners();
+
+ loadStartupCSS();
+ });
+
+ it('does nothing at first', () => {
+ expect(loadListener).not.toHaveBeenCalled();
+ });
+
+ describe('on window load', () => {
+ beforeEach(() => {
+ window.dispatchEvent(new Event('load'));
+ });
+
+ it('dispatches load to the print links', () => {
+ expect(loadListener.mock.calls.map(([el]) => el.getAttribute('src'))).toEqual([
+ './lorem-print.css',
+ './ipsum-print.css',
+ ]);
+ });
+ });
+});
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 0f5b3cd3f5e..53815820bbe 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
@@ -27,8 +27,10 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
</small>
<clipboard-button-stub
+ category="tertiary"
cssclass="btn-clipboard btn-transparent lh-100 position-static"
gfm="\`foo/bar/dummy.md\`"
+ size="medium"
text="foo/bar/dummy.md"
title="Copy file path"
tooltipplacement="top"
diff --git a/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js b/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js
deleted file mode 100644
index 8dc71f99010..00000000000
--- a/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import waitForPromises from 'helpers/wait_for_promises';
-import { shallowMount } from '@vue/test-utils';
-import { GlButton, GlAlert } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import WebIdeAlert from '~/blob/suggest_web_ide_ci/components/web_ide_alert.vue';
-
-const dismissEndpoint = '/-/user_callouts';
-const featureId = 'web_ide_alert_dismissed';
-const editPath = 'edit/master/-/.gitlab-ci.yml';
-
-describe('WebIdeAlert', () => {
- let wrapper;
- let mock;
-
- const findButton = () => wrapper.find(GlButton);
- const findAlert = () => wrapper.find(GlAlert);
- const dismissAlert = alertWrapper => alertWrapper.vm.$emit('dismiss');
- const getPostPayload = () => JSON.parse(mock.history.post[0].data);
-
- const createComponent = () => {
- wrapper = shallowMount(WebIdeAlert, {
- propsData: {
- dismissEndpoint,
- featureId,
- editPath,
- },
- });
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- mock.onPost(dismissEndpoint).reply(200);
-
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
- mock.restore();
- });
-
- describe('with defaults', () => {
- it('displays alert correctly', () => {
- expect(findAlert().exists()).toBe(true);
- });
-
- it('web ide button link has correct path', () => {
- expect(findButton().attributes('href')).toBe(editPath);
- });
-
- it('dismisses alert correctly', async () => {
- const alertWrapper = findAlert();
-
- dismissAlert(alertWrapper);
-
- await waitForPromises();
-
- expect(alertWrapper.exists()).toBe(false);
- expect(mock.history.post).toHaveLength(1);
- expect(getPostPayload()).toEqual({ feature_name: featureId });
- });
- });
-});
diff --git a/spec/frontend/boards/board_blank_state_spec.js b/spec/frontend/boards/board_blank_state_spec.js
deleted file mode 100644
index 3ffdda52f58..00000000000
--- a/spec/frontend/boards/board_blank_state_spec.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import Vue from 'vue';
-import boardsStore from '~/boards/stores/boards_store';
-import BoardBlankState from '~/boards/components/board_blank_state.vue';
-
-describe('Boards blank state', () => {
- let vm;
- let fail = false;
-
- beforeEach(done => {
- const Comp = Vue.extend(BoardBlankState);
-
- boardsStore.create();
-
- jest.spyOn(boardsStore, 'addList').mockImplementation();
- jest.spyOn(boardsStore, 'removeList').mockImplementation();
- jest.spyOn(boardsStore, 'generateDefaultLists').mockImplementation(
- () =>
- new Promise((resolve, reject) => {
- if (fail) {
- reject();
- } else {
- resolve({
- data: [
- {
- id: 1,
- title: 'To Do',
- label: { id: 1 },
- },
- {
- id: 2,
- title: 'Doing',
- label: { id: 2 },
- },
- ],
- });
- }
- }),
- );
-
- vm = new Comp();
-
- setImmediate(() => {
- vm.$mount();
- done();
- });
- });
-
- it('renders pre-defined labels', () => {
- expect(vm.$el.querySelectorAll('.board-blank-state-list li').length).toBe(2);
-
- expect(vm.$el.querySelectorAll('.board-blank-state-list li')[0].textContent.trim()).toEqual(
- 'To Do',
- );
-
- expect(vm.$el.querySelectorAll('.board-blank-state-list li')[1].textContent.trim()).toEqual(
- 'Doing',
- );
- });
-
- it('clears blank state', done => {
- vm.$el.querySelector('.btn-default').click();
-
- setImmediate(() => {
- expect(boardsStore.welcomeIsHidden()).toBeTruthy();
-
- done();
- });
- });
-
- it('creates pre-defined labels', done => {
- vm.$el.querySelector('.btn-success').click();
-
- setImmediate(() => {
- expect(boardsStore.addList).toHaveBeenCalledTimes(2);
- expect(boardsStore.addList).toHaveBeenCalledWith(expect.objectContaining({ title: 'To Do' }));
-
- expect(boardsStore.addList).toHaveBeenCalledWith(expect.objectContaining({ title: 'Doing' }));
-
- done();
- });
- });
-
- it('resets the store if request fails', done => {
- fail = true;
-
- vm.$el.querySelector('.btn-success').click();
-
- setImmediate(() => {
- expect(boardsStore.welcomeIsHidden()).toBeFalsy();
- expect(boardsStore.removeList).toHaveBeenCalledWith(undefined, 'label');
-
- done();
- });
- });
-});
diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js
index 41971137b95..e7c1cf79fdc 100644
--- a/spec/frontend/boards/boards_store_spec.js
+++ b/spec/frontend/boards/boards_store_spec.js
@@ -1,7 +1,7 @@
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import axios from '~/lib/utils/axios_utils';
-import boardsStore from '~/boards/stores/boards_store';
+import boardsStore, { gqlClient } from '~/boards/stores/boards_store';
import eventHub from '~/boards/eventhub';
import { listObj, listObjDuplicate } from './mock_data';
@@ -503,11 +503,15 @@ describe('boardsStore', () => {
beforeEach(() => {
requestSpy = jest.fn();
axiosMock.onPut(url).replyOnce(config => requestSpy(config));
+ jest.spyOn(gqlClient, 'mutate').mockReturnValue(Promise.resolve({}));
});
it('makes a request to update the board', () => {
requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
+ const expectedResponse = [
+ expect.objectContaining({ data: dummyResponse }),
+ expect.objectContaining({}),
+ ];
return expect(
boardsStore.createBoard({
@@ -555,11 +559,12 @@ describe('boardsStore', () => {
beforeEach(() => {
requestSpy = jest.fn();
axiosMock.onPost(url).replyOnce(config => requestSpy(config));
+ jest.spyOn(gqlClient, 'mutate').mockReturnValue(Promise.resolve({}));
});
it('makes a request to create a new board', () => {
requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
+ const expectedResponse = dummyResponse;
return expect(boardsStore.createBoard(board))
.resolves.toEqual(expectedResponse)
@@ -740,14 +745,6 @@ describe('boardsStore', () => {
expect(boardsStore.shouldAddBlankState()).toBe(true);
});
- it('adds the blank state', () => {
- boardsStore.addBlankState();
-
- const list = boardsStore.findList('type', 'blank', 'blank');
-
- expect(list).toBeDefined();
- });
-
it('removes list from state', () => {
boardsStore.addList(listObj);
diff --git a/spec/frontend/boards/components/board_configuration_options_spec.js b/spec/frontend/boards/components/board_configuration_options_spec.js
new file mode 100644
index 00000000000..e9a1cb6a4e8
--- /dev/null
+++ b/spec/frontend/boards/components/board_configuration_options_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import BoardConfigurationOptions from '~/boards/components/board_configuration_options.vue';
+
+describe('BoardConfigurationOptions', () => {
+ let wrapper;
+ const board = { hide_backlog_list: false, hide_closed_list: false };
+
+ const defaultProps = {
+ currentBoard: board,
+ board,
+ isNewForm: false,
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(BoardConfigurationOptions, {
+ propsData: { ...defaultProps },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const backlogListCheckbox = el => el.find('[data-testid="backlog-list-checkbox"]');
+ const closedListCheckbox = el => el.find('[data-testid="closed-list-checkbox"]');
+
+ const checkboxAssert = (backlogCheckbox, closedCheckbox) => {
+ expect(backlogListCheckbox(wrapper).attributes('checked')).toEqual(
+ backlogCheckbox ? undefined : 'true',
+ );
+ expect(closedListCheckbox(wrapper).attributes('checked')).toEqual(
+ closedCheckbox ? undefined : 'true',
+ );
+ };
+
+ it.each`
+ backlogCheckboxValue | closedCheckboxValue
+ ${true} | ${true}
+ ${true} | ${false}
+ ${false} | ${true}
+ ${false} | ${false}
+ `(
+ 'renders two checkbox when one is $backlogCheckboxValue and other is $closedCheckboxValue',
+ async ({ backlogCheckboxValue, closedCheckboxValue }) => {
+ await wrapper.setData({
+ hideBacklogList: backlogCheckboxValue,
+ hideClosedList: closedCheckboxValue,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ checkboxAssert(backlogCheckboxValue, closedCheckboxValue);
+ });
+ },
+ );
+});
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
index df117d06cdf..09e38001e2e 100644
--- a/spec/frontend/boards/components/board_content_spec.js
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -23,9 +23,6 @@ describe('BoardContent', () => {
return new Vuex.Store({
getters,
state,
- actions: {
- fetchIssuesForAllLists: () => {},
- },
});
};
diff --git a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
index 1dbcbd06407..e7139ceaa93 100644
--- a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
@@ -96,12 +96,22 @@ describe('boards sidebar remove issue', () => {
expect(findExpanded().isVisible()).toBe(false);
});
- it('emits changed event', async () => {
+ it('emits close event', async () => {
document.body.click();
await wrapper.vm.$nextTick();
- expect(wrapper.emitted().changed[1][0]).toBe(false);
+ expect(wrapper.emitted().close.length).toBe(1);
});
});
+
+ it('emits open when edit button is clicked and edit is initailized to false', async () => {
+ createComponent({ canUpdate: true });
+
+ findEditButton().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted().open.length).toBe(1);
+ });
});
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index bdbcd435708..6415a5a5d3a 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -6,12 +6,13 @@ import {
mockIssueWithModel,
mockIssue2WithModel,
rawIssue,
+ mockIssues,
} from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import { inactiveId, ListType } from '~/boards/constants';
import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql';
-import { fullBoardId } from '~/boards/boards_util';
+import { fullBoardId, formatListIssues } from '~/boards/boards_util';
const expectNotImplemented = action => {
it('is not implemented', () => {
@@ -237,6 +238,77 @@ describe('deleteList', () => {
expectNotImplemented(actions.deleteList);
});
+describe('fetchIssuesForList', () => {
+ const listId = mockLists[0].id;
+
+ const state = {
+ endpoints: {
+ fullPath: 'gitlab-org',
+ boardId: 1,
+ },
+ filterParams: {},
+ boardType: 'group',
+ };
+
+ const queryResponse = {
+ data: {
+ group: {
+ board: {
+ lists: {
+ nodes: [
+ {
+ id: listId,
+ issues: {
+ nodes: mockIssues,
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ };
+
+ const formattedIssues = formatListIssues(queryResponse.data.group.board.lists);
+
+ it('should commit mutation RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
+
+ testAction(
+ actions.fetchIssuesForList,
+ listId,
+ state,
+ [
+ {
+ type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS,
+ payload: { listIssues: formattedIssues, listId },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('should commit mutation RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
+
+ testAction(
+ actions.fetchIssuesForList,
+ listId,
+ state,
+ [{ type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId }],
+ [],
+ done,
+ );
+ });
+});
+
+describe('resetIssues', () => {
+ it('commits RESET_ISSUES mutation', () => {
+ return testAction(actions.resetIssues, {}, {}, [{ type: types.RESET_ISSUES }], []);
+ });
+});
+
describe('moveIssue', () => {
const listIssues = {
'gid://gitlab/List/1': [436, 437],
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index a13a99a507e..c80537bf168 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -145,6 +145,23 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR);
});
+ describe('RESET_ISSUES', () => {
+ it('should remove issues from issuesByListId state', () => {
+ const issuesByListId = {
+ 'gid://gitlab/List/1': [mockIssue.id],
+ };
+
+ state = {
+ ...state,
+ issuesByListId,
+ };
+
+ mutations[types.RESET_ISSUES](state);
+
+ expect(state.issuesByListId).toEqual({ 'gid://gitlab/List/1': [] });
+ });
+ });
+
describe('RECEIVE_ISSUES_FOR_LIST_SUCCESS', () => {
it('updates issuesByListId and issues on state', () => {
const listIssues = {
@@ -156,7 +173,6 @@ describe('Board Store Mutations', () => {
state = {
...state,
- isLoadingIssues: true,
issuesByListId: {},
issues: {},
boardLists: mockListsWithModel,
@@ -172,16 +188,6 @@ describe('Board Store Mutations', () => {
});
});
- describe('REQUEST_ISSUES_FOR_ALL_LISTS', () => {
- it('sets isLoadingIssues to true', () => {
- expect(state.isLoadingIssues).toBe(false);
-
- mutations.REQUEST_ISSUES_FOR_ALL_LISTS(state);
-
- expect(state.isLoadingIssues).toBe(true);
- });
- });
-
describe('RECEIVE_ISSUES_FOR_LIST_FAILURE', () => {
it('sets error message', () => {
state = {
@@ -200,51 +206,10 @@ describe('Board Store Mutations', () => {
});
});
- describe('RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS', () => {
- it('sets isLoadingIssues to false and updates issuesByListId object', () => {
- const listIssues = {
- 'gid://gitlab/List/1': [mockIssue.id],
- };
- const issues = {
- '1': mockIssue,
- };
-
- state = {
- ...state,
- isLoadingIssues: true,
- issuesByListId: {},
- issues: {},
- };
-
- mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS(state, { listData: listIssues, issues });
-
- expect(state.isLoadingIssues).toBe(false);
- expect(state.issuesByListId).toEqual(listIssues);
- expect(state.issues).toEqual(issues);
- });
- });
-
describe('REQUEST_ADD_ISSUE', () => {
expectNotImplemented(mutations.REQUEST_ADD_ISSUE);
});
- describe('RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE', () => {
- it('sets isLoadingIssues to false and sets error message', () => {
- state = {
- ...state,
- isLoadingIssues: true,
- error: undefined,
- };
-
- mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE(state);
-
- expect(state.isLoadingIssues).toBe(false);
- expect(state.error).toEqual(
- 'An error occurred while fetching the board issues. Please reload the page.',
- );
- });
- });
-
describe('UPDATE_ISSUE_BY_ID', () => {
const issueId = '1';
const prop = 'id';
@@ -254,7 +219,6 @@ describe('Board Store Mutations', () => {
beforeEach(() => {
state = {
...state,
- isLoadingIssues: true,
error: undefined,
issues: {
...issue,
diff --git a/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js b/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js
new file mode 100644
index 00000000000..e07afb5d736
--- /dev/null
+++ b/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js
@@ -0,0 +1,102 @@
+import { mount } from '@vue/test-utils';
+import { GlTable, GlBadge } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import TriggersList from '~/ci_settings_pipeline_triggers/components/triggers_list.vue';
+import { triggers } from '../mock_data';
+
+describe('TriggersList', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(TriggersList, {
+ propsData: { triggers, ...props },
+ });
+ };
+
+ const findTable = () => wrapper.find(GlTable);
+ const findHeaderAt = i => wrapper.findAll('thead th').at(i);
+ const findRows = () => wrapper.findAll('tbody tr');
+ const findRowAt = i => findRows().at(i);
+ const findCell = (i, col) =>
+ findRowAt(i)
+ .findAll('td')
+ .at(col);
+ const findClipboardBtn = i => findCell(i, 0).find(ClipboardButton);
+ const findInvalidBadge = i => findCell(i, 0).find(GlBadge);
+ const findEditBtn = i => findRowAt(i).find('[data-testid="edit-btn"]');
+ const findRevokeBtn = i => findRowAt(i).find('[data-testid="trigger_revoke_button"]');
+
+ beforeEach(() => {
+ createComponent();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays a table with expected headers', () => {
+ const headers = ['Token', 'Description', 'Owner', 'Last Used', ''];
+ headers.forEach((header, i) => {
+ expect(findHeaderAt(i).text()).toBe(header);
+ });
+ });
+
+ it('displays a table with rows', () => {
+ expect(findRows()).toHaveLength(triggers.length);
+
+ const [trigger] = triggers;
+
+ expect(findCell(0, 0).text()).toBe(trigger.token);
+ expect(findCell(0, 1).text()).toBe(trigger.description);
+ expect(findCell(0, 2).text()).toContain(trigger.owner.name);
+ });
+
+ it('displays a "copy to cliboard" button for exposed tokens', () => {
+ expect(findClipboardBtn(0).exists()).toBe(true);
+ expect(findClipboardBtn(0).props('text')).toBe(triggers[0].token);
+
+ expect(findClipboardBtn(1).exists()).toBe(false);
+ });
+
+ it('displays an "invalid" label for tokens without access', () => {
+ expect(findInvalidBadge(0).exists()).toBe(false);
+
+ expect(findInvalidBadge(1).exists()).toBe(true);
+ });
+
+ it('displays a time ago label when last used', () => {
+ expect(findCell(0, 3).text()).toBe('Never');
+
+ expect(
+ findCell(1, 3)
+ .find(TimeAgoTooltip)
+ .props('time'),
+ ).toBe(triggers[1].lastUsed);
+ });
+
+ it('displays actions in a rows', () => {
+ const [data] = triggers;
+
+ expect(findEditBtn(0).attributes('href')).toBe(data.editProjectTriggerPath);
+
+ expect(findRevokeBtn(0).attributes('href')).toBe(data.projectTriggerPath);
+ expect(findRevokeBtn(0).attributes('data-method')).toBe('delete');
+ expect(findRevokeBtn(0).attributes('data-confirm')).toBeTruthy();
+ });
+
+ describe('when there are no triggers set', () => {
+ beforeEach(() => {
+ createComponent({ triggers: [] });
+ });
+
+ it('does not display a table', () => {
+ expect(findTable().exists()).toBe(false);
+ });
+
+ it('displays a message', () => {
+ expect(wrapper.text()).toBe(
+ 'No triggers have been created yet. Add one using the form above.',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/ci_settings_pipeline_triggers/mock_data.js b/spec/frontend/ci_settings_pipeline_triggers/mock_data.js
new file mode 100644
index 00000000000..6813e941e03
--- /dev/null
+++ b/spec/frontend/ci_settings_pipeline_triggers/mock_data.js
@@ -0,0 +1,30 @@
+export const triggers = [
+ {
+ hasTokenExposed: true,
+ token: '0000',
+ description: 'My trigger',
+ owner: {
+ name: 'My User',
+ username: 'user1',
+ path: '/user1',
+ },
+ lastUsed: null,
+ canAccessProject: true,
+ editProjectTriggerPath: '/triggers/1/edit',
+ projectTriggerPath: '/trigger/1',
+ },
+ {
+ hasTokenExposed: false,
+ token: '1111',
+ description: "Anothe user's trigger",
+ owner: {
+ name: 'Someone else',
+ username: 'user2',
+ path: '/user2',
+ },
+ lastUsed: '2020-09-10T08:26:47.410Z',
+ canAccessProject: false,
+ editProjectTriggerPath: '/triggers/1/edit',
+ projectTriggerPath: '/trigger/1',
+ },
+];
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 ab32fb12058..5c2d096418d 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
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
-import { GlButton, GlFormCombobox } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants';
import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue';
import createStore from '~/ci_variable_list/store';
@@ -18,7 +18,6 @@ describe('Ci variable modal', () => {
store = createStore();
wrapper = method(CiVariableModal, {
attachToDocument: true,
- provide: { glFeatures: { ciKeyAutocomplete: true } },
stubs: {
GlModal: ModalStub,
},
@@ -42,27 +41,6 @@ describe('Ci variable modal', () => {
wrapper.destroy();
});
- describe('Feature flag', () => {
- describe('when off', () => {
- beforeEach(() => {
- createComponent(shallowMount, { provide: { glFeatures: { ciKeyAutocomplete: false } } });
- });
-
- it('does not render the autocomplete dropdown', () => {
- expect(wrapper.find(GlFormCombobox).exists()).toBe(false);
- });
- });
-
- describe('when on', () => {
- beforeEach(() => {
- createComponent(shallowMount);
- });
- it('renders the autocomplete dropdown', () => {
- expect(wrapper.find(GlFormCombobox).exists()).toBe(true);
- });
- });
- });
-
describe('Basic interactions', () => {
beforeEach(() => {
createComponent(shallowMount);
diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
index c263679a45c..25db8785edc 100644
--- a/spec/frontend/clusters/components/fluentd_output_settings_spec.js
+++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlAlert, GlDeprecatedDropdown, GlFormCheckbox } from '@gitlab/ui';
+import { GlAlert, GlDropdown, GlFormCheckbox } from '@gitlab/ui';
import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue';
import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
import eventHub from '~/clusters/event_hub';
@@ -36,7 +36,7 @@ describe('FluentdOutputSettings', () => {
};
const findSaveButton = () => wrapper.find({ ref: 'saveBtn' });
const findCancelButton = () => wrapper.find({ ref: 'cancelBtn' });
- const findProtocolDropdown = () => wrapper.find(GlDeprecatedDropdown);
+ const findProtocolDropdown = () => wrapper.find(GlDropdown);
const findCheckbox = name =>
wrapper.findAll(GlFormCheckbox).wrappers.find(x => x.text() === name);
const findHost = () => wrapper.find('#fluentd-host');
diff --git a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
index 3a9a608b2e2..1f07a0b7908 100644
--- a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
+++ b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlAlert, GlToggle, GlDeprecatedDropdown } from '@gitlab/ui';
+import { GlAlert, GlToggle, GlDropdown } from '@gitlab/ui';
import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue';
import { APPLICATION_STATUS, INGRESS } from '~/clusters/constants';
import eventHub from '~/clusters/event_hub';
@@ -28,10 +28,12 @@ describe('IngressModsecuritySettings', () => {
});
};
- const findSaveButton = () => wrapper.find('.btn-success');
- const findCancelButton = () => wrapper.find('[variant="secondary"]');
+ const findSaveButton = () =>
+ wrapper.find('[data-qa-selector="save_ingress_modsecurity_settings"]');
+ const findCancelButton = () =>
+ wrapper.find('[data-qa-selector="cancel_ingress_modsecurity_settings"]');
const findModSecurityToggle = () => wrapper.find(GlToggle);
- const findModSecurityDropdown = () => wrapper.find(GlDeprecatedDropdown);
+ const findModSecurityDropdown = () => wrapper.find(GlDropdown);
describe('when ingress is installed', () => {
beforeEach(() => {
diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js
index 11ebe1b5d61..b7f76211fd6 100644
--- a/spec/frontend/clusters/components/knative_domain_editor_spec.js
+++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui';
+import { GlDropdownItem, GlButton } from '@gitlab/ui';
import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue';
import { APPLICATION_STATUS } from '~/clusters/constants';
@@ -112,7 +112,7 @@ describe('KnativeDomainEditor', () => {
createComponent({ knative: { ...knative, availableDomains: [newDomain] } });
jest.spyOn(wrapper.vm, 'selectDomain');
- wrapper.find(GlDeprecatedDropdownItem).vm.$emit('click');
+ wrapper.find(GlDropdownItem).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectDomain).toHaveBeenCalledWith(newDomain);
diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js
index 57c538d2650..3e5f8de8e7b 100644
--- a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js
+++ b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlDropdownItem, GlIcon } from '@gitlab/ui';
import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue';
describe('CrossplaneProviderStack component', () => {
@@ -37,7 +37,7 @@ describe('CrossplaneProviderStack component', () => {
createComponent({ crossplane });
});
- const findDropdownElements = () => wrapper.findAll(GlDeprecatedDropdownItem);
+ const findDropdownElements = () => wrapper.findAll(GlDropdownItem);
const findFirstDropdownElement = () => findDropdownElements().at(0);
afterEach(() => {
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index 628c35ae839..34d99473eb7 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -164,18 +164,18 @@ describe('Clusters', () => {
});
it.each`
- nodeSize | lineNumber
- ${'Unknown'} | ${0}
- ${'1'} | ${1}
- ${'2'} | ${2}
- ${'1'} | ${3}
- ${'1'} | ${4}
- ${'Unknown'} | ${5}
- `('renders node size for each cluster', ({ nodeSize, lineNumber }) => {
+ nodeText | lineNumber
+ ${'Unable to Authenticate'} | ${0}
+ ${'1'} | ${1}
+ ${'2'} | ${2}
+ ${'1'} | ${3}
+ ${'1'} | ${4}
+ ${'Unknown Error'} | ${5}
+ `('renders node size for each cluster', ({ nodeText, lineNumber }) => {
const sizes = findTable().findAll('td:nth-child(3)');
const size = sizes.at(lineNumber);
- expect(size.text()).toBe(nodeSize);
+ expect(size.text()).toContain(nodeText);
expect(size.find(GlSkeletonLoading).exists()).toBe(false);
});
});
diff --git a/spec/frontend/clusters_list/components/node_error_help_text_spec.js b/spec/frontend/clusters_list/components/node_error_help_text_spec.js
new file mode 100644
index 00000000000..4d157b3a8ab
--- /dev/null
+++ b/spec/frontend/clusters_list/components/node_error_help_text_spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlPopover } from '@gitlab/ui';
+import NodeErrorHelpText from '~/clusters_list/components/node_error_help_text.vue';
+
+describe('NodeErrorHelpText', () => {
+ let wrapper;
+
+ const createWrapper = propsData => {
+ wrapper = shallowMount(NodeErrorHelpText, { propsData, stubs: { GlPopover } });
+ return wrapper.vm.$nextTick();
+ };
+
+ const findPopover = () => wrapper.find(GlPopover);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ errorType | wrapperText | popoverText
+ ${'authentication_error'} | ${'Unable to Authenticate'} | ${'GitLab failed to authenticate'}
+ ${'connection_error'} | ${'Unable to Connect'} | ${'GitLab failed to connect to the cluster'}
+ ${'http_error'} | ${'Unable to Connect'} | ${'There was an HTTP error when connecting to your cluster'}
+ ${'default'} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'}
+ ${'unknown_error_type'} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'}
+ ${null} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'}
+ `('displays error text', ({ errorType, wrapperText, popoverText }) => {
+ return createWrapper({ errorType, popoverId: 'id' }).then(() => {
+ expect(wrapper.text()).toContain(wrapperText);
+ expect(findPopover().text()).toContain(popoverText);
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js
index 48af3b91c94..ed32655d10e 100644
--- a/spec/frontend/clusters_list/mock_data.js
+++ b/spec/frontend/clusters_list/mock_data.js
@@ -6,6 +6,11 @@ export const clusterList = [
provider_type: 'gcp',
status: 'creating',
nodes: null,
+ kubernetes_errors: {
+ connection_error: 'authentication_error',
+ node_connection_error: 'connection_error',
+ metrics_connection_error: 'http_error',
+ },
},
{
name: 'My Cluster 2',
@@ -19,6 +24,7 @@ export const clusterList = [
usage: { cpu: '246155922n', memory: '1255212Ki' },
},
],
+ kubernetes_errors: {},
},
{
name: 'My Cluster 3',
@@ -36,6 +42,7 @@ export const clusterList = [
usage: { cpu: '307051934n', memory: '1379136Ki' },
},
],
+ kubernetes_errors: {},
},
{
name: 'My Cluster 4',
@@ -48,6 +55,7 @@ export const clusterList = [
usage: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' },
},
],
+ kubernetes_errors: {},
},
{
name: 'My Cluster 5',
@@ -59,12 +67,14 @@ export const clusterList = [
status: { allocatable: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' } },
},
],
+ kubernetes_errors: {},
},
{
name: 'My Cluster 6',
environment_scope: '*',
cluster_type: 'project_type',
status: 'cleanup_ongoing',
+ kubernetes_errors: {},
},
];
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 745a163951a..62b751ec59b 100644
--- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
+++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
@@ -56,6 +56,7 @@ exports[`Code navigation popover component renders popover 1`] = `
class="popover-body border-top"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="w-100"
data-testid="go-to-definition-btn"
diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js
index fdf3c2e85f3..a196b66daa0 100644
--- a/spec/frontend/commit/pipelines/pipelines_spec.js
+++ b/spec/frontend/commit/pipelines/pipelines_spec.js
@@ -21,6 +21,10 @@ describe('Pipelines table in Commits and Merge requests', () => {
preloadFixtures(jsonFixtureName);
+ const findRunPipelineBtn = () => vm.$el.querySelector('[data-testid="run_pipeline_button"]');
+ const findRunPipelineBtnMobile = () =>
+ vm.$el.querySelector('[data-testid="run_pipeline_button_mobile"]');
+
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -131,7 +135,8 @@ describe('Pipelines table in Commits and Merge requests', () => {
vm = mountComponent(PipelinesTable, { ...props });
setImmediate(() => {
- expect(vm.$el.querySelector('.js-run-mr-pipeline')).not.toBeNull();
+ expect(findRunPipelineBtn()).not.toBeNull();
+ expect(findRunPipelineBtnMobile()).not.toBeNull();
done();
});
});
@@ -147,7 +152,8 @@ describe('Pipelines table in Commits and Merge requests', () => {
vm = mountComponent(PipelinesTable, { ...props });
setImmediate(() => {
- expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
+ expect(findRunPipelineBtn()).toBeNull();
+ expect(findRunPipelineBtnMobile()).toBeNull();
done();
});
});
@@ -157,7 +163,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
const findModal = () =>
document.querySelector('#create-pipeline-for-fork-merge-request-modal');
- beforeEach(() => {
+ beforeEach(done => {
pipelineCopy.flags.detached_merge_request_pipeline = true;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
@@ -168,23 +174,46 @@ describe('Pipelines table in Commits and Merge requests', () => {
projectId: '5',
mergeRequestId: 3,
});
- });
- it('updates the loading state', done => {
jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve());
setImmediate(() => {
- vm.$el.querySelector('.js-run-mr-pipeline').click();
+ done();
+ });
+ });
- vm.$nextTick(() => {
- expect(findModal()).toBeNull();
- expect(vm.state.isRunningMergeRequestPipeline).toBe(true);
+ it('on desktop, shows a loading button', done => {
+ findRunPipelineBtn().click();
- setImmediate(() => {
- expect(vm.state.isRunningMergeRequestPipeline).toBe(false);
+ vm.$nextTick(() => {
+ expect(findModal()).toBeNull();
- done();
- });
+ expect(findRunPipelineBtn().disabled).toBe(true);
+ expect(findRunPipelineBtn().querySelector('.gl-spinner')).not.toBeNull();
+
+ setImmediate(() => {
+ expect(findRunPipelineBtn().disabled).toBe(false);
+ expect(findRunPipelineBtn().querySelector('.gl-spinner')).toBeNull();
+
+ done();
+ });
+ });
+ });
+
+ it('on mobile, shows a loading button', done => {
+ findRunPipelineBtnMobile().click();
+
+ vm.$nextTick(() => {
+ expect(findModal()).toBeNull();
+
+ expect(findModal()).toBeNull();
+ expect(findRunPipelineBtn().querySelector('.gl-spinner')).not.toBeNull();
+
+ setImmediate(() => {
+ expect(findRunPipelineBtn().disabled).toBe(false);
+ expect(findRunPipelineBtn().querySelector('.gl-spinner')).toBeNull();
+
+ done();
});
});
});
@@ -194,7 +223,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
const findModal = () =>
document.querySelector('#create-pipeline-for-fork-merge-request-modal');
- beforeEach(() => {
+ beforeEach(done => {
pipelineCopy.flags.detached_merge_request_pipeline = true;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
@@ -207,18 +236,29 @@ describe('Pipelines table in Commits and Merge requests', () => {
sourceProjectFullPath: 'test/parent-project',
targetProjectFullPath: 'test/fork-project',
});
- });
- it('shows a security warning modal', done => {
jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve());
setImmediate(() => {
- vm.$el.querySelector('.js-run-mr-pipeline').click();
+ done();
+ });
+ });
- vm.$nextTick(() => {
- expect(findModal()).not.toBeNull();
- done();
- });
+ it('on desktop, shows a security warning modal', done => {
+ findRunPipelineBtn().click();
+
+ vm.$nextTick(() => {
+ expect(findModal()).not.toBeNull();
+ done();
+ });
+ });
+
+ it('on mobile, shows a security warning modal', done => {
+ findRunPipelineBtnMobile().click();
+
+ vm.$nextTick(() => {
+ expect(findModal()).not.toBeNull();
+ done();
});
});
});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
index 4bf3ac430f5..e0913fe2e88 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
@@ -12,6 +12,7 @@ describe('CreateEksCluster', () => {
let vm;
let state;
const gitlabManagedClusterHelpPath = 'gitlab-managed-cluster-help-path';
+ const namespacePerEnvironmentHelpPath = 'namespace-per-environment-help-path';
const accountAndExternalIdsHelpPath = 'account-and-external-id-help-path';
const createRoleArnHelpPath = 'role-arn-help-path';
const kubernetesIntegrationHelpPath = 'kubernetes-integration';
@@ -26,6 +27,7 @@ describe('CreateEksCluster', () => {
vm = shallowMount(CreateEksCluster, {
propsData: {
gitlabManagedClusterHelpPath,
+ namespacePerEnvironmentHelpPath,
accountAndExternalIdsHelpPath,
createRoleArnHelpPath,
externalLinkIcon,
@@ -53,6 +55,12 @@ describe('CreateEksCluster', () => {
);
});
+ it('help url for namespace per environment cluster documentation', () => {
+ expect(vm.find(EksClusterConfigurationForm).props('namespacePerEnvironmentHelpPath')).toBe(
+ namespacePerEnvironmentHelpPath,
+ );
+ });
+
it('help url for gitlab managed cluster documentation', () => {
expect(vm.find(EksClusterConfigurationForm).props('kubernetesIntegrationHelpPath')).toBe(
kubernetesIntegrationHelpPath,
diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
index d7dd7072f67..2600415fc9f 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
@@ -169,6 +169,7 @@ describe('EksClusterConfigurationForm', () => {
store,
propsData: {
gitlabManagedClusterHelpPath: '',
+ namespacePerEnvironmentHelpPath: '',
kubernetesIntegrationHelpPath: '',
externalLinkIcon: '',
},
diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
index ed753888790..f929216689a 100644
--- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
@@ -14,6 +14,7 @@ import {
SET_ROLE,
SET_SECURITY_GROUP,
SET_GITLAB_MANAGED_CLUSTER,
+ SET_NAMESPACE_PER_ENVIRONMENT,
SET_INSTANCE_TYPE,
SET_NODE_COUNT,
REQUEST_CREATE_ROLE,
@@ -40,6 +41,7 @@ describe('EKS Cluster Store Actions', () => {
let instanceType;
let nodeCount;
let gitlabManagedCluster;
+ let namespacePerEnvironment;
let mock;
let state;
let newClusterUrl;
@@ -57,6 +59,7 @@ describe('EKS Cluster Store Actions', () => {
instanceType = 'small-1';
nodeCount = '5';
gitlabManagedCluster = true;
+ namespacePerEnvironment = true;
newClusterUrl = '/clusters/1';
@@ -76,19 +79,20 @@ describe('EKS Cluster Store Actions', () => {
});
it.each`
- action | mutation | payload | payloadDescription
- ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'}
- ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'}
- ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'}
- ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'}
- ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'}
- ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'}
- ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'}
- ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'}
- ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'}
- ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'}
- ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'}
- ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
+ action | mutation | payload | payloadDescription
+ ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'}
+ ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'}
+ ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'}
+ ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'}
+ ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'}
+ ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'}
+ ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'}
+ ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'}
+ ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'}
+ ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'}
+ ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'}
+ ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
+ ${'setNamespacePerEnvironment'} | ${SET_NAMESPACE_PER_ENVIRONMENT} | ${namespacePerEnvironment} | ${'namespace per environment'}
`(`$action commits $mutation with $payloadDescription payload`, data => {
const { action, mutation, payload } = data;
@@ -179,6 +183,7 @@ describe('EKS Cluster Store Actions', () => {
name: clusterName,
environment_scope: environmentScope,
managed: gitlabManagedCluster,
+ namespace_per_environment: namespacePerEnvironment,
provider_aws_attributes: {
kubernetes_version: kubernetesVersion,
region,
@@ -204,6 +209,7 @@ describe('EKS Cluster Store Actions', () => {
selectedInstanceType: instanceType,
nodeCount,
gitlabManagedCluster,
+ namespacePerEnvironment,
});
});
diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js
index 451c23f0fea..9ebc6ca26a2 100644
--- a/spec/frontend/design_management/components/design_todo_button_spec.js
+++ b/spec/frontend/design_management/components/design_todo_button_spec.js
@@ -111,7 +111,7 @@ describe('Design management design todo button', () => {
});
it('renders correct button text', () => {
- expect(wrapper.text()).toBe('Add a To-Do');
+ expect(wrapper.text()).toBe('Add a To Do');
});
describe('when clicked', () => {
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
index 822df1f6472..de276bd300b 100644
--- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
+++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
@@ -24,6 +24,7 @@ exports[`Design management list item component with notes renders item with mult
<img
alt="test"
class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img"
+ data-qa-filename="test"
data-qa-selector="design_image"
src=""
/>
@@ -94,6 +95,7 @@ exports[`Design management list item component with notes renders item with sing
<img
alt="test"
class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img"
+ data-qa-filename="test"
data-qa-selector="design_image"
src=""
/>
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap
index a7d6145285c..5eb86d4f9cb 100644
--- a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap
@@ -4,15 +4,16 @@ exports[`Design management pagination component hides components when designs ar
exports[`Design management pagination component renders navigation buttons 1`] = `
<div
- class="d-flex align-items-center"
+ class="gl-display-flex gl-align-items-center"
>
0 of 2
<gl-button-group-stub
- class="ml-3 mr-3"
+ class="gl-mx-5"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-previous-design"
disabled="true"
@@ -23,6 +24,7 @@ exports[`Design management pagination component renders navigation buttons 1`] =
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-next-design"
icon="angle-right"
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
index b286a74ebb8..723ac0491a7 100644
--- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
@@ -19,16 +19,16 @@ exports[`Design management toolbar component renders design and updated data 1`]
</a>
<div
- class="overflow-hidden d-flex align-items-center"
+ class="gl-overflow-hidden gl-display-flex gl-align-items-center"
>
<h2
- class="m-0 str-truncated-100 gl-font-base"
+ class="gl-m-0 str-truncated-100 gl-font-base"
>
test.jpg
</h2>
<small
- class="text-secondary"
+ class="gl-text-gray-500"
>
Updated 1 hour ago by Test Name
</small>
@@ -36,11 +36,12 @@ exports[`Design management toolbar component renders design and updated data 1`]
</div>
<design-navigation-stub
- class="ml-auto flex-shrink-0"
+ class="gl-ml-auto gl-flex-shrink-0"
id="1"
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d"
icon="download"
diff --git a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
index 1c6588a9628..1d9b9c002f9 100644
--- a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
+++ b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
@@ -43,7 +43,7 @@ describe('Design management pagination component', () => {
it('renders navigation buttons', () => {
wrapper.setData({
- designs: [{ id: '1' }, { id: '2' }],
+ designCollection: { designs: [{ id: '1' }, { id: '2' }] },
});
return wrapper.vm.$nextTick().then(() => {
@@ -54,7 +54,7 @@ describe('Design management pagination component', () => {
describe('keyboard buttons navigation', () => {
beforeEach(() => {
wrapper.setData({
- designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }],
+ designCollection: { designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }] },
});
});
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
index 3d7939df28e..eaa7460ae15 100644
--- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
+++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
@@ -5,6 +5,7 @@ exports[`Design management upload button component renders inverted upload desig
isinverted="true"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
size="small"
@@ -30,6 +31,7 @@ exports[`Design management upload button component renders inverted upload desig
exports[`Design management upload button component renders loading icon 1`] = `
<div>
<gl-button-stub
+ buttontextclasses=""
category="primary"
disabled="true"
icon=""
@@ -62,6 +64,7 @@ exports[`Design management upload button component renders loading icon 1`] = `
exports[`Design management upload button component renders upload design button 1`] = `
<div>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
size="small"
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index 1c7806c292f..a8b335c2c46 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -4,6 +4,7 @@ export const designListQueryResponse = {
id: '1',
issue: {
designCollection: {
+ copyState: 'READY',
designs: {
nodes: [
{
diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
index b80b7fdb43e..7ab2c02c786 100644
--- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -19,6 +19,7 @@ exports[`Design management index page designs does not render toolbar when there
>
<design-dropzone-stub
class="design-list-item design-list-item-new"
+ data-qa-selector="design_dropzone_content"
hasdesigns="true"
/>
</li>
@@ -110,6 +111,7 @@ exports[`Design management index page designs renders designs list and header wi
class="qa-selector-toolbar gl-display-flex gl-align-items-center"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="gl-mr-4 js-select-all"
icon=""
@@ -126,6 +128,7 @@ exports[`Design management index page designs renders designs list and header wi
buttonclass="gl-mr-3"
buttonsize="small"
buttonvariant="warning"
+ data-qa-selector="archive_button"
>
Archive selected
@@ -150,6 +153,7 @@ exports[`Design management index page designs renders designs list and header wi
>
<design-dropzone-stub
class="design-list-item design-list-item-new"
+ data-qa-selector="design_dropzone_content"
hasdesigns="true"
/>
</li>
@@ -171,6 +175,8 @@ exports[`Design management index page designs renders designs list and header wi
<input
class="design-checkbox"
+ data-qa-design="design-1-name"
+ data-qa-selector="design_checkbox"
type="checkbox"
/>
</li>
@@ -192,6 +198,8 @@ exports[`Design management index page designs renders designs list and header wi
<input
class="design-checkbox"
+ data-qa-design="design-2-name"
+ data-qa-selector="design_checkbox"
type="checkbox"
/>
</li>
@@ -213,6 +221,8 @@ exports[`Design management index page designs renders designs list and header wi
<input
class="design-checkbox"
+ data-qa-design="design-3-name"
+ data-qa-selector="design_checkbox"
type="checkbox"
/>
</li>
@@ -298,6 +308,7 @@ exports[`Design management index page when has no designs renders design dropzon
>
<design-dropzone-stub
class=""
+ data-qa-selector="design_dropzone_content"
/>
</li>
</ol>
diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
index c849e4d4ed6..8546f9fbf51 100644
--- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -67,6 +67,7 @@ exports[`Design management design index page renders design index 1`] = `
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
data-testid="resolved-comments"
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 661717d29a3..55ccb668e81 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -92,6 +92,8 @@ describe('Design management index page', () => {
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.find('.js-select-all');
const findToolbar = () => wrapper.find('.qa-selector-toolbar');
+ const findDesignCollectionIsCopying = () =>
+ wrapper.find('[data-testid="design-collection-is-copying"');
const findDeleteButton = () => wrapper.find(DeleteButton);
const findDropzone = () => wrapper.findAll(DesignDropzone).at(0);
const dropzoneClasses = () => findDropzone().classes();
@@ -99,6 +101,7 @@ describe('Design management index page', () => {
const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]');
const findDesigns = () => wrapper.findAll(Design);
+ const draggableAttributes = () => wrapper.find(VueDraggable).vm.$attrs;
async function moveDesigns(localWrapper) {
await jest.runOnlyPendingTimers();
@@ -115,8 +118,8 @@ describe('Design management index page', () => {
function createComponent({
loading = false,
- designs = [],
allVersions = [],
+ designCollection = { designs: mockDesigns, copyState: 'READY' },
createDesign = true,
stubs = {},
mockMutate = jest.fn().mockResolvedValue(),
@@ -124,7 +127,7 @@ describe('Design management index page', () => {
mutate = mockMutate;
const $apollo = {
queries: {
- designs: {
+ designCollection: {
loading,
},
permissions: {
@@ -137,8 +140,8 @@ describe('Design management index page', () => {
wrapper = shallowMount(Index, {
data() {
return {
- designs,
allVersions,
+ designCollection,
permissions: {
createDesign,
},
@@ -200,13 +203,13 @@ describe('Design management index page', () => {
});
it('renders a toolbar with buttons when there are designs', () => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ createComponent({ allVersions: [mockVersion] });
expect(findToolbar().exists()).toBe(true);
});
it('renders designs list and header with upload button', () => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ createComponent({ allVersions: [mockVersion] });
expect(wrapper.element).toMatchSnapshot();
});
@@ -236,7 +239,7 @@ describe('Design management index page', () => {
describe('when has no designs', () => {
beforeEach(() => {
- createComponent();
+ createComponent({ designCollection: { designs: [], copyState: 'READY' } });
});
it('renders design dropzone', () =>
@@ -259,6 +262,21 @@ describe('Design management index page', () => {
}));
});
+ describe('handling design collection copy state', () => {
+ it.each`
+ copyState | isRendered | description
+ ${'IN_PROGRESS'} | ${true} | ${'renders'}
+ ${'READY'} | ${false} | ${'does not render'}
+ ${'ERROR'} | ${false} | ${'does not render'}
+ `(
+ '$description the copying message if design collection copyState is $copyState',
+ ({ copyState, isRendered }) => {
+ createComponent({ designCollection: { designs: [], copyState } });
+ expect(findDesignCollectionIsCopying().exists()).toBe(isRendered);
+ },
+ );
+ });
+
describe('uploading designs', () => {
it('calls mutation on upload', () => {
createComponent({ stubs: { GlEmptyState } });
@@ -282,6 +300,10 @@ describe('Design management index page', () => {
{
__typename: 'Design',
id: expect.anything(),
+ currentUserTodos: {
+ __typename: 'TodoConnection',
+ nodes: [],
+ },
image: '',
imageV432x230: '',
filename: 'test',
@@ -531,13 +553,16 @@ describe('Design management index page', () => {
});
it('on latest version when has no designs toolbar buttons are invisible', () => {
- createComponent({ designs: [], allVersions: [mockVersion] });
+ createComponent({
+ designCollection: { designs: [], copyState: 'READY' },
+ allVersions: [mockVersion],
+ });
expect(findToolbar().isVisible()).toBe(false);
});
describe('on non-latest version', () => {
beforeEach(() => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ createComponent({ allVersions: [mockVersion] });
});
it('does not render design checkboxes', async () => {
@@ -628,7 +653,6 @@ describe('Design management index page', () => {
it('ensures fullscreen layout is not applied', () => {
createComponent(true);
- wrapper.vm.$router.push('/');
expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1);
expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
});
@@ -676,6 +700,20 @@ describe('Design management index page', () => {
).toBe('2');
});
+ it('prevents reordering when reorderDesigns mutation is in progress', async () => {
+ createComponentWithApollo({});
+
+ await moveDesigns(wrapper);
+
+ expect(draggableAttributes().disabled).toBe(true);
+
+ await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
+ await wrapper.vm.$nextTick(); // kick off the DOM update
+ await wrapper.vm.$nextTick(); // kick off the DOM update for finally block
+
+ expect(draggableAttributes().disabled).toBe(false);
+ });
+
it('displays flash if mutation had a recoverable error', async () => {
createComponentWithApollo({
moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors),
diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js
index d4cb9f75a77..fac4f7d368d 100644
--- a/spec/frontend/design_management/router_spec.js
+++ b/spec/frontend/design_management/router_spec.js
@@ -25,7 +25,7 @@ function factory(routeArg) {
mocks: {
$apollo: {
queries: {
- designs: { loading: true },
+ designCollection: { loading: true },
design: { loading: true },
permissions: { loading: true },
},
diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js
index 7e857d08d25..232cfa2f4ca 100644
--- a/spec/frontend/design_management/utils/design_management_utils_spec.js
+++ b/spec/frontend/design_management/utils/design_management_utils_spec.js
@@ -93,6 +93,10 @@ describe('optimistic responses', () => {
fullPath: '',
notesCount: 0,
event: 'NONE',
+ currentUserTodos: {
+ __typename: 'TodoConnection',
+ nodes: [],
+ },
diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' },
discussions: { __typename: 'DesignDiscussion', nodes: [] },
versions: {
diff --git a/spec/frontend/diff_comments_store_spec.js b/spec/frontend/diff_comments_store_spec.js
deleted file mode 100644
index 6f25c9dd3bc..00000000000
--- a/spec/frontend/diff_comments_store_spec.js
+++ /dev/null
@@ -1,136 +0,0 @@
-/* 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 cd3a6aa0e28..86560470ada 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -699,7 +699,7 @@ describe('diffs/components/app', () => {
describe('collapsed files', () => {
it('should render the collapsed files warning if there are any collapsed files', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ viewer: { collapsed: true } }];
+ state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }];
});
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
@@ -707,7 +707,7 @@ describe('diffs/components/app', () => {
it('should not render the collapsed files warning if the user has dismissed the alert already', async () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ viewer: { collapsed: true } }];
+ state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }];
});
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
index 670eab5472f..7bbffb7a1cd 100644
--- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js
+++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
@@ -50,7 +50,7 @@ describe('CollapsedFilesWarning', () => {
({ limited, containerClasses }) => {
createComponent({ limited });
- expect(wrapper.classes()).toEqual(containerClasses);
+ expect(wrapper.classes()).toEqual(['col-12'].concat(containerClasses));
},
);
diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js
index c48445790f7..9e4fcddd1b4 100644
--- a/spec/frontend/diffs/components/commit_item_spec.js
+++ b/spec/frontend/diffs/components/commit_item_spec.js
@@ -25,7 +25,7 @@ describe('diffs/components/commit_item', () => {
const getTitleElement = () => wrapper.find('.commit-row-message.item-title');
const getDescElement = () => wrapper.find('pre.commit-row-description');
const getDescExpandElement = () => wrapper.find('.commit-content .js-toggle-button');
- const getShaElement = () => wrapper.find('.commit-sha-group');
+ const getShaElement = () => wrapper.find('[data-testid="commit-sha-group"]');
const getAvatarElement = () => wrapper.find('.user-avatar-link');
const getCommitterElement = () => wrapper.find('.committer');
const getCommitActionsElement = () => wrapper.find('.commit-actions');
@@ -84,8 +84,8 @@ describe('diffs/components/commit_item', () => {
it('renders commit sha', () => {
const shaElement = getShaElement();
- const labelElement = shaElement.find('.label');
- const buttonElement = shaElement.find('button');
+ const labelElement = shaElement.find('[data-testid="commit-sha-group"] button');
+ const buttonElement = shaElement.find('button.input-group-text');
expect(labelElement.text()).toEqual(commit.short_id);
expect(buttonElement.props('text')).toBe(commit.id);
diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js
index a0cad32b9fb..3a236228c40 100644
--- a/spec/frontend/diffs/components/diff_file_header_spec.js
+++ b/spec/frontend/diffs/components/diff_file_header_spec.js
@@ -1,9 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import { GlIcon } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
-import EditButton from '~/diffs/components/edit_button.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
import { truncateSha } from '~/lib/utils/text_utility';
@@ -76,15 +74,7 @@ describe('DiffFileHeader component', () => {
const findReplacedFileButton = () => wrapper.find({ ref: 'replacedFileButton' });
const findViewFileButton = () => wrapper.find({ ref: 'viewButton' });
const findCollapseIcon = () => wrapper.find({ ref: 'collapseIcon' });
-
- const findIconByName = iconName => {
- const icons = wrapper.findAll(GlIcon).filter(w => w.props('name') === iconName);
- if (icons.length === 0) return icons;
- if (icons.length > 1) {
- throw new Error(`Multiple icons found for ${iconName}`);
- }
- return icons.at(0);
- };
+ const findEditButton = () => wrapper.find({ ref: 'editButton' });
const createComponent = props => {
mockStoreConfig = cloneDeep(defaultMockStoreConfig);
@@ -203,16 +193,6 @@ describe('DiffFileHeader component', () => {
describe('for any file', () => {
const otherModes = Object.keys(diffViewerModes).filter(m => m !== 'mode_changed');
- it('when edit button emits showForkMessage event it is re-emitted', () => {
- createComponent({
- addMergeRequestButtons: true,
- });
- wrapper.find(EditButton).vm.$emit('showForkMessage');
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted().showForkMessage).toBeDefined();
- });
- });
-
it('for mode_changed file mode displays mode changes', () => {
createComponent({
diffFile: {
@@ -271,16 +251,16 @@ describe('DiffFileHeader component', () => {
});
it('should not render edit button', () => {
createComponent({ addMergeRequestButtons: false });
- expect(wrapper.find(EditButton).exists()).toBe(false);
+ expect(findEditButton().exists()).toBe(false);
});
});
describe('when addMergeRequestButtons is true', () => {
describe('without discussions', () => {
- it('renders a disabled toggle discussions button', () => {
+ it('does not render a toggle discussions button', () => {
diffHasDiscussionsResultMock.mockReturnValue(false);
createComponent({ addMergeRequestButtons: true });
- expect(findToggleDiscussionsButton().attributes('disabled')).toBe('true');
+ expect(findToggleDiscussionsButton().exists()).toBe(false);
});
});
@@ -288,7 +268,7 @@ describe('DiffFileHeader component', () => {
it('dispatches toggleFileDiscussionWrappers when user clicks on toggle discussions button', () => {
diffHasDiscussionsResultMock.mockReturnValue(true);
createComponent({ addMergeRequestButtons: true });
- expect(findToggleDiscussionsButton().attributes('disabled')).toBeFalsy();
+ expect(findToggleDiscussionsButton().exists()).toBe(true);
findToggleDiscussionsButton().vm.$emit('click');
expect(
mockStoreConfig.modules.diffs.actions.toggleFileDiscussionWrappers,
@@ -300,7 +280,7 @@ describe('DiffFileHeader component', () => {
createComponent({
addMergeRequestButtons: true,
});
- expect(wrapper.find(EditButton).exists()).toBe(true);
+ expect(findEditButton().exists()).toBe(true);
});
describe('view on environment button', () => {
@@ -334,7 +314,7 @@ describe('DiffFileHeader component', () => {
});
it('should not render edit button', () => {
- expect(wrapper.find(EditButton).exists()).toBe(false);
+ expect(findEditButton().exists()).toBe(false);
});
});
describe('with file blob', () => {
@@ -345,7 +325,7 @@ describe('DiffFileHeader component', () => {
addMergeRequestButtons: true,
});
expect(findViewFileButton().attributes('href')).toBe(viewPath);
- expect(findViewFileButton().attributes('title')).toEqual(
+ expect(findViewFileButton().text()).toEqual(
`View file @ ${diffFile.content_sha.substr(0, 8)}`,
);
});
@@ -375,21 +355,6 @@ describe('DiffFileHeader component', () => {
addMergeRequestButtons: true,
};
- it.each`
- iconName | isShowingFullFile
- ${'doc-expand'} | ${false}
- ${'doc-changes'} | ${true}
- `(
- 'shows $iconName when isShowingFullFile set to $isShowingFullFile',
- ({ iconName, isShowingFullFile }) => {
- createComponent({
- ...fullyNotExpandedFileProps,
- diffFile: { ...fullyNotExpandedFileProps.diffFile, isShowingFullFile },
- });
- expect(findIconByName(iconName).exists()).toBe(true);
- },
- );
-
it('renders expand to full file button if not showing full file already', () => {
createComponent(fullyNotExpandedFileProps);
expect(findExpandButton().exists()).toBe(true);
@@ -455,7 +420,7 @@ describe('DiffFileHeader component', () => {
it('does not show edit button', () => {
createComponent({ diffFile: { ...diffFile, deleted_file: true } });
- expect(wrapper.find(EditButton).exists()).toBe(false);
+ expect(findEditButton().exists()).toBe(false);
});
});
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index 79f0f6bc327..a6f0d2bf11d 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -37,7 +37,7 @@ describe('DiffFile', () => {
expect(el.querySelectorAll('.diff-content.hidden').length).toEqual(0);
expect(el.querySelector('.js-file-title')).toBeDefined();
- expect(el.querySelector('.btn-clipboard')).toBeDefined();
+ expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined();
expect(el.querySelector('.file-title-name').innerText.indexOf(file_path)).toBeGreaterThan(-1);
expect(el.querySelector('.js-syntax-highlight')).toBeDefined();
@@ -47,7 +47,7 @@ describe('DiffFile', () => {
.then(() => {
expect(el.querySelectorAll('.line_content').length).toBe(8);
expect(el.querySelectorAll('.js-line-expansion-content').length).toBe(1);
- triggerEvent('.btn-clipboard');
+ triggerEvent('[data-testid="diff-file-copy-clipboard"]');
})
.then(done)
.catch(done.fail);
@@ -56,11 +56,11 @@ describe('DiffFile', () => {
it('should track a click event on copy to clip board button', done => {
const el = vm.$el;
- expect(el.querySelector('.btn-clipboard')).toBeDefined();
+ expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined();
vm.file.renderIt = true;
vm.$nextTick()
.then(() => {
- triggerEvent('.btn-clipboard');
+ triggerEvent('[data-testid="diff-file-copy-clipboard"]');
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', {
label: 'diff_copy_file_path_button',
@@ -90,8 +90,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This file is collapsed.');
- expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
+ expect(vm.$el.innerText).toContain('This diff is collapsed');
+ expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@@ -102,8 +102,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This file is collapsed.');
- expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
+ expect(vm.$el.innerText).toContain('This diff is collapsed');
+ expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@@ -121,8 +121,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This file is collapsed.');
- expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
+ expect(vm.$el.innerText).toContain('This diff is collapsed');
+ expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@@ -135,7 +135,7 @@ describe('DiffFile', () => {
vm.file.viewer.name = diffViewerModes.renamed;
vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This file is collapsed.');
+ expect(vm.$el.innerText).not.toContain('This diff is collapsed');
done();
});
@@ -148,7 +148,7 @@ describe('DiffFile', () => {
vm.file.viewer.name = diffViewerModes.mode_changed;
vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This file is collapsed.');
+ expect(vm.$el.innerText).not.toContain('This diff is collapsed');
done();
});
@@ -181,7 +181,7 @@ describe('DiffFile', () => {
});
it('updates local state when changing file state', done => {
- vm.file.viewer.collapsed = true;
+ vm.file.viewer.automaticallyCollapsed = true;
vm.$nextTick(() => {
expect(vm.isCollapsed).toBe(true);
diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js
new file mode 100644
index 00000000000..394b6cb1914
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_row_utils_spec.js
@@ -0,0 +1,203 @@
+import * as utils from '~/diffs/components/diff_row_utils';
+import {
+ MATCH_LINE_TYPE,
+ CONTEXT_LINE_TYPE,
+ OLD_NO_NEW_LINE_TYPE,
+ NEW_NO_NEW_LINE_TYPE,
+ EMPTY_CELL_TYPE,
+} from '~/diffs/constants';
+
+const LINE_CODE = 'abc123';
+
+describe('isHighlighted', () => {
+ it('should return true if line is highlighted', () => {
+ const state = { diffs: { highlightedRow: LINE_CODE } };
+ const line = { line_code: LINE_CODE };
+ const isCommented = false;
+ expect(utils.isHighlighted(state, line, isCommented)).toBe(true);
+ });
+
+ it('should return false if line is not highlighted', () => {
+ const state = { diffs: { highlightedRow: 'xxx' } };
+ const line = { line_code: LINE_CODE };
+ const isCommented = false;
+ expect(utils.isHighlighted(state, line, isCommented)).toBe(false);
+ });
+
+ it('should return true if isCommented is true', () => {
+ const state = { diffs: { highlightedRow: 'xxx' } };
+ const line = { line_code: LINE_CODE };
+ const isCommented = true;
+ expect(utils.isHighlighted(state, line, isCommented)).toBe(true);
+ });
+});
+
+describe('isContextLine', () => {
+ it('return true if line type is context', () => {
+ expect(utils.isContextLine(CONTEXT_LINE_TYPE)).toBe(true);
+ });
+
+ it('return false if line type is not context', () => {
+ expect(utils.isContextLine('xxx')).toBe(false);
+ });
+});
+
+describe('isMatchLine', () => {
+ it('return true if line type is match', () => {
+ expect(utils.isMatchLine(MATCH_LINE_TYPE)).toBe(true);
+ });
+
+ it('return false if line type is not match', () => {
+ expect(utils.isMatchLine('xxx')).toBe(false);
+ });
+});
+
+describe('isMetaLine', () => {
+ it.each`
+ type | expectation
+ ${OLD_NO_NEW_LINE_TYPE} | ${true}
+ ${NEW_NO_NEW_LINE_TYPE} | ${true}
+ ${EMPTY_CELL_TYPE} | ${true}
+ ${'xxx'} | ${false}
+ `('should return $expectation if type is $type', ({ type, expectation }) => {
+ expect(utils.isMetaLine(type)).toBe(expectation);
+ });
+});
+
+describe('shouldRenderCommentButton', () => {
+ it('should return false if comment button is not rendered', () => {
+ expect(utils.shouldRenderCommentButton(true, false)).toBe(false);
+ });
+
+ it('should return false if not logged in', () => {
+ expect(utils.shouldRenderCommentButton(false, true)).toBe(false);
+ });
+
+ it('should return true logged in and rendered', () => {
+ expect(utils.shouldRenderCommentButton(true, true)).toBe(true);
+ });
+});
+
+describe('hasDiscussions', () => {
+ it('should return false if line is undefined', () => {
+ expect(utils.hasDiscussions()).toBe(false);
+ });
+
+ it('should return false if discussions is undefined', () => {
+ expect(utils.hasDiscussions({})).toBe(false);
+ });
+
+ it('should return false if discussions has legnth of 0', () => {
+ expect(utils.hasDiscussions({ discussions: [] })).toBe(false);
+ });
+
+ it('should return true if discussions has legnth > 0', () => {
+ expect(utils.hasDiscussions({ discussions: [1] })).toBe(true);
+ });
+});
+
+describe('lineHref', () => {
+ it(`should return #${LINE_CODE}`, () => {
+ expect(utils.lineHref({ line_code: LINE_CODE })).toEqual(`#${LINE_CODE}`);
+ });
+
+ it(`should return '#' if line is undefined`, () => {
+ expect(utils.lineHref()).toEqual('#');
+ });
+
+ it(`should return '#' if line_code is undefined`, () => {
+ expect(utils.lineHref({})).toEqual('#');
+ });
+});
+
+describe('lineCode', () => {
+ it(`should return undefined if line_code is undefined`, () => {
+ expect(utils.lineCode()).toEqual(undefined);
+ expect(utils.lineCode({ left: {} })).toEqual(undefined);
+ expect(utils.lineCode({ right: {} })).toEqual(undefined);
+ });
+
+ it(`should return ${LINE_CODE}`, () => {
+ expect(utils.lineCode({ line_code: LINE_CODE })).toEqual(LINE_CODE);
+ expect(utils.lineCode({ left: { line_code: LINE_CODE } })).toEqual(LINE_CODE);
+ expect(utils.lineCode({ right: { line_code: LINE_CODE } })).toEqual(LINE_CODE);
+ });
+});
+
+describe('classNameMapCell', () => {
+ it.each`
+ line | hll | loggedIn | hovered | expectation
+ ${undefined} | ${true} | ${true} | ${true} | ${[]}
+ ${{ type: 'new' }} | ${false} | ${false} | ${false} | ${['new', { hll: false, 'is-over': false }]}
+ ${{ type: 'new' }} | ${true} | ${true} | ${false} | ${['new', { hll: true, 'is-over': false }]}
+ ${{ type: 'new' }} | ${true} | ${false} | ${true} | ${['new', { hll: true, 'is-over': false }]}
+ ${{ type: 'new' }} | ${true} | ${true} | ${true} | ${['new', { hll: true, 'is-over': true }]}
+ `('should return $expectation', ({ line, hll, loggedIn, hovered, expectation }) => {
+ const classes = utils.classNameMapCell(line, hll, loggedIn, hovered);
+ expect(classes).toEqual(expectation);
+ });
+});
+
+describe('addCommentTooltip', () => {
+ const brokenSymLinkTooltip =
+ 'Commenting on symbolic links that replace or are replaced by files is currently not supported.';
+ const brokenRealTooltip =
+ 'Commenting on files that replace or are replaced by symbolic links is currently not supported.';
+ it('should return default tooltip', () => {
+ expect(utils.addCommentTooltip()).toBeUndefined();
+ });
+
+ it('should return broken symlink tooltip', () => {
+ expect(utils.addCommentTooltip({ commentsDisabled: { wasSymbolic: true } })).toEqual(
+ brokenSymLinkTooltip,
+ );
+ expect(utils.addCommentTooltip({ commentsDisabled: { isSymbolic: true } })).toEqual(
+ brokenSymLinkTooltip,
+ );
+ });
+
+ it('should return broken real tooltip', () => {
+ expect(utils.addCommentTooltip({ commentsDisabled: { wasReal: true } })).toEqual(
+ brokenRealTooltip,
+ );
+ expect(utils.addCommentTooltip({ commentsDisabled: { isReal: true } })).toEqual(
+ brokenRealTooltip,
+ );
+ });
+});
+
+describe('parallelViewLeftLineType', () => {
+ it(`should return ${OLD_NO_NEW_LINE_TYPE}`, () => {
+ expect(utils.parallelViewLeftLineType({ right: { type: NEW_NO_NEW_LINE_TYPE } })).toEqual(
+ OLD_NO_NEW_LINE_TYPE,
+ );
+ });
+
+ it(`should return 'new'`, () => {
+ expect(utils.parallelViewLeftLineType({ left: { type: 'new' } })).toContain('new');
+ });
+
+ it(`should return ${EMPTY_CELL_TYPE}`, () => {
+ expect(utils.parallelViewLeftLineType({})).toContain(EMPTY_CELL_TYPE);
+ });
+
+ it(`should return hll:true`, () => {
+ expect(utils.parallelViewLeftLineType({}, true)[1]).toEqual({ hll: true });
+ });
+});
+
+describe('shouldShowCommentButton', () => {
+ it.each`
+ hover | context | meta | discussions | expectation
+ ${true} | ${false} | ${false} | ${false} | ${true}
+ ${false} | ${false} | ${false} | ${false} | ${false}
+ ${true} | ${true} | ${false} | ${false} | ${false}
+ ${true} | ${true} | ${true} | ${false} | ${false}
+ ${true} | ${true} | ${true} | ${true} | ${false}
+ `(
+ 'should return $expectation when hover is $hover',
+ ({ hover, context, meta, discussions, expectation }) => {
+ expect(utils.shouldShowCommentButton(hover, context, meta, discussions)).toBe(expectation);
+ },
+ );
+});
diff --git a/spec/frontend/diffs/components/diff_table_cell_spec.js b/spec/frontend/diffs/components/diff_table_cell_spec.js
deleted file mode 100644
index 02f5c27eecb..00000000000
--- a/spec/frontend/diffs/components/diff_table_cell_spec.js
+++ /dev/null
@@ -1,279 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { TEST_HOST } from 'helpers/test_constants';
-import DiffTableCell from '~/diffs/components/diff_table_cell.vue';
-import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
-import { LINE_POSITION_RIGHT } from '~/diffs/constants';
-import { createStore } from '~/mr_notes/stores';
-import discussionsMockData from '../mock_data/diff_discussions';
-import diffFileMockData from '../mock_data/diff_file';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-const TEST_USER_ID = 'abc123';
-const TEST_USER = { id: TEST_USER_ID };
-const TEST_LINE_NUMBER = 1;
-const TEST_LINE_CODE = 'LC_42';
-const TEST_FILE_HASH = diffFileMockData.file_hash;
-
-describe('DiffTableCell', () => {
- const symlinkishFileTooltip =
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.';
- const realishFileTooltip =
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.';
- const otherFileTooltip = 'Add a comment to this line';
-
- let wrapper;
- let line;
- let store;
-
- beforeEach(() => {
- store = createStore();
- store.state.notes.userData = TEST_USER;
-
- line = {
- line_code: TEST_LINE_CODE,
- type: 'new',
- old_line: null,
- new_line: 1,
- discussions: [{ ...discussionsMockData }],
- discussionsExpanded: true,
- text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
- rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
- meta_data: null,
- };
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const setWindowLocation = value => {
- Object.defineProperty(window, 'location', {
- writable: true,
- value,
- });
- };
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(DiffTableCell, {
- localVue,
- store,
- propsData: {
- line,
- fileHash: TEST_FILE_HASH,
- contextLinesPath: '/context/lines/path',
- isHighlighted: false,
- ...props,
- },
- });
- };
-
- const findTd = () => wrapper.find({ ref: 'td' });
- const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButton' });
- const findLineNumber = () => wrapper.find({ ref: 'lineNumberRef' });
- const findTooltip = () => wrapper.find({ ref: 'addNoteTooltip' });
- const findAvatars = () => wrapper.find(DiffGutterAvatars);
-
- describe('td', () => {
- it('highlights when isHighlighted true', () => {
- createComponent({ isHighlighted: true });
-
- expect(findTd().classes()).toContain('hll');
- });
-
- it('does not highlight when isHighlighted false', () => {
- createComponent({ isHighlighted: false });
-
- expect(findTd().classes()).not.toContain('hll');
- });
- });
-
- describe('comment button', () => {
- it.each`
- showCommentButton | userData | query | mergeRefHeadComments | expectation
- ${true} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true}
- ${true} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true}
- ${true} | ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false}
- ${false} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${false}
- ${false} | ${TEST_USER} | ${'bogus'} | ${true} | ${false}
- ${true} | ${null} | ${''} | ${true} | ${false}
- `(
- 'exists is $expectation - with showCommentButton ($showCommentButton) userData ($userData) query ($query)',
- ({ showCommentButton, userData, query, mergeRefHeadComments, expectation }) => {
- store.state.notes.userData = userData;
- gon.features = { mergeRefHeadComments };
- setWindowLocation({ href: `${TEST_HOST}?${query}` });
- createComponent({ showCommentButton });
-
- wrapper.setData({ isCommentButtonRendered: showCommentButton });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findNoteButton().exists()).toBe(expectation);
- });
- },
- );
-
- it.each`
- isHover | otherProps | discussions | expectation
- ${true} | ${{}} | ${[]} | ${true}
- ${false} | ${{}} | ${[]} | ${false}
- ${true} | ${{ line: { ...line, type: 'context' } }} | ${[]} | ${false}
- ${true} | ${{ line: { ...line, type: 'old-nonewline' } }} | ${[]} | ${false}
- ${true} | ${{}} | ${[{}]} | ${false}
- `(
- 'visible is $expectation - with isHover ($isHover), discussions ($discussions), otherProps ($otherProps)',
- ({ isHover, otherProps, discussions, expectation }) => {
- line.discussions = discussions;
- createComponent({
- showCommentButton: true,
- isHover,
- ...otherProps,
- });
-
- wrapper.setData({
- isCommentButtonRendered: true,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findNoteButton().isVisible()).toBe(expectation);
- });
- },
- );
-
- it.each`
- disabled | commentsDisabled
- ${'disabled'} | ${true}
- ${undefined} | ${false}
- `(
- 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled',
- ({ disabled, commentsDisabled }) => {
- line.commentsDisabled = commentsDisabled;
-
- createComponent({
- showCommentButton: true,
- isHover: true,
- });
-
- wrapper.setData({ isCommentButtonRendered: true });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findNoteButton().attributes('disabled')).toBe(disabled);
- });
- },
- );
-
- it.each`
- tooltip | commentsDisabled
- ${symlinkishFileTooltip} | ${{ wasSymbolic: true }}
- ${symlinkishFileTooltip} | ${{ isSymbolic: true }}
- ${realishFileTooltip} | ${{ wasReal: true }}
- ${realishFileTooltip} | ${{ isReal: true }}
- ${otherFileTooltip} | ${false}
- `(
- 'has the correct tooltip when commentsDisabled=$commentsDisabled',
- ({ tooltip, commentsDisabled }) => {
- line.commentsDisabled = commentsDisabled;
-
- createComponent({
- showCommentButton: true,
- isHover: true,
- });
-
- wrapper.setData({ isCommentButtonRendered: true });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findTooltip().attributes('title')).toBe(tooltip);
- });
- },
- );
- });
-
- describe('line number', () => {
- describe('without lineNumber prop', () => {
- it('does not render', () => {
- createComponent({ lineType: 'old' });
-
- expect(findLineNumber().exists()).toBe(false);
- });
- });
-
- describe('with lineNumber prop', () => {
- describe.each`
- lineProps | expectedHref | expectedClickArg
- ${{ line_code: TEST_LINE_CODE }} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE}
- ${{ line_code: undefined }} | ${'#'} | ${undefined}
- ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE } }} | ${'#'} | ${TEST_LINE_CODE}
- ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE } }} | ${'#'} | ${TEST_LINE_CODE}
- `('with line ($lineProps)', ({ lineProps, expectedHref, expectedClickArg }) => {
- beforeEach(() => {
- jest.spyOn(store, 'dispatch').mockImplementation();
- Object.assign(line, lineProps);
- createComponent({ lineNumber: TEST_LINE_NUMBER });
- });
-
- it('renders', () => {
- expect(findLineNumber().exists()).toBe(true);
- expect(findLineNumber().attributes()).toEqual({
- href: expectedHref,
- 'data-linenumber': TEST_LINE_NUMBER.toString(),
- });
- });
-
- it('on click, dispatches setHighlightedRow', () => {
- expect(store.dispatch).not.toHaveBeenCalled();
-
- findLineNumber().trigger('click');
-
- expect(store.dispatch).toHaveBeenCalledWith('diffs/setHighlightedRow', expectedClickArg);
- });
- });
- });
- });
-
- describe('diff-gutter-avatars', () => {
- describe('with showCommentButton', () => {
- beforeEach(() => {
- jest.spyOn(store, 'dispatch').mockImplementation();
-
- createComponent({ showCommentButton: true });
- });
-
- it('renders', () => {
- expect(findAvatars().props()).toEqual({
- discussions: line.discussions,
- discussionsExpanded: line.discussionsExpanded,
- });
- });
-
- it('toggles line discussion', () => {
- expect(store.dispatch).not.toHaveBeenCalled();
-
- findAvatars().vm.$emit('toggleLineDiscussions');
-
- expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', {
- lineCode: TEST_LINE_CODE,
- fileHash: TEST_FILE_HASH,
- expanded: !line.discussionsExpanded,
- });
- });
- });
-
- it.each`
- props | lineProps | expectation
- ${{ showCommentButton: true }} | ${{}} | ${true}
- ${{ showCommentButton: false }} | ${{}} | ${false}
- ${{ showCommentButton: true, linePosition: LINE_POSITION_RIGHT }} | ${{ type: null }} | ${false}
- ${{ showCommentButton: true }} | ${{ discussions: [] }} | ${false}
- `(
- 'exists is $expectation - with props ($props), line ($lineProps)',
- ({ props, lineProps, expectation }) => {
- Object.assign(line, lineProps);
- createComponent(props);
-
- expect(findAvatars().exists()).toBe(expectation);
- },
- );
- });
-});
diff --git a/spec/frontend/diffs/components/edit_button_spec.js b/spec/frontend/diffs/components/edit_button_spec.js
deleted file mode 100644
index 71512c1c4af..00000000000
--- a/spec/frontend/diffs/components/edit_button_spec.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton } from '@gitlab/ui';
-import EditButton from '~/diffs/components/edit_button.vue';
-
-const editPath = 'test-path';
-
-describe('EditButton', () => {
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(EditButton, {
- propsData: { ...props },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('has correct href attribute', () => {
- createComponent({
- editPath,
- canCurrentUserFork: false,
- });
-
- expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(editPath);
- });
-
- it('emits a show fork message event if current user can fork', () => {
- createComponent({
- editPath,
- canCurrentUserFork: true,
- });
- wrapper.find(GlDeprecatedButton).trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('showForkMessage')).toBeTruthy();
- });
- });
-
- it('doesnt emit a show fork message event if current user cannot fork', () => {
- createComponent({
- editPath,
- canCurrentUserFork: false,
- });
- wrapper.find(GlDeprecatedButton).trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('showForkMessage')).toBeFalsy();
- });
- });
-
- it('doesnt emit a show fork message event if current user can modify blob', () => {
- createComponent({
- editPath,
- canCurrentUserFork: true,
- canModifyBlob: true,
- });
- 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/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js
index c2a4424ee95..babaaa21dab 100644
--- a/spec/frontend/diffs/mock_data/diff_file.js
+++ b/spec/frontend/diffs/mock_data/diff_file.js
@@ -27,7 +27,7 @@ export default {
viewer: {
name: 'text',
error: null,
- collapsed: false,
+ automaticallyCollapsed: false,
},
added_lines: 2,
removed_lines: 0,
diff --git a/spec/frontend/diffs/mock_data/diff_file_unreadable.js b/spec/frontend/diffs/mock_data/diff_file_unreadable.js
index 8c2df45988e..fca81faabf6 100644
--- a/spec/frontend/diffs/mock_data/diff_file_unreadable.js
+++ b/spec/frontend/diffs/mock_data/diff_file_unreadable.js
@@ -26,7 +26,7 @@ export default {
viewer: {
name: 'text',
error: null,
- collapsed: false,
+ automaticallyCollapsed: false,
},
added_lines: 0,
removed_lines: 0,
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index 4f647b0cd41..c3e4ee9c531 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -483,14 +483,14 @@ describe('DiffsStoreActions', () => {
id: 1,
renderIt: false,
viewer: {
- collapsed: false,
+ automaticallyCollapsed: false,
},
},
{
id: 2,
renderIt: false,
viewer: {
- collapsed: false,
+ automaticallyCollapsed: false,
},
},
],
@@ -967,7 +967,7 @@ describe('DiffsStoreActions', () => {
{
file_hash: 'HASH',
viewer: {
- collapsed,
+ automaticallyCollapsed: collapsed,
},
renderIt,
},
@@ -1167,7 +1167,7 @@ describe('DiffsStoreActions', () => {
file_hash: 'testhash',
alternate_viewer: { name: updatedViewerName },
};
- const updatedViewer = { name: updatedViewerName, collapsed: false };
+ const updatedViewer = { name: updatedViewerName, automaticallyCollapsed: false };
const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }];
let renamedFile;
let mock;
diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js
index dac5be2d656..0083f1d8b44 100644
--- a/spec/frontend/diffs/store/getters_spec.js
+++ b/spec/frontend/diffs/store/getters_spec.js
@@ -51,13 +51,19 @@ describe('Diffs Module Getters', () => {
describe('hasCollapsedFile', () => {
it('returns true when all files are collapsed', () => {
- localState.diffFiles = [{ viewer: { collapsed: true } }, { viewer: { collapsed: true } }];
+ localState.diffFiles = [
+ { viewer: { automaticallyCollapsed: true } },
+ { viewer: { automaticallyCollapsed: true } },
+ ];
expect(getters.hasCollapsedFile(localState)).toEqual(true);
});
it('returns true when at least one file is collapsed', () => {
- localState.diffFiles = [{ viewer: { collapsed: false } }, { viewer: { collapsed: true } }];
+ localState.diffFiles = [
+ { viewer: { automaticallyCollapsed: false } },
+ { viewer: { automaticallyCollapsed: true } },
+ ];
expect(getters.hasCollapsedFile(localState)).toEqual(true);
});
@@ -139,50 +145,74 @@ describe('Diffs Module Getters', () => {
describe('diffHasExpandedDiscussions', () => {
it('returns true when one of the discussions is expanded', () => {
- discussionMock1.expanded = false;
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [discussionMock, discussionMock],
+ discussionsExpanded: true,
+ },
+ ],
+ };
- expect(
- getters.diffHasExpandedDiscussions(localState, {
- getDiffFileDiscussions: () => [discussionMock, discussionMock],
- })(diffFileMock),
- ).toEqual(true);
+ expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(true);
});
it('returns false when there are no discussions', () => {
- expect(
- getters.diffHasExpandedDiscussions(localState, { getDiffFileDiscussions: () => [] })(
- diffFileMock,
- ),
- ).toEqual(false);
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [],
+ discussionsExpanded: true,
+ },
+ ],
+ };
+ expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false);
});
it('returns false when no discussion is expanded', () => {
- discussionMock.expanded = false;
- discussionMock1.expanded = false;
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [discussionMock, discussionMock],
+ discussionsExpanded: false,
+ },
+ ],
+ };
- expect(
- getters.diffHasExpandedDiscussions(localState, {
- getDiffFileDiscussions: () => [discussionMock, discussionMock1],
- })(diffFileMock),
- ).toEqual(false);
+ expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false);
});
});
describe('diffHasDiscussions', () => {
it('returns true when getDiffFileDiscussions returns discussions', () => {
- expect(
- getters.diffHasDiscussions(localState, {
- getDiffFileDiscussions: () => [discussionMock],
- })(diffFileMock),
- ).toEqual(true);
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [discussionMock, discussionMock],
+ discussionsExpanded: false,
+ },
+ ],
+ };
+
+ expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(true);
});
it('returns false when getDiffFileDiscussions returns no discussions', () => {
- expect(
- getters.diffHasDiscussions(localState, {
- getDiffFileDiscussions: () => [],
- })(diffFileMock),
- ).toEqual(false);
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [],
+ discussionsExpanded: false,
+ },
+ ],
+ };
+
+ expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(false);
});
});
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index e1d855ae0cf..a84ad63c695 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -130,14 +130,14 @@ describe('DiffsStoreMutations', () => {
it('should change the collapsed prop from diffFiles', () => {
const diffFile = {
viewer: {
- collapsed: true,
+ automaticallyCollapsed: true,
},
};
const state = { expandAllFiles: true, diffFiles: [diffFile] };
mutations[types.EXPAND_ALL_FILES](state);
- expect(state.diffFiles[0].viewer.collapsed).toEqual(false);
+ expect(state.diffFiles[0].viewer.automaticallyCollapsed).toEqual(false);
});
});
@@ -933,12 +933,12 @@ describe('DiffsStoreMutations', () => {
describe('SET_FILE_COLLAPSED', () => {
it('sets collapsed', () => {
const state = {
- diffFiles: [{ file_path: 'test', viewer: { collapsed: false } }],
+ diffFiles: [{ file_path: 'test', viewer: { automaticallyCollapsed: false } }],
};
mutations[types.SET_FILE_COLLAPSED](state, { filePath: 'test', collapsed: true });
- expect(state.diffFiles[0].viewer.collapsed).toBe(true);
+ expect(state.diffFiles[0].viewer.automaticallyCollapsed).toBe(true);
});
});
diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js
index 53c6d0835bc..2f174c45ad7 100644
--- a/spec/frontend/emoji/emoji_spec.js
+++ b/spec/frontend/emoji/emoji_spec.js
@@ -1,7 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import { trimText } from 'helpers/text_helper';
import axios from '~/lib/utils/axios_utils';
-import { initEmojiMap, glEmojiTag, EMOJI_VERSION } from '~/emoji';
+import { initEmojiMap, glEmojiTag, searchEmoji, EMOJI_VERSION } from '~/emoji';
import isEmojiUnicodeSupported, {
isFlagEmoji,
isRainbowFlagEmoji,
@@ -31,25 +31,35 @@ const emptySupportMap = {
};
const emojiFixtureMap = {
+ atom: {
+ name: 'atom',
+ moji: '⚛',
+ description: 'atom symbol',
+ unicodeVersion: '4.1',
+ },
bomb: {
name: 'bomb',
moji: '💣',
unicodeVersion: '6.0',
+ description: 'bomb',
},
construction_worker_tone5: {
name: 'construction_worker_tone5',
moji: '👷🏿',
unicodeVersion: '8.0',
+ description: 'construction worker tone 5',
},
five: {
name: 'five',
moji: '5️⃣',
unicodeVersion: '3.0',
+ description: 'keycap digit five',
},
grey_question: {
name: 'grey_question',
moji: '❔',
unicodeVersion: '6.0',
+ description: 'white question mark ornament',
},
};
@@ -57,8 +67,15 @@ describe('gl_emoji', () => {
let mock;
beforeEach(() => {
+ const emojiData = Object.fromEntries(
+ Object.values(emojiFixtureMap).map(m => {
+ const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m;
+ return [n, { c, e, d, u }];
+ }),
+ );
+
mock = new MockAdapter(axios);
- mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200);
+ mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData));
return initEmojiMap().catch(() => {});
});
@@ -378,4 +395,24 @@ describe('gl_emoji', () => {
expect(isSupported).toBeFalsy();
});
});
+
+ describe('searchEmoji', () => {
+ const { atom, grey_question } = emojiFixtureMap;
+ const contains = (e, term) =>
+ expect(searchEmoji(term).map(({ name }) => name)).toContain(e.name);
+
+ it('should match by full name', () => contains(grey_question, 'grey_question'));
+ it('should match by full alias', () => contains(atom, 'atom_symbol'));
+ it('should match by full description', () => contains(grey_question, 'ornament'));
+
+ it('should match by partial name', () => contains(grey_question, 'question'));
+ it('should match by partial alias', () => contains(atom, '_symbol'));
+ it('should match by partial description', () => contains(grey_question, 'ment'));
+
+ it('should fuzzy match by name', () => contains(grey_question, 'greion'));
+ it('should fuzzy match by alias', () => contains(atom, 'atobol'));
+ it('should fuzzy match by description', () => contains(grey_question, 'ornt'));
+
+ it('should match by character', () => contains(grey_question, '❔'));
+ });
});
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
index 35ca323f5a9..c958fb7ce03 100644
--- a/spec/frontend/environment.js
+++ b/spec/frontend/environment.js
@@ -1,4 +1,4 @@
-/* eslint-disable import/no-commonjs */
+/* eslint-disable import/no-commonjs, max-classes-per-file */
const path = require('path');
const { ErrorWithStack } = require('jest-util');
@@ -58,6 +58,14 @@ class CustomEnvironment extends JSDOMEnvironment {
measure: () => null,
getEntriesByName: () => [],
});
+
+ this.global.PerformanceObserver = class {
+ /* eslint-disable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */
+ constructor(callback) {}
+ disconnect() {}
+ observe(element, initObject) {}
+ /* eslint-enable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */
+ };
}
async teardown() {
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index fe32bf918dd..22b066fae41 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -40,6 +40,9 @@ describe('Environment', () => {
return axios.waitForAll();
};
+ const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a');
+ const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a');
+
beforeEach(() => {
mock = new MockAdapter(axios);
});
@@ -108,9 +111,16 @@ describe('Environment', () => {
it('should make an API request when using tabs', () => {
jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
- wrapper.find('.js-environments-tab-stopped').trigger('click');
+ findEnvironmentsTabStopped().trigger('click');
expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' });
});
+
+ it('should not make the same API request when clicking on the current scope tab', () => {
+ // component starts at available
+ jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
+ findEnvironmentsTabAvailable().trigger('click');
+ expect(wrapper.vm.updateContent).toHaveBeenCalledTimes(0);
+ });
});
});
});
diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js
index f33c8de0094..14c710dd7ba 100644
--- a/spec/frontend/environments/folder/environments_folder_view_spec.js
+++ b/spec/frontend/environments/folder/environments_folder_view_spec.js
@@ -46,9 +46,10 @@ describe('Environments Folder View', () => {
wrapper = mount(EnvironmentsFolderViewComponent, { propsData: mockData });
};
- const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available');
+ const findEnvironmentsTabAvailable = () =>
+ wrapper.find('[data-testid="environments-tab-available"]');
- const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped');
+ const findEnvironmentsTabStopped = () => wrapper.find('[data-testid="environments-tab-stopped"]');
beforeEach(() => {
mock = new MockAdapter(axios);
diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
index 21edcb7235a..f4a765a3d73 100644
--- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
+++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
@@ -1,7 +1,6 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlFormInput } from '@gitlab/ui';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { GlFormInput, GlButton } from '@gitlab/ui';
import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue';
import createStore from '~/error_tracking_settings/store';
import { defaultProps } from '../mock';
@@ -43,7 +42,7 @@ describe('error tracking settings form', () => {
.attributes('id'),
).toBe('error-tracking-token');
- expect(wrapper.findAll(LoadingButton).exists()).toBe(true);
+ expect(wrapper.findAll(GlButton).exists()).toBe(true);
});
it('is rendered with labels and placeholders', () => {
@@ -72,9 +71,10 @@ describe('error tracking settings form', () => {
});
it('shows loading spinner', () => {
- const { label, loading } = wrapper.find(LoadingButton).props();
- expect(loading).toBe(true);
- expect(label).toBe('Connecting');
+ const buttonEl = wrapper.find(GlButton);
+
+ expect(buttonEl.props('loading')).toBe(true);
+ expect(buttonEl.text()).toBe('Connecting');
});
});
diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
new file mode 100644
index 00000000000..47f786827f1
--- /dev/null
+++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
@@ -0,0 +1,159 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import Component from '~/feature_flags/components/configure_feature_flags_modal.vue';
+import Callout from '~/vue_shared/components/callout.vue';
+
+describe('Configure Feature Flags Modal', () => {
+ const mockEvent = { preventDefault: jest.fn() };
+ const provide = {
+ projectName: 'fakeProjectName',
+ featureFlagsHelpPagePath: '/help/path',
+ };
+
+ const propsData = {
+ helpClientLibrariesPath: '/help/path/#flags',
+ helpClientExamplePath: '/feature-flags#clientexample',
+ apiUrl: '/api/url',
+ instanceId: 'instance-id-token',
+ isRotating: false,
+ hasRotateError: false,
+ canUserRotateToken: true,
+ };
+
+ let wrapper;
+ const factory = (props = {}, { mountFn = shallowMount, ...options } = {}) => {
+ wrapper = mountFn(Component, {
+ provide,
+ stubs: { GlSprintf },
+ propsData: {
+ ...propsData,
+ ...props,
+ },
+ ...options,
+ });
+ };
+
+ const findGlModal = () => wrapper.find(GlModal);
+ const findPrimaryAction = () => findGlModal().props('actionPrimary');
+ const findProjectNameInput = () => wrapper.find('#project_name_verification');
+ const findDangerCallout = () =>
+ wrapper.findAll(Callout).filter(c => c.props('category') === 'danger');
+
+ describe('idle', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory);
+
+ it('should have Primary and Cancel actions', () => {
+ expect(findGlModal().props('actionCancel').text).toBe('Close');
+ expect(findPrimaryAction().text).toBe('Regenerate instance ID');
+ });
+
+ it('should default disable the primary action', async () => {
+ const [{ disabled }] = findPrimaryAction().attributes;
+ expect(disabled).toBe(true);
+ });
+
+ it('should emit a `token` event when clicking on the Primary action', async () => {
+ findGlModal().vm.$emit('primary', mockEvent);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('token')).toEqual([[]]);
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
+ });
+
+ it('should clear the project name input after generating the token', async () => {
+ findProjectNameInput().vm.$emit('input', provide.projectName);
+ findGlModal().vm.$emit('primary', mockEvent);
+ await wrapper.vm.$nextTick();
+ expect(findProjectNameInput().attributes('value')).toBe('');
+ });
+
+ it('should provide an input for filling the project name', () => {
+ expect(findProjectNameInput().exists()).toBe(true);
+ expect(findProjectNameInput().attributes('value')).toBe('');
+ });
+
+ it('should display an help text', () => {
+ const help = wrapper.find('p');
+ expect(help.text()).toMatch(/More Information/);
+ });
+
+ it('should have links to the documentation', () => {
+ expect(wrapper.find('[data-testid="help-link"]').attributes('href')).toBe(
+ provide.featureFlagsHelpPagePath,
+ );
+ expect(wrapper.find('[data-testid="help-client-link"]').attributes('href')).toBe(
+ propsData.helpClientLibrariesPath,
+ );
+ });
+
+ it('should display one and only one danger callout', () => {
+ const dangerCallout = findDangerCallout();
+ expect(dangerCallout.length).toBe(1);
+ expect(dangerCallout.at(0).props('message')).toMatch(/Regenerating the instance ID/);
+ });
+
+ it('should display a message asking to fill the project name', () => {
+ expect(wrapper.find('[data-testid="prevent-accident-text"]').text()).toMatch(
+ provide.projectName,
+ );
+ });
+
+ it('should display the api URL in an input box', () => {
+ const input = wrapper.find('#api_url');
+ expect(input.element.value).toBe('/api/url');
+ });
+
+ it('should display the instance ID in an input box', () => {
+ const input = wrapper.find('#instance_id');
+ expect(input.element.value).toBe('instance-id-token');
+ });
+ });
+
+ describe('verified', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory);
+
+ it('should enable the primary action', async () => {
+ findProjectNameInput().vm.$emit('input', provide.projectName);
+ await wrapper.vm.$nextTick();
+ const [{ disabled }] = findPrimaryAction().attributes;
+ expect(disabled).toBe(false);
+ });
+ });
+
+ describe('cannot rotate token', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory.bind(null, { canUserRotateToken: false }));
+
+ it('should not display the primary action', async () => {
+ expect(findPrimaryAction()).toBe(null);
+ });
+
+ it('shold not display regenerating instance ID', async () => {
+ expect(findDangerCallout().exists()).toBe(false);
+ });
+
+ it('should disable the project name input', async () => {
+ expect(findProjectNameInput().exists()).toBe(false);
+ });
+ });
+
+ describe('has rotate error', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory.bind(null, { hasRotateError: false }));
+
+ it('should display an error', async () => {
+ expect(wrapper.find('.text-danger')).toExist();
+ expect(wrapper.find('[name="warning"]')).toExist();
+ });
+ });
+
+ describe('is rotating', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory.bind(null, { isRotating: true }));
+
+ it('should disable the project name input', async () => {
+ expect(findProjectNameInput().attributes('disabled')).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
new file mode 100644
index 00000000000..f2e587bb8d9
--- /dev/null
+++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
@@ -0,0 +1,197 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { GlToggle, GlAlert } from '@gitlab/ui';
+import { TEST_HOST } from 'spec/test_constants';
+import { mockTracking } from 'helpers/tracking_helper';
+import { LEGACY_FLAG, NEW_VERSION_FLAG, NEW_FLAG_ALERT } from '~/feature_flags/constants';
+import Form from '~/feature_flags/components/form.vue';
+import editModule from '~/feature_flags/store/modules/edit';
+import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue';
+import axios from '~/lib/utils/axios_utils';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const userCalloutId = 'feature_flags_new_version';
+const userCalloutsPath = `${TEST_HOST}/user_callouts`;
+
+describe('Edit feature flag form', () => {
+ let wrapper;
+ let mock;
+
+ const store = new Vuex.Store({
+ modules: {
+ edit: editModule,
+ },
+ });
+
+ const factory = (opts = {}) => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ wrapper = shallowMount(EditFeatureFlag, {
+ localVue,
+ propsData: {
+ endpoint: `${TEST_HOST}/feature_flags.json`,
+ path: '/feature_flags',
+ environmentsEndpoint: 'environments.json',
+ projectId: '8',
+ featureFlagIssuesEndpoint: `${TEST_HOST}/feature_flags/5/issues`,
+ showUserCallout: true,
+ userCalloutId,
+ userCalloutsPath,
+ },
+ store,
+ provide: {
+ glFeatures: {
+ featureFlagsNewVersion: true,
+ },
+ },
+ ...opts,
+ });
+ };
+
+ beforeEach(done => {
+ mock = new MockAdapter(axios);
+ mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, {
+ id: 21,
+ iid: 5,
+ active: true,
+ created_at: '2019-01-17T17:27:39.778Z',
+ updated_at: '2019-01-17T17:27:39.778Z',
+ name: 'feature_flag',
+ description: '',
+ version: LEGACY_FLAG,
+ edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit',
+ destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21',
+ scopes: [
+ {
+ id: 21,
+ active: false,
+ environment_scope: '*',
+ created_at: '2019-01-17T17:27:39.778Z',
+ updated_at: '2019-01-17T17:27:39.778Z',
+ },
+ ],
+ });
+ factory();
+ setImmediate(() => done());
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ const findAlert = () => wrapper.find(GlAlert);
+
+ it('should display the iid', () => {
+ expect(wrapper.find('h3').text()).toContain('^5');
+ });
+
+ it('should render the toggle', () => {
+ expect(wrapper.find(GlToggle).exists()).toBe(true);
+ });
+
+ it('should set the value of the toggle to whether or not the flag is active', () => {
+ expect(wrapper.find(GlToggle).props('value')).toBe(true);
+ });
+
+ it('should not alert users that feature flags are changing soon', () => {
+ expect(findAlert().text()).toContain('GitLab is moving to a new way of managing feature flags');
+ });
+
+ describe('with error', () => {
+ it('should render the error', () => {
+ store.dispatch('edit/receiveUpdateFeatureFlagError', { message: ['The name is required'] });
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.alert-danger').exists()).toEqual(true);
+ expect(wrapper.find('.alert-danger').text()).toContain('The name is required');
+ });
+ });
+ });
+
+ describe('without error', () => {
+ it('renders form title', () => {
+ expect(wrapper.text()).toContain('^5 feature_flag');
+ });
+
+ it('should render feature flag form', () => {
+ expect(wrapper.find(Form).exists()).toEqual(true);
+ });
+
+ it('should set the version of the form from the feature flag', () => {
+ expect(wrapper.find(Form).props('version')).toBe(LEGACY_FLAG);
+
+ mock.resetHandlers();
+
+ mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, {
+ id: 21,
+ iid: 5,
+ active: true,
+ created_at: '2019-01-17T17:27:39.778Z',
+ updated_at: '2019-01-17T17:27:39.778Z',
+ name: 'feature_flag',
+ description: '',
+ version: NEW_VERSION_FLAG,
+ edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit',
+ destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21',
+ strategies: [],
+ });
+
+ factory();
+
+ return axios.waitForAll().then(() => {
+ expect(wrapper.find(Form).props('version')).toBe(NEW_VERSION_FLAG);
+ });
+ });
+
+ it('renders the related issues widget', () => {
+ const expected = `${TEST_HOST}/feature_flags/5/issues`;
+
+ expect(wrapper.find(Form).props('featureFlagIssuesEndpoint')).toBe(expected);
+ });
+
+ it('should track when the toggle is clicked', () => {
+ const toggle = wrapper.find(GlToggle);
+ const spy = mockTracking('_category_', toggle.element, jest.spyOn);
+
+ toggle.trigger('click');
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'feature_flag_toggle',
+ });
+ });
+ });
+
+ describe('without new version flags', () => {
+ beforeEach(() => factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } }));
+
+ it('should alert users that feature flags are changing soon', () => {
+ expect(findAlert().text()).toBe(NEW_FLAG_ALERT);
+ });
+ });
+
+ describe('dismissing new version alert', () => {
+ beforeEach(() => {
+ factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } });
+ mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200);
+ findAlert().vm.$emit('dismiss');
+ return wrapper.vm.$nextTick();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should hide the alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('should send the dismissal event', () => {
+ expect(mock.history.post.length).toBe(1);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/environments_dropdown_spec.js b/spec/frontend/feature_flags/components/environments_dropdown_spec.js
new file mode 100644
index 00000000000..2aa75ef6652
--- /dev/null
+++ b/spec/frontend/feature_flags/components/environments_dropdown_spec.js
@@ -0,0 +1,145 @@
+import MockAdapter from 'axios-mock-adapter';
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon, GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui';
+import { TEST_HOST } from 'spec/test_constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+describe('Feature flags > Environments dropdown ', () => {
+ let wrapper;
+ let mock;
+ const results = ['production', 'staging'];
+ const factory = props => {
+ wrapper = shallowMount(EnvironmentsDropdown, {
+ propsData: {
+ endpoint: `${TEST_HOST}/environments.json'`,
+ ...props,
+ },
+ });
+ };
+
+ const findEnvironmentSearchInput = () => wrapper.find(GlSearchBoxByType);
+ const findDropdownMenu = () => wrapper.find('.dropdown-menu');
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ describe('without value', () => {
+ it('renders the placeholder', () => {
+ factory();
+ expect(findEnvironmentSearchInput().vm.$attrs.placeholder).toBe('Search an environment spec');
+ });
+ });
+
+ describe('with value', () => {
+ it('sets filter to equal the value', () => {
+ factory({ value: 'production' });
+ expect(findEnvironmentSearchInput().props('value')).toBe('production');
+ });
+ });
+
+ describe('on focus', () => {
+ it('sets results with the received data', async () => {
+ mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
+ factory();
+ findEnvironmentSearchInput().vm.$emit('focus');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true);
+ expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true);
+ });
+ });
+
+ describe('on keyup', () => {
+ it('sets results with the received data', async () => {
+ mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
+ factory();
+ findEnvironmentSearchInput().vm.$emit('keyup');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true);
+ expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true);
+ });
+ });
+
+ describe('on input change', () => {
+ describe('on success', () => {
+ beforeEach(async () => {
+ mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
+ factory();
+ findEnvironmentSearchInput().vm.$emit('focus');
+ findEnvironmentSearchInput().vm.$emit('input', 'production');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ });
+
+ it('sets filter value', () => {
+ expect(findEnvironmentSearchInput().props('value')).toBe('production');
+ });
+
+ describe('with received data', () => {
+ it('sets is loading to false', () => {
+ expect(wrapper.vm.isLoading).toBe(false);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ });
+
+ it('shows the suggestions', () => {
+ expect(findDropdownMenu().exists()).toBe(true);
+ });
+
+ it('emits event when a suggestion is clicked', async () => {
+ const button = wrapper
+ .findAll(GlDeprecatedButton)
+ .filter(b => b.text() === 'production')
+ .at(0);
+ button.vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('selectEnvironment')).toEqual([['production']]);
+ });
+ });
+
+ describe('on click clear button', () => {
+ beforeEach(async () => {
+ wrapper.find(GlDeprecatedButton).vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('resets filter value', () => {
+ expect(findEnvironmentSearchInput().props('value')).toBe('');
+ });
+
+ it('closes list of suggestions', () => {
+ expect(wrapper.vm.showSuggestions).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('on click create button', () => {
+ beforeEach(async () => {
+ mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, []);
+ factory();
+ findEnvironmentSearchInput().vm.$emit('focus');
+ findEnvironmentSearchInput().vm.$emit('input', 'production');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ });
+
+ it('emits create event', async () => {
+ wrapper
+ .findAll(GlDeprecatedButton)
+ .at(0)
+ .vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('createClicked')).toEqual([['production']]);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js
new file mode 100644
index 00000000000..5ff39937113
--- /dev/null
+++ b/spec/frontend/feature_flags/components/feature_flags_spec.js
@@ -0,0 +1,343 @@
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import { TEST_HOST } from 'spec/test_constants';
+import Api from '~/api';
+import { createStore } from '~/feature_flags/store';
+import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue';
+import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue';
+import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue';
+import UserListsTable from '~/feature_flags/components/user_lists_table.vue';
+import ConfigureFeatureFlagsModal from '~/feature_flags/components/configure_feature_flags_modal.vue';
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '~/feature_flags/constants';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import axios from '~/lib/utils/axios_utils';
+import { getRequestData, userList } from '../mock_data';
+
+describe('Feature flags', () => {
+ const mockData = {
+ endpoint: `${TEST_HOST}/endpoint.json`,
+ csrfToken: 'testToken',
+ featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
+ featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example',
+ unleashApiUrl: `${TEST_HOST}/api/unleash`,
+ unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
+ canUserConfigure: true,
+ canUserRotateToken: true,
+ newFeatureFlagPath: 'feature-flags/new',
+ newUserListPath: '/user-list/new',
+ projectId: '8',
+ };
+
+ let wrapper;
+ let mock;
+ let store;
+
+ const factory = (propsData = mockData, fn = shallowMount) => {
+ store = createStore();
+ wrapper = fn(FeatureFlagsComponent, {
+ store,
+ propsData,
+ provide: {
+ projectName: 'fakeProjectName',
+ errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
+ featureFlagsHelpPagePath: '/help/feature-flags',
+ },
+ stubs: {
+ FeatureFlagsTab,
+ },
+ });
+ };
+
+ const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]');
+ const newButton = () => wrapper.find('[data-testid="ff-new-button"]');
+ const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]');
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(Api, 'fetchFeatureFlagUserLists').mockResolvedValue({
+ data: [userList],
+ headers: {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '8',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '40',
+ 'X-Total-Pages': '5',
+ },
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('without permissions', () => {
+ const propsData = {
+ endpoint: `${TEST_HOST}/endpoint.json`,
+ csrfToken: 'testToken',
+ errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
+ featureFlagsHelpPagePath: '/help/feature-flags',
+ canUserConfigure: false,
+ canUserRotateToken: false,
+ featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
+ featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example',
+ unleashApiUrl: `${TEST_HOST}/api/unleash`,
+ unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
+ projectId: '8',
+ };
+
+ beforeEach(done => {
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .reply(200, getRequestData, {});
+
+ factory(propsData);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('does not render configure button', () => {
+ expect(configureButton().exists()).toBe(false);
+ });
+
+ it('does not render new feature flag button', () => {
+ expect(newButton().exists()).toBe(false);
+ });
+
+ it('does not render new user list button', () => {
+ expect(newUserListButton().exists()).toBe(false);
+ });
+ });
+
+ describe('loading state', () => {
+ it('renders a loading icon', () => {
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .replyOnce(200, getRequestData, {});
+
+ factory();
+
+ const loadingElement = wrapper.find(GlLoadingIcon);
+
+ expect(loadingElement.exists()).toBe(true);
+ expect(loadingElement.props('label')).toEqual('Loading feature flags');
+ });
+ });
+
+ describe('successful request', () => {
+ describe('without feature flags', () => {
+ let emptyState;
+
+ beforeEach(async () => {
+ mock.onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }).reply(
+ 200,
+ {
+ feature_flags: [],
+ count: {
+ all: 0,
+ enabled: 0,
+ disabled: 0,
+ },
+ },
+ {},
+ );
+
+ factory();
+ await wrapper.vm.$nextTick();
+
+ emptyState = wrapper.find(GlEmptyState);
+ });
+
+ it('should render the empty state', async () => {
+ await axios.waitForAll();
+ emptyState = wrapper.find(GlEmptyState);
+ expect(emptyState.exists()).toBe(true);
+ });
+
+ it('renders configure button', () => {
+ expect(configureButton().exists()).toBe(true);
+ });
+
+ it('renders new feature flag button', () => {
+ expect(newButton().exists()).toBe(true);
+ });
+
+ it('renders new user list button', () => {
+ expect(newUserListButton().exists()).toBe(true);
+ expect(newUserListButton().attributes('href')).toBe('/user-list/new');
+ });
+
+ describe('in feature flags tab', () => {
+ it('renders generic title', () => {
+ expect(emptyState.props('title')).toEqual('Get started with feature flags');
+ });
+ });
+ });
+
+ describe('with paginated feature flags', () => {
+ beforeEach(done => {
+ mock
+ .onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .replyOnce(200, getRequestData, {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '2',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '5',
+ });
+
+ factory();
+ jest.spyOn(store, 'dispatch');
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('should render a table with feature flags', () => {
+ const table = wrapper.find(FeatureFlagsTable);
+ expect(table.exists()).toBe(true);
+ expect(table.props(FEATURE_FLAG_SCOPE)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: getRequestData.feature_flags[0].name,
+ description: getRequestData.feature_flags[0].description,
+ }),
+ ]),
+ );
+ });
+
+ it('should toggle a flag when receiving the toggle-flag event', () => {
+ const table = wrapper.find(FeatureFlagsTable);
+
+ const [flag] = table.props(FEATURE_FLAG_SCOPE);
+ table.vm.$emit('toggle-flag', flag);
+
+ expect(store.dispatch).toHaveBeenCalledWith('index/toggleFeatureFlag', flag);
+ });
+
+ it('renders configure button', () => {
+ expect(configureButton().exists()).toBe(true);
+ });
+
+ it('renders new feature flag button', () => {
+ expect(newButton().exists()).toBe(true);
+ });
+
+ it('renders new user list button', () => {
+ expect(newUserListButton().exists()).toBe(true);
+ expect(newUserListButton().attributes('href')).toBe('/user-list/new');
+ });
+
+ describe('pagination', () => {
+ it('should render pagination', () => {
+ expect(wrapper.find(TablePagination).exists()).toBe(true);
+ });
+
+ it('should make an API request when page is clicked', () => {
+ jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions');
+ wrapper.find(TablePagination).vm.change(4);
+
+ expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
+ scope: FEATURE_FLAG_SCOPE,
+ page: '4',
+ });
+ });
+
+ it('should make an API request when using tabs', () => {
+ jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions');
+ wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab');
+
+ expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
+ scope: USER_LIST_SCOPE,
+ page: '1',
+ });
+ });
+ });
+ });
+
+ describe('in user lists tab', () => {
+ beforeEach(done => {
+ factory();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+ beforeEach(() => {
+ wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should display the user list table', () => {
+ expect(wrapper.find(UserListsTable).exists()).toBe(true);
+ });
+
+ it('should set the user lists to display', () => {
+ expect(wrapper.find(UserListsTable).props('userLists')).toEqual([userList]);
+ });
+ });
+ });
+
+ describe('unsuccessful request', () => {
+ beforeEach(done => {
+ mock
+ .onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .replyOnce(500, {});
+ Api.fetchFeatureFlagUserLists.mockRejectedValueOnce();
+
+ factory();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('should render error state', () => {
+ const emptyState = wrapper.find(GlEmptyState);
+ expect(emptyState.props('title')).toEqual('There was an error fetching the feature flags.');
+ expect(emptyState.props('description')).toEqual(
+ 'Try again in a few moments or contact your support team.',
+ );
+ });
+
+ it('renders configure button', () => {
+ expect(configureButton().exists()).toBe(true);
+ });
+
+ it('renders new feature flag button', () => {
+ expect(newButton().exists()).toBe(true);
+ });
+
+ it('renders new user list button', () => {
+ expect(newUserListButton().exists()).toBe(true);
+ expect(newUserListButton().attributes('href')).toBe('/user-list/new');
+ });
+ });
+
+ describe('rotate instance id', () => {
+ beforeEach(done => {
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .reply(200, getRequestData, {});
+ factory();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('should fire the rotate action when a `token` event is received', () => {
+ const actionSpy = jest.spyOn(wrapper.vm, 'rotateInstanceId');
+ const modal = wrapper.find(ConfigureFeatureFlagsModal);
+ modal.vm.$emit('token');
+
+ expect(actionSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js
new file mode 100644
index 00000000000..bc90c5ceb2d
--- /dev/null
+++ b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js
@@ -0,0 +1,168 @@
+import { mount } from '@vue/test-utils';
+import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui';
+import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue';
+
+const DEFAULT_PROPS = {
+ title: 'test',
+ count: 5,
+ alerts: ['an alert', 'another alert'],
+ isLoading: false,
+ loadingLabel: 'test loading',
+ errorState: false,
+ errorTitle: 'test title',
+ emptyState: true,
+ emptyTitle: 'test empty',
+};
+
+const DEFAULT_PROVIDE = {
+ errorStateSvgPath: '/error.svg',
+ featureFlagsHelpPagePath: '/help/page/path',
+};
+
+describe('feature_flags/components/feature_flags_tab.vue', () => {
+ let wrapper;
+
+ const factory = (props = {}) =>
+ mount(
+ {
+ components: {
+ GlTabs,
+ FeatureFlagsTab,
+ },
+ render(h) {
+ return h(GlTabs, [
+ h(FeatureFlagsTab, { props: this.$attrs, on: this.$listeners }, this.$slots.default),
+ ]);
+ },
+ },
+ {
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...props,
+ },
+ provide: DEFAULT_PROVIDE,
+ slots: {
+ default: '<p data-testid="test-slot">testing</p>',
+ },
+ },
+ );
+
+ afterEach(() => {
+ if (wrapper?.destroy) {
+ wrapper.destroy();
+ }
+
+ wrapper = null;
+ });
+
+ describe('alerts', () => {
+ let alerts;
+
+ beforeEach(() => {
+ wrapper = factory();
+ alerts = wrapper.findAll(GlAlert);
+ });
+
+ it('should show any alerts', () => {
+ expect(alerts).toHaveLength(DEFAULT_PROPS.alerts.length);
+ alerts.wrappers.forEach((alert, i) => expect(alert.text()).toBe(DEFAULT_PROPS.alerts[i]));
+ });
+
+ it('should emit a dismiss event for a dismissed alert', () => {
+ alerts.at(0).vm.$emit('dismiss');
+
+ expect(wrapper.find(FeatureFlagsTab).emitted('dismissAlert')).toEqual([[0]]);
+ });
+ });
+
+ describe('loading', () => {
+ beforeEach(() => {
+ wrapper = factory({ isLoading: true });
+ });
+
+ it('should show a loading icon and nothing else', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.findAll(GlEmptyState)).toHaveLength(0);
+ });
+ });
+
+ describe('error', () => {
+ let emptyState;
+
+ beforeEach(() => {
+ wrapper = factory({ errorState: true });
+ emptyState = wrapper.find(GlEmptyState);
+ });
+
+ it('should show an error state if there has been an error', () => {
+ expect(emptyState.text()).toContain(DEFAULT_PROPS.errorTitle);
+ expect(emptyState.text()).toContain(
+ 'Try again in a few moments or contact your support team.',
+ );
+ expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath);
+ });
+ });
+
+ describe('empty', () => {
+ let emptyState;
+ let emptyStateLink;
+
+ beforeEach(() => {
+ wrapper = factory({ emptyState: true });
+ emptyState = wrapper.find(GlEmptyState);
+ emptyStateLink = emptyState.find(GlLink);
+ });
+
+ it('should show an empty state if it is empty', () => {
+ expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyTitle);
+ expect(emptyState.text()).toContain(
+ 'Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
+ );
+ expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath);
+ expect(emptyStateLink.attributes('href')).toBe(DEFAULT_PROVIDE.featureFlagsHelpPagePath);
+ expect(emptyStateLink.text()).toBe('More information');
+ });
+ });
+
+ describe('slot', () => {
+ let slot;
+
+ beforeEach(async () => {
+ wrapper = factory();
+ await wrapper.vm.$nextTick();
+
+ slot = wrapper.find('[data-testid="test-slot"]');
+ });
+
+ it('should display the passed slot', () => {
+ expect(slot.exists()).toBe(true);
+ expect(slot.text()).toBe('testing');
+ });
+ });
+
+ describe('count', () => {
+ it('should display a count if there is one', async () => {
+ wrapper = factory();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlBadge).text()).toBe(DEFAULT_PROPS.count.toString());
+ });
+ it('should display 0 if there is no count', async () => {
+ wrapper = factory({ count: undefined });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlBadge).text()).toBe('0');
+ });
+ });
+
+ describe('title', () => {
+ it('should show the title', async () => {
+ wrapper = factory();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="feature-flags-tab-title"]').text()).toBe(
+ DEFAULT_PROPS.title,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js
new file mode 100644
index 00000000000..c59ecbf3b06
--- /dev/null
+++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js
@@ -0,0 +1,262 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlToggle, GlBadge } from '@gitlab/ui';
+import { trimText } from 'helpers/text_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ NEW_VERSION_FLAG,
+ LEGACY_FLAG,
+ DEFAULT_PERCENT_ROLLOUT,
+} from '~/feature_flags/constants';
+import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue';
+
+const getDefaultProps = () => ({
+ featureFlags: [
+ {
+ id: 1,
+ iid: 1,
+ active: true,
+ name: 'flag name',
+ description: 'flag description',
+ destroy_path: 'destroy/path',
+ edit_path: 'edit/path',
+ version: LEGACY_FLAG,
+ scopes: [
+ {
+ id: 1,
+ active: true,
+ environmentScope: 'scope',
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ shouldBeDestroyed: false,
+ },
+ ],
+ },
+ ],
+ csrfToken: 'fakeToken',
+});
+
+describe('Feature flag table', () => {
+ let wrapper;
+ let props;
+
+ const createWrapper = (propsData, opts = {}) => {
+ wrapper = shallowMount(FeatureFlagsTable, {
+ propsData,
+ ...opts,
+ });
+ };
+
+ beforeEach(() => {
+ props = getDefaultProps();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('with an active scope and a standard rollout strategy', () => {
+ beforeEach(() => {
+ createWrapper(props);
+ });
+
+ it('Should render a table', () => {
+ expect(wrapper.classes('table-holder')).toBe(true);
+ });
+
+ it('Should render rows', () => {
+ expect(wrapper.find('.gl-responsive-table-row').exists()).toBe(true);
+ });
+
+ it('should render an ID column', () => {
+ expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true);
+ expect(trimText(wrapper.find('.js-feature-flag-id').text())).toEqual('^1');
+ });
+
+ it('Should render a status column', () => {
+ const badge = wrapper.find('[data-testid="feature-flag-status-badge"]');
+
+ expect(badge.exists()).toBe(true);
+ expect(trimText(badge.text())).toEqual('Active');
+ });
+
+ it('Should render a feature flag column', () => {
+ expect(wrapper.find('.js-feature-flag-title').exists()).toBe(true);
+ expect(trimText(wrapper.find('.feature-flag-name').text())).toEqual('flag name');
+
+ expect(trimText(wrapper.find('.feature-flag-description').text())).toEqual(
+ 'flag description',
+ );
+ });
+
+ it('should render an environments specs column', () => {
+ const envColumn = wrapper.find('.js-feature-flag-environments');
+
+ expect(envColumn).toBeDefined();
+ expect(trimText(envColumn.text())).toBe('scope');
+ });
+
+ it('should render an environments specs badge with active class', () => {
+ const envColumn = wrapper.find('.js-feature-flag-environments');
+
+ expect(trimText(envColumn.find(GlBadge).text())).toBe('scope');
+ });
+
+ it('should render an actions column', () => {
+ expect(wrapper.find('.table-action-buttons').exists()).toBe(true);
+ expect(wrapper.find('.js-feature-flag-delete-button').exists()).toBe(true);
+ expect(wrapper.find('.js-feature-flag-edit-button').exists()).toBe(true);
+ expect(wrapper.find('.js-feature-flag-edit-button').attributes('href')).toEqual('edit/path');
+ });
+ });
+
+ describe('when active and with an update toggle', () => {
+ let toggle;
+ let spy;
+
+ beforeEach(() => {
+ props.featureFlags[0].update_path = props.featureFlags[0].destroy_path;
+ createWrapper(props);
+ toggle = wrapper.find(GlToggle);
+ spy = mockTracking('_category_', toggle.element, jest.spyOn);
+ });
+
+ it('should have a toggle', () => {
+ expect(toggle.exists()).toBe(true);
+ expect(toggle.props('value')).toBe(true);
+ });
+
+ it('should trigger a toggle event', () => {
+ toggle.vm.$emit('change');
+ const flag = { ...props.featureFlags[0], active: !props.featureFlags[0].active };
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('toggle-flag')).toEqual([[flag]]);
+ });
+ });
+
+ it('should track a click', () => {
+ toggle.trigger('click');
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'feature_flag_toggle',
+ });
+ });
+ });
+
+ describe('with an active scope and a percentage rollout strategy', () => {
+ beforeEach(() => {
+ props.featureFlags[0].scopes[0].rolloutStrategy = ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
+ props.featureFlags[0].scopes[0].rolloutPercentage = '54';
+ createWrapper(props);
+ });
+
+ it('should render an environments specs badge with percentage', () => {
+ const envColumn = wrapper.find('.js-feature-flag-environments');
+
+ expect(trimText(envColumn.find(GlBadge).text())).toBe('scope: 54%');
+ });
+ });
+
+ describe('with an inactive scope', () => {
+ beforeEach(() => {
+ props.featureFlags[0].scopes[0].active = false;
+ createWrapper(props);
+ });
+
+ it('should render an environments specs badge with inactive class', () => {
+ const envColumn = wrapper.find('.js-feature-flag-environments');
+
+ expect(trimText(envColumn.find(GlBadge).text())).toBe('scope');
+ });
+ });
+
+ describe('with a new version flag', () => {
+ let badges;
+
+ beforeEach(() => {
+ const newVersionProps = {
+ ...props,
+ featureFlags: [
+ {
+ id: 1,
+ iid: 1,
+ active: true,
+ name: 'flag name',
+ description: 'flag description',
+ destroy_path: 'destroy/path',
+ edit_path: 'edit/path',
+ version: NEW_VERSION_FLAG,
+ scopes: [],
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ scopes: [{ environment_scope: '*' }],
+ },
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50' },
+ scopes: [{ environment_scope: 'production' }, { environment_scope: 'staging' }],
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: { userIds: '1,2,3,4' },
+ scopes: [{ environment_scope: 'review/*' }],
+ },
+ {
+ name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ parameters: {},
+ user_list: { name: 'test list' },
+ scopes: [{ environment_scope: '*' }],
+ },
+ ],
+ },
+ ],
+ };
+ createWrapper(newVersionProps, { provide: { glFeatures: { featureFlagsNewVersion: true } } });
+
+ badges = wrapper.findAll('[data-testid="strategy-badge"]');
+ });
+
+ it('shows All Environments if the environment scope is *', () => {
+ expect(badges.at(0).text()).toContain('All Environments');
+ });
+
+ it('shows the environment scope if another is set', () => {
+ expect(badges.at(1).text()).toContain('production');
+ expect(badges.at(1).text()).toContain('staging');
+ expect(badges.at(2).text()).toContain('review/*');
+ });
+
+ it('shows All Users for the default strategy', () => {
+ expect(badges.at(0).text()).toContain('All Users');
+ });
+
+ it('shows the percent for a percent rollout', () => {
+ expect(badges.at(1).text()).toContain('Percent of users - 50%');
+ });
+
+ it('shows the number of users for users with ID', () => {
+ expect(badges.at(2).text()).toContain('User IDs - 4 users');
+ });
+
+ it('shows the name of a user list for user list', () => {
+ expect(badges.at(3).text()).toContain('User List - test list');
+ });
+ });
+
+ it('renders a feature flag without an iid', () => {
+ delete props.featureFlags[0].iid;
+ createWrapper(props);
+
+ expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true);
+ expect(trimText(wrapper.find('.js-feature-flag-id').text())).toBe('');
+ });
+});
diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js
new file mode 100644
index 00000000000..451bb4176ef
--- /dev/null
+++ b/spec/frontend/feature_flags/components/form_spec.js
@@ -0,0 +1,485 @@
+import { uniqueId } from 'lodash';
+import { shallowMount } from '@vue/test-utils';
+import { GlFormTextarea, GlFormCheckbox, GlButton } from '@gitlab/ui';
+import Api from '~/api';
+import Form from '~/feature_flags/components/form.vue';
+import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue';
+import Strategy from '~/feature_flags/components/strategy.vue';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ INTERNAL_ID_PREFIX,
+ DEFAULT_PERCENT_ROLLOUT,
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+} from '~/feature_flags/constants';
+import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+import { featureFlag, userList, allUsersStrategy } from '../mock_data';
+
+jest.mock('~/api.js');
+
+describe('feature flag form', () => {
+ let wrapper;
+ const requiredProps = {
+ cancelPath: 'feature_flags',
+ submitText: 'Create',
+ environmentsEndpoint: '/environments.json',
+ projectId: '1',
+ };
+
+ const factory = (props = {}) => {
+ wrapper = shallowMount(Form, {
+ propsData: props,
+ provide: {
+ glFeatures: {
+ featureFlagPermissions: true,
+ featureFlagsNewVersion: true,
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [] });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render provided submitText', () => {
+ factory(requiredProps);
+
+ expect(wrapper.find('.js-ff-submit').text()).toEqual(requiredProps.submitText);
+ });
+
+ it('should render provided cancelPath', () => {
+ factory(requiredProps);
+
+ expect(wrapper.find('.js-ff-cancel').attributes('href')).toEqual(requiredProps.cancelPath);
+ });
+
+ it('does not render the related issues widget without the featureFlagIssuesEndpoint', () => {
+ factory(requiredProps);
+
+ expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(false);
+ });
+
+ it('renders the related issues widget when the featureFlagIssuesEndpoint is provided', () => {
+ factory({
+ ...requiredProps,
+ featureFlagIssuesEndpoint: '/some/endpoint',
+ });
+
+ expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(true);
+ });
+
+ describe('without provided data', () => {
+ beforeEach(() => {
+ factory(requiredProps);
+ });
+
+ it('should render name input text', () => {
+ expect(wrapper.find('#feature-flag-name').exists()).toBe(true);
+ });
+
+ it('should render description textarea', () => {
+ expect(wrapper.find('#feature-flag-description').exists()).toBe(true);
+ });
+
+ describe('scopes', () => {
+ it('should render scopes table', () => {
+ expect(wrapper.find('.js-scopes-table').exists()).toBe(true);
+ });
+
+ it('should render scopes table with a new row ', () => {
+ expect(wrapper.find('.js-add-new-scope').exists()).toBe(true);
+ });
+
+ describe('status toggle', () => {
+ describe('without filled text input', () => {
+ it('should add a new scope with the text value empty and the status', () => {
+ wrapper.find(ToggleButton).vm.$emit('change', true);
+
+ expect(wrapper.vm.formScopes).toHaveLength(1);
+ expect(wrapper.vm.formScopes[0].active).toEqual(true);
+ expect(wrapper.vm.formScopes[0].environmentScope).toEqual('');
+
+ expect(wrapper.vm.newScope).toEqual('');
+ });
+ });
+
+ it('should be disabled if the feature flag is not active', done => {
+ wrapper.setProps({ active: false });
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true);
+ done();
+ });
+ });
+ });
+ });
+ });
+
+ describe('with provided data', () => {
+ beforeEach(() => {
+ factory({
+ ...requiredProps,
+ name: featureFlag.name,
+ description: featureFlag.description,
+ active: true,
+ version: LEGACY_FLAG,
+ scopes: [
+ {
+ id: 1,
+ active: true,
+ environmentScope: 'scope',
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '54',
+ rolloutUserIds: '123',
+ shouldIncludeUserIds: true,
+ },
+ {
+ id: 2,
+ active: true,
+ environmentScope: 'scope',
+ canUpdate: false,
+ protected: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '54',
+ rolloutUserIds: '123',
+ shouldIncludeUserIds: true,
+ },
+ ],
+ });
+ });
+
+ describe('scopes', () => {
+ it('should be possible to remove a scope', () => {
+ expect(wrapper.find('.js-feature-flag-delete').exists()).toEqual(true);
+ });
+
+ it('renders empty row to add a new scope', () => {
+ expect(wrapper.find('.js-add-new-scope').exists()).toEqual(true);
+ });
+
+ it('renders the user id checkbox', () => {
+ expect(wrapper.find(GlFormCheckbox).exists()).toBe(true);
+ });
+
+ it('renders the user id text area', () => {
+ expect(wrapper.find(GlFormTextarea).exists()).toBe(true);
+
+ expect(wrapper.find(GlFormTextarea).vm.value).toBe('123');
+ });
+
+ describe('update scope', () => {
+ describe('on click on toggle', () => {
+ it('should update the scope', () => {
+ wrapper.find(ToggleButton).vm.$emit('change', false);
+
+ expect(wrapper.vm.formScopes[0].active).toBe(false);
+ });
+
+ it('should be disabled if the feature flag is not active', done => {
+ wrapper.setProps({ active: false });
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true);
+ done();
+ });
+ });
+ });
+ describe('on strategy change', () => {
+ it('should not include user IDs if All Users is selected', () => {
+ const scope = wrapper.find({ ref: 'scopeRow' });
+ scope.find('select').setValue(ROLLOUT_STRATEGY_ALL_USERS);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(scope.find('#rollout-user-id-0').exists()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('deleting an existing scope', () => {
+ beforeEach(() => {
+ wrapper.find('.js-delete-scope').vm.$emit('click');
+ });
+
+ it('should add `shouldBeDestroyed` key the clicked scope', () => {
+ expect(wrapper.vm.formScopes[0].shouldBeDestroyed).toBe(true);
+ });
+
+ it('should not render deleted scopes', () => {
+ expect(wrapper.vm.filteredScopes).toEqual([expect.objectContaining({ id: 2 })]);
+ });
+ });
+
+ describe('deleting a new scope', () => {
+ it('should remove the scope from formScopes', () => {
+ factory({
+ ...requiredProps,
+ name: 'feature_flag_1',
+ description: 'this is a feature flag',
+ scopes: [
+ {
+ environmentScope: 'new_scope',
+ active: false,
+ id: uniqueId(INTERNAL_ID_PREFIX),
+ canUpdate: true,
+ protected: false,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ },
+ ],
+ },
+ ],
+ });
+
+ wrapper.find('.js-delete-scope').vm.$emit('click');
+
+ expect(wrapper.vm.formScopes).toEqual([]);
+ });
+ });
+
+ describe('with * scope', () => {
+ beforeEach(() => {
+ factory({
+ ...requiredProps,
+ name: 'feature_flag_1',
+ description: 'this is a feature flag',
+ scopes: [
+ {
+ environmentScope: '*',
+ active: false,
+ canUpdate: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ },
+ ],
+ });
+ });
+
+ it('renders read only name', () => {
+ expect(wrapper.find('.js-scope-all').exists()).toEqual(true);
+ });
+ });
+
+ describe('without permission to update', () => {
+ it('should have the flag name input disabled', () => {
+ const input = wrapper.find('#feature-flag-name');
+
+ expect(input.element.disabled).toBe(true);
+ });
+
+ it('should have the flag discription text area disabled', () => {
+ const textarea = wrapper.find('#feature-flag-description');
+
+ expect(textarea.element.disabled).toBe(true);
+ });
+
+ it('should have the scope that cannot be updated be disabled', () => {
+ const row = wrapper.findAll('.gl-responsive-table-row').at(2);
+
+ expect(row.find(EnvironmentsDropdown).vm.disabled).toBe(true);
+ expect(row.find(ToggleButton).vm.disabledInput).toBe(true);
+ expect(row.find('.js-delete-scope').exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('on submit', () => {
+ const selectFirstRolloutStrategyOption = dropdownIndex => {
+ wrapper
+ .findAll('select.js-rollout-strategy')
+ .at(dropdownIndex)
+ .findAll('option')
+ .at(1)
+ .setSelected();
+ };
+
+ beforeEach(() => {
+ factory({
+ ...requiredProps,
+ name: 'feature_flag_1',
+ active: true,
+ description: 'this is a feature flag',
+ scopes: [
+ {
+ id: 1,
+ environmentScope: 'production',
+ canUpdate: true,
+ protected: true,
+ active: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ },
+ ],
+ });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should emit handleSubmit with the updated data', () => {
+ wrapper.find('#feature-flag-name').setValue('feature_flag_2');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ wrapper
+ .find('.js-new-scope-name')
+ .find(EnvironmentsDropdown)
+ .vm.$emit('selectEnvironment', 'review');
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ wrapper
+ .find('.js-add-new-scope')
+ .find(ToggleButton)
+ .vm.$emit('change', true);
+ })
+ .then(() => {
+ wrapper.find(ToggleButton).vm.$emit('change', true);
+ return wrapper.vm.$nextTick();
+ })
+
+ .then(() => {
+ selectFirstRolloutStrategyOption(0);
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ selectFirstRolloutStrategyOption(2);
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ wrapper.find('.js-rollout-percentage').setValue('55');
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ wrapper.find({ ref: 'submitButton' }).vm.$emit('click');
+
+ const data = wrapper.emitted().handleSubmit[0][0];
+
+ expect(data.name).toEqual('feature_flag_2');
+ expect(data.description).toEqual('this is a feature flag');
+ expect(data.active).toBe(true);
+
+ expect(data.scopes).toEqual([
+ {
+ id: 1,
+ active: true,
+ environmentScope: 'production',
+ canUpdate: true,
+ protected: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '55',
+ rolloutUserIds: '',
+ shouldIncludeUserIds: false,
+ },
+ {
+ id: expect.any(String),
+ active: false,
+ environmentScope: 'review',
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ },
+ {
+ id: expect.any(String),
+ active: true,
+ environmentScope: '',
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ shouldIncludeUserIds: false,
+ },
+ ]);
+ });
+ });
+ });
+ });
+
+ describe('with strategies', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
+ factory({
+ ...requiredProps,
+ name: featureFlag.name,
+ description: featureFlag.description,
+ active: true,
+ version: NEW_VERSION_FLAG,
+ strategies: [
+ {
+ type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '30' },
+ scopes: [],
+ },
+ {
+ type: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ scopes: [{ environment_scope: 'review/*' }],
+ },
+ ],
+ });
+ });
+
+ it('should request the user lists on mount', () => {
+ return wrapper.vm.$nextTick(() => {
+ expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1');
+ });
+ });
+
+ it('should show the strategy component', () => {
+ const strategy = wrapper.find(Strategy);
+ expect(strategy.exists()).toBe(true);
+ expect(strategy.props('strategy')).toEqual({
+ type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '30' },
+ scopes: [],
+ });
+ });
+
+ it('should show one strategy component per strategy', () => {
+ expect(wrapper.findAll(Strategy)).toHaveLength(2);
+ });
+
+ it('adds an all users strategy when clicking the Add button', () => {
+ wrapper.find(GlButton).vm.$emit('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ const strategies = wrapper.findAll(Strategy);
+
+ expect(strategies).toHaveLength(3);
+ expect(strategies.at(2).props('strategy')).toEqual(allUsersStrategy);
+ });
+ });
+
+ it('should remove a strategy on delete', () => {
+ const strategy = {
+ type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '30' },
+ scopes: [],
+ };
+ wrapper.find(Strategy).vm.$emit('delete');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(Strategy)).toHaveLength(1);
+ expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy);
+ });
+ });
+
+ it('should provide the user lists to the strategy', () => {
+ expect(wrapper.find(Strategy).props('userLists')).toEqual([userList]);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js
new file mode 100644
index 00000000000..10e9ed4d3bf
--- /dev/null
+++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js
@@ -0,0 +1,103 @@
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
+import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+const TEST_HOST = '/test';
+const TEST_SEARCH = 'production';
+
+describe('New Environments Dropdown', () => {
+ let wrapper;
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ wrapper = shallowMount(NewEnvironmentsDropdown, { propsData: { endpoint: TEST_HOST } });
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('before results', () => {
+ it('should show a loading icon', () => {
+ axiosMock.onGet(TEST_HOST).reply(() => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ wrapper.find(GlSearchBoxByType).vm.$emit('focus');
+ return axios.waitForAll();
+ });
+
+ it('should not show any dropdown items', () => {
+ axiosMock.onGet(TEST_HOST).reply(() => {
+ expect(wrapper.findAll(GlDropdownItem)).toHaveLength(0);
+ });
+ wrapper.find(GlSearchBoxByType).vm.$emit('focus');
+ return axios.waitForAll();
+ });
+ });
+
+ describe('with empty results', () => {
+ let item;
+ beforeEach(() => {
+ axiosMock.onGet(TEST_HOST).reply(200, []);
+ wrapper.find(GlSearchBoxByType).vm.$emit('focus');
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
+ return axios
+ .waitForAll()
+ .then(() => wrapper.vm.$nextTick())
+ .then(() => {
+ item = wrapper.find(GlDropdownItem);
+ });
+ });
+
+ it('should display a Create item label', () => {
+ expect(item.text()).toBe('Create production');
+ });
+
+ it('should display that no matching items are found', () => {
+ expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(true);
+ });
+
+ it('should emit a new scope when selected', () => {
+ item.vm.$emit('click');
+ expect(wrapper.emitted('add')).toEqual([[TEST_SEARCH]]);
+ });
+ });
+
+ describe('with results', () => {
+ let items;
+ beforeEach(() => {
+ axiosMock.onGet(TEST_HOST).reply(httpStatusCodes.OK, ['prod', 'production']);
+ wrapper.find(GlSearchBoxByType).vm.$emit('focus');
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', 'prod');
+ return axios.waitForAll().then(() => {
+ items = wrapper.findAll(GlDropdownItem);
+ });
+ });
+
+ it('should display one item per result', () => {
+ expect(items).toHaveLength(2);
+ });
+
+ it('should emit an add if an item is clicked', () => {
+ items.at(0).vm.$emit('click');
+ expect(wrapper.emitted('add')).toEqual([['prod']]);
+ });
+
+ it('should not display a create label', () => {
+ items = items.filter(i => i.text().startsWith('Create'));
+ expect(items).toHaveLength(0);
+ });
+
+ it('should not display a message about no results', () => {
+ expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
new file mode 100644
index 00000000000..284ba09d7fd
--- /dev/null
+++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
@@ -0,0 +1,145 @@
+import Vuex from 'vuex';
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { GlAlert } from '@gitlab/ui';
+import { TEST_HOST } from 'spec/test_constants';
+import Form from '~/feature_flags/components/form.vue';
+import newModule from '~/feature_flags/store/modules/new';
+import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ DEFAULT_PERCENT_ROLLOUT,
+ NEW_FLAG_ALERT,
+} from '~/feature_flags/constants';
+import axios from '~/lib/utils/axios_utils';
+import { allUsersStrategy } from '../mock_data';
+
+const userCalloutId = 'feature_flags_new_version';
+const userCalloutsPath = `${TEST_HOST}/user_callouts`;
+
+describe('New feature flag form', () => {
+ let wrapper;
+
+ const store = new Vuex.Store({
+ modules: {
+ new: newModule,
+ },
+ });
+
+ const factory = (opts = {}) => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ wrapper = shallowMount(NewFeatureFlag, {
+ propsData: {
+ endpoint: `${TEST_HOST}/feature_flags.json`,
+ path: '/feature_flags',
+ environmentsEndpoint: 'environments.json',
+ projectId: '8',
+ showUserCallout: true,
+ userCalloutId,
+ userCalloutsPath,
+ },
+ store,
+ provide: {
+ glFeatures: {
+ featureFlagsNewVersion: true,
+ },
+ },
+ ...opts,
+ });
+ };
+
+ beforeEach(() => {
+ factory();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findAlert = () => wrapper.find(GlAlert);
+
+ describe('with error', () => {
+ it('should render the error', () => {
+ store.dispatch('new/receiveCreateFeatureFlagError', { message: ['The name is required'] });
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.alert').exists()).toEqual(true);
+ expect(wrapper.find('.alert').text()).toContain('The name is required');
+ });
+ });
+ });
+
+ it('renders form title', () => {
+ expect(wrapper.find('h3').text()).toEqual('New feature flag');
+ });
+
+ it('should render feature flag form', () => {
+ expect(wrapper.find(Form).exists()).toEqual(true);
+ });
+
+ it('does not render the related issues widget', () => {
+ expect(wrapper.find(Form).props('featureFlagIssuesEndpoint')).toBe('');
+ });
+
+ it('should render default * row', () => {
+ const defaultScope = {
+ id: expect.any(String),
+ environmentScope: '*',
+ active: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+ expect(wrapper.vm.scopes).toEqual([defaultScope]);
+
+ expect(wrapper.find(Form).props('scopes')).toContainEqual(defaultScope);
+ });
+
+ it('should not alert users that feature flags are changing soon', () => {
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
+ });
+
+ it('should pass in the project ID', () => {
+ expect(wrapper.find(Form).props('projectId')).toBe('8');
+ });
+
+ it('has an all users strategy by default', () => {
+ const strategies = wrapper.find(Form).props('strategies');
+
+ expect(strategies).toEqual([allUsersStrategy]);
+ });
+
+ describe('without new version flags', () => {
+ beforeEach(() => factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } }));
+
+ it('should alert users that feature flags are changing soon', () => {
+ expect(findAlert().text()).toBe(NEW_FLAG_ALERT);
+ });
+ });
+
+ describe('dismissing new version alert', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200);
+ factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } });
+ findAlert().vm.$emit('dismiss');
+ return wrapper.vm.$nextTick();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should hide the alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('should send the dismissal event', () => {
+ expect(mock.history.post.length).toBe(1);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js
new file mode 100644
index 00000000000..8436f1cbe97
--- /dev/null
+++ b/spec/frontend/feature_flags/components/strategy_spec.js
@@ -0,0 +1,320 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlFormSelect, GlFormTextarea, GlFormInput, GlLink, GlToken, GlButton } from '@gitlab/ui';
+import {
+ PERCENT_ROLLOUT_GROUP_ID,
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+} from '~/feature_flags/constants';
+import Strategy from '~/feature_flags/components/strategy.vue';
+import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue';
+
+import { userList } from '../mock_data';
+
+const provide = {
+ strategyTypeDocsPagePath: 'link-to-strategy-docs',
+ environmentsScopeDocsPath: 'link-scope-docs',
+};
+
+describe('Feature flags strategy', () => {
+ let wrapper;
+
+ const findStrategy = () => wrapper.find('[data-testid="strategy"]');
+ const findDocsLinks = () => wrapper.findAll(GlLink);
+
+ const factory = (
+ opts = {
+ propsData: {
+ strategy: {},
+ index: 0,
+ endpoint: '',
+ userLists: [userList],
+ },
+ provide,
+ },
+ ) => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ wrapper = shallowMount(Strategy, opts);
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('helper links', () => {
+ const propsData = { strategy: {}, index: 0, endpoint: '', userLists: [userList] };
+ factory({ propsData, provide });
+
+ it('should display 2 helper links', () => {
+ const links = findDocsLinks();
+ expect(links.exists()).toBe(true);
+ expect(links.at(0).attributes('href')).toContain('docs');
+ expect(links.at(1).attributes('href')).toContain('docs');
+ });
+ });
+
+ describe.each`
+ name | parameter | value | newValue | input
+ ${ROLLOUT_STRATEGY_ALL_USERS} | ${null} | ${null} | ${null} | ${null}
+ ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${'percentage'} | ${'50'} | ${'20'} | ${GlFormInput}
+ ${ROLLOUT_STRATEGY_USER_ID} | ${'userIds'} | ${'1,2'} | ${'1,2,3'} | ${GlFormTextarea}
+ `('with strategy $name', ({ name, parameter, value, newValue, input }) => {
+ let propsData;
+ let strategy;
+ beforeEach(() => {
+ const parameters = {};
+ if (parameter !== null) {
+ parameters[parameter] = value;
+ }
+ strategy = { name, parameters };
+ propsData = { strategy, index: 0, endpoint: '' };
+ factory({ propsData, provide });
+ });
+
+ it('should set the select to match the strategy name', () => {
+ expect(wrapper.find(GlFormSelect).attributes('value')).toBe(name);
+ });
+
+ it('should not show inputs for other parameters', () => {
+ [GlFormTextarea, GlFormInput, GlFormSelect]
+ .filter(component => component !== input)
+ .map(component => findStrategy().findAll(component))
+ .forEach(inputWrapper => expect(inputWrapper).toHaveLength(0));
+ });
+
+ if (parameter !== null) {
+ it(`should show the input for ${parameter} with the correct value`, () => {
+ const inputWrapper = findStrategy().find(input);
+ expect(inputWrapper.exists()).toBe(true);
+ expect(inputWrapper.attributes('value')).toBe(value);
+ });
+
+ it(`should emit a change event when altering ${parameter}`, () => {
+ const inputWrapper = findStrategy().find(input);
+ inputWrapper.vm.$emit('input', newValue);
+ expect(wrapper.emitted('change')).toEqual([
+ [{ name, parameters: expect.objectContaining({ [parameter]: newValue }), scopes: [] }],
+ ]);
+ });
+ }
+ });
+
+ describe('with strategy gitlabUserList', () => {
+ let propsData;
+ let strategy;
+ beforeEach(() => {
+ strategy = { name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, userListId: '2', parameters: {} };
+ propsData = { strategy, index: 0, endpoint: '', userLists: [userList] };
+ factory({ propsData, provide });
+ });
+
+ it('should set the select to match the strategy name', () => {
+ expect(wrapper.find(GlFormSelect).attributes('value')).toBe(
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ );
+ });
+
+ it('should not show inputs for other parameters', () => {
+ expect(
+ findStrategy()
+ .find(GlFormTextarea)
+ .exists(),
+ ).toBe(false);
+ expect(
+ findStrategy()
+ .find(GlFormInput)
+ .exists(),
+ ).toBe(false);
+ });
+
+ it('should show the input for userListId with the correct value', () => {
+ const inputWrapper = findStrategy().find(GlFormSelect);
+ expect(inputWrapper.exists()).toBe(true);
+ expect(inputWrapper.attributes('value')).toBe('2');
+ });
+
+ it('should emit a change event when altering the userListId', () => {
+ const inputWrapper = findStrategy().find(GlFormSelect);
+ inputWrapper.vm.$emit('input', '3');
+ inputWrapper.vm.$emit('change', '3');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ userListId: '3',
+ scopes: [],
+ parameters: {},
+ },
+ ],
+ ]);
+ });
+ });
+ });
+
+ describe('with a strategy', () => {
+ describe('with a single environment scope defined', () => {
+ let strategy;
+
+ beforeEach(() => {
+ strategy = {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50' },
+ scopes: [{ environmentScope: 'production' }],
+ };
+ const propsData = { strategy, index: 0, endpoint: '' };
+ factory({ propsData, provide });
+ });
+
+ it('should revert to all-environments scope when last scope is removed', () => {
+ const token = wrapper.find(GlToken);
+ token.vm.$emit('close');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlToken)).toHaveLength(0);
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID },
+ scopes: [{ environmentScope: '*' }],
+ },
+ ],
+ ]);
+ });
+ });
+ });
+
+ describe('with an all-environments scope defined', () => {
+ let strategy;
+
+ beforeEach(() => {
+ strategy = {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50' },
+ scopes: [{ environmentScope: '*' }],
+ };
+ const propsData = { strategy, index: 0, endpoint: '' };
+ factory({ propsData, provide });
+ });
+
+ it('should change the parameters if a different strategy is chosen', () => {
+ const select = wrapper.find(GlFormSelect);
+ select.vm.$emit('input', ROLLOUT_STRATEGY_ALL_USERS);
+ select.vm.$emit('change', ROLLOUT_STRATEGY_ALL_USERS);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlFormInput).exists()).toBe(false);
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ scopes: [{ environmentScope: '*' }],
+ },
+ ],
+ ]);
+ });
+ });
+
+ it('should display selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlToken)).toHaveLength(1);
+ expect(wrapper.find(GlToken).text()).toBe('production');
+ });
+ });
+
+ it('should display all selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ dropdown.vm.$emit('add', 'staging');
+ return wrapper.vm.$nextTick().then(() => {
+ const tokens = wrapper.findAll(GlToken);
+ expect(tokens).toHaveLength(2);
+ expect(tokens.at(0).text()).toBe('production');
+ expect(tokens.at(1).text()).toBe('staging');
+ });
+ });
+
+ it('should emit selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID },
+ scopes: [
+ { environmentScope: '*', shouldBeDestroyed: true },
+ { environmentScope: 'production' },
+ ],
+ },
+ ],
+ ]);
+ });
+ });
+
+ it('should emit a delete if the delete button is clicked', () => {
+ wrapper.find(GlButton).vm.$emit('click');
+ expect(wrapper.emitted('delete')).toEqual([[]]);
+ });
+ });
+
+ describe('without scopes defined', () => {
+ beforeEach(() => {
+ const strategy = {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50' },
+ scopes: [],
+ };
+ const propsData = { strategy, index: 0, endpoint: '' };
+ factory({ propsData, provide });
+ });
+
+ it('should display selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlToken)).toHaveLength(1);
+ expect(wrapper.find(GlToken).text()).toBe('production');
+ });
+ });
+
+ it('should display all selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ dropdown.vm.$emit('add', 'staging');
+ return wrapper.vm.$nextTick().then(() => {
+ const tokens = wrapper.findAll(GlToken);
+ expect(tokens).toHaveLength(2);
+ expect(tokens.at(0).text()).toBe('production');
+ expect(tokens.at(1).text()).toBe('staging');
+ });
+ });
+
+ it('should emit selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID },
+ scopes: [{ environmentScope: 'production' }],
+ },
+ ],
+ ]);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/user_lists_table_spec.js b/spec/frontend/feature_flags/components/user_lists_table_spec.js
new file mode 100644
index 00000000000..d6ced3be168
--- /dev/null
+++ b/spec/frontend/feature_flags/components/user_lists_table_spec.js
@@ -0,0 +1,98 @@
+import { mount } from '@vue/test-utils';
+import * as timeago from 'timeago.js';
+import { GlModal } from '@gitlab/ui';
+import UserListsTable from '~/feature_flags/components/user_lists_table.vue';
+import { userList } from '../mock_data';
+
+jest.mock('timeago.js', () => ({
+ format: jest.fn().mockReturnValue('2 weeks ago'),
+ register: jest.fn(),
+}));
+
+describe('User Lists Table', () => {
+ let wrapper;
+ let userLists;
+
+ beforeEach(() => {
+ userLists = new Array(5).fill(userList).map((x, i) => ({ ...x, id: i }));
+ wrapper = mount(UserListsTable, {
+ propsData: { userLists },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should display the details of a user list', () => {
+ expect(wrapper.find('[data-testid="ffUserListName"]').text()).toBe(userList.name);
+ expect(wrapper.find('[data-testid="ffUserListIds"]').text()).toBe(
+ userList.user_xids.replace(/,/g, ', '),
+ );
+ expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago');
+ expect(timeago.format).toHaveBeenCalledWith(userList.created_at);
+ });
+
+ it('should set the title for a tooltip on the created stamp', () => {
+ expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe(
+ 'Feb 4, 2020 8:13am GMT+0000',
+ );
+ });
+
+ it('should display a user list entry per user list', () => {
+ const lists = wrapper.findAll('[data-testid="ffUserList"]');
+ expect(lists).toHaveLength(5);
+ lists.wrappers.forEach(list => {
+ expect(list.find('[data-testid="ffUserListName"]').exists()).toBe(true);
+ expect(list.find('[data-testid="ffUserListIds"]').exists()).toBe(true);
+ expect(list.find('[data-testid="ffUserListTimestamp"]').exists()).toBe(true);
+ });
+ });
+
+ describe('edit button', () => {
+ it('should link to the path for the user list', () => {
+ expect(wrapper.find('[data-testid="edit-user-list"]').attributes('href')).toBe(userList.path);
+ });
+ });
+
+ describe('delete button', () => {
+ it('should display the confirmation modal', () => {
+ const modal = wrapper.find(GlModal);
+
+ wrapper.find('[data-testid="delete-user-list"]').trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(modal.text()).toContain(`Delete ${userList.name}?`);
+ expect(modal.text()).toContain(`User list ${userList.name} will be removed.`);
+ });
+ });
+ });
+
+ describe('confirmation modal', () => {
+ let modal;
+
+ beforeEach(() => {
+ modal = wrapper.find(GlModal);
+
+ wrapper.find('button').trigger('click');
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should emit delete with list on confirmation', () => {
+ modal.find('[data-testid="modal-confirm"]').trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('delete')).toEqual([[userLists[0]]]);
+ });
+ });
+
+ it('should not emit delete with list when not confirmed', () => {
+ modal.find('button').trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('delete')).toBeUndefined();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js
new file mode 100644
index 00000000000..47e4957f208
--- /dev/null
+++ b/spec/frontend/feature_flags/mock_data.js
@@ -0,0 +1,109 @@
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+} from '~/feature_flags/constants';
+
+export const featureFlag = {
+ id: 1,
+ active: true,
+ created_at: '2018-12-12T22:07:31.401Z',
+ updated_at: '2018-12-12T22:07:31.401Z',
+ name: 'test flag',
+ description: 'flag for tests',
+ destroy_path: 'feature_flags/1',
+ update_path: 'feature_flags/1',
+ edit_path: 'feature_flags/1/edit',
+ scopes: [
+ {
+ id: 1,
+ active: true,
+ environment_scope: '*',
+ can_update: true,
+ protected: false,
+ created_at: '2019-01-14T06:41:40.987Z',
+ updated_at: '2019-01-14T06:41:40.987Z',
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ },
+ ],
+ },
+ {
+ id: 2,
+ active: false,
+ environment_scope: 'production',
+ can_update: true,
+ protected: false,
+ created_at: '2019-01-14T06:41:40.987Z',
+ updated_at: '2019-01-14T06:41:40.987Z',
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ },
+ ],
+ },
+ {
+ id: 3,
+ active: false,
+ environment_scope: 'review/*',
+ can_update: true,
+ protected: false,
+ created_at: '2019-01-14T06:41:40.987Z',
+ updated_at: '2019-01-14T06:41:40.987Z',
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ },
+ ],
+ },
+ {
+ id: 4,
+ active: true,
+ environment_scope: 'development',
+ can_update: true,
+ protected: false,
+ created_at: '2019-01-14T06:41:40.987Z',
+ updated_at: '2019-01-14T06:41:40.987Z',
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ percentage: '86',
+ },
+ },
+ ],
+ },
+ ],
+};
+
+export const getRequestData = {
+ feature_flags: [featureFlag],
+ count: {
+ all: 1,
+ disabled: 1,
+ enabled: 0,
+ },
+};
+
+export const rotateData = { token: 'oP6sCNRqtRHmpy1gw2-F' };
+
+export const userList = {
+ name: 'test_users',
+ user_xids: 'user3,user4,user5',
+ id: 2,
+ iid: 2,
+ project_id: 1,
+ created_at: '2020-02-04T08:13:10.507Z',
+ updated_at: '2020-02-04T08:13:10.507Z',
+ path: '/path/to/user/list',
+ edit_path: '/path/to/user/list/edit',
+};
+
+export const allUsersStrategy = {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ scopes: [],
+};
diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js
new file mode 100644
index 00000000000..4f20b9713bf
--- /dev/null
+++ b/spec/frontend/feature_flags/store/edit/actions_spec.js
@@ -0,0 +1,334 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'spec/test_constants';
+import {
+ setEndpoint,
+ setPath,
+ updateFeatureFlag,
+ requestUpdateFeatureFlag,
+ receiveUpdateFeatureFlagSuccess,
+ receiveUpdateFeatureFlagError,
+ fetchFeatureFlag,
+ requestFeatureFlag,
+ receiveFeatureFlagSuccess,
+ receiveFeatureFlagError,
+ toggleActive,
+} from '~/feature_flags/store/modules/edit/actions';
+import state from '~/feature_flags/store/modules/edit/state';
+import {
+ mapStrategiesToRails,
+ mapFromScopesViewModel,
+} from '~/feature_flags/store/modules/helpers';
+import {
+ NEW_VERSION_FLAG,
+ LEGACY_FLAG,
+ ROLLOUT_STRATEGY_ALL_USERS,
+} from '~/feature_flags/constants';
+import * as types from '~/feature_flags/store/modules/edit/mutation_types';
+import axios from '~/lib/utils/axios_utils';
+
+jest.mock('~/lib/utils/url_utility');
+
+describe('Feature flags Edit Module actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setEndpoint', () => {
+ it('should commit SET_ENDPOINT mutation', done => {
+ testAction(
+ setEndpoint,
+ 'feature_flags.json',
+ mockedState,
+ [{ type: types.SET_ENDPOINT, payload: 'feature_flags.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setPath', () => {
+ it('should commit SET_PATH mutation', done => {
+ testAction(
+ setPath,
+ '/feature_flags',
+ mockedState,
+ [{ type: types.SET_PATH, payload: '/feature_flags' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('updateFeatureFlag', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', done => {
+ const featureFlag = {
+ name: 'feature_flag',
+ description: 'feature flag',
+ scopes: [
+ {
+ id: '1',
+ environmentScope: '*',
+ active: true,
+ shouldBeDestroyed: false,
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ },
+ ],
+ version: LEGACY_FLAG,
+ active: true,
+ };
+ mock.onPut(mockedState.endpoint, mapFromScopesViewModel(featureFlag)).replyOnce(200);
+
+ testAction(
+ updateFeatureFlag,
+ featureFlag,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUpdateFeatureFlag',
+ },
+ {
+ type: 'receiveUpdateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ it('handles new version flags as well', done => {
+ const featureFlag = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ version: NEW_VERSION_FLAG,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ id: 1,
+ scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }],
+ shouldBeDestroyed: false,
+ },
+ ],
+ };
+ mock.onPut(mockedState.endpoint, mapStrategiesToRails(featureFlag)).replyOnce(200);
+
+ testAction(
+ updateFeatureFlag,
+ featureFlag,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUpdateFeatureFlag',
+ },
+ {
+ type: 'receiveUpdateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', done => {
+ mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] });
+
+ testAction(
+ updateFeatureFlag,
+ {
+ name: 'feature_flag',
+ description: 'feature flag',
+ scopes: [{ environment_scope: '*', active: true }],
+ },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUpdateFeatureFlag',
+ },
+ {
+ type: 'receiveUpdateFeatureFlagError',
+ payload: { message: [] },
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestUpdateFeatureFlag', () => {
+ it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', done => {
+ testAction(
+ requestUpdateFeatureFlag,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_UPDATE_FEATURE_FLAG }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUpdateFeatureFlagSuccess', () => {
+ it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', done => {
+ testAction(
+ receiveUpdateFeatureFlagSuccess,
+ null,
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUpdateFeatureFlagError', () => {
+ it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', done => {
+ testAction(
+ receiveUpdateFeatureFlagError,
+ 'There was an error',
+ mockedState,
+ [{ type: types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchFeatureFlag', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 1 });
+
+ testAction(
+ fetchFeatureFlag,
+ { id: 1 },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestFeatureFlag',
+ },
+ {
+ type: 'receiveFeatureFlagSuccess',
+ payload: { id: 1 },
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
+
+ testAction(
+ fetchFeatureFlag,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestFeatureFlag',
+ },
+ {
+ type: 'receiveFeatureFlagError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestFeatureFlag', () => {
+ it('should commit REQUEST_FEATURE_FLAG mutation', done => {
+ testAction(
+ requestFeatureFlag,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_FEATURE_FLAG }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFeatureFlagSuccess', () => {
+ it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', done => {
+ testAction(
+ receiveFeatureFlagSuccess,
+ { id: 1 },
+ mockedState,
+ [{ type: types.RECEIVE_FEATURE_FLAG_SUCCESS, payload: { id: 1 } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFeatureFlagError', () => {
+ it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', done => {
+ testAction(
+ receiveFeatureFlagError,
+ null,
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_FEATURE_FLAG_ERROR,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggelActive', () => {
+ it('should commit TOGGLE_ACTIVE mutation', done => {
+ testAction(
+ toggleActive,
+ true,
+ mockedState,
+ [{ type: types.TOGGLE_ACTIVE, payload: true }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/edit/mutations_spec.js b/spec/frontend/feature_flags/store/edit/mutations_spec.js
new file mode 100644
index 00000000000..21d4e962b48
--- /dev/null
+++ b/spec/frontend/feature_flags/store/edit/mutations_spec.js
@@ -0,0 +1,150 @@
+import state from '~/feature_flags/store/modules/edit/state';
+import mutations from '~/feature_flags/store/modules/edit/mutations';
+import * as types from '~/feature_flags/store/modules/edit/mutation_types';
+
+describe('Feature flags Edit Module Mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_ENDPOINT', () => {
+ it('should set endpoint', () => {
+ mutations[types.SET_ENDPOINT](stateCopy, 'feature_flags.json');
+
+ expect(stateCopy.endpoint).toEqual('feature_flags.json');
+ });
+ });
+
+ describe('SET_PATH', () => {
+ it('should set provided options', () => {
+ mutations[types.SET_PATH](stateCopy, 'feature_flags');
+
+ expect(stateCopy.path).toEqual('feature_flags');
+ });
+ });
+
+ describe('REQUEST_FEATURE_FLAG', () => {
+ it('should set isLoading to true', () => {
+ mutations[types.REQUEST_FEATURE_FLAG](stateCopy);
+
+ expect(stateCopy.isLoading).toEqual(true);
+ });
+
+ it('should set error to an empty array', () => {
+ mutations[types.REQUEST_FEATURE_FLAG](stateCopy);
+
+ expect(stateCopy.error).toEqual([]);
+ });
+ });
+
+ describe('RECEIVE_FEATURE_FLAG_SUCCESS', () => {
+ const data = {
+ name: '*',
+ description: 'All environments',
+ scopes: [{ id: 1 }],
+ iid: 5,
+ version: 'new_version_flag',
+ strategies: [
+ { id: 1, scopes: [{ environment_scope: '*' }], name: 'default', parameters: {} },
+ ],
+ };
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_FEATURE_FLAG_SUCCESS](stateCopy, data);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to false', () => {
+ expect(stateCopy.hasError).toEqual(false);
+ });
+
+ it('should set name with the provided one', () => {
+ expect(stateCopy.name).toEqual(data.name);
+ });
+
+ it('should set description with the provided one', () => {
+ expect(stateCopy.description).toEqual(data.description);
+ });
+
+ it('should set scope with the provided one', () => {
+ expect(stateCopy.scope).toEqual(data.scope);
+ });
+
+ it('should set the iid to the provided one', () => {
+ expect(stateCopy.iid).toEqual(data.iid);
+ });
+
+ it('should set the version to the provided one', () => {
+ expect(stateCopy.version).toBe('new_version_flag');
+ });
+
+ it('should set the strategies to the provided one', () => {
+ expect(stateCopy.strategies).toEqual([
+ {
+ id: 1,
+ scopes: [{ environmentScope: '*', shouldBeDestroyed: false }],
+ name: 'default',
+ parameters: {},
+ shouldBeDestroyed: false,
+ },
+ ]);
+ });
+ });
+
+ describe('RECEIVE_FEATURE_FLAG_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_FEATURE_FLAG_ERROR](stateCopy);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.hasError).toEqual(true);
+ });
+ });
+
+ describe('REQUEST_UPDATE_FEATURE_FLAG', () => {
+ beforeEach(() => {
+ mutations[types.REQUEST_UPDATE_FEATURE_FLAG](stateCopy);
+ });
+
+ it('should set isSendingRequest to true', () => {
+ expect(stateCopy.isSendingRequest).toEqual(true);
+ });
+
+ it('should set error to an empty array', () => {
+ expect(stateCopy.error).toEqual([]);
+ });
+ });
+
+ describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => {
+ it('should set isSendingRequest to false', () => {
+ mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy);
+
+ expect(stateCopy.isSendingRequest).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, {
+ message: ['Name is required'],
+ });
+ });
+
+ it('should set isSendingRequest to false', () => {
+ expect(stateCopy.isSendingRequest).toEqual(false);
+ });
+
+ it('should set error to the given message', () => {
+ expect(stateCopy.error).toEqual(['Name is required']);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/helpers_spec.js b/spec/frontend/feature_flags/store/helpers_spec.js
new file mode 100644
index 00000000000..0bc15ab70aa
--- /dev/null
+++ b/spec/frontend/feature_flags/store/helpers_spec.js
@@ -0,0 +1,514 @@
+import { uniqueId } from 'lodash';
+import {
+ mapToScopesViewModel,
+ mapFromScopesViewModel,
+ createNewEnvironmentScope,
+ mapStrategiesToViewModel,
+ mapStrategiesToRails,
+} from '~/feature_flags/store/modules/helpers';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ PERCENT_ROLLOUT_GROUP_ID,
+ INTERNAL_ID_PREFIX,
+ DEFAULT_PERCENT_ROLLOUT,
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+} from '~/feature_flags/constants';
+
+describe('feature flags helpers spec', () => {
+ describe('mapToScopesViewModel', () => {
+ it('converts the data object from the Rails API into something more usable by Vue', () => {
+ const input = [
+ {
+ id: 3,
+ environment_scope: 'environment_scope',
+ active: true,
+ can_update: true,
+ protected: true,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ percentage: '56',
+ },
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: {
+ userIds: '123,234',
+ },
+ },
+ ],
+
+ _destroy: true,
+ },
+ ];
+
+ const expected = [
+ expect.objectContaining({
+ id: 3,
+ environmentScope: 'environment_scope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '56',
+ rolloutUserIds: '123, 234',
+ shouldBeDestroyed: true,
+ }),
+ ];
+
+ const actual = mapToScopesViewModel(input);
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('returns Boolean properties even when their Rails counterparts were not provided (are `undefined`)', () => {
+ const input = [
+ {
+ id: 3,
+ environment_scope: 'environment_scope',
+ },
+ ];
+
+ const [result] = mapToScopesViewModel(input);
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ active: false,
+ canUpdate: false,
+ protected: false,
+ shouldBeDestroyed: false,
+ }),
+ );
+ });
+
+ it('returns an empty array if null or undefined is provided as a parameter', () => {
+ expect(mapToScopesViewModel(null)).toEqual([]);
+ expect(mapToScopesViewModel(undefined)).toEqual([]);
+ });
+
+ describe('with user IDs per environment', () => {
+ let oldGon;
+
+ beforeEach(() => {
+ oldGon = window.gon;
+ window.gon = { features: { featureFlagsUsersPerEnvironment: true } };
+ });
+
+ afterEach(() => {
+ window.gon = oldGon;
+ });
+
+ it('sets the user IDs as a comma separated string', () => {
+ const input = [
+ {
+ id: 3,
+ environment_scope: 'environment_scope',
+ active: true,
+ can_update: true,
+ protected: true,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ percentage: '56',
+ },
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: {
+ userIds: '123,234',
+ },
+ },
+ ],
+
+ _destroy: true,
+ },
+ ];
+
+ const expected = [
+ {
+ id: 3,
+ environmentScope: 'environment_scope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '56',
+ rolloutUserIds: '123, 234',
+ shouldBeDestroyed: true,
+ shouldIncludeUserIds: true,
+ },
+ ];
+
+ const actual = mapToScopesViewModel(input);
+
+ expect(actual).toEqual(expected);
+ });
+ });
+ });
+
+ describe('mapFromScopesViewModel', () => {
+ it('converts the object emitted from the Vue component into an object than is in the right format to be submitted to the Rails API', () => {
+ const input = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ scopes: [
+ {
+ id: 4,
+ environmentScope: 'environmentScope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ shouldBeDestroyed: true,
+ shouldIncludeUserIds: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '48',
+ rolloutUserIds: '123, 234',
+ },
+ ],
+ };
+
+ const expected = {
+ operations_feature_flag: {
+ name: 'name',
+ description: 'description',
+ active: true,
+ version: LEGACY_FLAG,
+ scopes_attributes: [
+ {
+ id: 4,
+ environment_scope: 'environmentScope',
+ active: true,
+ can_update: true,
+ protected: true,
+ _destroy: true,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ groupId: PERCENT_ROLLOUT_GROUP_ID,
+ percentage: '48',
+ },
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: {
+ userIds: '123,234',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ const actual = mapFromScopesViewModel(input);
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('should strip out internal IDs', () => {
+ const input = {
+ scopes: [{ id: 3 }, { id: uniqueId(INTERNAL_ID_PREFIX) }],
+ };
+
+ const result = mapFromScopesViewModel(input);
+ const [realId, internalId] = result.operations_feature_flag.scopes_attributes;
+
+ expect(realId.id).toBe(3);
+ expect(internalId.id).toBeUndefined();
+ });
+
+ it('returns scopes_attributes as [] if param.scopes is null or undefined', () => {
+ let {
+ operations_feature_flag: { scopes_attributes: actualScopes },
+ } = mapFromScopesViewModel({ scopes: null });
+
+ expect(actualScopes).toEqual([]);
+
+ ({
+ operations_feature_flag: { scopes_attributes: actualScopes },
+ } = mapFromScopesViewModel({ scopes: undefined }));
+
+ expect(actualScopes).toEqual([]);
+ });
+ describe('with user IDs per environment', () => {
+ it('sets the user IDs as a comma separated string', () => {
+ const input = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ scopes: [
+ {
+ id: 4,
+ environmentScope: 'environmentScope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ shouldBeDestroyed: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '48',
+ rolloutUserIds: '123, 234',
+ shouldIncludeUserIds: true,
+ },
+ ],
+ };
+
+ const expected = {
+ operations_feature_flag: {
+ name: 'name',
+ description: 'description',
+ version: LEGACY_FLAG,
+ active: true,
+ scopes_attributes: [
+ {
+ id: 4,
+ environment_scope: 'environmentScope',
+ active: true,
+ can_update: true,
+ protected: true,
+ _destroy: true,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ groupId: PERCENT_ROLLOUT_GROUP_ID,
+ percentage: '48',
+ },
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: {
+ userIds: '123,234',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ const actual = mapFromScopesViewModel(input);
+
+ expect(actual).toEqual(expected);
+ });
+ });
+ });
+
+ describe('createNewEnvironmentScope', () => {
+ it('should return a new environment scope object populated with the default options', () => {
+ const expected = {
+ environmentScope: '',
+ active: false,
+ id: expect.stringContaining(INTERNAL_ID_PREFIX),
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+
+ const actual = createNewEnvironmentScope();
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('should return a new environment scope object with overrides applied', () => {
+ const overrides = {
+ environmentScope: 'environmentScope',
+ active: true,
+ };
+
+ const expected = {
+ environmentScope: 'environmentScope',
+ active: true,
+ id: expect.stringContaining(INTERNAL_ID_PREFIX),
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+
+ const actual = createNewEnvironmentScope(overrides);
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('sets canUpdate and protected when called with featureFlagPermissions=true', () => {
+ expect(createNewEnvironmentScope({}, true)).toEqual(
+ expect.objectContaining({
+ canUpdate: true,
+ protected: false,
+ }),
+ );
+ });
+ });
+
+ describe('mapStrategiesToViewModel', () => {
+ it('should map rails casing to view model casing', () => {
+ expect(
+ mapStrategiesToViewModel([
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ scopes: [
+ {
+ environment_scope: '*',
+ id: '1',
+ },
+ ],
+ },
+ ]),
+ ).toEqual([
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ shouldBeDestroyed: false,
+ scopes: [
+ {
+ shouldBeDestroyed: false,
+ environmentScope: '*',
+ id: '1',
+ },
+ ],
+ },
+ ]);
+ });
+
+ it('inserts spaces between user ids', () => {
+ const strategy = mapStrategiesToViewModel([
+ {
+ id: '1',
+ name: 'userWithId',
+ parameters: { userIds: 'user1,user2,user3' },
+ scopes: [],
+ },
+ ])[0];
+
+ expect(strategy.parameters).toEqual({ userIds: 'user1, user2, user3' });
+ });
+ });
+
+ describe('mapStrategiesToRails', () => {
+ it('should map rails casing to view model casing', () => {
+ expect(
+ mapStrategiesToRails({
+ name: 'test',
+ description: 'test description',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies: [
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ shouldBeDestroyed: true,
+ scopes: [
+ {
+ environmentScope: '*',
+ id: '1',
+ shouldBeDestroyed: true,
+ },
+ ],
+ },
+ ],
+ }),
+ ).toEqual({
+ operations_feature_flag: {
+ name: 'test',
+ description: 'test description',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies_attributes: [
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ _destroy: true,
+ scopes_attributes: [
+ {
+ environment_scope: '*',
+ id: '1',
+ _destroy: true,
+ },
+ ],
+ },
+ ],
+ },
+ });
+ });
+
+ it('should insert a default * scope if there are none', () => {
+ expect(
+ mapStrategiesToRails({
+ name: 'test',
+ description: 'test description',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies: [
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ scopes: [],
+ },
+ ],
+ }),
+ ).toEqual({
+ operations_feature_flag: {
+ name: 'test',
+ description: 'test description',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies_attributes: [
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ scopes_attributes: [
+ {
+ environment_scope: '*',
+ },
+ ],
+ },
+ ],
+ },
+ });
+ });
+
+ it('removes white space between user ids', () => {
+ const result = mapStrategiesToRails({
+ name: 'test',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies: [
+ {
+ id: '1',
+ name: 'userWithId',
+ parameters: { userIds: 'user1, user2, user3' },
+ scopes: [],
+ },
+ ],
+ });
+
+ const strategyAttrs = result.operations_feature_flag.strategies_attributes[0];
+
+ expect(strategyAttrs.parameters).toEqual({ userIds: 'user1,user2,user3' });
+ });
+
+ it('preserves the value of active', () => {
+ const result = mapStrategiesToRails({
+ name: 'test',
+ version: NEW_VERSION_FLAG,
+ active: false,
+ strategies: [],
+ });
+
+ expect(result.operations_feature_flag.active).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js
new file mode 100644
index 00000000000..0ada84aed33
--- /dev/null
+++ b/spec/frontend/feature_flags/store/index/actions_spec.js
@@ -0,0 +1,605 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'spec/test_constants';
+import Api from '~/api';
+import {
+ requestFeatureFlags,
+ receiveFeatureFlagsSuccess,
+ receiveFeatureFlagsError,
+ fetchFeatureFlags,
+ setFeatureFlagsEndpoint,
+ setFeatureFlagsOptions,
+ setInstanceIdEndpoint,
+ setInstanceId,
+ rotateInstanceId,
+ requestRotateInstanceId,
+ receiveRotateInstanceIdSuccess,
+ receiveRotateInstanceIdError,
+ toggleFeatureFlag,
+ updateFeatureFlag,
+ receiveUpdateFeatureFlagSuccess,
+ receiveUpdateFeatureFlagError,
+ requestUserLists,
+ receiveUserListsSuccess,
+ receiveUserListsError,
+ fetchUserLists,
+ deleteUserList,
+ receiveDeleteUserListError,
+ clearAlert,
+} from '~/feature_flags/store/modules/index/actions';
+import { mapToScopesViewModel } from '~/feature_flags/store/modules/helpers';
+import state from '~/feature_flags/store/modules/index/state';
+import * as types from '~/feature_flags/store/modules/index/mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data';
+
+jest.mock('~/api.js');
+
+describe('Feature flags actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setFeatureFlagsEndpoint', () => {
+ it('should commit SET_FEATURE_FLAGS_ENDPOINT mutation', done => {
+ testAction(
+ setFeatureFlagsEndpoint,
+ 'feature_flags.json',
+ mockedState,
+ [{ type: types.SET_FEATURE_FLAGS_ENDPOINT, payload: 'feature_flags.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setFeatureFlagsOptions', () => {
+ it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', done => {
+ testAction(
+ setFeatureFlagsOptions,
+ { page: '1', scope: 'all' },
+ mockedState,
+ [{ type: types.SET_FEATURE_FLAGS_OPTIONS, payload: { page: '1', scope: 'all' } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setInstanceIdEndpoint', () => {
+ it('should commit SET_INSTANCE_ID_ENDPOINT mutation', done => {
+ testAction(
+ setInstanceIdEndpoint,
+ 'instance_id.json',
+ mockedState,
+ [{ type: types.SET_INSTANCE_ID_ENDPOINT, payload: 'instance_id.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setInstanceId', () => {
+ it('should commit SET_INSTANCE_ID mutation', done => {
+ testAction(
+ setInstanceId,
+ 'test_instance_id',
+ mockedState,
+ [{ type: types.SET_INSTANCE_ID, payload: 'test_instance_id' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchFeatureFlags', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, getRequestData, {});
+
+ testAction(
+ fetchFeatureFlags,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestFeatureFlags',
+ },
+ {
+ payload: { data: getRequestData, headers: {} },
+ type: 'receiveFeatureFlagsSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
+
+ testAction(
+ fetchFeatureFlags,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestFeatureFlags',
+ },
+ {
+ type: 'receiveFeatureFlagsError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestFeatureFlags', () => {
+ it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', done => {
+ testAction(
+ requestFeatureFlags,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_FEATURE_FLAGS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFeatureFlagsSuccess', () => {
+ it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', done => {
+ testAction(
+ receiveFeatureFlagsSuccess,
+ { data: getRequestData, headers: {} },
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_FEATURE_FLAGS_SUCCESS,
+ payload: { data: getRequestData, headers: {} },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFeatureFlagsError', () => {
+ it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', done => {
+ testAction(
+ receiveFeatureFlagsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_FEATURE_FLAGS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchUserLists', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} });
+ });
+
+ describe('success', () => {
+ it('dispatches requestUserLists and receiveUserListsSuccess ', done => {
+ testAction(
+ fetchUserLists,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUserLists',
+ },
+ {
+ payload: { data: [userList], headers: {} },
+ type: 'receiveUserListsSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestUserLists and receiveUserListsError ', done => {
+ Api.fetchFeatureFlagUserLists.mockRejectedValue();
+
+ testAction(
+ fetchUserLists,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUserLists',
+ },
+ {
+ type: 'receiveUserListsError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestUserLists', () => {
+ it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => {
+ testAction(
+ requestUserLists,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_USER_LISTS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUserListsSuccess', () => {
+ it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => {
+ testAction(
+ receiveUserListsSuccess,
+ { data: [userList], headers: {} },
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_USER_LISTS_SUCCESS,
+ payload: { data: [userList], headers: {} },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUserListsError', () => {
+ it('should commit RECEIVE_USER_LISTS_ERROR mutation', done => {
+ testAction(
+ receiveUserListsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_USER_LISTS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('rotateInstanceId', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.rotateEndpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', done => {
+ mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {});
+
+ testAction(
+ rotateInstanceId,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestRotateInstanceId',
+ },
+ {
+ payload: { data: rotateData, headers: {} },
+ type: 'receiveRotateInstanceIdSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
+
+ testAction(
+ rotateInstanceId,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestRotateInstanceId',
+ },
+ {
+ type: 'receiveRotateInstanceIdError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestRotateInstanceId', () => {
+ it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', done => {
+ testAction(
+ requestRotateInstanceId,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_ROTATE_INSTANCE_ID }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveRotateInstanceIdSuccess', () => {
+ it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', done => {
+ testAction(
+ receiveRotateInstanceIdSuccess,
+ { data: rotateData, headers: {} },
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS,
+ payload: { data: rotateData, headers: {} },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveRotateInstanceIdError', () => {
+ it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', done => {
+ testAction(
+ receiveRotateInstanceIdError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_ROTATE_INSTANCE_ID_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleFeatureFlag', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.featureFlags = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+ describe('success', () => {
+ it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => {
+ mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {});
+
+ testAction(
+ toggleFeatureFlag,
+ featureFlag,
+ mockedState,
+ [],
+ [
+ {
+ type: 'updateFeatureFlag',
+ payload: featureFlag,
+ },
+ {
+ payload: featureFlag,
+ type: 'receiveUpdateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ describe('error', () => {
+ it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => {
+ mock.onPut(featureFlag.update_path).replyOnce(500);
+
+ testAction(
+ toggleFeatureFlag,
+ featureFlag,
+ mockedState,
+ [],
+ [
+ {
+ type: 'updateFeatureFlag',
+ payload: featureFlag,
+ },
+ {
+ payload: featureFlag.id,
+ type: 'receiveUpdateFeatureFlagError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+ describe('updateFeatureFlag', () => {
+ beforeEach(() => {
+ mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
+ ...f,
+ scopes: mapToScopesViewModel(f.scopes || []),
+ }));
+ });
+
+ it('commits UPDATE_FEATURE_FLAG with the given flag', done => {
+ testAction(
+ updateFeatureFlag,
+ featureFlag,
+ mockedState,
+ [
+ {
+ type: 'UPDATE_FEATURE_FLAG',
+ payload: featureFlag,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+ describe('receiveUpdateFeatureFlagSuccess', () => {
+ beforeEach(() => {
+ mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
+ ...f,
+ scopes: mapToScopesViewModel(f.scopes || []),
+ }));
+ });
+
+ it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', done => {
+ testAction(
+ receiveUpdateFeatureFlagSuccess,
+ featureFlag,
+ mockedState,
+ [
+ {
+ type: 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS',
+ payload: featureFlag,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+ describe('receiveUpdateFeatureFlagError', () => {
+ beforeEach(() => {
+ mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
+ ...f,
+ scopes: mapToScopesViewModel(f.scopes || []),
+ }));
+ });
+
+ it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', done => {
+ testAction(
+ receiveUpdateFeatureFlagError,
+ featureFlag.id,
+ mockedState,
+ [
+ {
+ type: 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR',
+ payload: featureFlag.id,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+ describe('deleteUserList', () => {
+ beforeEach(() => {
+ mockedState.userLists = [userList];
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ Api.deleteFeatureFlagUserList.mockResolvedValue();
+ });
+
+ it('should refresh the user lists', done => {
+ testAction(
+ deleteUserList,
+ userList,
+ mockedState,
+ [],
+ [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } });
+ });
+
+ it('should dispatch receiveDeleteUserListError', done => {
+ testAction(
+ deleteUserList,
+ userList,
+ mockedState,
+ [],
+ [
+ { type: 'requestDeleteUserList', payload: userList },
+ {
+ type: 'receiveDeleteUserListError',
+ payload: { list: userList, error: 'some error' },
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveDeleteUserListError', () => {
+ it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', done => {
+ testAction(
+ receiveDeleteUserListError,
+ { list: userList, error: 'mock error' },
+ mockedState,
+ [
+ {
+ type: 'RECEIVE_DELETE_USER_LIST_ERROR',
+ payload: { list: userList, error: 'mock error' },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('clearAlert', () => {
+ it('should commit RECEIVE_CLEAR_ALERT', done => {
+ const alertIndex = 3;
+
+ testAction(
+ clearAlert,
+ alertIndex,
+ mockedState,
+ [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/index/mutations_spec.js b/spec/frontend/feature_flags/store/index/mutations_spec.js
new file mode 100644
index 00000000000..5e236fe2222
--- /dev/null
+++ b/spec/frontend/feature_flags/store/index/mutations_spec.js
@@ -0,0 +1,332 @@
+import state from '~/feature_flags/store/modules/index/state';
+import mutations from '~/feature_flags/store/modules/index/mutations';
+import * as types from '~/feature_flags/store/modules/index/mutation_types';
+import { mapToScopesViewModel } from '~/feature_flags/store/modules/helpers';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data';
+
+describe('Feature flags store Mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_FEATURE_FLAGS_ENDPOINT', () => {
+ it('should set endpoint', () => {
+ mutations[types.SET_FEATURE_FLAGS_ENDPOINT](stateCopy, 'feature_flags.json');
+
+ expect(stateCopy.endpoint).toEqual('feature_flags.json');
+ });
+ });
+
+ describe('SET_FEATURE_FLAGS_OPTIONS', () => {
+ it('should set provided options', () => {
+ mutations[types.SET_FEATURE_FLAGS_OPTIONS](stateCopy, { page: '1', scope: 'all' });
+
+ expect(stateCopy.options).toEqual({ page: '1', scope: 'all' });
+ });
+ });
+
+ describe('SET_INSTANCE_ID_ENDPOINT', () => {
+ it('should set provided endpoint', () => {
+ mutations[types.SET_INSTANCE_ID_ENDPOINT](stateCopy, 'rotate_token.json');
+
+ expect(stateCopy.rotateEndpoint).toEqual('rotate_token.json');
+ });
+ });
+
+ describe('SET_INSTANCE_ID', () => {
+ it('should set provided token', () => {
+ mutations[types.SET_INSTANCE_ID](stateCopy, rotateData.token);
+
+ expect(stateCopy.instanceId).toEqual(rotateData.token);
+ });
+ });
+
+ describe('REQUEST_FEATURE_FLAGS', () => {
+ it('should set isLoading to true', () => {
+ mutations[types.REQUEST_FEATURE_FLAGS](stateCopy);
+
+ expect(stateCopy.isLoading).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_FEATURE_FLAGS_SUCCESS', () => {
+ const headers = {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '2',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '5',
+ };
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_FEATURE_FLAGS_SUCCESS](stateCopy, { data: getRequestData, headers });
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to false', () => {
+ expect(stateCopy.hasError).toEqual(false);
+ });
+
+ it('should set featureFlags with the transformed data', () => {
+ const expected = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+
+ expect(stateCopy.featureFlags).toEqual(expected);
+ });
+
+ it('should set count with the given data', () => {
+ expect(stateCopy.count.featureFlags).toEqual(37);
+ });
+
+ it('should set pagination', () => {
+ expect(stateCopy.pageInfo.featureFlags).toEqual(
+ parseIntPagination(normalizeHeaders(headers)),
+ );
+ });
+ });
+
+ describe('RECEIVE_FEATURE_FLAGS_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_FEATURE_FLAGS_ERROR](stateCopy);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.hasError).toEqual(true);
+ });
+ });
+
+ describe('REQUEST_USER_LISTS', () => {
+ it('sets isLoading to true', () => {
+ mutations[types.REQUEST_USER_LISTS](stateCopy);
+ expect(stateCopy.isLoading).toBe(true);
+ });
+ });
+
+ describe('RECEIVE_USER_LISTS_SUCCESS', () => {
+ const headers = {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '2',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '5',
+ };
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LISTS_SUCCESS](stateCopy, { data: [userList], headers });
+ });
+
+ it('sets isLoading to false', () => {
+ expect(stateCopy.isLoading).toBe(false);
+ });
+
+ it('sets userLists to the received userLists', () => {
+ expect(stateCopy.userLists).toEqual([userList]);
+ });
+
+ it('sets pagination info for user lits', () => {
+ expect(stateCopy.pageInfo.userLists).toEqual(parseIntPagination(normalizeHeaders(headers)));
+ });
+
+ it('sets the count for user lists', () => {
+ expect(stateCopy.count.userLists).toBe(parseInt(headers['X-TOTAL'], 10));
+ });
+ });
+
+ describe('RECEIVE_USER_LISTS_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LISTS_ERROR](stateCopy);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.hasError).toEqual(true);
+ });
+ });
+
+ describe('REQUEST_ROTATE_INSTANCE_ID', () => {
+ beforeEach(() => {
+ mutations[types.REQUEST_ROTATE_INSTANCE_ID](stateCopy);
+ });
+
+ it('should set isRotating to true', () => {
+ expect(stateCopy.isRotating).toBe(true);
+ });
+
+ it('should set hasRotateError to false', () => {
+ expect(stateCopy.hasRotateError).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_ROTATE_INSTANCE_ID_SUCCESS', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS](stateCopy, { data: rotateData });
+ });
+
+ it('should set the instance id to the received data', () => {
+ expect(stateCopy.instanceId).toBe(rotateData.token);
+ });
+
+ it('should set isRotating to false', () => {
+ expect(stateCopy.isRotating).toBe(false);
+ });
+
+ it('should set hasRotateError to false', () => {
+ expect(stateCopy.hasRotateError).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_ROTATE_INSTANCE_ID_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](stateCopy);
+ });
+
+ it('should set isRotating to false', () => {
+ expect(stateCopy.isRotating).toBe(false);
+ });
+
+ it('should set hasRotateError to true', () => {
+ expect(stateCopy.hasRotateError).toBe(true);
+ });
+ });
+
+ describe('UPDATE_FEATURE_FLAG', () => {
+ beforeEach(() => {
+ stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+ stateCopy.count = { featureFlags: 1, userLists: 0 };
+
+ mutations[types.UPDATE_FEATURE_FLAG](stateCopy, {
+ ...featureFlag,
+ scopes: mapToScopesViewModel(featureFlag.scopes || []),
+ active: false,
+ });
+ });
+
+ it('should update the flag with the matching ID', () => {
+ expect(stateCopy.featureFlags).toEqual([
+ {
+ ...featureFlag,
+ scopes: mapToScopesViewModel(featureFlag.scopes || []),
+ active: false,
+ },
+ ]);
+ });
+ });
+
+ describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => {
+ const runUpdate = (stateCount, flagState, featureFlagUpdateParams) => {
+ stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ ...flagState,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+ stateCopy.count.featureFlags = stateCount;
+
+ mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, {
+ ...featureFlag,
+ ...featureFlagUpdateParams,
+ });
+ };
+
+ it('updates the flag with the matching ID', () => {
+ runUpdate({ all: 1, enabled: 1, disabled: 0 }, { active: true }, { active: false });
+
+ expect(stateCopy.featureFlags).toEqual([
+ {
+ ...featureFlag,
+ scopes: mapToScopesViewModel(featureFlag.scopes || []),
+ active: false,
+ },
+ ]);
+ });
+ });
+
+ describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => {
+ beforeEach(() => {
+ stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+ stateCopy.count = { enabled: 1, disabled: 0 };
+
+ mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, featureFlag.id);
+ });
+
+ it('should update the flag with the matching ID, toggling active', () => {
+ expect(stateCopy.featureFlags).toEqual([
+ {
+ ...featureFlag,
+ scopes: mapToScopesViewModel(featureFlag.scopes || []),
+ active: false,
+ },
+ ]);
+ });
+ });
+
+ describe('REQUEST_DELETE_USER_LIST', () => {
+ beforeEach(() => {
+ stateCopy.userLists = [userList];
+ mutations[types.REQUEST_DELETE_USER_LIST](stateCopy, userList);
+ });
+
+ it('should remove the deleted list', () => {
+ expect(stateCopy.userLists).not.toContain(userList);
+ });
+ });
+
+ describe('RECEIVE_DELETE_USER_LIST_ERROR', () => {
+ beforeEach(() => {
+ stateCopy.userLists = [];
+ mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](stateCopy, {
+ list: userList,
+ error: 'some error',
+ });
+ });
+
+ it('should set isLoading to false and hasError to false', () => {
+ expect(stateCopy.isLoading).toBe(false);
+ expect(stateCopy.hasError).toBe(false);
+ });
+
+ it('should add the user list back to the list of user lists', () => {
+ expect(stateCopy.userLists).toContain(userList);
+ });
+ });
+
+ describe('RECEIVE_CLEAR_ALERT', () => {
+ it('clears the alert', () => {
+ stateCopy.alerts = ['a server error'];
+
+ mutations[types.RECEIVE_CLEAR_ALERT](stateCopy, 0);
+
+ expect(stateCopy.alerts).toEqual([]);
+ });
+
+ it('clears the alert at the specified index', () => {
+ stateCopy.alerts = ['a server error', 'another error', 'final error'];
+
+ mutations[types.RECEIVE_CLEAR_ALERT](stateCopy, 1);
+
+ expect(stateCopy.alerts).toEqual(['a server error', 'final error']);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js
new file mode 100644
index 00000000000..cfcddd9451f
--- /dev/null
+++ b/spec/frontend/feature_flags/store/new/actions_spec.js
@@ -0,0 +1,223 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'spec/test_constants';
+import {
+ setEndpoint,
+ setPath,
+ createFeatureFlag,
+ requestCreateFeatureFlag,
+ receiveCreateFeatureFlagSuccess,
+ receiveCreateFeatureFlagError,
+} from '~/feature_flags/store/modules/new/actions';
+import state from '~/feature_flags/store/modules/new/state';
+import * as types from '~/feature_flags/store/modules/new/mutation_types';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+} from '~/feature_flags/constants';
+import {
+ mapFromScopesViewModel,
+ mapStrategiesToRails,
+} from '~/feature_flags/store/modules/helpers';
+import axios from '~/lib/utils/axios_utils';
+
+jest.mock('~/lib/utils/url_utility');
+
+describe('Feature flags New Module Actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setEndpoint', () => {
+ it('should commit SET_ENDPOINT mutation', done => {
+ testAction(
+ setEndpoint,
+ 'feature_flags.json',
+ mockedState,
+ [{ type: types.SET_ENDPOINT, payload: 'feature_flags.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setPath', () => {
+ it('should commit SET_PATH mutation', done => {
+ testAction(
+ setPath,
+ '/feature_flags',
+ mockedState,
+ [{ type: types.SET_PATH, payload: '/feature_flags' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('createFeatureFlag', () => {
+ let mock;
+
+ const actionParams = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ version: LEGACY_FLAG,
+ scopes: [
+ {
+ id: 1,
+ environmentScope: 'environmentScope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ shouldBeDestroyed: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', done => {
+ const convertedActionParams = mapFromScopesViewModel(actionParams);
+
+ mock.onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams).replyOnce(200);
+
+ testAction(
+ createFeatureFlag,
+ actionParams,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestCreateFeatureFlag',
+ },
+ {
+ type: 'receiveCreateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+
+ it('sends strategies for new style feature flags', done => {
+ const newVersionFlagParams = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ version: NEW_VERSION_FLAG,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ id: 1,
+ scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }],
+ shouldBeDestroyed: false,
+ },
+ ],
+ };
+ mock
+ .onPost(`${TEST_HOST}/endpoint.json`, mapStrategiesToRails(newVersionFlagParams))
+ .replyOnce(200);
+
+ testAction(
+ createFeatureFlag,
+ newVersionFlagParams,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestCreateFeatureFlag',
+ },
+ {
+ type: 'receiveCreateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', done => {
+ const convertedActionParams = mapFromScopesViewModel(actionParams);
+
+ mock
+ .onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams)
+ .replyOnce(500, { message: [] });
+
+ testAction(
+ createFeatureFlag,
+ actionParams,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestCreateFeatureFlag',
+ },
+ {
+ type: 'receiveCreateFeatureFlagError',
+ payload: { message: [] },
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestCreateFeatureFlag', () => {
+ it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', done => {
+ testAction(
+ requestCreateFeatureFlag,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_CREATE_FEATURE_FLAG }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveCreateFeatureFlagSuccess', () => {
+ it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', done => {
+ testAction(
+ receiveCreateFeatureFlagSuccess,
+ null,
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveCreateFeatureFlagError', () => {
+ it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', done => {
+ testAction(
+ receiveCreateFeatureFlagError,
+ 'There was an error',
+ mockedState,
+ [{ type: types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/new/mutations_spec.js b/spec/frontend/feature_flags/store/new/mutations_spec.js
new file mode 100644
index 00000000000..95eba96ed72
--- /dev/null
+++ b/spec/frontend/feature_flags/store/new/mutations_spec.js
@@ -0,0 +1,65 @@
+import state from '~/feature_flags/store/modules/new/state';
+import mutations from '~/feature_flags/store/modules/new/mutations';
+import * as types from '~/feature_flags/store/modules/new/mutation_types';
+
+describe('Feature flags New Module Mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_ENDPOINT', () => {
+ it('should set endpoint', () => {
+ mutations[types.SET_ENDPOINT](stateCopy, 'feature_flags.json');
+
+ expect(stateCopy.endpoint).toEqual('feature_flags.json');
+ });
+ });
+
+ describe('SET_PATH', () => {
+ it('should set provided options', () => {
+ mutations[types.SET_PATH](stateCopy, 'feature_flags');
+
+ expect(stateCopy.path).toEqual('feature_flags');
+ });
+ });
+
+ describe('REQUEST_CREATE_FEATURE_FLAG', () => {
+ it('should set isSendingRequest to true', () => {
+ mutations[types.REQUEST_CREATE_FEATURE_FLAG](stateCopy);
+
+ expect(stateCopy.isSendingRequest).toEqual(true);
+ });
+
+ it('should set error to an empty array', () => {
+ mutations[types.REQUEST_CREATE_FEATURE_FLAG](stateCopy);
+
+ expect(stateCopy.error).toEqual([]);
+ });
+ });
+
+ describe('RECEIVE_CREATE_FEATURE_FLAG_SUCCESS', () => {
+ it('should set isSendingRequest to false', () => {
+ mutations[types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](stateCopy);
+
+ expect(stateCopy.isSendingRequest).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_CREATE_FEATURE_FLAG_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](stateCopy, {
+ message: ['Name is required'],
+ });
+ });
+
+ it('should set isSendingRequest to false', () => {
+ expect(stateCopy.isSendingRequest).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.error).toEqual(['Name is required']);
+ });
+ });
+});
diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb
new file mode 100644
index 00000000000..bda62f4850a
--- /dev/null
+++ b/spec/frontend/fixtures/releases.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Releases (JavaScript fixtures)' do
+ include ApiHelpers
+ include JavaScriptFixturesHelpers
+
+ let_it_be(:admin) { create(:admin, username: 'administrator', email: 'admin@example.gitlab.com') }
+ let_it_be(:namespace) { create(:namespace, path: 'releases-namespace') }
+ let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'releases-project') }
+
+ let_it_be(:milestone_12_3) do
+ create(:milestone,
+ id: 123,
+ project: project,
+ title: '12.3',
+ description: 'The 12.3 milestone',
+ start_date: Time.zone.parse('2018-12-10'),
+ due_date: Time.zone.parse('2019-01-10'))
+ end
+
+ let_it_be(:milestone_12_4) do
+ create(:milestone,
+ id: 124,
+ project: project,
+ title: '12.4',
+ description: 'The 12.4 milestone',
+ start_date: Time.zone.parse('2019-01-10'),
+ due_date: Time.zone.parse('2019-02-10'))
+ end
+
+ let_it_be(:open_issues_12_3) do
+ create_list(:issue, 2, milestone: milestone_12_3, project: project)
+ end
+
+ let_it_be(:closed_issues_12_3) do
+ create_list(:issue, 3, :closed, milestone: milestone_12_3, project: project)
+ end
+
+ let_it_be(:open_issues_12_4) do
+ create_list(:issue, 3, milestone: milestone_12_4, project: project)
+ end
+
+ let_it_be(:closed_issues_12_4) do
+ create_list(:issue, 1, :closed, milestone: milestone_12_4, project: project)
+ end
+
+ let_it_be(:release) do
+ create(:release,
+ milestones: [milestone_12_3, milestone_12_4],
+ project: project,
+ tag: 'v1.1',
+ name: 'The first release',
+ author: admin,
+ description: 'Best. Release. **Ever.** :rocket:',
+ created_at: Time.zone.parse('2018-12-3'),
+ released_at: Time.zone.parse('2018-12-10'))
+ end
+
+ let_it_be(:evidence) do
+ create(:evidence,
+ release: release,
+ collected_at: Time.zone.parse('2018-12-03'))
+ end
+
+ let_it_be(:other_link) do
+ create(:release_link,
+ id: 10,
+ release: release,
+ name: 'linux-amd64 binaries',
+ filepath: '/binaries/linux-amd64',
+ url: 'https://downloads.example.com/bin/gitlab-linux-amd64')
+ end
+
+ let_it_be(:runbook_link) do
+ create(:release_link,
+ id: 11,
+ release: release,
+ name: 'Runbook',
+ url: "#{release.project.web_url}/runbook",
+ link_type: :runbook)
+ end
+
+ let_it_be(:package_link) do
+ create(:release_link,
+ id: 12,
+ release: release,
+ name: 'Package',
+ url: 'https://example.com/package',
+ link_type: :package)
+ end
+
+ let_it_be(:image_link) do
+ create(:release_link,
+ id: 13,
+ release: release,
+ name: 'Image',
+ url: 'https://example.com/image',
+ link_type: :image)
+ end
+
+ after(:all) do
+ remove_repository(project)
+ end
+
+ describe API::Releases, type: :request do
+ before(:all) do
+ clean_frontend_fixtures('api/releases/')
+ end
+
+ it 'api/releases/release.json' do
+ get api("/projects/#{project.id}/releases/#{release.tag}", admin)
+
+ expect(response).to be_successful
+ end
+ end
+
+ graphql_query_path = 'releases/queries/all_releases.query.graphql'
+
+ describe "~/#{graphql_query_path}", type: :request do
+ include GraphqlHelpers
+
+ before(:all) do
+ clean_frontend_fixtures('graphql/releases/')
+ end
+
+ it "graphql/#{graphql_query_path}.json" do
+ query = File.read(File.join(Rails.root, '/app/assets/javascripts', graphql_query_path))
+
+ post_graphql(query, current_user: admin, variables: { fullPath: project.full_path })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+end
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 6c40b1ba3a7..38a0da95080 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -7,15 +7,228 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete
import { TEST_HOST } from 'helpers/test_constants';
import { getJSONFixture } from 'helpers/fixtures';
+import waitForPromises from 'jest/helpers/wait_for_promises';
+
+import MockAdapter from 'axios-mock-adapter';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import axios from '~/lib/utils/axios_utils';
+
const labelsFixture = getJSONFixture('autocomplete_sources/labels.json');
describe('GfmAutoComplete', () => {
- const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
- fetchData: () => {},
- });
+ const fetchDataMock = { fetchData: jest.fn() };
+ let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock);
let atwhoInstance;
let sorterValue;
+ let filterValue;
+
+ describe('.typesWithBackendFiltering', () => {
+ it('should contain vulnerabilities', () => {
+ expect(GfmAutoComplete.typesWithBackendFiltering).toContain('vulnerabilities');
+ });
+ });
+
+ describe('DefaultOptions.filter', () => {
+ let items;
+
+ beforeEach(() => {
+ jest.spyOn(fetchDataMock, 'fetchData');
+ jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {});
+ });
+
+ describe('assets loading', () => {
+ beforeEach(() => {
+ atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' };
+ items = ['loading'];
+
+ filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items);
+ });
+
+ it('should call the fetchData function without query', () => {
+ expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+');
+ });
+
+ it('should not call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
+ });
+
+ it('should return the passed unfiltered items', () => {
+ expect(filterValue).toEqual(items);
+ });
+ });
+
+ describe('backend filtering', () => {
+ beforeEach(() => {
+ atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' };
+ items = [];
+ });
+
+ describe('when previous query is different from current one', () => {
+ beforeEach(() => {
+ gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
+ previousQuery: 'oldquery',
+ ...fetchDataMock,
+ });
+ filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items);
+ });
+
+ it('should call the fetchData function with query', () => {
+ expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+', 'newquery');
+ });
+
+ it('should not call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
+ });
+
+ it('should return the passed unfiltered items', () => {
+ expect(filterValue).toEqual(items);
+ });
+ });
+
+ describe('when previous query is not different from current one', () => {
+ beforeEach(() => {
+ gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
+ previousQuery: 'oldquery',
+ ...fetchDataMock,
+ });
+ filterValue = gfmAutoCompleteCallbacks.filter.call(
+ atwhoInstance,
+ 'oldquery',
+ items,
+ 'searchKey',
+ );
+ });
+
+ it('should not call the fetchData function', () => {
+ expect(fetchDataMock.fetchData).not.toHaveBeenCalled();
+ });
+
+ it('should call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith(
+ 'oldquery',
+ items,
+ 'searchKey',
+ );
+ });
+ });
+ });
+ });
+
+ describe('fetchData', () => {
+ const { fetchData } = GfmAutoComplete.prototype;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(axios, 'get');
+ jest.spyOn(AjaxCache, 'retrieve');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('already loading data', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '+': true },
+ dataSources: {},
+ cachedData: {},
+ };
+ fetchData.call(context, {}, '+', '');
+ });
+
+ it('should not call either axios nor AjaxCache', () => {
+ expect(axios.get).not.toHaveBeenCalled();
+ expect(AjaxCache.retrieve).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('backend filtering', () => {
+ describe('data is not in cache', () => {
+ let context;
+
+ beforeEach(() => {
+ context = {
+ isLoadingData: { '+': false },
+ dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
+ cachedData: {},
+ };
+ });
+
+ it('should call axios with query', () => {
+ fetchData.call(context, {}, '+', 'query');
+
+ expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
+ params: { search: 'query' },
+ });
+ });
+
+ it.each([200, 500])('should set the loading state', async responseStatus => {
+ mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus);
+
+ fetchData.call(context, {}, '+', 'query');
+
+ expect(context.isLoadingData['+']).toBe(true);
+
+ await waitForPromises();
+
+ expect(context.isLoadingData['+']).toBe(false);
+ });
+ });
+
+ describe('data is in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '+': false },
+ dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
+ cachedData: { '+': [{}] },
+ };
+ fetchData.call(context, {}, '+', 'query');
+ });
+
+ it('should anyway call axios with query ignoring cache', () => {
+ expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
+ params: { search: 'query' },
+ });
+ });
+ });
+ });
+
+ describe('frontend filtering', () => {
+ describe('data is not in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '#': false },
+ dataSources: { issues: 'issues_autocomplete_url' },
+ cachedData: {},
+ };
+ fetchData.call(context, {}, '#', 'query');
+ });
+
+ it('should call AjaxCache', () => {
+ expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true);
+ });
+ });
+
+ describe('data is in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '#': false },
+ dataSources: { issues: 'issues_autocomplete_url' },
+ cachedData: { '#': [{}] },
+ loadData: () => {},
+ };
+ fetchData.call(context, {}, '#', 'query');
+ });
+
+ it('should not call AjaxCache', () => {
+ expect(AjaxCache.retrieve).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
describe('DefaultOptions.sorter', () => {
describe('assets loading', () => {
diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
index 0befe1aa192..e880f585daa 100644
--- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
+++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
@@ -17,6 +17,7 @@ exports[`grafana integration component default state to match the default snapsh
</h3>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-settings-toggle"
icon=""
@@ -92,20 +93,17 @@ exports[`grafana integration component default state to match the default snapsh
</p>
</gl-form-group-stub>
- <div
- class="gl-display-flex gl-justify-content-end"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ icon=""
+ size="medium"
+ variant="success"
>
- <gl-button-stub
- category="primary"
- icon=""
- size="medium"
- variant="success"
- >
-
- Save Changes
- </gl-button-stub>
- </div>
+ Save Changes
+
+ </gl-button-stub>
</form>
</div>
</section>
diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js
index f5df8c180d5..d4aa29eaadd 100644
--- a/spec/frontend/groups/components/item_actions_spec.js
+++ b/spec/frontend/groups/components/item_actions_spec.js
@@ -1,84 +1,87 @@
-import Vue from 'vue';
-
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemActionsComponent from '~/groups/components/item_actions.vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ItemActions 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);
+describe('ItemActions', () => {
+ let wrapper;
+ const parentGroup = mockChildren[0];
- return mountComponent(Component, {
- group,
+ const defaultProps = {
+ group: mockParentGroupItem,
parentGroup,
- });
-};
-
-describe('ItemActionsComponent', () => {
- let vm;
+ };
- beforeEach(() => {
- vm = createComponent();
- });
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemActions, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
afterEach(() => {
- vm.$destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
- 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,
- );
- });
- });
- });
+ const findEditGroupBtn = () => wrapper.find('[data-testid="edit-group-btn"]');
+ const findEditGroupIcon = () => findEditGroupBtn().find(GlIcon);
+ const findLeaveGroupBtn = () => wrapper.find('[data-testid="leave-group-btn"]');
+ const findLeaveGroupIcon = () => findLeaveGroupBtn().find(GlIcon);
describe('template', () => {
- it('should render component template correctly', () => {
- expect(vm.$el.classList.contains('controls')).toBeTruthy();
- });
+ it('renders component template correctly', () => {
+ createComponent();
- it('should render Edit Group button with correct attribute values', () => {
- const group = { ...mockParentGroupItem };
- group.canEdit = true;
- const newVm = createComponent(group);
+ expect(wrapper.classes()).toContain('controls');
+ });
- const editBtn = newVm.$el.querySelector('a.edit-group');
+ it('renders "Edit group" button with correct attribute values', () => {
+ const group = {
+ ...mockParentGroupItem,
+ canEdit: true,
+ };
+
+ createComponent({ group });
+
+ expect(findEditGroupBtn().exists()).toBe(true);
+ expect(findEditGroupBtn().classes()).toContain('no-expand');
+ expect(findEditGroupBtn().attributes('href')).toBe(group.editPath);
+ expect(findEditGroupBtn().attributes('aria-label')).toBe('Edit group');
+ expect(findEditGroupBtn().attributes('data-original-title')).toBe('Edit group');
+ expect(findEditGroupIcon().exists()).toBe(true);
+ expect(findEditGroupIcon().props('name')).toBe('settings');
+ });
- 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').length).not.toBe(0);
- expect(editBtn.querySelector('svg').getAttribute('data-testid')).toBe('settings-icon');
+ describe('`canLeave` is true', () => {
+ const group = {
+ ...mockParentGroupItem,
+ canLeave: true,
+ };
- newVm.$destroy();
- });
+ beforeEach(() => {
+ createComponent({ group });
+ });
- it('should render Leave Group button with correct attribute values', () => {
- const group = { ...mockParentGroupItem };
- group.canLeave = true;
- const newVm = createComponent(group);
+ it('renders "Leave this group" button with correct attribute values', () => {
+ expect(findLeaveGroupBtn().exists()).toBe(true);
+ expect(findLeaveGroupBtn().classes()).toContain('no-expand');
+ expect(findLeaveGroupBtn().attributes('href')).toBe(group.leavePath);
+ expect(findLeaveGroupBtn().attributes('aria-label')).toBe('Leave this group');
+ expect(findLeaveGroupBtn().attributes('data-original-title')).toBe('Leave this group');
+ expect(findLeaveGroupIcon().exists()).toBe(true);
+ expect(findLeaveGroupIcon().props('name')).toBe('leave');
+ });
- const leaveBtn = newVm.$el.querySelector('a.leave-group');
+ it('emits event on "Leave this group" button click', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- 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').length).not.toBe(0);
- expect(leaveBtn.querySelector('svg').getAttribute('data-testid')).toBe('leave-icon');
+ findLeaveGroupBtn().trigger('click');
- newVm.$destroy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('showLeaveGroupModal', group, parentGroup);
+ });
});
});
});
diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js
index 4ff7482414c..b2915607a06 100644
--- a/spec/frontend/groups/components/item_caret_spec.js
+++ b/spec/frontend/groups/components/item_caret_spec.js
@@ -1,38 +1,48 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ItemCaret from '~/groups/components/item_caret.vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemCaretComponent from '~/groups/components/item_caret.vue';
+describe('ItemCaret', () => {
+ let wrapper;
-const createComponent = (isGroupOpen = false) => {
- const Component = Vue.extend(itemCaretComponent);
+ const defaultProps = {
+ isGroupOpen: false,
+ };
- return mountComponent(Component, {
- isGroupOpen,
- });
-};
-
-describe('ItemCaretComponent', () => {
- let vm;
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemCaret, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
afterEach(() => {
- vm.$destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
+ const findAllGlIcons = () => wrapper.findAll(GlIcon);
+ const findGlIcon = () => wrapper.find(GlIcon);
+
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('renders component template correctly', () => {
+ createComponent();
- it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
- vm = createComponent(true);
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-down-icon');
+ expect(wrapper.classes()).toContain('folder-caret');
+ expect(findAllGlIcons()).toHaveLength(1);
});
- it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
- vm = createComponent();
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-right-icon');
+ it.each`
+ isGroupOpen | icon
+ ${true} | ${'angle-down'}
+ ${false} | ${'angle-right'}
+ `('renders "$icon" icon when `isGroupOpen` is $isGroupOpen', ({ isGroupOpen, icon }) => {
+ createComponent({
+ isGroupOpen,
+ });
+
+ expect(findGlIcon().props('name')).toBe(icon);
});
});
});
diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js
index 771643609ec..d8c88a608ac 100644
--- a/spec/frontend/groups/components/item_stats_spec.js
+++ b/spec/frontend/groups/components/item_stats_spec.js
@@ -1,119 +1,50 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import ItemStats from '~/groups/components/item_stats.vue';
+import ItemStatsValue from '~/groups/components/item_stats_value.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';
+import { mockParentGroupItem, ITEM_TYPE } from '../mock_data';
-const createComponent = (item = mockParentGroupItem) => {
- const Component = Vue.extend(itemStatsComponent);
+describe('ItemStats', () => {
+ let wrapper;
- 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);
+ const defaultProps = {
+ item: mockParentGroupItem,
+ };
- expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
- vm.$destroy();
- });
- });
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemStats, {
+ propsData: { ...defaultProps, ...props },
});
+ };
- 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();
- });
- });
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
+ const findItemStatsValue = () => wrapper.find(ItemStatsValue);
+
describe('template', () => {
it('renders component container element correctly', () => {
- const vm = createComponent();
+ createComponent();
- expect(vm.$el.classList.contains('stats')).toBeTruthy();
-
- vm.$destroy();
+ expect(wrapper.classes()).toContain('stats');
});
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');
+ const item = {
+ ...mockParentGroupItem,
+ type: ITEM_TYPE.PROJECT,
+ starCount: 4,
+ };
- 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);
+ createComponent({ item });
- vm.$destroy();
+ expect(findItemStatsValue().exists()).toBe(true);
+ expect(findItemStatsValue().props('cssClass')).toBe('project-stars');
+ expect(wrapper.contains('.last-updated')).toBe(true);
});
});
});
diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js
index 11246390444..6f018aa79a0 100644
--- a/spec/frontend/groups/components/item_stats_value_spec.js
+++ b/spec/frontend/groups/components/item_stats_value_spec.js
@@ -1,82 +1,67 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ItemStatsValue from '~/groups/components/item_stats_value.vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemStatsValueComponent from '~/groups/components/item_stats_value.vue';
+describe('ItemStatsValue', () => {
+ let wrapper;
-const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => {
- const Component = Vue.extend(itemStatsValueComponent);
+ const defaultProps = {
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ };
- return mountComponent(Component, {
- title,
- cssClass,
- iconName,
- tooltipPlacement,
- value,
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemStatsValue, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
-};
-describe('ItemStatsValueComponent', () => {
- describe('computed', () => {
- let vm;
- const itemConfig = {
- title: 'Subgroups',
- cssClass: 'number-subgroups',
- iconName: 'folder',
- tooltipPlacement: 'left',
- };
+ const findGlIcon = () => wrapper.find(GlIcon);
+ const findStatValue = () => wrapper.find('[data-testid="itemStatValue"]');
- describe('isValuePresent', () => {
- it('returns true if non-empty `value` is present', () => {
- vm = createComponent({ ...itemConfig, value: 10 });
+ describe('template', () => {
+ describe('when `value` is not provided', () => {
+ it('does not render value count', () => {
+ createComponent();
- expect(vm.isValuePresent).toBeTruthy();
+ expect(findStatValue().exists()).toBe(false);
});
+ });
- it('returns false if empty `value` is present', () => {
- vm = createComponent(itemConfig);
-
- expect(vm.isValuePresent).toBeFalsy();
+ describe('when `value` is provided', () => {
+ beforeEach(() => {
+ createComponent({
+ value: 10,
+ });
});
- afterEach(() => {
- vm.$destroy();
+ it('renders component element correctly', () => {
+ expect(wrapper.classes()).toContain('number-subgroups');
});
- });
- });
- describe('template', () => {
- let vm;
- beforeEach(() => {
- vm = createComponent({
- title: 'Subgroups',
- cssClass: 'number-subgroups',
- iconName: 'folder',
- tooltipPlacement: 'left',
- value: 10,
+ it('renders element tooltip correctly', () => {
+ expect(wrapper.attributes('data-original-title')).toBe('Subgroups');
+ expect(wrapper.attributes('data-placement')).toBe('left');
});
- });
- 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').getAttribute('data-testid')).toBe('folder-icon');
- });
+ it('renders element icon correctly', () => {
+ expect(findGlIcon().exists()).toBe(true);
+ expect(findGlIcon().props('name')).toBe('folder');
+ });
- it('renders value count correctly', () => {
- expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10');
+ it('renders value count correctly', () => {
+ expect(findStatValue().classes()).toContain('stat-value');
+ expect(findStatValue().text()).toBe('10');
+ });
});
});
});
diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js
index 477c413ddcd..5e7056be218 100644
--- a/spec/frontend/groups/components/item_type_icon_spec.js
+++ b/spec/frontend/groups/components/item_type_icon_spec.js
@@ -1,53 +1,53 @@
-import Vue from 'vue';
-
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ItemTypeIcon 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('ItemTypeIcon', () => {
+ let wrapper;
-describe('ItemTypeIconComponent', () => {
- describe('template', () => {
- it('should render component template correctly', () => {
- const vm = createComponent();
+ const defaultProps = {
+ itemType: ITEM_TYPE.GROUP,
+ isGroupOpen: false,
+ };
- expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
- vm.$destroy();
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemTypeIcon, {
+ propsData: { ...defaultProps, ...props },
});
+ };
- it('should render folder open or close icon based `isGroupOpen` prop value', () => {
- let vm;
-
- vm = createComponent(ITEM_TYPE.GROUP, true);
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-open-icon');
- vm.$destroy();
+ const findGlIcon = () => wrapper.find(GlIcon);
- vm = createComponent(ITEM_TYPE.GROUP);
+ describe('template', () => {
+ it('renders component template correctly', () => {
+ createComponent();
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-o-icon');
- vm.$destroy();
+ expect(wrapper.classes()).toContain('item-type-icon');
});
- it('should render bookmark icon based on `isProject` prop value', () => {
- let vm;
-
- vm = createComponent(ITEM_TYPE.PROJECT);
-
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('bookmark-icon');
- vm.$destroy();
-
- vm = createComponent(ITEM_TYPE.GROUP);
-
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).not.toBe('bookmark-icon');
- vm.$destroy();
- });
+ it.each`
+ type | isGroupOpen | icon
+ ${ITEM_TYPE.GROUP} | ${true} | ${'folder-open'}
+ ${ITEM_TYPE.GROUP} | ${false} | ${'folder-o'}
+ ${ITEM_TYPE.PROJECT} | ${true} | ${'bookmark'}
+ ${ITEM_TYPE.PROJECT} | ${false} | ${'bookmark'}
+ `(
+ 'shows "$icon" icon when `itemType` is "$type" and `isGroupOpen` is $isGroupOpen',
+ ({ type, isGroupOpen, icon }) => {
+ createComponent({
+ itemType: type,
+ isGroupOpen,
+ });
+ expect(findGlIcon().props('name')).toBe(icon);
+ },
+ );
});
});
diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js
index 70fce0d60fb..95a111ef5da 100644
--- a/spec/frontend/groups/members/index_spec.js
+++ b/spec/frontend/groups/members/index_spec.js
@@ -1,5 +1,5 @@
import { createWrapper } from '@vue/test-utils';
-import initGroupMembersApp from '~/groups/members';
+import { initGroupMembersApp } from '~/groups/members';
import GroupMembersApp from '~/groups/members/components/app.vue';
import { membersJsonString, membersParsed } from './mock_data';
@@ -9,7 +9,7 @@ describe('initGroupMembersApp', () => {
let wrapper;
const setup = () => {
- vm = initGroupMembersApp(el);
+ vm = initGroupMembersApp(el, ['account']);
wrapper = createWrapper(vm);
};
@@ -63,4 +63,10 @@ describe('initGroupMembersApp', () => {
expect(vm.$store.state.members).toEqual(membersParsed);
});
+
+ it('sets `tableFields` in Vuex store', () => {
+ setup();
+
+ expect(vm.$store.state.tableFields).toEqual(['account']);
+ });
});
diff --git a/spec/frontend/helpers/experimentation_helper.js b/spec/frontend/helpers/experimentation_helper.js
new file mode 100644
index 00000000000..c08c25155e8
--- /dev/null
+++ b/spec/frontend/helpers/experimentation_helper.js
@@ -0,0 +1,14 @@
+import { merge } from 'lodash';
+
+export function withGonExperiment(experimentKey, value = true) {
+ let origGon;
+
+ beforeEach(() => {
+ origGon = window.gon;
+ window.gon = merge({}, window.gon || {}, { experiments: { [experimentKey]: value } });
+ });
+
+ afterEach(() => {
+ window.gon = origGon;
+ });
+}
diff --git a/spec/frontend/helpers/keep_alive_component_helper.js b/spec/frontend/helpers/keep_alive_component_helper.js
new file mode 100644
index 00000000000..54f40bf9093
--- /dev/null
+++ b/spec/frontend/helpers/keep_alive_component_helper.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+
+export function keepAlive(KeptAliveComponent) {
+ return Vue.extend({
+ components: {
+ KeptAliveComponent,
+ },
+ data() {
+ return {
+ view: 'KeptAliveComponent',
+ };
+ },
+ methods: {
+ async activate() {
+ this.view = 'KeptAliveComponent';
+ await this.$nextTick();
+ },
+ async deactivate() {
+ this.view = 'div';
+ await this.$nextTick();
+ },
+ async reactivate() {
+ await this.deactivate();
+ await this.activate();
+ },
+ },
+ template: `<keep-alive><component :is="view"></component></keep-alive>`,
+ });
+}
diff --git a/spec/frontend/helpers/keep_alive_component_helper_spec.js b/spec/frontend/helpers/keep_alive_component_helper_spec.js
new file mode 100644
index 00000000000..dcccc14f396
--- /dev/null
+++ b/spec/frontend/helpers/keep_alive_component_helper_spec.js
@@ -0,0 +1,32 @@
+import { mount } from '@vue/test-utils';
+import { keepAlive } from './keep_alive_component_helper';
+
+const component = {
+ template: '<div>Test Component</div>',
+};
+
+describe('keepAlive', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(keepAlive(component));
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('converts a component to a keep-alive component', async () => {
+ const { element } = wrapper.find(component);
+
+ await wrapper.vm.deactivate();
+ expect(wrapper.find(component).exists()).toBe(false);
+
+ await wrapper.vm.activate();
+
+ // assert that when the component is destroyed and re-rendered, the
+ // newly rendered component has the reference to the old component
+ // (i.e. the old component was deactivated and activated)
+ expect(wrapper.find(component).element).toBe(element);
+ });
+});
diff --git a/spec/frontend/helpers/local_storage_helper.js b/spec/frontend/helpers/local_storage_helper.js
index cd39b660bfd..0318b80aaef 100644
--- a/spec/frontend/helpers/local_storage_helper.js
+++ b/spec/frontend/helpers/local_storage_helper.js
@@ -35,7 +35,7 @@ export const createLocalStorageSpy = () => {
clear: jest.fn(() => {
storage = {};
}),
- getItem: jest.fn(key => storage[key]),
+ getItem: jest.fn(key => (key in storage ? storage[key] : null)),
setItem: jest.fn((key, value) => {
storage[key] = value;
}),
diff --git a/spec/frontend/helpers/local_storage_helper_spec.js b/spec/frontend/helpers/local_storage_helper_spec.js
index 6b44ea3a4c3..5d9961e7631 100644
--- a/spec/frontend/helpers/local_storage_helper_spec.js
+++ b/spec/frontend/helpers/local_storage_helper_spec.js
@@ -18,11 +18,11 @@ describe('localStorage helper', () => {
localStorage.removeItem('test', 'testing');
- expect(localStorage.getItem('test')).toBeUndefined();
+ expect(localStorage.getItem('test')).toBe(null);
expect(localStorage.getItem('test2')).toBe('testing');
localStorage.clear();
- expect(localStorage.getItem('test2')).toBeUndefined();
+ expect(localStorage.getItem('test2')).toBe(null);
});
});
diff --git a/spec/frontend/helpers/vue_test_utils_helper.js b/spec/frontend/helpers/vue_test_utils_helper.js
index 68326e37ae7..ead898f04d3 100644
--- a/spec/frontend/helpers/vue_test_utils_helper.js
+++ b/spec/frontend/helpers/vue_test_utils_helper.js
@@ -33,3 +33,10 @@ export const waitForMutation = (store, expectedMutationType) =>
}
});
});
+
+export const extendedWrapper = wrapper =>
+ Object.defineProperty(wrapper, 'findByTestId', {
+ value(id) {
+ return this.find(`[data-testid="${id}"]`);
+ },
+ });
diff --git a/spec/frontend/helpers/wait_for_text.js b/spec/frontend/helpers/wait_for_text.js
new file mode 100644
index 00000000000..6bed8a90a98
--- /dev/null
+++ b/spec/frontend/helpers/wait_for_text.js
@@ -0,0 +1,3 @@
+import { findByText } from '@testing-library/dom';
+
+export const waitForText = async (text, container = document) => findByText(container, text);
diff --git a/spec/frontend/ide/components/commit_sidebar/actions_spec.js b/spec/frontend/ide/components/commit_sidebar/actions_spec.js
index a303e2b9bee..0003e13c92f 100644
--- a/spec/frontend/ide/components/commit_sidebar/actions_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/actions_spec.js
@@ -83,12 +83,12 @@ describe('IDE commit sidebar actions', () => {
});
});
- describe('commitToCurrentBranchText', () => {
+ describe('currentBranchText', () => {
it('escapes current branch', () => {
const injectedSrc = '<img src="x" />';
createComponent({ currentBranchId: injectedSrc });
- expect(vm.commitToCurrentBranchText).not.toContain(injectedSrc);
+ expect(vm.currentBranchText).not.toContain(injectedSrc);
});
});
diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js
index c9ac2ac423d..bcc98669427 100644
--- a/spec/frontend/ide/components/ide_review_spec.js
+++ b/spec/frontend/ide/components/ide_review_spec.js
@@ -1,14 +1,19 @@
import Vue from 'vue';
+import Vuex from 'vuex';
+import { createLocalVue, mount } from '@vue/test-utils';
import IdeReview from '~/ide/components/ide_review.vue';
+import EditorModeDropdown from '~/ide/components/editor_mode_dropdown.vue';
import { createStore } from '~/ide/stores';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { trimText } from '../../helpers/text_helper';
+import { keepAlive } from '../../helpers/keep_alive_component_helper';
import { file } from '../helpers';
import { projectData } from '../mock_data';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('IDE review mode', () => {
- const Component = Vue.extend(IdeReview);
- let vm;
+ let wrapper;
let store;
beforeEach(() => {
@@ -21,15 +26,53 @@ describe('IDE review mode', () => {
loading: false,
});
- vm = createComponentWithStore(Component, store).$mount();
+ wrapper = mount(keepAlive(IdeReview), {
+ store,
+ localVue,
+ });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
+ expect(wrapper.text()).toContain('fileName');
+ });
+
+ describe('activated', () => {
+ let inititializeSpy;
+
+ beforeEach(async () => {
+ inititializeSpy = jest.spyOn(wrapper.find(IdeReview).vm, 'initialize');
+ store.state.viewer = 'editor';
+
+ await wrapper.vm.reactivate();
+ });
+
+ it('re initializes the component', () => {
+ expect(inititializeSpy).toHaveBeenCalled();
+ });
+
+ it('updates viewer to "diff" by default', () => {
+ expect(store.state.viewer).toBe('diff');
+ });
+
+ describe('merge request is defined', () => {
+ beforeEach(async () => {
+ store.state.currentMergeRequestId = '1';
+ store.state.projects.abcproject.mergeRequests['1'] = {
+ iid: 123,
+ web_url: 'testing123',
+ };
+
+ await wrapper.vm.reactivate();
+ });
+
+ it('updates viewer to "mrdiff"', async () => {
+ expect(store.state.viewer).toBe('mrdiff');
+ });
+ });
});
describe('merge request', () => {
@@ -40,32 +83,27 @@ describe('IDE review mode', () => {
web_url: 'testing123',
};
- return vm.$nextTick();
+ return wrapper.vm.$nextTick();
});
it('renders edit dropdown', () => {
- expect(vm.$el.querySelector('.btn')).not.toBe(null);
+ expect(wrapper.find(EditorModeDropdown).exists()).toBe(true);
});
- it('renders merge request link & IID', () => {
+ it('renders merge request link & IID', async () => {
store.state.viewer = 'mrdiff';
- return vm.$nextTick(() => {
- const link = vm.$el.querySelector('.ide-review-sub-header');
+ await wrapper.vm.$nextTick();
- expect(link.querySelector('a').getAttribute('href')).toBe('testing123');
- expect(trimText(link.textContent)).toBe('Merge request (!123)');
- });
+ expect(trimText(wrapper.text())).toContain('Merge request (!123)');
});
- it('changes text to latest changes when viewer is not mrdiff', () => {
+ it('changes text to latest changes when viewer is not mrdiff', async () => {
store.state.viewer = 'diff';
- return vm.$nextTick(() => {
- expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe(
- 'Latest changes',
- );
- });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain('Latest changes');
});
});
});
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
index 67257b40879..86e4e8d8f89 100644
--- a/spec/frontend/ide/components/ide_side_bar_spec.js
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -1,57 +1,88 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlSkeletonLoading } from '@gitlab/ui';
import { createStore } from '~/ide/stores';
-import ideSidebar from '~/ide/components/ide_side_bar.vue';
+import IdeSidebar from '~/ide/components/ide_side_bar.vue';
+import IdeTree from '~/ide/components/ide_tree.vue';
+import RepoCommitSection from '~/ide/components/repo_commit_section.vue';
import { leftSidebarViews } from '~/ide/constants';
import { projectData } from '../mock_data';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('IdeSidebar', () => {
- let vm;
+ let wrapper;
let store;
- beforeEach(() => {
+ function createComponent() {
store = createStore();
- const Component = Vue.extend(ideSidebar);
-
store.state.currentProjectId = 'abcproject';
store.state.projects.abcproject = projectData;
- vm = createComponentWithStore(Component, store).$mount();
- });
+ return mount(IdeSidebar, {
+ store,
+ localVue,
+ });
+ }
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ wrapper = null;
});
it('renders a sidebar', () => {
- expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
+ wrapper = createComponent();
+
+ expect(wrapper.find('[data-testid="ide-side-bar-inner"]').exists()).toBe(true);
});
- it('renders loading icon component', done => {
- vm.$store.state.loading = true;
+ it('renders loading components', async () => {
+ wrapper = createComponent();
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
- expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+ store.state.loading = true;
- done();
- });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(3);
});
describe('activityBarComponent', () => {
it('renders tree component', () => {
- expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull();
+ wrapper = createComponent();
+
+ expect(wrapper.find(IdeTree).exists()).toBe(true);
});
- it('renders commit component', done => {
- vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+ it('renders commit component', async () => {
+ wrapper = createComponent();
+
+ store.state.currentActivityView = leftSidebarViews.commit.name;
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull();
+ await wrapper.vm.$nextTick();
- done();
- });
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(true);
});
});
+
+ it('keeps the current activity view components alive', async () => {
+ wrapper = createComponent();
+
+ const ideTreeComponent = wrapper.find(IdeTree).element;
+
+ store.state.currentActivityView = leftSidebarViews.commit.name;
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(IdeTree).exists()).toBe(false);
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(true);
+
+ store.state.currentActivityView = leftSidebarViews.edit.name;
+
+ await wrapper.vm.$nextTick();
+
+ // reference to the elements remains the same, meaning the components were kept alive
+ expect(wrapper.find(IdeTree).element).toEqual(ideTreeComponent);
+ });
});
diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js
index 4593ef6049b..dd57a5c5f4d 100644
--- a/spec/frontend/ide/components/ide_tree_list_spec.js
+++ b/spec/frontend/ide/components/ide_tree_list_spec.js
@@ -38,15 +38,9 @@ describe('IDE tree list', () => {
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;
@@ -67,8 +61,6 @@ describe('IDE tree list', () => {
beforeEach(() => {
bootstrapWithTree(emptyBranchTree);
- jest.spyOn(vm, 'updateViewer');
-
vm.$mount();
});
diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js
index 899daa0bf57..ad00dec2e48 100644
--- a/spec/frontend/ide/components/ide_tree_spec.js
+++ b/spec/frontend/ide/components/ide_tree_spec.js
@@ -1,19 +1,22 @@
import Vue from 'vue';
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
import IdeTree from '~/ide/components/ide_tree.vue';
import { createStore } from '~/ide/stores';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { keepAlive } from '../../helpers/keep_alive_component_helper';
import { file } from '../helpers';
import { projectData } from '../mock_data';
-describe('IdeRepoTree', () => {
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('IdeTree', () => {
let store;
- let vm;
+ let wrapper;
beforeEach(() => {
store = createStore();
- const IdeRepoTree = Vue.extend(IdeTree);
-
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = { ...projectData };
@@ -22,14 +25,36 @@ describe('IdeRepoTree', () => {
loading: false,
});
- vm = createComponentWithStore(IdeRepoTree, store).$mount();
+ wrapper = mount(keepAlive(IdeTree), {
+ store,
+ localVue,
+ });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
+ expect(wrapper.text()).toContain('fileName');
+ });
+
+ describe('activated', () => {
+ let inititializeSpy;
+
+ beforeEach(async () => {
+ inititializeSpy = jest.spyOn(wrapper.find(IdeTree).vm, 'initialize');
+ store.state.viewer = 'diff';
+
+ await wrapper.vm.reactivate();
+ });
+
+ it('re initializes the component', () => {
+ expect(inititializeSpy).toHaveBeenCalled();
+ });
+
+ it('updates viewer to "editor" by default', () => {
+ expect(store.state.viewer).toBe('editor');
+ });
});
});
diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js
index 3b837622720..096079308cd 100644
--- a/spec/frontend/ide/components/repo_commit_section_spec.js
+++ b/spec/frontend/ide/components/repo_commit_section_spec.js
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import { createStore } from '~/ide/stores';
import { createRouter } from '~/ide/ide_router';
+import { keepAlive } from '../../helpers/keep_alive_component_helper';
import RepoCommitSection from '~/ide/components/repo_commit_section.vue';
import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue';
import { stageKeys } from '~/ide/constants';
@@ -14,7 +15,7 @@ describe('RepoCommitSection', () => {
let store;
function createComponent() {
- wrapper = mount(RepoCommitSection, { store });
+ wrapper = mount(keepAlive(RepoCommitSection), { store });
}
function setupDefaultState() {
@@ -64,6 +65,7 @@ describe('RepoCommitSection', () => {
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
describe('empty state', () => {
@@ -168,4 +170,21 @@ describe('RepoCommitSection', () => {
expect(wrapper.find(EmptyState).exists()).toBe(false);
});
});
+
+ describe('activated', () => {
+ let inititializeSpy;
+
+ beforeEach(async () => {
+ createComponent();
+
+ inititializeSpy = jest.spyOn(wrapper.find(RepoCommitSection).vm, 'initialize');
+ store.state.viewer = 'diff';
+
+ await wrapper.vm.reactivate();
+ });
+
+ it('re initializes the component', () => {
+ expect(inititializeSpy).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js
index 307806e0a8a..1b98d488854 100644
--- a/spec/frontend/incidents/components/incidents_list_spec.js
+++ b/spec/frontend/incidents/components/incidents_list_spec.js
@@ -5,7 +5,6 @@ import {
GlTable,
GlAvatar,
GlPagination,
- GlSearchBoxByType,
GlTab,
GlTabs,
GlBadge,
@@ -15,13 +14,24 @@ import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility';
import IncidentsList from '~/incidents/components/incidents_list.vue';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { I18N, INCIDENT_STATUS_TABS } from '~/incidents/constants';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import {
+ I18N,
+ INCIDENT_STATUS_TABS,
+ TH_CREATED_AT_TEST_ID,
+ TH_SEVERITY_TEST_ID,
+ TH_PUBLISHED_TEST_ID,
+} from '~/incidents/constants';
import mockIncidents from '../mocks/incidents.json';
+import mockFilters from '../mocks/incidents_filter.json';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
- joinPaths: jest.fn().mockName('joinPaths'),
- mergeUrlParams: jest.fn().mockName('mergeUrlParams'),
+ joinPaths: jest.fn(),
+ mergeUrlParams: jest.fn(),
+ setUrlParams: jest.fn(),
+ updateHistory: jest.fn(),
}));
describe('Incidents List', () => {
@@ -41,9 +51,7 @@ describe('Incidents List', () => {
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
- const findDateColumnHeader = () =>
- wrapper.find('[data-testid="incident-management-created-at-sort"]');
- const findSearch = () => wrapper.find(GlSearchBoxByType);
+ const findSearch = () => wrapper.find(FilteredSearchBar);
const findAssingees = () => wrapper.findAll('[data-testid="incident-assignees"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
@@ -73,9 +81,13 @@ describe('Incidents List', () => {
newIssuePath,
incidentTemplateName,
incidentType,
- issuePath: '/project/isssues',
+ issuePath: '/project/issues',
publishedAvailable: true,
emptyListSvgPath,
+ textQuery: '',
+ authorUsernamesQuery: '',
+ assigneeUsernamesQuery: '',
+ issuesIncidentDetails: false,
},
stubs: {
GlButton: true,
@@ -171,13 +183,6 @@ describe('Incidents List', () => {
expect(src).toBe(avatarUrl);
});
- it('contains a link to the issue details', () => {
- findTableRows()
- .at(0)
- .trigger('click');
- expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/isssues/`, mockIncidents[0].iid));
- });
-
it('renders a closed icon for closed incidents', () => {
expect(findClosedIcon().length).toBe(
mockIncidents.filter(({ state }) => state === 'closed').length,
@@ -188,6 +193,30 @@ describe('Incidents List', () => {
it('renders severity per row', () => {
expect(findSeverity().length).toBe(mockIncidents.length);
});
+
+ it('contains a link to the issue details page', () => {
+ findTableRows()
+ .at(0)
+ .trigger('click');
+ expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/issues/`, mockIncidents[0].iid));
+ });
+
+ it('contains a link to the incident details page', async () => {
+ beforeEach(() =>
+ mountComponent({
+ data: { incidents: { list: mockIncidents }, incidentsCount: {} },
+ loading: false,
+ provide: { glFeatures: { issuesIncidentDetails: true } },
+ }),
+ );
+
+ findTableRows()
+ .at(0)
+ .trigger('click');
+ expect(visitUrl).toHaveBeenCalledWith(
+ joinPaths(`/project/issues/incident`, mockIncidents[0].iid),
+ );
+ });
});
describe('Create Incident', () => {
@@ -207,11 +236,10 @@ describe('Incidents List', () => {
);
});
- it('sets button loading on click', () => {
+ it('sets button loading on click', async () => {
findCreateIncidentBtn().vm.$emit('click');
- return wrapper.vm.$nextTick().then(() => {
- expect(findCreateIncidentBtn().attributes('loading')).toBe('true');
- });
+ await wrapper.vm.$nextTick();
+ expect(findCreateIncidentBtn().attributes('loading')).toBe('true');
});
it("doesn't show the button when list is empty", () => {
@@ -243,51 +271,47 @@ describe('Incidents List', () => {
});
describe('prevPage', () => {
- it('returns prevPage button', () => {
+ it('returns prevPage button', async () => {
findPagination().vm.$emit('input', 3);
- return wrapper.vm.$nextTick(() => {
- expect(
- findPagination()
- .findAll('.page-item')
- .at(0)
- .text(),
- ).toBe('Prev');
- });
+ await wrapper.vm.$nextTick();
+ expect(
+ findPagination()
+ .findAll('.page-item')
+ .at(0)
+ .text(),
+ ).toBe('Prev');
});
- it('returns prevPage number', () => {
+ it('returns prevPage number', async () => {
findPagination().vm.$emit('input', 3);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.prevPage).toBe(2);
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.prevPage).toBe(2);
});
- it('returns 0 when it is the first page', () => {
+ it('returns 0 when it is the first page', async () => {
findPagination().vm.$emit('input', 1);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.prevPage).toBe(0);
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.prevPage).toBe(0);
});
});
describe('nextPage', () => {
- it('returns nextPage button', () => {
+ it('returns nextPage button', async () => {
findPagination().vm.$emit('input', 3);
- return wrapper.vm.$nextTick(() => {
- expect(
- findPagination()
- .findAll('.page-item')
- .at(1)
- .text(),
- ).toBe('Next');
- });
+ await wrapper.vm.$nextTick();
+ expect(
+ findPagination()
+ .findAll('.page-item')
+ .at(1)
+ .text(),
+ ).toBe('Next');
});
- it('returns nextPage number', () => {
+ it('returns nextPage number', async () => {
mountComponent({
data: {
incidents: {
@@ -301,21 +325,19 @@ describe('Incidents List', () => {
});
findPagination().vm.$emit('input', 1);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.nextPage).toBe(2);
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.nextPage).toBe(2);
});
- it('returns `null` when currentPage is already last page', () => {
+ it('returns `null` when currentPage is already last page', async () => {
findStatusTabs().vm.$emit('input', 1);
findPagination().vm.$emit('input', 1);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.nextPage).toBeNull();
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.nextPage).toBeNull();
});
});
- describe('Search', () => {
+ describe('Filtered search component', () => {
beforeEach(() => {
mountComponent({
data: {
@@ -331,15 +353,62 @@ describe('Incidents List', () => {
});
it('renders the search component for incidents', () => {
- expect(findSearch().exists()).toBe(true);
+ expect(findSearch().props('searchInputPlaceholder')).toBe('Search or filter results…');
+ expect(findSearch().props('tokens')).toEqual([
+ {
+ type: 'author_username',
+ icon: 'user',
+ title: 'Author',
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: '/project/path',
+ fetchAuthors: expect.any(Function),
+ },
+ {
+ type: 'assignee_username',
+ icon: 'user',
+ title: 'Assignees',
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: '/project/path',
+ fetchAuthors: expect.any(Function),
+ },
+ ]);
+ expect(findSearch().props('recentSearchesStorageKey')).toBe('incidents');
+ });
+
+ it('returns correctly applied filter search values', async () => {
+ const searchTerm = 'foo';
+ wrapper.setData({
+ searchTerm,
+ });
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
});
- it('sets the `searchTerm` graphql variable', () => {
- const SEARCH_TERM = 'Simple Incident';
+ it('updates props tied to getIncidents GraphQL query', () => {
+ wrapper.vm.handleFilterIncidents(mockFilters);
- findSearch().vm.$emit('input', SEARCH_TERM);
+ expect(wrapper.vm.authorUsername).toBe('root');
+ expect(wrapper.vm.assigneeUsernames).toEqual('root2');
+ expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
+ });
- expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM);
+ it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
+ wrapper.setData({
+ authorUsername: 'foo',
+ searchTerm: 'bar',
+ });
+
+ wrapper.vm.handleFilterIncidents([]);
+
+ expect(wrapper.vm.authorUsername).toBe('');
+ expect(wrapper.vm.searchTerm).toBe('');
});
});
@@ -383,13 +452,25 @@ describe('Incidents List', () => {
});
});
- it('updates sort with new direction and column key', () => {
- expect(findDateColumnHeader().attributes('aria-sort')).toBe('descending');
+ const descSort = 'descending';
+ const ascSort = 'ascending';
+ const noneSort = 'none';
- findDateColumnHeader().trigger('click');
- return wrapper.vm.$nextTick(() => {
- expect(findDateColumnHeader().attributes('aria-sort')).toBe('ascending');
- });
+ it.each`
+ selector | initialSort | firstSort | nextSort
+ ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort}
+ ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ `('updates sort with new direction', async ({ selector, initialSort, firstSort, nextSort }) => {
+ const [[attr, value]] = Object.entries(selector);
+ const columnHeader = () => wrapper.find(`[${attr}="${value}"]`);
+ expect(columnHeader().attributes('aria-sort')).toBe(initialSort);
+ columnHeader().trigger('click');
+ await wrapper.vm.$nextTick();
+ expect(columnHeader().attributes('aria-sort')).toBe(firstSort);
+ columnHeader().trigger('click');
+ await wrapper.vm.$nextTick();
+ expect(columnHeader().attributes('aria-sort')).toBe(nextSort);
});
});
});
diff --git a/spec/frontend/incidents/mocks/incidents_filter.json b/spec/frontend/incidents/mocks/incidents_filter.json
new file mode 100644
index 00000000000..9f54e259b1d
--- /dev/null
+++ b/spec/frontend/incidents/mocks/incidents_filter.json
@@ -0,0 +1,14 @@
+ [
+ {
+ "type": "assignee_username",
+ "value": { "data": "root2" }
+ },
+ {
+ "type": "author_username",
+ "value": { "data": "root" }
+ },
+ {
+ "type": "filtered-search-term",
+ "value": { "data": "bar" }
+ }
+ ] \ No newline at end of file
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
index cab2165b5db..e4620590e62 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
@@ -93,23 +93,20 @@ exports[`Alert integration settings form default state should match the default
</gl-form-checkbox-stub>
</gl-form-group-stub>
- <div
- class="gl-display-flex gl-justify-content-end"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="js-no-auto-disable"
+ data-qa-selector="save_changes_button"
+ icon=""
+ size="medium"
+ type="submit"
+ variant="success"
>
- <gl-button-stub
- category="primary"
- class="js-no-auto-disable"
- data-qa-selector="save_changes_button"
- icon=""
- size="medium"
- type="submit"
- variant="success"
- >
-
- Save changes
- </gl-button-stub>
- </div>
+ Save changes
+
+ </gl-button-stub>
</form>
</div>
`;
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap
index 3ad4c13382d..53c3e131466 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap
@@ -18,6 +18,7 @@ exports[`IncidentsSettingTabs should render the component 1`] = `
</h4>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-settings-toggle"
icon=""
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
index 78bb238fcb6..ea2c512bf40 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
@@ -42,24 +42,21 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
/>
</div>
- <div
- class="gl-display-flex gl-justify-content-end"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="gl-mt-3"
+ data-testid="webhook-reset-btn"
+ icon=""
+ role="button"
+ size="medium"
+ tabindex="0"
+ variant="default"
>
- <gl-button-stub
- category="primary"
- class="gl-mt-3"
- data-testid="webhook-reset-btn"
- icon=""
- role="button"
- size="medium"
- tabindex="0"
- variant="default"
- >
-
- Reset webhook URL
- </gl-button-stub>
- </div>
+ Reset webhook URL
+
+ </gl-button-stub>
<gl-modal-stub
modalclass=""
@@ -76,22 +73,19 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
</gl-modal-stub>
</gl-form-group-stub>
- <div
- class="gl-display-flex gl-justify-content-end"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="js-no-auto-disable"
+ icon=""
+ size="medium"
+ type="submit"
+ variant="success"
>
- <gl-button-stub
- category="primary"
- class="js-no-auto-disable"
- icon=""
- size="medium"
- type="submit"
- variant="success"
- >
-
- Save changes
- </gl-button-stub>
- </div>
+ Save changes
+
+ </gl-button-stub>
</form>
</div>
`;
diff --git a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
new file mode 100644
index 00000000000..02f311f579f
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
@@ -0,0 +1,51 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import { createStore } from '~/integrations/edit/store';
+
+import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
+
+describe('ConfirmationModal', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(ConfirmationModal, {
+ store: createStore(),
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findGlModal = () => wrapper.find(GlModal);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders GlModal with correct copy', () => {
+ expect(findGlModal().exists()).toBe(true);
+ expect(findGlModal().attributes('title')).toBe('Save settings?');
+ expect(findGlModal().text()).toContain(
+ 'Saving will update the default settings for all projects that are not using custom settings.',
+ );
+ expect(findGlModal().text()).toContain(
+ 'Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
+ );
+ });
+
+ it('emits `submit` event when `primary` event is emitted on GlModal', async () => {
+ expect(wrapper.emitted().submit).toBeUndefined();
+
+ findGlModal().vm.$emit('primary');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted().submit).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index eeb5d21d62c..efcc727277a 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -4,6 +4,7 @@ import { createStore } from '~/integrations/edit/store';
import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
+import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
@@ -22,6 +23,7 @@ describe('IntegrationForm', () => {
stubs: {
OverrideDropdown,
ActiveCheckbox,
+ ConfirmationModal,
JiraTriggerFields,
TriggerFields,
},
@@ -40,6 +42,7 @@ describe('IntegrationForm', () => {
const findOverrideDropdown = () => wrapper.find(OverrideDropdown);
const findActiveCheckbox = () => wrapper.find(ActiveCheckbox);
+ const findConfirmationModal = () => wrapper.find(ConfirmationModal);
const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields);
const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields);
const findTriggerFields = () => wrapper.find(TriggerFields);
@@ -63,6 +66,26 @@ describe('IntegrationForm', () => {
});
});
+ describe('integrationLevel is instance', () => {
+ it('renders ConfirmationModal', () => {
+ createComponent({
+ integrationLevel: 'instance',
+ });
+
+ expect(findConfirmationModal().exists()).toBe(true);
+ });
+ });
+
+ describe('integrationLevel is not instance', () => {
+ it('does not render ConfirmationModal', () => {
+ createComponent({
+ integrationLevel: 'project',
+ });
+
+ expect(findConfirmationModal().exists()).toBe(false);
+ });
+ });
+
describe('type is "slack"', () => {
beforeEach(() => {
createComponent({ type: 'slack' });
diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js
index 821972b7698..27ba0768331 100644
--- a/spec/frontend/integrations/edit/mock_data.js
+++ b/spec/frontend/integrations/edit/mock_data.js
@@ -2,6 +2,7 @@ export const mockIntegrationProps = {
id: 25,
initialActivated: true,
showActive: true,
+ editable: true,
triggerFieldsProps: {
initialTriggerCommit: false,
initialTriggerMergeRequest: false,
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
new file mode 100644
index 00000000000..0be0fbbde2d
--- /dev/null
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -0,0 +1,115 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink } from '@gitlab/ui';
+import Api from '~/api';
+import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
+
+const groupId = '1';
+const groupName = 'testgroup';
+const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
+const defaultAccessLevel = '10';
+const helpLink = 'https://example.com';
+
+const createComponent = () => {
+ return shallowMount(InviteMembersModal, {
+ propsData: {
+ groupId,
+ groupName,
+ accessLevels,
+ defaultAccessLevel,
+ helpLink,
+ },
+ stubs: {
+ GlSprintf,
+ 'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>',
+ },
+ });
+};
+
+describe('InviteMembersModal', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findDatepicker = () => wrapper.find(GlDatepicker);
+ const findLink = () => wrapper.find(GlLink);
+ const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
+ const findInviteButton = () => wrapper.find({ ref: 'inviteButton' });
+
+ describe('rendering the modal', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('renders the modal with the correct title', () => {
+ expect(wrapper.attributes('title')).toBe('Invite team members');
+ });
+
+ it('renders the Cancel button text correctly', () => {
+ expect(findCancelButton().text()).toBe('Cancel');
+ });
+
+ it('renders the Invite button text correctly', () => {
+ expect(findInviteButton().text()).toBe('Invite');
+ });
+
+ describe('rendering the access levels dropdown', () => {
+ it('sets the default dropdown text to the default access level name', () => {
+ expect(findDropdown().attributes('text')).toBe('Guest');
+ });
+
+ it('renders dropdown items for each accessLevel', () => {
+ expect(findDropdownItems()).toHaveLength(5);
+ });
+ });
+
+ describe('rendering the help link', () => {
+ it('renders the correct link', () => {
+ expect(findLink().attributes('href')).toBe(helpLink);
+ });
+ });
+
+ describe('rendering the access expiration date field', () => {
+ it('renders the datepicker', () => {
+ expect(findDatepicker()).toExist();
+ });
+ });
+ });
+
+ describe('submitting the invite form', () => {
+ const postData = {
+ user_id: '1',
+ access_level: '10',
+ expires_at: new Date(),
+ format: 'json',
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent();
+
+ jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
+ wrapper.vm.$toast = { show: jest.fn() };
+
+ wrapper.vm.submitForm(postData);
+ });
+
+ it('calls Api inviteGroupMember with the correct params', () => {
+ expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
+ });
+
+ describe('when the invite was sent successfully', () => {
+ const toastMessageSuccessful = 'Users were succesfully added';
+
+ it('displays the successful toastMessage', () => {
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
+ toastMessageSuccessful,
+ wrapper.vm.toastOptions,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
new file mode 100644
index 00000000000..450d37a9748
--- /dev/null
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -0,0 +1,58 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLink } from '@gitlab/ui';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+
+const displayText = 'Invite team members';
+const icon = 'plus';
+
+const createComponent = (props = {}) => {
+ return shallowMount(InviteMembersTrigger, {
+ propsData: {
+ displayText,
+ ...props,
+ },
+ });
+};
+
+describe('InviteMembersTrigger', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('displayText', () => {
+ const findLink = () => wrapper.find(GlLink);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('includes the correct displayText for the link', () => {
+ expect(findLink().text()).toBe(displayText);
+ });
+ });
+
+ describe('icon', () => {
+ const findIcon = () => wrapper.find(GlIcon);
+
+ it('includes the correct icon when an icon is sent', () => {
+ wrapper = createComponent({ icon });
+
+ expect(findIcon().attributes('name')).toBe(icon);
+ });
+
+ it('does not include an icon when icon is not sent', () => {
+ wrapper = createComponent();
+
+ expect(findIcon().exists()).toBe(false);
+ });
+
+ it('does not include an icon when empty string is sent', () => {
+ wrapper = createComponent({ icon: '' });
+
+ expect(findIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
index bfbe4ec8e70..17a195df494 100644
--- a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
+++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
@@ -48,7 +48,10 @@ describe('AddIssuableForm', () => {
const input = findFormInput(wrapper);
if (input) input.blur();
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
describe('with data', () => {
diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
index 553721fa783..f2cb9042ba6 100644
--- a/spec/frontend/issuable/related_issues/components/issue_token_spec.js
+++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
@@ -1,241 +1,146 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
import { PathIdSeparator } from '~/related_issues/constants';
-import issueToken from '~/related_issues/components/issue_token.vue';
+import IssueToken from '~/related_issues/components/issue_token.vue';
describe('IssueToken', () => {
const idKey = 200;
const displayReference = 'foo/bar#123';
- const title = 'some title';
- const pathIdSeparator = PathIdSeparator.Issue;
const eventNamespace = 'pendingIssuable';
- let IssueToken;
- let vm;
+ const path = '/foo/bar/issues/123';
+ const pathIdSeparator = PathIdSeparator.Issue;
+ const title = 'some title';
- beforeEach(() => {
- IssueToken = Vue.extend(issueToken);
- });
+ let wrapper;
+
+ const defaultProps = {
+ idKey,
+ displayReference,
+ pathIdSeparator,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(IssueToken, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
afterEach(() => {
- if (vm) {
- vm.$destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
}
});
+ const findLink = () => wrapper.find({ ref: 'link' });
+ const findReference = () => wrapper.find({ ref: 'reference' });
+ const findReferenceIcon = () => wrapper.find('[data-testid="referenceIcon"]');
+ const findRemoveBtn = () => wrapper.find('[data-testid="removeBtn"]');
+ const findTitle = () => wrapper.find({ ref: 'title' });
+
describe('with reference supplied', () => {
beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- },
- }).$mount();
+ createComponent();
});
it('shows reference', () => {
- expect(vm.$el.textContent.trim()).toEqual(displayReference);
+ expect(wrapper.text()).toContain(displayReference);
});
it('does not link without path specified', () => {
- expect(vm.$refs.link.tagName.toLowerCase()).toEqual('span');
- expect(vm.$refs.link.getAttribute('href')).toBeNull();
+ expect(findLink().element.tagName).toBe('SPAN');
+ expect(findLink().attributes('href')).toBeUndefined();
});
});
describe('with reference and title supplied', () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- title,
- },
- }).$mount();
- });
-
it('shows reference and title', () => {
- expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
- expect(vm.$refs.title.textContent.trim()).toEqual(title);
- });
- });
-
- describe('with path supplied', () => {
- const path = '/foo/bar/issues/123';
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- title,
- path,
- },
- }).$mount();
- });
+ createComponent({
+ title,
+ });
- it('links reference and title', () => {
- expect(vm.$refs.link.getAttribute('href')).toEqual(path);
+ expect(findReference().text()).toBe(displayReference);
+ expect(findTitle().text()).toBe(title);
});
});
- describe('with state supplied', () => {
- describe("`state: 'opened'`", () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- state: 'opened',
- },
- }).$mount();
+ describe('with path and title supplied', () => {
+ it('links reference and title', () => {
+ createComponent({
+ path,
+ title,
});
- it('shows green circle icon', () => {
- expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined();
- });
- });
-
- describe("`state: 'reopened'`", () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- state: 'reopened',
- },
- }).$mount();
- });
-
- it('shows green circle icon', () => {
- expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined();
- });
+ expect(findLink().attributes('href')).toBe(path);
});
+ });
- describe("`state: 'closed'`", () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- state: 'closed',
- },
- }).$mount();
+ describe('with state supplied', () => {
+ it.each`
+ state | icon | cssClass
+ ${'opened'} | ${'issue-open-m'} | ${'issue-token-state-icon-open'}
+ ${'reopened'} | ${'issue-open-m'} | ${'issue-token-state-icon-open'}
+ ${'closed'} | ${'issue-close'} | ${'issue-token-state-icon-closed'}
+ `('shows "$icon" icon when "$state"', ({ state, icon, cssClass }) => {
+ createComponent({
+ path,
+ state,
});
- it('shows red minus icon', () => {
- expect(vm.$el.querySelector('.issue-token-state-icon-closed.fa.fa-minus')).toBeDefined();
- });
+ expect(findReferenceIcon().props('name')).toBe(icon);
+ expect(findReferenceIcon().classes()).toContain(cssClass);
});
});
describe('with reference, title, state', () => {
const state = 'opened';
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- title,
- state,
- },
- }).$mount();
- });
it('shows reference, title, and state', () => {
- const stateIcon = vm.$refs.reference.querySelector('svg');
+ createComponent({
+ title,
+ state,
+ });
- expect(stateIcon.getAttribute('aria-label')).toEqual(state);
- expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
- expect(vm.$refs.title.textContent.trim()).toEqual(title);
+ expect(findReferenceIcon().attributes('aria-label')).toBe(state);
+ expect(findReference().text()).toBe(displayReference);
+ expect(findTitle().text()).toBe(title);
});
});
describe('with canRemove', () => {
describe('`canRemove: false` (default)', () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- },
- }).$mount();
- });
-
it('does not have remove button', () => {
- expect(vm.$el.querySelector('.issue-token-remove-button')).toBeNull();
+ createComponent();
+
+ expect(findRemoveBtn().exists()).toBe(false);
});
});
describe('`canRemove: true`', () => {
beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- canRemove: true,
- },
- }).$mount();
+ createComponent({
+ eventNamespace,
+ canRemove: true,
+ });
});
it('has remove button', () => {
- expect(vm.$el.querySelector('.issue-token-remove-button')).toBeDefined();
+ expect(findRemoveBtn().exists()).toBe(true);
});
- });
- });
-
- describe('methods', () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- },
- }).$mount();
- });
- it('when getting checked', () => {
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
- vm.onRemoveRequest();
+ it('emits event when clicked', () => {
+ findRemoveBtn().trigger('click');
- expect(vm.$emit).toHaveBeenCalledWith('pendingIssuableRemoveRequest', vm.idKey);
- });
- });
+ const emitted = wrapper.emitted(`${eventNamespace}RemoveRequest`);
- describe('tooltip', () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- canRemove: true,
- },
- }).$mount();
- });
-
- it('should not be escaped', () => {
- const { originalTitle } = vm.$refs.removeButton.dataset;
+ expect(emitted).toHaveLength(1);
+ expect(emitted[0]).toEqual([idKey]);
+ });
- expect(originalTitle).toEqual(`Remove ${displayReference}`);
+ it('tooltip should not be escaped', () => {
+ expect(findRemoveBtn().attributes('data-original-title')).toBe(
+ `Remove ${displayReference}`,
+ );
+ });
});
});
});
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
index 0f88e4d71fe..b758b85beef 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -18,7 +18,10 @@ describe('RelatedIssuesBlock', () => {
const findIssueCountBadgeAddButton = () => wrapper.find(GlButton);
afterEach(() => {
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
describe('with defaults', () => {
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
index 6cf0b9d21ea..39bc244297b 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
@@ -14,7 +14,10 @@ describe('RelatedIssuesList', () => {
let wrapper;
afterEach(() => {
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
describe('with defaults', () => {
diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/issuable_create/components/issuable_form_spec.js
index e2c6b4d9521..e489d1dae3e 100644
--- a/spec/frontend/issuable_create/components/issuable_form_spec.js
+++ b/spec/frontend/issuable_create/components/issuable_form_spec.js
@@ -79,6 +79,7 @@ describe('IssuableForm', () => {
markdownDocsPath: wrapper.vm.descriptionHelpPath,
addSpacingClasses: false,
showSuggestPopover: true,
+ textareaValue: '',
});
expect(descriptionFieldEl.find('textarea').exists()).toBe(true);
expect(descriptionFieldEl.find('textarea').attributes('placeholder')).toBe(
diff --git a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
index 8d50df5e406..766a27015bb 100644
--- a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
+++ b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
@@ -9,6 +9,7 @@ describe('Highlight Bar', () => {
let wrapper;
const alert = {
+ iid: 1,
startedAt: '2020-05-29T10:39:22Z',
detailsUrl: 'http://127.0.0.1:3000/root/unique-alerts/-/alert_management/1/details',
eventCount: 1,
@@ -39,7 +40,8 @@ describe('Highlight Bar', () => {
it('renders a link to the alert page', () => {
expect(findLink().exists()).toBe(true);
expect(findLink().attributes('href')).toBe(alert.detailsUrl);
- expect(findLink().text()).toContain(alert.title);
+ expect(findLink().attributes('title')).toBe(alert.title);
+ expect(findLink().text()).toBe(`#${alert.iid}`);
});
it('renders formatted start time of the alert', () => {
diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
index a51b497cd79..6babba37b57 100644
--- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
+++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
@@ -79,7 +79,7 @@ describe('Incident Tabs component', () => {
it('renders the alert details table with the correct props', () => {
const alert = { iid: mockAlert.iid };
- expect(findAlertDetailsComponent().props('alert')).toEqual(alert);
+ expect(findAlertDetailsComponent().props('alert')).toMatchObject(alert);
expect(findAlertDetailsComponent().props('loading')).toBe(true);
});
diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js
index befb670c6cd..c0175e774a2 100644
--- a/spec/frontend/issue_show/issue_spec.js
+++ b/spec/frontend/issue_show/issue_spec.js
@@ -14,12 +14,8 @@ useMockIntersectionObserver();
jest.mock('~/lib/utils/poll');
const setupHTML = initialData => {
- document.body.innerHTML = `
- <div id="js-issuable-app"></div>
- <script id="js-issuable-app-initial-data" type="application/json">
- ${JSON.stringify(initialData)}
- </script>
- `;
+ document.body.innerHTML = `<div id="js-issuable-app"></div>`;
+ document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(initialData);
};
describe('Issue show index', () => {
diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js
index 294f88bbc74..e50d304bb08 100644
--- a/spec/frontend/jobs/store/utils_spec.js
+++ b/spec/frontend/jobs/store/utils_spec.js
@@ -35,6 +35,14 @@ describe('Jobs Store Utils', () => {
lines: [],
});
});
+
+ it('pre-closes a section when specified in options', () => {
+ const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } };
+
+ const parsedHeaderLine = parseHeaderLine(headerLine, 2);
+
+ expect(parsedHeaderLine.isClosed).toBe(true);
+ });
});
describe('parseLine', () => {
diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js
new file mode 100644
index 00000000000..ee1971a4931
--- /dev/null
+++ b/spec/frontend/lib/dompurify_spec.js
@@ -0,0 +1,98 @@
+import { sanitize } from '~/lib/dompurify';
+
+// GDK
+const rootGon = {
+ sprite_file_icons: '/assets/icons-123a.svg',
+ sprite_icons: '/assets/icons-456b.svg',
+};
+
+// Production
+const absoluteGon = {
+ sprite_file_icons: `${window.location.protocol}//${window.location.hostname}/assets/icons-123a.svg`,
+ sprite_icons: `${window.location.protocol}//${window.location.hostname}/assets/icons-456b.svg`,
+};
+
+const expectedSanitized = '<svg><use></use></svg>';
+
+const safeUrls = {
+ root: Object.values(rootGon).map(url => `${url}#ellipsis_h`),
+ absolute: Object.values(absoluteGon).map(url => `${url}#ellipsis_h`),
+};
+
+const unsafeUrls = [
+ '/an/evil/url',
+ '../../../evil/url',
+ 'https://evil.url/assets/icons-123a.svg',
+ 'https://evil.url/assets/icons-456b.svg',
+ `https://evil.url/${rootGon.sprite_icons}`,
+ `https://evil.url/${rootGon.sprite_file_icons}`,
+ `https://evil.url/${absoluteGon.sprite_icons}`,
+ `https://evil.url/${absoluteGon.sprite_file_icons}`,
+];
+
+describe('~/lib/dompurify', () => {
+ let originalGon;
+
+ it('uses local configuration when given', () => {
+ // As dompurify uses a "Persistent Configuration", it might
+ // ignore config, this check verifies we respect
+ // https://github.com/cure53/DOMPurify#persistent-configuration
+ expect(sanitize('<br>', { ALLOWED_TAGS: [] })).toBe('');
+ expect(sanitize('<strong></strong>', { ALLOWED_TAGS: [] })).toBe('');
+ });
+
+ describe.each`
+ type | gon
+ ${'root'} | ${rootGon}
+ ${'absolute'} | ${absoluteGon}
+ `('when gon contains $type icon urls', ({ type, gon }) => {
+ beforeAll(() => {
+ originalGon = window.gon;
+ window.gon = gon;
+ });
+
+ afterAll(() => {
+ window.gon = originalGon;
+ });
+
+ it('allows no href attrs', () => {
+ const htmlHref = `<svg><use></use></svg>`;
+ expect(sanitize(htmlHref)).toBe(htmlHref);
+ });
+
+ it.each(safeUrls[type])('allows safe URL %s', url => {
+ const htmlHref = `<svg><use href="${url}"></use></svg>`;
+ expect(sanitize(htmlHref)).toBe(htmlHref);
+
+ const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`;
+ expect(sanitize(htmlXlink)).toBe(htmlXlink);
+ });
+
+ it.each(unsafeUrls)('sanitizes unsafe URL %s', url => {
+ const htmlHref = `<svg><use href="${url}"></use></svg>`;
+ const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`;
+
+ expect(sanitize(htmlHref)).toBe(expectedSanitized);
+ expect(sanitize(htmlXlink)).toBe(expectedSanitized);
+ });
+ });
+
+ describe('when gon does not contain icon urls', () => {
+ beforeAll(() => {
+ originalGon = window.gon;
+ window.gon = {};
+ });
+
+ afterAll(() => {
+ window.gon = originalGon;
+ });
+
+ it.each([...safeUrls.root, ...safeUrls.absolute, ...unsafeUrls])('sanitizes URL %s', url => {
+ const htmlHref = `<svg><use href="${url}"></use></svg>`;
+ const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`;
+
+ expect(sanitize(htmlHref)).toBe(expectedSanitized);
+ expect(sanitize(htmlXlink)).toBe(expectedSanitized);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/axios_startup_calls_spec.js b/spec/frontend/lib/utils/axios_startup_calls_spec.js
index e804cae7914..e12bf725560 100644
--- a/spec/frontend/lib/utils/axios_startup_calls_spec.js
+++ b/spec/frontend/lib/utils/axios_startup_calls_spec.js
@@ -111,21 +111,44 @@ describe('setupAxiosStartupCalls', () => {
});
});
- it('removes GitLab Base URL from startup call', async () => {
- const oldGon = window.gon;
- window.gon = { gitlab_url: 'https://example.org/gitlab' };
-
- window.gl.startup_calls = {
- '/startup': {
- fetchCall: mockFetchCall(200),
- },
- };
- setupAxiosStartupCalls(axios);
+ describe('startup call', () => {
+ let oldGon;
+
+ beforeEach(() => {
+ oldGon = window.gon;
+ window.gon = { gitlab_url: 'https://example.org/gitlab' };
+ });
+
+ afterEach(() => {
+ window.gon = oldGon;
+ });
- const { data } = await axios.get('https://example.org/gitlab/startup');
+ it('removes GitLab Base URL from startup call', async () => {
+ window.gl.startup_calls = {
+ '/startup': {
+ fetchCall: mockFetchCall(200),
+ },
+ };
+ setupAxiosStartupCalls(axios);
- expect(data).toEqual(STARTUP_JS_RESPONSE);
+ const { data } = await axios.get('https://example.org/gitlab/startup');
- window.gon = oldGon;
+ expect(data).toEqual(STARTUP_JS_RESPONSE);
+ });
+
+ it('sorts the params in the requested API url', async () => {
+ window.gl.startup_calls = {
+ '/startup?alpha=true&bravo=true': {
+ fetchCall: mockFetchCall(200),
+ },
+ };
+ setupAxiosStartupCalls(axios);
+
+ // Use a full url instead of passing options = { params: { ... } } to axios.get
+ // to ensure the params are listed in the specified order.
+ const { data } = await axios.get('https://example.org/gitlab/startup?bravo=true&alpha=true');
+
+ expect(data).toEqual(STARTUP_JS_RESPONSE);
+ });
});
});
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 5b1fdea058b..a7973d66b50 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -667,3 +667,26 @@ describe('differenceInMilliseconds', () => {
expect(datetimeUtility.differenceInMilliseconds(startDate, endDate)).toBe(expected);
});
});
+
+describe('dateAtFirstDayOfMonth', () => {
+ const date = new Date('2019-07-16T12:00:00.000Z');
+
+ it('returns the date at the first day of the month', () => {
+ const startDate = datetimeUtility.dateAtFirstDayOfMonth(date);
+ const expectedStartDate = new Date('2019-07-01T12:00:00.000Z');
+
+ expect(startDate).toStrictEqual(expectedStartDate);
+ });
+});
+
+describe('datesMatch', () => {
+ const date = new Date('2019-07-17T00:00:00.000Z');
+
+ it.each`
+ date1 | date2 | expected
+ ${date} | ${new Date('2019-07-17T00:00:00.000Z')} | ${true}
+ ${date} | ${new Date('2019-07-17T12:00:00.000Z')} | ${false}
+ `('returns $expected for $date1 matches $date2', ({ date1, date2, expected }) => {
+ expect(datetimeUtility.datesMatch(date1, date2)).toBe(expected);
+ });
+});
diff --git a/spec/frontend/lib/utils/experimentation_spec.js b/spec/frontend/lib/utils/experimentation_spec.js
new file mode 100644
index 00000000000..2c5d2f89297
--- /dev/null
+++ b/spec/frontend/lib/utils/experimentation_spec.js
@@ -0,0 +1,20 @@
+import * as experimentUtils from '~/lib/utils/experimentation';
+
+const TEST_KEY = 'abc';
+
+describe('experiment Utilities', () => {
+ describe('isExperimentEnabled', () => {
+ it.each`
+ experiments | value
+ ${{ [TEST_KEY]: true }} | ${true}
+ ${{ [TEST_KEY]: false }} | ${false}
+ ${{ def: true }} | ${false}
+ ${{}} | ${false}
+ ${null} | ${false}
+ `('returns correct value of $value for experiments=$experiments', ({ experiments, value }) => {
+ window.gon = { experiments };
+
+ expect(experimentUtils.isExperimentEnabled(TEST_KEY)).toEqual(value);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 869ae274a3f..2afc1694281 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -664,6 +664,19 @@ describe('URL utility', () => {
});
});
+ describe('cleanLeadingSeparator', () => {
+ it.each`
+ path | expected
+ ${'/foo/bar'} | ${'foo/bar'}
+ ${'foo/bar'} | ${'foo/bar'}
+ ${'//foo/bar'} | ${'foo/bar'}
+ ${'/./foo/bar'} | ${'./foo/bar'}
+ ${''} | ${''}
+ `('$path becomes $expected', ({ path, expected }) => {
+ expect(urlUtils.cleanLeadingSeparator(path)).toBe(expected);
+ });
+ });
+
describe('joinPaths', () => {
it.each`
paths | expected
@@ -688,6 +701,18 @@ describe('URL utility', () => {
});
});
+ describe('stripFinalUrlSegment', () => {
+ it.each`
+ path | expected
+ ${'http://fake.domain/twitter/typeahead-js/-/tags/v0.11.0'} | ${'http://fake.domain/twitter/typeahead-js/-/tags/'}
+ ${'http://fake.domain/bar/cool/-/nested/content'} | ${'http://fake.domain/bar/cool/-/nested/'}
+ ${'http://fake.domain/bar/cool?q="search"'} | ${'http://fake.domain/bar/'}
+ ${'http://fake.domain/bar/cool#link-to-something'} | ${'http://fake.domain/bar/'}
+ `('stripFinalUrlSegment $path => $expected', ({ path, expected }) => {
+ expect(urlUtils.stripFinalUrlSegment(path)).toBe(expected);
+ });
+ });
+
describe('escapeFileUrl', () => {
it('encodes URL excluding the slashes', () => {
expect(urlUtils.escapeFileUrl('/foo-bar/file.md')).toBe('/foo-bar/file.md');
@@ -787,4 +812,36 @@ describe('URL utility', () => {
expect(urlUtils.getHTTPProtocol(url)).toBe(expectation);
});
});
+
+ describe('stripPathTail', () => {
+ it.each`
+ path | expected
+ ${''} | ${''}
+ ${'index.html'} | ${''}
+ ${'/'} | ${'/'}
+ ${'/foo/bar'} | ${'/foo/'}
+ ${'/foo/bar/'} | ${'/foo/bar/'}
+ ${'/foo/bar/index.html'} | ${'/foo/bar/'}
+ `('strips the filename from $path => $expected', ({ path, expected }) => {
+ expect(urlUtils.stripPathTail(path)).toBe(expected);
+ });
+ });
+
+ describe('getURLOrigin', () => {
+ it('when no url passed, returns correct origin from window location', () => {
+ const origin = 'https://foo.bar';
+
+ setWindowLocation({ origin });
+ expect(urlUtils.getURLOrigin()).toBe(origin);
+ });
+
+ it.each`
+ url | expectation
+ ${'not-a-url'} | ${null}
+ ${'wss://example.com'} | ${'wss://example.com'}
+ ${'https://foo.bar/foo/bar'} | ${'https://foo.bar'}
+ `('returns correct origin for $url', ({ url, expectation }) => {
+ expect(urlUtils.getURLOrigin(url)).toBe(expectation);
+ });
+ });
});
diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js
index 16f04d032fd..37509f77f71 100644
--- a/spec/frontend/merge_request_spec.js
+++ b/spec/frontend/merge_request_spec.js
@@ -3,8 +3,6 @@ import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
import MergeRequest from '~/merge_request';
-import CloseReopenReportToggle from '~/close_reopen_report_toggle';
-import IssuablesHelper from '~/helpers/issuables_helper';
describe('MergeRequest', () => {
const test = {};
@@ -112,66 +110,7 @@ describe('MergeRequest', () => {
});
});
- describe('class constructor', () => {
- beforeEach(() => {
- jest.spyOn($, 'ajax').mockImplementation();
- });
-
- it('calls .initCloseReopenReport', () => {
- jest.spyOn(IssuablesHelper, 'initCloseReopenReport').mockImplementation(() => {});
-
- new MergeRequest(); // eslint-disable-line no-new
-
- expect(IssuablesHelper.initCloseReopenReport).toHaveBeenCalled();
- });
-
- it('calls .initDroplab', () => {
- const container = {
- querySelector: jest.fn().mockName('container.querySelector'),
- };
- const dropdownTrigger = {};
- const dropdownList = {};
- const button = {};
-
- jest.spyOn(CloseReopenReportToggle.prototype, 'initDroplab').mockImplementation(() => {});
- jest.spyOn(document, 'querySelector').mockReturnValue(container);
-
- container.querySelector
- .mockReturnValueOnce(dropdownTrigger)
- .mockReturnValueOnce(dropdownList)
- .mockReturnValueOnce(button);
-
- new MergeRequest(); // eslint-disable-line no-new
-
- expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown');
- expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle');
- expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu');
- expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button');
- expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled();
- });
- });
-
describe('hideCloseButton', () => {
- describe('merge request of another user', () => {
- beforeEach(() => {
- loadFixtures('merge_requests/merge_request_with_task_list.html');
- test.el = document.querySelector('.js-issuable-actions');
- new MergeRequest(); // eslint-disable-line no-new
- MergeRequest.hideCloseButton();
- });
-
- it('hides the dropdown close item and selects the next item', () => {
- const closeItem = test.el.querySelector('li.close-item');
- const smallCloseItem = test.el.querySelector('.js-close-item');
- const reportItem = test.el.querySelector('li.report-item');
-
- expect(closeItem).toHaveClass('hidden');
- expect(smallCloseItem).toHaveClass('hidden');
- expect(reportItem).toHaveClass('droplab-item-selected');
- expect(reportItem).not.toHaveClass('hidden');
- });
- });
-
describe('merge request of current_user', () => {
beforeEach(() => {
loadFixtures('merge_requests/merge_request_of_current_user.html');
@@ -180,10 +119,8 @@ describe('MergeRequest', () => {
});
it('hides the close button', () => {
- const closeButton = test.el.querySelector('.btn-close');
const smallCloseItem = test.el.querySelector('.js-close-item');
- expect(closeButton).toHaveClass('hidden');
expect(smallCloseItem).toHaveClass('hidden');
});
});
diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
index c30fb572826..9b2aa3a5b5b 100644
--- a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
@@ -1,79 +1,146 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`GroupEmptyState Renders an empty state for BAD_QUERY 1`] = `
-<gl-empty-state-stub
- compact="true"
- primarybuttonlink="/path/to/settings"
- primarybuttontext="Verify configuration"
- svgpath="/path/to/empty-group-illustration.svg"
- title="Query cannot be processed"
-/>
+exports[`GroupEmptyState given state BAD_QUERY passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": null,
+ "primaryButtonLink": "/path/to/settings",
+ "primaryButtonText": "Verify configuration",
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "Query cannot be processed",
+}
`;
-exports[`GroupEmptyState Renders an empty state for BAD_QUERY 2`] = `"The Prometheus server responded with \\"bad request\\". Please check your queries are correct and are supported in your Prometheus version. <a href=\\"/path/to/docs\\">More information</a>"`;
+exports[`GroupEmptyState given state BAD_QUERY renders the slotted content 1`] = `
+<div>
+ <div>
+ The Prometheus server responded with "bad request". Please check your queries are correct and are supported in your Prometheus version.
+ <a
+ href="/path/to/docs"
+ >
+ More information
+ </a>
+ </div>
+</div>
+`;
-exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 1`] = `
-<gl-empty-state-stub
- compact="true"
- description="We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating."
- primarybuttonlink="/path/to/settings"
- primarybuttontext="Verify configuration"
- svgpath="/path/to/empty-group-illustration.svg"
- title="Connection failed"
-/>
+exports[`GroupEmptyState given state CONNECTION_FAILED passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating.",
+ "primaryButtonLink": "/path/to/settings",
+ "primaryButtonText": "Verify configuration",
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "Connection failed",
+}
`;
-exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 2`] = `undefined`;
+exports[`GroupEmptyState given state CONNECTION_FAILED renders the slotted content 1`] = `<div />`;
-exports[`GroupEmptyState Renders an empty state for FOO STATE 1`] = `
-<gl-empty-state-stub
- compact="true"
- description="An error occurred while loading the data. Please try again."
- svgpath="/path/to/empty-group-illustration.svg"
- title="An error has occurred"
-/>
+exports[`GroupEmptyState given state FOO STATE passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": "An error occurred while loading the data. Please try again.",
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "An error has occurred",
+}
`;
-exports[`GroupEmptyState Renders an empty state for FOO STATE 2`] = `undefined`;
+exports[`GroupEmptyState given state FOO STATE renders the slotted content 1`] = `<div />`;
-exports[`GroupEmptyState Renders an empty state for LOADING 1`] = `
-<gl-empty-state-stub
- compact="true"
- description="Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available."
- svgpath="/path/to/empty-group-illustration.svg"
- title="Waiting for performance data"
-/>
+exports[`GroupEmptyState given state LOADING passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.",
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "Waiting for performance data",
+}
`;
-exports[`GroupEmptyState Renders an empty state for LOADING 2`] = `undefined`;
+exports[`GroupEmptyState given state LOADING renders the slotted content 1`] = `<div />`;
-exports[`GroupEmptyState Renders an empty state for NO_DATA 1`] = `
-<gl-empty-state-stub
- compact="true"
- svgpath="/path/to/empty-group-illustration.svg"
- title="No data to display"
-/>
+exports[`GroupEmptyState given state NO_DATA passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": null,
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "No data to display",
+}
`;
-exports[`GroupEmptyState Renders an empty state for NO_DATA 2`] = `"The data source is connected, but there is no data to display. <a href=\\"/path/to/docs\\">More information</a>"`;
+exports[`GroupEmptyState given state NO_DATA renders the slotted content 1`] = `
+<div>
+ <div>
+ The data source is connected, but there is no data to display.
+ <a
+ href="/path/to/docs"
+ >
+ More information
+ </a>
+ </div>
+</div>
+`;
-exports[`GroupEmptyState Renders an empty state for TIMEOUT 1`] = `
-<gl-empty-state-stub
- compact="true"
- svgpath="/path/to/empty-group-illustration.svg"
- title="Connection timed out"
-/>
+exports[`GroupEmptyState given state TIMEOUT passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": null,
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "Connection timed out",
+}
`;
-exports[`GroupEmptyState Renders an empty state for TIMEOUT 2`] = `"Charts can't be displayed as the request for data has timed out. <a href=\\"/path/to/docs\\">More information</a>"`;
+exports[`GroupEmptyState given state TIMEOUT renders the slotted content 1`] = `
+<div>
+ <div>
+ Charts can't be displayed as the request for data has timed out.
+ <a
+ href="/path/to/docs"
+ >
+ More information
+ </a>
+ </div>
+</div>
+`;
-exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 1`] = `
-<gl-empty-state-stub
- compact="true"
- description="An error occurred while loading the data. Please try again."
- svgpath="/path/to/empty-group-illustration.svg"
- title="An error has occurred"
-/>
+exports[`GroupEmptyState given state UNKNOWN_ERROR passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": "An error occurred while loading the data. Please try again.",
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "An error has occurred",
+}
`;
-exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 2`] = `undefined`;
+exports[`GroupEmptyState given state UNKNOWN_ERROR renders the slotted content 1`] = `<div />`;
diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js
index 90bd6f67196..3b94c4c6806 100644
--- a/spec/frontend/monitoring/components/group_empty_state_spec.js
+++ b/spec/frontend/monitoring/components/group_empty_state_spec.js
@@ -1,7 +1,13 @@
+import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import GroupEmptyState from '~/monitoring/components/group_empty_state.vue';
import { metricStates } from '~/monitoring/constants';
+const MockGlEmptyState = {
+ props: GlEmptyState.props,
+ template: '<div><slot name="description"></slot></div>',
+};
+
function createComponent(props) {
return shallowMount(GroupEmptyState, {
propsData: {
@@ -10,11 +16,20 @@ function createComponent(props) {
settingsPath: '/path/to/settings',
svgPath: '/path/to/empty-group-illustration.svg',
},
+ stubs: {
+ GlEmptyState: MockGlEmptyState,
+ },
});
}
describe('GroupEmptyState', () => {
- const supportedStates = [
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe.each([
metricStates.NO_DATA,
metricStates.TIMEOUT,
metricStates.CONNECTION_FAILED,
@@ -22,13 +37,17 @@ describe('GroupEmptyState', () => {
metricStates.LOADING,
metricStates.UNKNOWN_ERROR,
'FOO STATE', // does not fail with unknown states
- ];
+ ])('given state %s', selectedState => {
+ beforeEach(() => {
+ wrapper = createComponent({ selectedState });
+ });
- it.each(supportedStates)('Renders an empty state for %s', selectedState => {
- const wrapper = createComponent({ selectedState });
+ it('renders the slotted content', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
- expect(wrapper.element).toMatchSnapshot();
- // slot is not rendered by the stub, test it separately
- expect(wrapper.vm.currentState.slottedDescription).toMatchSnapshot();
+ it('passes the expected props to GlEmptyState', () => {
+ expect(wrapper.find(MockGlEmptyState).props()).toMatchSnapshot();
+ });
});
});
diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js
index affd6c1d1d2..d82590c7e9e 100644
--- a/spec/frontend/notes/components/discussion_counter_spec.js
+++ b/spec/frontend/notes/components/discussion_counter_spec.js
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import notesModule from '~/notes/stores/modules';
import DiscussionCounter from '~/notes/components/discussion_counter.vue';
import { noteableDataMock, discussionMock, notesDataMock, userDataMock } from '../mock_data';
@@ -9,6 +9,7 @@ import * as types from '~/notes/stores/mutation_types';
describe('DiscussionCounter component', () => {
let store;
let wrapper;
+ let setExpandDiscussionsFn;
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -16,6 +17,7 @@ describe('DiscussionCounter component', () => {
beforeEach(() => {
window.mrTabs = {};
const { state, getters, mutations, actions } = notesModule();
+ setExpandDiscussionsFn = jest.fn().mockImplementation(actions.setExpandDiscussions);
store = new Vuex.Store({
state: {
@@ -24,7 +26,10 @@ describe('DiscussionCounter component', () => {
},
getters,
mutations,
- actions,
+ actions: {
+ ...actions,
+ setExpandDiscussions: setExpandDiscussionsFn,
+ },
});
store.dispatch('setNoteableData', {
...noteableDataMock,
@@ -84,7 +89,7 @@ describe('DiscussionCounter component', () => {
wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find(`.is-active`).exists()).toBe(isActive);
- expect(wrapper.findAll('[role="group"').length).toBe(groupLength);
+ expect(wrapper.findAll(GlButton)).toHaveLength(groupLength);
});
});
@@ -103,23 +108,22 @@ describe('DiscussionCounter component', () => {
it('calls button handler when clicked', () => {
updateStoreWithExpanded(true);
- wrapper.setMethods({ handleExpandDiscussions: jest.fn() });
- toggleAllButton.trigger('click');
+ toggleAllButton.vm.$emit('click');
- expect(wrapper.vm.handleExpandDiscussions).toHaveBeenCalledTimes(1);
+ expect(setExpandDiscussionsFn).toHaveBeenCalledTimes(1);
});
it('collapses all discussions if expanded', () => {
updateStoreWithExpanded(true);
expect(wrapper.vm.allExpanded).toBe(true);
- expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up');
+ expect(toggleAllButton.props('icon')).toBe('angle-up');
- toggleAllButton.trigger('click');
+ toggleAllButton.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.allExpanded).toBe(false);
- expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down');
+ expect(toggleAllButton.props('icon')).toBe('angle-down');
});
});
@@ -127,13 +131,13 @@ describe('DiscussionCounter component', () => {
updateStoreWithExpanded(false);
expect(wrapper.vm.allExpanded).toBe(false);
- expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down');
+ expect(toggleAllButton.props('icon')).toBe('angle-down');
- toggleAllButton.trigger('click');
+ toggleAllButton.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.allExpanded).toBe(true);
- expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up');
+ expect(toggleAllButton.props('icon')).toBe('angle-up');
});
});
});
diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js
index 91ff796b9de..e3e3518fd31 100644
--- a/spec/frontend/notes/components/discussion_filter_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_spec.js
@@ -74,13 +74,15 @@ describe('DiscussionFilter component', () => {
});
it('renders the all filters', () => {
- expect(wrapper.findAll('.dropdown-menu li').length).toBe(discussionFiltersMock.length);
+ expect(wrapper.findAll('.discussion-filter-container .dropdown-item').length).toBe(
+ discussionFiltersMock.length,
+ );
});
it('renders the default selected item', () => {
expect(
wrapper
- .find('#discussion-filter-dropdown')
+ .find('#discussion-filter-dropdown .dropdown-item')
.text()
.trim(),
).toBe(discussionFiltersMock[0].title);
@@ -88,7 +90,7 @@ describe('DiscussionFilter component', () => {
it('updates to the selected item', () => {
const filterItem = wrapper.find(
- `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`,
+ `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"]`,
);
filterItem.trigger('click');
@@ -98,7 +100,9 @@ describe('DiscussionFilter component', () => {
it('only updates when selected filter changes', () => {
wrapper
- .find(`.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`)
+ .find(
+ `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"]`,
+ )
.trigger('click');
expect(filterDiscussion).not.toHaveBeenCalled();
@@ -106,7 +110,7 @@ describe('DiscussionFilter component', () => {
it('disables commenting when "Show history only" filter is applied', () => {
const filterItem = wrapper.find(
- `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`,
+ `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"]`,
);
filterItem.trigger('click');
@@ -115,7 +119,7 @@ describe('DiscussionFilter component', () => {
it('enables commenting when "Show history only" filter is not applied', () => {
const filterItem = wrapper.find(
- `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`,
+ `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"]`,
);
filterItem.trigger('click');
@@ -124,10 +128,10 @@ describe('DiscussionFilter component', () => {
it('renders a dropdown divider for the default filter', () => {
const defaultFilter = wrapper.findAll(
- `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] > *`,
+ `.discussion-filter-container .dropdown-item-wrapper > *`,
);
- expect(defaultFilter.at(defaultFilter.length - 1).classes('dropdown-divider')).toBe(true);
+ expect(defaultFilter.at(1).classes('gl-new-dropdown-divider')).toBe(true);
});
describe('Merge request tabs', () => {
diff --git a/spec/frontend/notes/components/sort_discussion_spec.js b/spec/frontend/notes/components/sort_discussion_spec.js
index 575f1057db2..49b85d60a27 100644
--- a/spec/frontend/notes/components/sort_discussion_spec.js
+++ b/spec/frontend/notes/components/sort_discussion_spec.js
@@ -55,7 +55,7 @@ describe('Sort Discussion component', () => {
it('calls the right actions', () => {
createComponent();
- wrapper.find('.js-newest-first').trigger('click');
+ wrapper.find('.js-newest-first').vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', DESC);
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'change_discussion_sort_direction', {
@@ -67,7 +67,7 @@ describe('Sort Discussion component', () => {
it('shows the "Oldest First" as the dropdown', () => {
createComponent();
- expect(wrapper.find('.js-dropdown-text').text()).toBe('Oldest first');
+ expect(wrapper.find('.js-dropdown-text').props('text')).toBe('Oldest first');
});
});
@@ -79,7 +79,7 @@ describe('Sort Discussion component', () => {
describe('when the dropdown item is clicked', () => {
it('calls the right actions', () => {
- wrapper.find('.js-oldest-first').trigger('click');
+ wrapper.find('.js-oldest-first').vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', ASC);
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'change_discussion_sort_direction', {
@@ -87,13 +87,13 @@ describe('Sort Discussion component', () => {
});
});
- it('applies the active class to the correct button in the dropdown', () => {
- expect(wrapper.find('.js-newest-first').classes()).toContain('is-active');
+ it('sets is-checked to true on the active button in the dropdown', () => {
+ expect(wrapper.find('.js-newest-first').props('isChecked')).toBe(true);
});
});
it('shows the "Newest First" as the dropdown', () => {
- expect(wrapper.find('.js-dropdown-text').text()).toBe('Newest first');
+ expect(wrapper.find('.js-dropdown-text').props('text')).toBe('Newest first');
});
});
});
diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
index 4d9e0af1545..d317264bdae 100644
--- a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
@@ -2,151 +2,163 @@
exports[`PackageTitle renders with tags 1`] = `
<div
- class="gl-display-flex gl-justify-content-space-between gl-py-3"
+ class="gl-display-flex gl-flex-direction-column"
data-qa-selector="package_title"
>
<div
- class="gl-flex-direction-column"
+ class="gl-display-flex gl-justify-content-space-between gl-py-3"
>
<div
- class="gl-display-flex"
+ class="gl-flex-direction-column"
>
- <!---->
-
<div
- class="gl-display-flex gl-flex-direction-column"
+ class="gl-display-flex"
>
- <h1
- class="gl-font-size-h1 gl-mt-3 gl-mb-2"
- data-testid="title"
- >
- Test package
- </h1>
+ <!---->
<div
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ class="gl-display-flex gl-flex-direction-column"
>
- <gl-icon-stub
- class="gl-mr-3"
- name="eye"
- size="16"
- />
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ data-testid="title"
+ >
+ Test package
+ </h1>
- <gl-sprintf-stub
- message="v%{version} published %{timeAgo}"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="eye"
+ size="16"
+ />
+
+ <gl-sprintf-stub
+ message="v%{version} published %{timeAgo}"
+ />
+ </div>
</div>
</div>
- </div>
-
- <div
- class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
- >
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-type"
- icon="package"
- link=""
- size="s"
- text="maven"
- />
- </div>
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-size"
- icon="disk"
- link=""
- size="s"
- text="300 bytes"
- />
- </div>
+
<div
- class="gl-display-flex gl-align-items-center gl-mr-5"
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
>
- <package-tags-stub
- hidelabel="true"
- tagdisplaylimit="2"
- tags="[object Object],[object Object],[object Object],[object Object]"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <metadata-item-stub
+ data-testid="package-type"
+ icon="package"
+ link=""
+ size="s"
+ text="maven"
+ />
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <metadata-item-stub
+ data-testid="package-size"
+ icon="disk"
+ link=""
+ size="s"
+ text="300 bytes"
+ />
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <package-tags-stub
+ hidelabel="true"
+ tagdisplaylimit="2"
+ tags="[object Object],[object Object],[object Object],[object Object]"
+ />
+ </div>
</div>
</div>
+
+ <!---->
</div>
- <!---->
+ <p />
</div>
`;
exports[`PackageTitle renders without tags 1`] = `
<div
- class="gl-display-flex gl-justify-content-space-between gl-py-3"
+ class="gl-display-flex gl-flex-direction-column"
data-qa-selector="package_title"
>
<div
- class="gl-flex-direction-column"
+ class="gl-display-flex gl-justify-content-space-between gl-py-3"
>
<div
- class="gl-display-flex"
+ class="gl-flex-direction-column"
>
- <!---->
-
<div
- class="gl-display-flex gl-flex-direction-column"
+ class="gl-display-flex"
>
- <h1
- class="gl-font-size-h1 gl-mt-3 gl-mb-2"
- data-testid="title"
- >
- Test package
- </h1>
+ <!---->
<div
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ class="gl-display-flex gl-flex-direction-column"
>
- <gl-icon-stub
- class="gl-mr-3"
- name="eye"
- size="16"
- />
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ data-testid="title"
+ >
+ Test package
+ </h1>
- <gl-sprintf-stub
- message="v%{version} published %{timeAgo}"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="eye"
+ size="16"
+ />
+
+ <gl-sprintf-stub
+ message="v%{version} published %{timeAgo}"
+ />
+ </div>
</div>
</div>
- </div>
-
- <div
- class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
- >
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-type"
- icon="package"
- link=""
- size="s"
- text="maven"
- />
- </div>
+
<div
- class="gl-display-flex gl-align-items-center gl-mr-5"
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
>
- <metadata-item-stub
- data-testid="package-size"
- icon="disk"
- link=""
- size="s"
- text="300 bytes"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <metadata-item-stub
+ data-testid="package-type"
+ icon="package"
+ link=""
+ size="s"
+ text="maven"
+ />
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <metadata-item-stub
+ data-testid="package-size"
+ icon="disk"
+ link=""
+ size="s"
+ text="300 bytes"
+ />
+ </div>
</div>
</div>
+
+ <!---->
</div>
- <!---->
+ <p />
</div>
`;
diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js
index 0e95ee4cfd3..06e5950eb5d 100644
--- a/spec/frontend/packages/details/store/getters_spec.js
+++ b/spec/frontend/packages/details/store/getters_spec.js
@@ -69,7 +69,7 @@ describe('Getters PackageDetails Store', () => {
const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`;
const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`;
- const pypiPipCommandStr = `pip install ${pypiPackage.name} --index-url ${registryUrl}`;
+ const pypiPipCommandStr = `pip install ${pypiPackage.name} --extra-index-url ${registryUrl}`;
const composerRegistryIncludeStr = '{"type":"composer","url":"foo"}';
const composerPackageIncludeStr = JSON.stringify({
[packageWithoutBuildInfo.name]: packageWithoutBuildInfo.version,
diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
index 6ff9376565a..794e583a487 100644
--- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
+++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -1,457 +1,463 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_list_app renders 1`] = `
-<b-tabs-stub
- activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo"
- class="gl-tabs"
- contentclass=",gl-tab-content"
- navclass="gl-tabs-nav"
- nofade="true"
- nonavstyle="true"
- tag="div"
->
- <template>
-
- <b-tab-stub
- tag="div"
- title="All"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+<div>
+ <package-title-stub
+ packagehelpurl="foo"
+ />
+
+ <b-tabs-stub
+ activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo"
+ class="gl-tabs"
+ contentclass=",gl-tab-content"
+ navclass="gl-tabs-nav"
+ nofade="true"
+ nonavstyle="true"
+ tag="div"
+ >
+ <template>
+
+ <b-tab-stub
+ tag="div"
+ title="All"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no packages yet
+ </h1>
- <!---->
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="Composer"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Composer"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no Composer packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Composer packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no Composer packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no Composer packages yet
+ </h1>
- <!---->
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="Conan"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Conan"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no Conan packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Conan packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no Conan packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no Conan packages yet
+ </h1>
- <!---->
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="Maven"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Maven"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no Maven packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Maven packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no Maven packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no Maven packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
- <!---->
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="NPM"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="NPM"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no NPM packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no NPM packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no NPM packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no NPM packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
- <!---->
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="NuGet"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="NuGet"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no NuGet packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no NuGet packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no NuGet packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no NuGet packages yet
+ </h1>
- <!---->
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="PyPi"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="PyPi"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no PyPi packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no PyPi packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no PyPi packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no PyPi packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
- <!---->
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
-
- <!---->
- </template>
- <template>
- <div
- class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
- >
- <package-filter-stub
- class="mr-1"
- />
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
- <package-sort-stub />
- </div>
- </template>
-</b-tabs-stub>
+ <!---->
+ </template>
+ <template>
+ <div
+ class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
+ >
+ <package-filter-stub
+ class="gl-mr-2"
+ />
+
+ <package-sort-stub />
+ </div>
+ </template>
+ </b-tabs-stub>
+</div>
`;
diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js
index 19ff4290f50..217096f822a 100644
--- a/spec/frontend/packages/list/components/packages_list_app_spec.js
+++ b/spec/frontend/packages/list/components/packages_list_app_spec.js
@@ -36,6 +36,7 @@ describe('packages_list_app', () => {
resourceId: 'project_id',
emptyListIllustration: 'helpSvg',
emptyListHelpUrl,
+ packageHelpUrl: 'foo',
},
filterQuery,
},
diff --git a/spec/frontend/packages/list/components/packages_title_spec.js b/spec/frontend/packages/list/components/packages_title_spec.js
new file mode 100644
index 00000000000..5e9ebd8ecb0
--- /dev/null
+++ b/spec/frontend/packages/list/components/packages_title_spec.js
@@ -0,0 +1,71 @@
+import { shallowMount } from '@vue/test-utils';
+import PackageTitle from '~/packages/list/components/package_title.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list//constants';
+
+describe('PackageTitle', () => {
+ let wrapper;
+ let store;
+
+ const findTitleArea = () => wrapper.find(TitleArea);
+ const findMetadataItem = () => wrapper.find(MetadataItem);
+
+ const mountComponent = (propsData = { packageHelpUrl: 'foo' }) => {
+ wrapper = shallowMount(PackageTitle, {
+ store,
+ propsData,
+ stubs: {
+ TitleArea,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('title area', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findTitleArea().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ mountComponent();
+
+ expect(findTitleArea().props()).toMatchObject({
+ title: LIST_TITLE_TEXT,
+ infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }],
+ });
+ });
+ });
+
+ describe.each`
+ packagesCount | exist | text
+ ${null} | ${false} | ${''}
+ ${undefined} | ${false} | ${''}
+ ${0} | ${true} | ${'0 Packages'}
+ ${1} | ${true} | ${'1 Package'}
+ ${2} | ${true} | ${'2 Packages'}
+ `('when packagesCount is $packagesCount metadata item', ({ packagesCount, exist, text }) => {
+ beforeEach(() => {
+ mountComponent({ packagesCount, packageHelpUrl: 'foo' });
+ });
+
+ it(`is ${exist} that it exists`, () => {
+ expect(findMetadataItem().exists()).toBe(exist);
+ });
+
+ if (exist) {
+ it('has the correct props', () => {
+ expect(findMetadataItem().props()).toMatchObject({
+ icon: 'package',
+ text,
+ });
+ });
+ }
+ });
+});
diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
index 6aaefed92d0..5faae5690db 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
@@ -52,27 +52,6 @@ exports[`packages_list_row renders 1`] = `
<!---->
<div
- class="gl-display-flex gl-align-items-center"
- >
- <gl-icon-stub
- class="gl-ml-3 gl-mr-2 gl-min-w-0"
- name="review-list"
- size="16"
- />
-
- <gl-link-stub
- class="gl-text-body gl-min-w-0"
- data-testid="packages-row-project"
- href="/foo/bar/baz"
- >
- <gl-truncate-stub
- position="end"
- text="foo/bar/baz"
- />
- </gl-link-stub>
- </div>
-
- <div
class="d-flex align-items-center"
data-testid="package-type"
>
@@ -86,6 +65,10 @@ exports[`packages_list_row renders 1`] = `
Maven
</span>
</div>
+
+ <package-path-stub
+ path="foo/bar/baz"
+ />
</div>
</div>
</div>
@@ -118,6 +101,7 @@ exports[`packages_list_row renders 1`] = `
>
<gl-button-stub
aria-label="Remove package"
+ buttontextclasses=""
category="primary"
data-testid="action-delete"
icon="remove"
diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
index 9a0c52cee47..acdf7c49ebd 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
+++ b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
@@ -32,7 +32,8 @@ exports[`publish_method renders 1`] = `
</gl-link-stub>
<clipboard-button-stub
- cssclass="gl-border-0 gl-py-0 gl-px-2"
+ category="tertiary"
+ size="small"
text="sha-baz"
title="Copy commit SHA"
tooltipplacement="top"
diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js
index f4eabf7bb67..0d0ea4e2122 100644
--- a/spec/frontend/packages/shared/components/package_list_row_spec.js
+++ b/spec/frontend/packages/shared/components/package_list_row_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
import PackageTags from '~/packages/shared/components/package_tags.vue';
+import PackagePath from '~/packages/shared/components/package_path.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import { packageList } from '../../mock_data';
@@ -11,7 +12,7 @@ describe('packages_list_row', () => {
const [packageWithoutTags, packageWithTags] = packageList;
const findPackageTags = () => wrapper.find(PackageTags);
- const findProjectLink = () => wrapper.find('[data-testid="packages-row-project"]');
+ const findPackagePath = () => wrapper.find(PackagePath);
const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]');
const findPackageType = () => wrapper.find('[data-testid="package-type"]');
@@ -63,8 +64,9 @@ describe('packages_list_row', () => {
mountComponent({ isGroup: true });
});
- it('has project field', () => {
- expect(findProjectLink().exists()).toBe(true);
+ it('has a package path component', () => {
+ expect(findPackagePath().exists()).toBe(true);
+ expect(findPackagePath().props()).toMatchObject({ path: 'foo/bar/baz' });
});
});
diff --git a/spec/frontend/packages/shared/components/package_path_spec.js b/spec/frontend/packages/shared/components/package_path_spec.js
new file mode 100644
index 00000000000..40d455ac77c
--- /dev/null
+++ b/spec/frontend/packages/shared/components/package_path_spec.js
@@ -0,0 +1,86 @@
+import { shallowMount } from '@vue/test-utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import PackagePath from '~/packages/shared/components/package_path.vue';
+
+describe('PackagePath', () => {
+ let wrapper;
+
+ const mountComponent = (propsData = { path: 'foo' }) => {
+ wrapper = shallowMount(PackagePath, {
+ propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const BASE_ICON = 'base-icon';
+ const ROOT_LINK = 'root-link';
+ const ROOT_CHEVRON = 'root-chevron';
+ const ELLIPSIS_ICON = 'ellipsis-icon';
+ const ELLIPSIS_CHEVRON = 'ellipsis-chevron';
+ const LEAF_LINK = 'leaf-link';
+
+ const findItem = name => wrapper.find(`[data-testid="${name}"]`);
+ const findTooltip = w => getBinding(w.element, 'gl-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe.each`
+ path | rootUrl | shouldExist | shouldNotExist
+ ${'foo/bar'} | ${'/foo/bar'} | ${[]} | ${[ROOT_CHEVRON, ELLIPSIS_ICON, ELLIPSIS_CHEVRON, LEAF_LINK]}
+ ${'foo/bar/baz'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK]} | ${[ELLIPSIS_ICON, ELLIPSIS_CHEVRON]}
+ ${'foo/bar/baz/baz2'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK, ELLIPSIS_ICON, ELLIPSIS_CHEVRON]} | ${[]}
+ ${'foo/bar/baz/baz2/bar2'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK, ELLIPSIS_ICON, ELLIPSIS_CHEVRON]} | ${[]}
+ `('given path $path', ({ path, shouldExist, shouldNotExist, rootUrl }) => {
+ const pathPieces = path.split('/').slice(1);
+ const hasTooltip = shouldExist.includes(ELLIPSIS_ICON);
+
+ beforeEach(() => {
+ mountComponent({ path });
+ });
+
+ it('should have a base icon', () => {
+ expect(findItem(BASE_ICON).exists()).toBe(true);
+ });
+
+ it('should have a root link', () => {
+ const root = findItem(ROOT_LINK);
+ expect(root.exists()).toBe(true);
+ expect(root.attributes('href')).toBe(rootUrl);
+ });
+
+ if (hasTooltip) {
+ it('should have a tooltip', () => {
+ const tooltip = findTooltip(findItem(ELLIPSIS_ICON));
+ expect(tooltip).toBeDefined();
+ expect(tooltip.value).toMatchObject({
+ title: path,
+ });
+ });
+ }
+
+ if (shouldExist.length) {
+ it.each(shouldExist)(`should have %s`, element => {
+ expect(findItem(element).exists()).toBe(true);
+ });
+ }
+
+ if (shouldNotExist.length) {
+ it.each(shouldNotExist)(`should not have %s`, element => {
+ expect(findItem(element).exists()).toBe(false);
+ });
+ }
+
+ if (shouldExist.includes(LEAF_LINK)) {
+ it('the last link should be the last piece of the path', () => {
+ const leaf = findItem(LEAF_LINK);
+ expect(leaf.attributes('href')).toBe(`/${path}`);
+ expect(leaf.text()).toBe(pathPieces[pathPieces.length - 1]);
+ });
+ }
+ });
+});
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 2fbc700d4f5..ddeaa2a79db 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
@@ -39,6 +39,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
/>
</form>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
size="medium"
@@ -48,6 +49,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
</gl-button-stub>
<gl-button-stub
+ buttontextclasses=""
category="primary"
disabled="true"
icon=""
@@ -60,6 +62,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
</gl-button-stub>
<gl-button-stub
+ buttontextclasses=""
category="primary"
disabled="true"
icon=""
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
index 5a61f9fca69..5da998d9d2d 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
@@ -1,23 +1,18 @@
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';
+const imageUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
describe('Pipeline Schedule Callout', () => {
let calloutComponent;
beforeEach(() => {
setFixtures(`
- <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div>
+ <div id='pipeline-schedules-callout' data-docs-url=${docsUrl} data-image-url=${imageUrl}></div>
`);
});
@@ -30,13 +25,13 @@ describe('Pipeline Schedule Callout', () => {
expect(calloutComponent).toBeDefined();
});
- it('correctly sets illustrationSvg', () => {
- expect(calloutComponent.illustrationSvg).toContain('<svg');
- });
-
it('correctly sets docsUrl', () => {
expect(calloutComponent.docsUrl).toContain(docsUrl);
});
+
+ it('correctly sets imageUrl', () => {
+ expect(calloutComponent.imageUrl).toContain(imageUrl);
+ });
});
describe(`when ${cookieKey} cookie is set`, () => {
@@ -68,8 +63,8 @@ describe('Pipeline Schedule Callout', () => {
expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
});
- it('renders the callout svg', () => {
- expect(calloutComponent.$el.outerHTML).toContain('<svg');
+ it('renders the callout img', () => {
+ expect(calloutComponent.$el.outerHTML).toContain('<img');
});
it('renders the callout title', () => {
diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js
index 55286e0ec7e..cdbd6d4437e 100644
--- a/spec/frontend/pipeline_new/mock_data.js
+++ b/spec/frontend/pipeline_new/mock_data.js
@@ -14,9 +14,9 @@ export const mockProjectId = '21';
export const mockPostParams = {
ref: 'tag-1',
- variables: [
- { key: 'test_var', value: 'test_var_val', variable_type: 'env_var' },
- { key: 'test_file', value: 'test_file_val', variable_type: 'file' },
+ variables_attributes: [
+ { key: 'test_var', secret_value: 'test_var_val', variable_type: 'env_var' },
+ { key: 'test_file', secret_value: 'test_file_val', variable_type: 'file' },
],
};
diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js
index 989f6c17197..08a43199594 100644
--- a/spec/frontend/pipelines/components/dag/dag_spec.js
+++ b/spec/frontend/pipelines/components/dag/dag_spec.js
@@ -4,13 +4,8 @@ import Dag from '~/pipelines/components/dag/dag.vue';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue';
-import {
- ADD_NOTE,
- REMOVE_NOTE,
- REPLACE_NOTES,
- PARSE_FAILURE,
- UNSUPPORTED_DATA,
-} from '~/pipelines/components/dag//constants';
+import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/pipelines/components/dag/constants';
+import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/pipelines/constants';
import {
mockParsedGraphQLNodes,
tooSmallGraph,
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index d977db58a0e..062c9759a65 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -3,23 +3,27 @@ import { mount } from '@vue/test-utils';
import { setHTMLFixture } from 'helpers/fixtures';
import PipelineStore from '~/pipelines/stores/pipeline_store';
import graphComponent from '~/pipelines/components/graph/graph_component.vue';
-import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
+import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import graphJSON from './mock_data';
import linkedPipelineJSON from './linked_pipelines_mock_data';
import PipelinesMediator from '~/pipelines/pipeline_details_mediator';
describe('graph component', () => {
- const store = new PipelineStore();
- store.storePipeline(linkedPipelineJSON);
- const mediator = new PipelinesMediator({ endpoint: '' });
-
+ let store;
+ let mediator;
let wrapper;
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]');
+ const findStageColumns = () => wrapper.findAll(StageColumnComponent);
+ const findStageColumnAt = i => findStageColumns().at(i);
beforeEach(() => {
+ mediator = new PipelinesMediator({ endpoint: '' });
+ store = new PipelineStore();
+ store.storePipeline(linkedPipelineJSON);
+
setHTMLFixture('<div class="layout-page"></div>');
});
@@ -43,7 +47,7 @@ describe('graph component', () => {
});
describe('with data', () => {
- it('should render the graph', () => {
+ beforeEach(() => {
wrapper = mount(graphComponent, {
propsData: {
isLoading: false,
@@ -51,26 +55,17 @@ describe('graph component', () => {
mediator,
},
});
+ });
+ it('renders the graph', () => {
expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true);
-
- expect(wrapper.find(stageColumnComponent).classes()).toContain('no-margin');
-
- expect(
- wrapper
- .findAll(stageColumnComponent)
- .at(1)
- .classes(),
- ).toContain('left-margin');
-
- expect(wrapper.find('.stage-column:nth-child(2) .build:nth-child(1)').classes()).toContain(
- 'left-connector',
- );
-
expect(wrapper.find('.loading-icon').exists()).toBe(false);
-
expect(wrapper.find('.stage-column-list').exists()).toBe(true);
});
+
+ it('renders columns in the graph', () => {
+ expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length);
+ });
});
describe('when linked pipelines are present', () => {
@@ -93,26 +88,26 @@ describe('graph component', () => {
expect(wrapper.find('.fa-spinner').exists()).toBe(false);
});
- it('should include the stage column list', () => {
- expect(wrapper.find(stageColumnComponent).exists()).toBe(true);
- });
-
- it('should include the no-margin class on the first child if there is only one job', () => {
- const firstStageColumnElement = wrapper.find(stageColumnComponent);
-
- expect(firstStageColumnElement.classes()).toContain('no-margin');
+ it('should include the stage column', () => {
+ expect(findStageColumnAt(0).exists()).toBe(true);
});
- it('should include the has-only-one-job class on the first child', () => {
- const firstStageColumnElement = wrapper.find('.stage-column-list .stage-column');
-
- expect(firstStageColumnElement.classes()).toContain('has-only-one-job');
+ it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => {
+ expect(findStageColumnAt(0).classes()).toEqual(
+ expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']),
+ );
});
it('should include the left-margin class on the second child', () => {
- const firstStageColumnElement = wrapper.find('.stage-column-list .stage-column:last-child');
+ expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
+ });
- expect(firstStageColumnElement.classes()).toContain('left-margin');
+ it('should include the left-connector class in the build of the second child', () => {
+ expect(
+ findStageColumnAt(1)
+ .find('.build:nth-child(1)')
+ .classes('left-connector'),
+ ).toBe(true);
});
it('should include the js-has-linked-pipelines flag', () => {
@@ -134,12 +129,7 @@ describe('graph component', () => {
describe('stageConnectorClass', () => {
it('it returns left-margin when there is a triggerer', () => {
- expect(
- wrapper
- .findAll(stageColumnComponent)
- .at(1)
- .classes(),
- ).toContain('left-margin');
+ expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
});
});
});
@@ -248,6 +238,16 @@ describe('graph component', () => {
.catch(done.fail);
});
});
+
+ describe('when column requests a refresh', () => {
+ beforeEach(() => {
+ findStageColumnAt(0).vm.$emit('refreshPipelineGraph');
+ });
+
+ it('refreshPipelineGraph is emitted', () => {
+ expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1);
+ });
+ });
});
});
});
@@ -268,7 +268,7 @@ describe('graph component', () => {
it('should include the first column with a no margin', () => {
const firstColumn = wrapper.find('.stage-column');
- expect(firstColumn.classes()).toContain('no-margin');
+ expect(firstColumn.classes('no-margin')).toBe(true);
});
it('should not render a linked pipelines column', () => {
@@ -278,16 +278,11 @@ describe('graph component', () => {
describe('stageConnectorClass', () => {
it('it returns no-margin when no triggerer and there is one job', () => {
- expect(wrapper.find(stageColumnComponent).classes()).toContain('no-margin');
+ expect(findStageColumnAt(0).classes('no-margin')).toBe(true);
});
it('it returns left-margin when no triggerer and not the first stage', () => {
- expect(
- wrapper
- .findAll(stageColumnComponent)
- .at(1)
- .classes(),
- ).toContain('left-margin');
+ expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
});
});
});
@@ -302,12 +297,9 @@ describe('graph component', () => {
},
});
- expect(
- wrapper
- .find('.stage-column:nth-child(2) .stage-name')
- .text()
- .trim(),
- ).toEqual('Deploy &lt;img src=x onerror=alert(document.domain)&gt;');
+ expect(findStageColumnAt(1).props('title')).toEqual(
+ 'Deploy &lt;img src=x onerror=alert(document.domain)&gt;',
+ );
});
});
});
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index 5388d624d3c..2e10b0f068c 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -1,115 +1,164 @@
import { shallowMount } from '@vue/test-utils';
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlLoadingIcon } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import {
+ mockCancelledPipelineHeader,
+ mockFailedPipelineHeader,
+ mockRunningPipelineHeader,
+ mockSuccessfulPipelineHeader,
+} from './mock_data';
+import axios from '~/lib/utils/axios_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';
describe('Pipeline details header', () => {
let wrapper;
let glModalDirective;
-
- const threeWeeksAgo = new Date();
- threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+ let mockAxios;
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',
+ const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
+ const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]');
+ const findDeleteButton = () => wrapper.find('[data-testid="deletePipeline"]');
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ const defaultProvideOptions = {
+ pipelineId: 14,
+ pipelineIid: 1,
+ paths: {
+ retry: '/retry',
+ cancel: '/cancel',
+ delete: '/delete',
+ fullProject: '/namespace/my-project',
},
- isLoading: false,
};
- const createComponent = (props = {}) => {
+ const createComponent = (pipelineMock = mockRunningPipelineHeader, { isLoading } = false) => {
glModalDirective = jest.fn();
- wrapper = shallowMount(HeaderComponent, {
- propsData: {
- ...props,
+ const $apollo = {
+ queries: {
+ pipeline: {
+ loading: isLoading,
+ stopPolling: jest.fn(),
+ startPolling: jest.fn(),
+ },
+ },
+ };
+
+ return shallowMount(HeaderComponent, {
+ data() {
+ return {
+ pipeline: pipelineMock,
+ };
+ },
+ provide: {
+ ...defaultProvideOptions,
},
directives: {
glModal: {
- bind(el, { value }) {
+ bind(_, { value }) {
glModalDirective(value);
},
},
},
+ mocks: { $apollo },
});
};
beforeEach(() => {
- jest.spyOn(eventHub, '$emit');
-
- createComponent(defaultProps);
+ mockAxios = new MockAdapter(axios);
+ mockAxios.onGet('*').replyOnce(200);
});
afterEach(() => {
- eventHub.$off();
-
wrapper.destroy();
wrapper = null;
+
+ mockAxios.restore();
});
- 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('initial loading', () => {
+ beforeEach(() => {
+ wrapper = createComponent(null, { isLoading: true });
});
- });
- describe('action buttons', () => {
- it('should not trigger eventHub when nothing happens', () => {
- expect(eventHub.$emit).not.toHaveBeenCalled();
+ it('shows a loading state while graphQL is fetching initial data', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
});
+ });
+
+ describe('visible state', () => {
+ it.each`
+ state | pipelineData | retryValue | cancelValue
+ ${'cancelled'} | ${mockCancelledPipelineHeader} | ${true} | ${false}
+ ${'failed'} | ${mockFailedPipelineHeader} | ${true} | ${false}
+ ${'running'} | ${mockRunningPipelineHeader} | ${false} | ${true}
+ ${'successful'} | ${mockSuccessfulPipelineHeader} | ${false} | ${false}
+ `(
+ 'with a $state pipeline, it will show actions: retry $retryValue and cancel $cancelValue',
+ ({ pipelineData, retryValue, cancelValue }) => {
+ wrapper = createComponent(pipelineData);
+
+ expect(findRetryButton().exists()).toBe(retryValue);
+ expect(findCancelButton().exists()).toBe(cancelValue);
+ },
+ );
+ });
- it('should call postAction when retry button action is clicked', () => {
- wrapper.find('[data-testid="retryButton"]').vm.$emit('click');
+ describe('actions', () => {
+ describe('Retry action', () => {
+ beforeEach(() => {
+ wrapper = createComponent(mockCancelledPipelineHeader);
+ });
- expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
- });
+ it('should call axios with the right path when retry button is clicked', async () => {
+ jest.spyOn(axios, 'post');
+ findRetryButton().vm.$emit('click');
- it('should call postAction when cancel button action is clicked', () => {
- wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click');
+ await wrapper.vm.$nextTick();
- expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
+ expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.retry);
+ });
});
- it('does not show delete modal', () => {
- expect(findDeleteModal()).not.toBeVisible();
+ describe('Cancel action', () => {
+ beforeEach(() => {
+ wrapper = createComponent(mockRunningPipelineHeader);
+ });
+
+ it('should call axios with the right path when cancel button is clicked', async () => {
+ jest.spyOn(axios, 'post');
+ findCancelButton().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.cancel);
+ });
});
- describe('when delete button action is clicked', () => {
- it('displays delete modal', () => {
+ describe('Delete action', () => {
+ beforeEach(() => {
+ wrapper = createComponent(mockFailedPipelineHeader);
+ });
+
+ it('displays delete modal when clicking on delete and does not call the delete action', async () => {
+ jest.spyOn(axios, 'delete');
+ findDeleteButton().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID);
+ expect(axios.delete).not.toHaveBeenCalled();
});
- it('should call delete when modal is submitted', () => {
+ it('should call delete path when modal is submitted', async () => {
+ jest.spyOn(axios, 'delete');
findDeleteModal().vm.$emit('ok');
- expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
+ await wrapper.vm.$nextTick();
+
+ expect(axios.delete).toHaveBeenCalledWith(defaultProvideOptions.paths.delete);
});
});
});
diff --git a/spec/frontend/pipelines/legacy_header_component_spec.js b/spec/frontend/pipelines/legacy_header_component_spec.js
new file mode 100644
index 00000000000..fb7feb8898a
--- /dev/null
+++ b/spec/frontend/pipelines/legacy_header_component_spec.js
@@ -0,0 +1,116 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import LegacyHeaderComponent from '~/pipelines/components/legacy_header_component.vue';
+import CiHeader from '~/vue_shared/components/header_ci_component.vue';
+import eventHub from '~/pipelines/event_hub';
+
+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(LegacyHeaderComponent, {
+ 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('[data-testid="retryButton"]').vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
+ });
+
+ it('should call postAction when cancel button action is clicked', () => {
+ wrapper.find('[data-testid="cancelPipeline"]').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/mock_data.js b/spec/frontend/pipelines/mock_data.js
index e63efc543f1..2afdbb05107 100644
--- a/spec/frontend/pipelines/mock_data.js
+++ b/spec/frontend/pipelines/mock_data.js
@@ -1,3 +1,7 @@
+const PIPELINE_RUNNING = 'RUNNING';
+const PIPELINE_CANCELED = 'CANCELED';
+const PIPELINE_FAILED = 'FAILED';
+
export const pipelineWithStages = {
id: 20333396,
user: {
@@ -320,6 +324,80 @@ export const pipelineWithStages = {
triggered: [],
};
+const threeWeeksAgo = new Date();
+threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
+export const mockPipelineHeader = {
+ detailedStatus: {},
+ id: 123,
+ userPermissions: {
+ destroyPipeline: true,
+ },
+ createdAt: threeWeeksAgo.toISOString(),
+ user: {
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatarUrl: 'link',
+ },
+};
+
+export const mockFailedPipelineHeader = {
+ ...mockPipelineHeader,
+ status: PIPELINE_FAILED,
+ retryable: true,
+ cancelable: false,
+ detailedStatus: {
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ detailsPath: 'path',
+ },
+};
+
+export const mockRunningPipelineHeader = {
+ ...mockPipelineHeader,
+ status: PIPELINE_RUNNING,
+ retryable: false,
+ cancelable: true,
+ detailedStatus: {
+ group: 'running',
+ icon: 'status_running',
+ label: 'running',
+ text: 'running',
+ detailsPath: 'path',
+ },
+};
+
+export const mockCancelledPipelineHeader = {
+ ...mockPipelineHeader,
+ status: PIPELINE_CANCELED,
+ retryable: true,
+ cancelable: false,
+ detailedStatus: {
+ group: 'cancelled',
+ icon: 'status_cancelled',
+ label: 'cancelled',
+ text: 'cancelled',
+ detailsPath: 'path',
+ },
+};
+
+export const mockSuccessfulPipelineHeader = {
+ ...mockPipelineHeader,
+ status: 'SUCCESS',
+ retryable: false,
+ cancelable: false,
+ detailedStatus: {
+ group: 'success',
+ icon: 'status_success',
+ label: 'success',
+ text: 'success',
+ detailsPath: 'path',
+ },
+};
+
export const stageReply = {
name: 'deploy',
title: 'deploy: running',
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index b0ad6bbd228..1298a2a1524 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -1,9 +1,17 @@
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
-import { GlFilteredSearch } from '@gitlab/ui';
+import { GlFilteredSearch, GlButton, GlLoadingIcon } from '@gitlab/ui';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
+import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+
+import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue';
+import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue';
+import BlankState from '~/pipelines/components/pipelines_list/blank_state.vue';
+import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
+
import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue';
import Store from '~/pipelines/stores/pipelines_store';
import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data';
@@ -49,6 +57,20 @@ describe('Pipelines', () => {
};
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
+ const findByTestId = id => wrapper.find(`[data-testid="${id}"]`);
+ const findNavigationTabs = () => wrapper.find(NavigationTabs);
+ const findNavigationControls = () => wrapper.find(NavigationControls);
+ const findTab = tab => findByTestId(`pipelines-tab-${tab}`);
+
+ const findRunPipelineButton = () => findByTestId('run-pipeline-button');
+ const findCiLintButton = () => findByTestId('ci-lint-button');
+ const findCleanCacheButton = () => findByTestId('clear-cache-button');
+
+ const findEmptyState = () => wrapper.find(EmptyState);
+ const findBlankState = () => wrapper.find(BlankState);
+ const findStagesDropdown = () => wrapper.find('.js-builds-dropdown-button');
+
+ const findTablePagination = () => wrapper.find(TablePagination);
const createComponent = (props = defaultProps, methods) => {
wrapper = mount(PipelinesComponent, {
@@ -87,19 +109,19 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('renders Run Pipeline link', () => {
- expect(wrapper.find('.js-run-pipeline').attributes('href')).toBe(paths.newPipelinePath);
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
});
it('renders CI Lint link', () => {
- expect(wrapper.find('.js-ci-lint').attributes('href')).toBe(paths.ciLintPath);
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
});
it('renders Clear Runner Cache button', () => {
- expect(wrapper.find('.js-clear-cache').text()).toBe('Clear Runner Caches');
+ expect(findCleanCacheButton().text()).toBe('Clear Runner Caches');
});
it('renders pipelines table', () => {
@@ -127,23 +149,31 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('renders Run Pipeline link', () => {
- expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath);
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
});
it('renders CI Lint link', () => {
- expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath);
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
});
it('renders Clear Runner Cache button', () => {
- expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches');
+ expect(findCleanCacheButton().text()).toBe('Clear Runner Caches');
});
it('renders tab empty state', () => {
- expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.');
+ expect(findBlankState().text()).toBe('There are currently no pipelines.');
+ });
+
+ it('renders tab empty state finished scope', () => {
+ wrapper.vm.scope = 'finished';
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findBlankState().text()).toBe('There are currently no finished pipelines.');
+ });
});
});
@@ -165,18 +195,23 @@ describe('Pipelines', () => {
});
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,
- );
+ expect(
+ findEmptyState()
+ .find('h4')
+ .text(),
+ ).toBe('Build with confidence');
+ expect(
+ findEmptyState()
+ .find(GlButton)
+ .attributes('href'),
+ ).toBe(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();
+ expect(findTab('all').exists()).toBe(false);
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
});
@@ -189,20 +224,18 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('renders buttons', () => {
- expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath);
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
- expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath);
- expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches');
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
+ expect(findCleanCacheButton().text()).toBe('Clear Runner Caches');
});
it('renders error state', () => {
- expect(wrapper.find('.empty-state').text()).toContain(
- 'There was an error fetching the pipelines.',
- );
+ expect(findBlankState().text()).toContain('There was an error fetching the pipelines.');
});
});
});
@@ -218,13 +251,13 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('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();
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
it('renders pipelines table', () => {
@@ -252,17 +285,17 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('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();
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
it('renders tab empty state', () => {
- expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.');
+ expect(wrapper.find('.empty-state h4').text()).toBe('There are currently no pipelines.');
});
});
@@ -284,18 +317,22 @@ describe('Pipelines', () => {
});
it('renders empty state without button to set CI', () => {
- expect(wrapper.find('.js-empty-state').text()).toEqual(
+ expect(findEmptyState().text()).toBe(
'This project is not currently set up to run pipelines.',
);
- expect(wrapper.find('.js-get-started-pipelines').exists()).toBeFalsy();
+ expect(
+ findEmptyState()
+ .find(GlButton)
+ .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();
+ expect(findTab('all').exists()).toBe(false);
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
});
@@ -309,13 +346,13 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('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();
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
it('renders error state', () => {
@@ -342,14 +379,20 @@ describe('Pipelines', () => {
);
});
- it('should render navigation tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
-
- expect(wrapper.find('.js-pipelines-tab-finished').text()).toContain('Finished');
-
- expect(wrapper.find('.js-pipelines-tab-branches').text()).toContain('Branches');
+ it('should set up navigation tabs', () => {
+ expect(findNavigationTabs().props('tabs')).toEqual([
+ { name: 'All', scope: 'all', count: '3', isActive: true },
+ { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
+ { name: 'Branches', scope: 'branches', isActive: false },
+ { name: 'Tags', scope: 'tags', isActive: false },
+ ]);
+ });
- expect(wrapper.find('.js-pipelines-tab-tags').text()).toContain('Tags');
+ it('should render navigation tabs', () => {
+ expect(findTab('all').html()).toContain('All');
+ expect(findTab('finished').text()).toContain('Finished');
+ expect(findTab('branches').text()).toContain('Branches');
+ expect(findTab('tags').text()).toContain('Tags');
});
it('should make an API request when using tabs', () => {
@@ -362,7 +405,7 @@ describe('Pipelines', () => {
);
return waitForPromises().then(() => {
- wrapper.find('.js-pipelines-tab-finished').trigger('click');
+ findTab('finished').trigger('click');
expect(updateContentMock).toHaveBeenCalledWith({ scope: 'finished', page: '1' });
});
@@ -401,133 +444,172 @@ describe('Pipelines', () => {
});
});
- describe('methods', () => {
+ describe('User Interaction', () => {
+ let updateContentMock;
+
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,
- },
- );
+ beforeEach(() => {
+ mock.onGet(paths.endpoint).reply(200, pipelines);
+ createComponent();
- wrapper.vm.onChangeTab('running');
+ updateContentMock = jest.spyOn(wrapper.vm, 'updateContent');
+
+ return waitForPromises();
+ });
+
+ describe('when user changes tabs', () => {
+ it('should set page to 1', () => {
+ findNavigationTabs().vm.$emit('onChangeTab', 'running');
expect(updateContentMock).toHaveBeenCalledWith({ scope: 'running', page: '1' });
});
});
- describe('onChangePage', () => {
+ describe('when user changes page', () => {
it('should update page and keep scope', () => {
- const updateContentMock = jest.fn(() => {});
- createComponent(
- { hasGitlabCi: true, canCreatePipeline: true, ...paths },
- {
- updateContent: updateContentMock,
- },
- );
-
- wrapper.vm.onChangePage(4);
+ findTablePagination().vm.change(4);
expect(updateContentMock).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4' });
});
});
- });
- describe('computed properties', () => {
- beforeEach(() => {
- createComponent();
- });
+ 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);
- describe('tabs', () => {
- it('returns default tabs', () => {
- expect(wrapper.vm.tabs).toEqual([
- { name: 'All', scope: 'all', count: undefined, isActive: true },
- { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
- { name: 'Branches', scope: 'branches', isActive: false },
- { name: 'Tags', scope: 'tags', isActive: false },
- ]);
+ createComponent();
});
- });
- describe('emptyTabMessage', () => {
- it('returns message with finished scope', () => {
- wrapper.vm.scope = 'finished';
+ 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 wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no finished pipelines.');
+ return waitForPromises()
+ .then(() => {
+ wrapper.vm.isMakingRequest = true;
+ findStagesDropdown().trigger('click');
+ })
+ .then(() => {
+ expect(cancelMock).toHaveBeenCalled();
+ expect(stopMock).toHaveBeenCalled();
+ expect(restartMock).toHaveBeenCalled();
+ });
});
});
- it('returns message without scope when scope is `all`', () => {
- expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pipelines.');
+ 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(() => {
+ findStagesDropdown().trigger('click');
+ expect(stopMock).toHaveBeenCalled();
+ })
+ .then(() => {
+ expect(restartMock).toHaveBeenCalled();
+ });
+ });
});
});
+ });
- describe('stateToRender', () => {
- it('returns loading state when the app is loading', () => {
- expect(wrapper.vm.stateToRender).toEqual('loading');
+ describe('Rendered content', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('displays different content', () => {
+ it('shows loading state when the app is loading', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
- it('returns error state when app has error', () => {
+ it('shows 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');
+ expect(findBlankState().props('message')).toBe(
+ 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.',
+ );
});
});
- it('returns table list when app has pipelines', () => {
+ it('shows 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');
+ expect(wrapper.find(PipelinesTableComponent).exists()).toBe(true);
});
});
- it('returns empty tab when app does not have pipelines but project has pipelines', () => {
+ it('shows 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');
+ expect(findBlankState().exists()).toBe(true);
+ expect(findBlankState().props('message')).toBe('There are currently no pipelines.');
});
});
- it('returns empty tab when project has CI', () => {
+ it('shows empty tab when project has CI', () => {
wrapper.vm.isLoading = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.stateToRender).toEqual('emptyTab');
+ expect(findBlankState().exists()).toBe(true);
+ expect(findBlankState().props('message')).toBe('There are currently no pipelines.');
});
});
- it('returns empty state when project does not have pipelines nor CI', () => {
+ it('shows 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');
+ expect(wrapper.find(EmptyState).exists()).toBe(true);
});
});
});
- describe('shouldRenderTabs', () => {
+ describe('displays tabs', () => {
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);
+ expect(findNavigationTabs().exists()).toBe(true);
});
});
@@ -537,7 +619,7 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ expect(findNavigationTabs().exists()).toBe(true);
});
});
@@ -547,7 +629,7 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ expect(findNavigationTabs().exists()).toBe(true);
});
});
@@ -557,7 +639,7 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ expect(findNavigationTabs().exists()).toBe(true);
});
});
@@ -565,7 +647,7 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(false);
+ expect(findNavigationTabs().exists()).toBe(false);
});
});
@@ -576,17 +658,17 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(false);
+ expect(findNavigationTabs().exists()).toBe(false);
});
});
});
- describe('shouldRenderButtons', () => {
+ describe('displays buttons', () => {
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);
+ expect(findNavigationControls().exists()).toBe(true);
});
});
@@ -594,77 +676,12 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderButtons).toEqual(false);
+ expect(findNavigationControls().exists()).toBe(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;
diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js
index 1d03f0b655f..872cb5c87be 100644
--- a/spec/frontend/pipelines/test_reports/mock_data.js
+++ b/spec/frontend/pipelines/test_reports/mock_data.js
@@ -9,4 +9,20 @@ export default [
status: TestStatus.SKIPPED,
system_output: null,
},
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0,
+ name: 'Test#error text',
+ stack_trace: null,
+ status: TestStatus.ERROR,
+ system_output: null,
+ },
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0,
+ name: 'Test#unknown text',
+ stack_trace: null,
+ status: TestStatus.UNKNOWN,
+ system_output: null,
+ },
];
diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
index 2feb6aa5799..af2150be7a0 100644
--- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
@@ -61,18 +61,17 @@ describe('Test reports suite table', () => {
expect(allCaseRows().length).toBe(testCases.length);
});
- it('renders the correct icon for each status', () => {
- const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED);
- const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED);
- const successTest = testCases.findIndex(x => x.status === TestStatus.SUCCESS);
+ it.each([
+ TestStatus.ERROR,
+ TestStatus.FAILED,
+ TestStatus.SKIPPED,
+ TestStatus.SUCCESS,
+ 'unknown',
+ ])('renders the correct icon for test case with %s status', status => {
+ const test = testCases.findIndex(x => x.status === status);
+ const row = findCaseRowAtIndex(test);
- const failedRow = findCaseRowAtIndex(failedTest);
- const skippedRow = findCaseRowAtIndex(skippedTest);
- const successRow = findCaseRowAtIndex(successTest);
-
- expect(findIconForRow(failedRow, TestStatus.FAILED).exists()).toBe(true);
- expect(findIconForRow(skippedRow, TestStatus.SKIPPED).exists()).toBe(true);
- expect(findIconForRow(successRow, TestStatus.SUCCESS).exists()).toBe(true);
+ expect(findIconForRow(row, status).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/project_find_file_spec.js
index 757a02a04a3..6a50f68a4e9 100644
--- a/spec/frontend/project_find_file_spec.js
+++ b/spec/frontend/project_find_file_spec.js
@@ -1,11 +1,12 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import { TEST_HOST } from 'helpers/test_constants';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import ProjectFindFile from '~/project_find_file';
import axios from '~/lib/utils/axios_utils';
-jest.mock('dompurify', () => ({
+jest.mock('~/lib/dompurify', () => ({
+ addHook: jest.fn(),
sanitize: jest.fn(val => val),
}));
diff --git a/spec/frontend/projects/commit_box/info/load_branches_spec.js b/spec/frontend/projects/commit_box/info/load_branches_spec.js
new file mode 100644
index 00000000000..ebd4ee45dab
--- /dev/null
+++ b/spec/frontend/projects/commit_box/info/load_branches_spec.js
@@ -0,0 +1,68 @@
+import axios from 'axios';
+import waitForPromises from 'helpers/wait_for_promises';
+import MockAdapter from 'axios-mock-adapter';
+import { loadBranches } from '~/projects/commit_box/info/load_branches';
+
+const mockCommitPath = '/commit/abcd/branches';
+const mockBranchesRes =
+ '<a href="/-/commits/master">master</a><span><a href="/-/commits/my-branch">my-branch</a></span>';
+
+describe('~/projects/commit_box/info/load_branches', () => {
+ let mock;
+ let el;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockCommitPath).reply(200, mockBranchesRes);
+
+ el = document.createElement('div');
+ el.dataset.commitPath = mockCommitPath;
+ el.innerHTML = '<div class="commit-info branches"><span class="spinner"/></div>';
+ });
+
+ it('loads and renders branches info', async () => {
+ loadBranches(el);
+ await waitForPromises();
+
+ expect(el.innerHTML).toBe(`<div class="commit-info branches">${mockBranchesRes}</div>`);
+ });
+
+ it('does not load when no container is provided', async () => {
+ loadBranches(null);
+ await waitForPromises();
+
+ expect(mock.history.get).toHaveLength(0);
+ });
+
+ describe('when braches request returns unsafe content', () => {
+ beforeEach(() => {
+ mock
+ .onGet(mockCommitPath)
+ .reply(200, '<a onload="alert(\'xss!\');" href="/-/commits/master">master</a>');
+ });
+
+ it('displays sanitized html', async () => {
+ loadBranches(el);
+ await waitForPromises();
+
+ expect(el.innerHTML).toBe(
+ '<div class="commit-info branches"><a href="/-/commits/master">master</a></div>',
+ );
+ });
+ });
+
+ describe('when braches request fails', () => {
+ beforeEach(() => {
+ mock.onGet(mockCommitPath).reply(500, 'Error!');
+ });
+
+ it('attempts to load and renders an error', async () => {
+ loadBranches(el);
+ await waitForPromises();
+
+ expect(el.innerHTML).toBe(
+ '<div class="commit-info branches">Failed to load branches. Please try again.</div>',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
index 455467e7b29..a0fd6012546 100644
--- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
@@ -17,6 +17,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = `
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
role="button"
diff --git a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
index 692b8f6cf52..4630415f61c 100644
--- a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
@@ -18,6 +18,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = `
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
role="button"
@@ -84,6 +85,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = `
<template>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-modal-action-cancel"
icon=""
@@ -98,6 +100,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = `
<!---->
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-modal-action-primary"
disabled="true"
diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js
index 3b375c5610f..41b9c0c3763 100644
--- a/spec/frontend/projects/settings/access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/access_dropdown_spec.js
@@ -14,6 +14,7 @@ describe('AccessDropdown', () => {
`);
const $dropdown = $('#dummy-dropdown');
$dropdown.data('defaultLabel', defaultLabel);
+ gon.features = { deployKeysOnProtectedBranches: true };
const options = {
$dropdown,
accessLevelsData: {
@@ -37,6 +38,9 @@ describe('AccessDropdown', () => {
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.GROUP },
+ { type: LEVEL_TYPES.DEPLOY_KEY },
+ { type: LEVEL_TYPES.DEPLOY_KEY },
+ { type: LEVEL_TYPES.DEPLOY_KEY },
];
beforeEach(() => {
@@ -49,7 +53,7 @@ describe('AccessDropdown', () => {
const label = dropdown.toggleLabel();
- expect(label).toBe('1 role, 2 users, 3 groups');
+ expect(label).toBe('1 role, 2 users, 3 deploy keys, 3 groups');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
@@ -122,6 +126,21 @@ describe('AccessDropdown', () => {
expect($dropdownToggleText).not.toHaveClass('is-default');
});
});
+
+ describe('with users and deploy keys', () => {
+ beforeEach(() => {
+ const selectedTypes = [LEVEL_TYPES.DEPLOY_KEY, LEVEL_TYPES.USER];
+ dropdown.setSelectedItems(dummyItems.filter(item => selectedTypes.includes(item.type)));
+ $dropdownToggleText.addClass('is-default');
+ });
+
+ it('displays number of deploy keys', () => {
+ const label = dropdown.toggleLabel();
+
+ expect(label).toBe('2 users, 3 deploy keys');
+ expect($dropdownToggleText).not.toHaveClass('is-default');
+ });
+ });
});
describe('userRowHtml', () => {
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
index 0f3b699f6b2..62aeb4ddee5 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
@@ -218,9 +218,7 @@ describe('ServiceDeskRoot', () => {
.$nextTick()
.then(waitForPromises)
.then(() => {
- expect(wrapper.html()).toContain(
- 'An error occurred while saving the template. Please check if the template exists.',
- );
+ expect(wrapper.html()).toContain('An error occured while making the changes:');
});
});
});
diff --git a/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js
new file mode 100644
index 00000000000..17821d8be31
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js
@@ -0,0 +1,71 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import component from '~/registry/explorer/components/details_page/partial_cleanup_alert.vue';
+import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '~/registry/explorer/constants';
+
+describe('Partial Cleanup alert', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.find(GlAlert);
+ const findRunLink = () => wrapper.find('[data-testid="run-link"');
+ const findHelpLink = () => wrapper.find('[data-testid="help-link"');
+
+ const mountComponent = () => {
+ wrapper = shallowMount(component, {
+ stubs: { GlSprintf },
+ propsData: {
+ runCleanupPoliciesHelpPagePath: 'foo',
+ cleanupPoliciesHelpPagePath: 'bar',
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it(`gl-alert has the correct properties`, () => {
+ mountComponent();
+
+ expect(findAlert().props()).toMatchObject({
+ title: DELETE_ALERT_TITLE,
+ variant: 'warning',
+ });
+ });
+
+ it('has the right text', () => {
+ mountComponent();
+
+ expect(wrapper.text()).toMatchInterpolatedText(DELETE_ALERT_LINK_TEXT);
+ });
+
+ it('contains run link', () => {
+ mountComponent();
+
+ const link = findRunLink();
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: 'foo',
+ target: '_blank',
+ });
+ });
+
+ it('contains help link', () => {
+ mountComponent();
+
+ const link = findHelpLink();
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: 'bar',
+ target: '_blank',
+ });
+ });
+
+ it('GlAlert dismiss event triggers a dismiss event', () => {
+ mountComponent();
+
+ findAlert().vm.$emit('dismiss');
+ expect(wrapper.emitted('dismiss')).toEqual([[]]);
+ });
+});
diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
index 7a27f8fa431..3c997093d46 100644
--- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlSprintf, GlLink } from '@gitlab/ui';
+import { GlSprintf } from '@gitlab/ui';
import Component from '~/registry/explorer/components/list_page/registry_header.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import {
@@ -19,12 +19,8 @@ describe('registry_header', () => {
const findTitleArea = () => wrapper.find(TitleArea);
const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]');
- const findInfoArea = () => wrapper.find('[data-testid="info-area"]');
- const findIntroText = () => wrapper.find('[data-testid="default-intro"]');
const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]');
const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]');
- const findDisabledExpirationPolicyMessage = () =>
- wrapper.find('[data-testid="expiration-disabled-message"]');
const mountComponent = (propsData, slots) => {
wrapper = shallowMount(Component, {
@@ -123,44 +119,18 @@ describe('registry_header', () => {
});
});
- describe('info area', () => {
- it('exists', () => {
- mountComponent();
-
- expect(findInfoArea().exists()).toBe(true);
- });
-
+ describe('info messages', () => {
describe('default message', () => {
- beforeEach(() => {
- return mountComponent({ helpPagePath: 'bar' });
- });
-
- it('exists', () => {
- expect(findIntroText().exists()).toBe(true);
- });
-
- it('has the correct copy', () => {
- expect(findIntroText().text()).toMatchInterpolatedText(LIST_INTRO_TEXT);
- });
+ it('is correctly bound to title_area props', () => {
+ mountComponent({ helpPagePath: 'foo' });
- it('has the correct link', () => {
- expect(
- findIntroText()
- .find(GlLink)
- .attributes('href'),
- ).toBe('bar');
+ expect(findTitleArea().props('infoMessages')).toEqual([
+ { text: LIST_INTRO_TEXT, link: 'foo' },
+ ]);
});
});
describe('expiration policy info message', () => {
- describe('when there are no images', () => {
- it('is hidden', () => {
- mountComponent();
-
- expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
- });
- });
-
describe('when there are images', () => {
describe('when expiration policy is disabled', () => {
beforeEach(() => {
@@ -170,43 +140,27 @@ describe('registry_header', () => {
imagesCount: 1,
});
});
- it('message exist', () => {
- expect(findDisabledExpirationPolicyMessage().exists()).toBe(true);
- });
- it('has the correct copy', () => {
- expect(findDisabledExpirationPolicyMessage().text()).toMatchInterpolatedText(
- EXPIRATION_POLICY_DISABLED_MESSAGE,
- );
- });
- it('has the correct link', () => {
- expect(
- findDisabledExpirationPolicyMessage()
- .find(GlLink)
- .attributes('href'),
- ).toBe('foo');
+ it('the prop is correctly bound', () => {
+ expect(findTitleArea().props('infoMessages')).toEqual([
+ { text: LIST_INTRO_TEXT, link: '' },
+ { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: 'foo' },
+ ]);
});
});
- describe('when expiration policy is enabled', () => {
+ describe.each`
+ desc | props
+ ${'when there are no images'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 0 }}
+ ${'when expiration policy is enabled'} | ${{ expirationPolicy: { enabled: true }, imagesCount: 1 }}
+ ${'when the expiration policy is completely disabled'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 1, hideExpirationPolicyData: true }}
+ `('$desc', ({ props }) => {
it('message does not exist', () => {
- mountComponent({
- expirationPolicy: { enabled: true },
- imagesCount: 1,
- });
-
- expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
- });
- });
- describe('when the expiration policy is completely disabled', () => {
- it('message does not exist', () => {
- mountComponent({
- expirationPolicy: { enabled: true },
- imagesCount: 1,
- hideExpirationPolicyData: true,
- });
+ mountComponent(props);
- expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
+ expect(findTitleArea().props('infoMessages')).toEqual([
+ { text: LIST_INTRO_TEXT, link: '' },
+ ]);
});
});
});
diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js
index 66e8a4aea0d..86b52c4f06a 100644
--- a/spec/frontend/registry/explorer/pages/details_spec.js
+++ b/spec/frontend/registry/explorer/pages/details_spec.js
@@ -3,6 +3,7 @@ import { GlPagination } from '@gitlab/ui';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/details.vue';
import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue';
+import PartialCleanupAlert from '~/registry/explorer/components/details_page/partial_cleanup_alert.vue';
import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue';
import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue';
import TagsList from '~/registry/explorer/components/details_page/tags_list.vue';
@@ -30,8 +31,10 @@ describe('Details Page', () => {
const findDeleteAlert = () => wrapper.find(DeleteAlert);
const findDetailsHeader = () => wrapper.find(DetailsHeader);
const findEmptyTagsState = () => wrapper.find(EmptyTagsState);
+ const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert);
- const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
+ const routeIdGenerator = override =>
+ window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar', ...override }));
const tagsArrayToSelectedTags = tags =>
tags.reduce((acc, c) => {
@@ -39,7 +42,7 @@ describe('Details Page', () => {
return acc;
}, {});
- const mountComponent = options => {
+ const mountComponent = ({ options, routeParams } = {}) => {
wrapper = shallowMount(component, {
store,
stubs: {
@@ -48,7 +51,7 @@ describe('Details Page', () => {
mocks: {
$route: {
params: {
- id: routeId,
+ id: routeIdGenerator(routeParams),
},
},
},
@@ -224,7 +227,7 @@ describe('Details Page', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', {
tag: store.state.tags[0],
- params: routeId,
+ params: routeIdGenerator(),
});
});
});
@@ -239,7 +242,7 @@ describe('Details Page', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', {
ids: store.state.tags.map(t => t.name),
- params: routeId,
+ params: routeIdGenerator(),
});
});
});
@@ -273,11 +276,57 @@ describe('Details Page', () => {
it('has the correct props', () => {
store.commit(SET_INITIAL_STATE, { ...config });
mountComponent({
- data: () => ({
- deleteAlertType,
- }),
+ options: {
+ data: () => ({
+ deleteAlertType,
+ }),
+ },
});
expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType });
});
});
+
+ describe('Partial Cleanup Alert', () => {
+ const config = {
+ runCleanupPoliciesHelpPagePath: 'foo',
+ cleanupPoliciesHelpPagePath: 'bar',
+ };
+
+ describe('when expiration_policy_started is not null', () => {
+ const routeParams = { cleanup_policy_started_at: Date.now().toString() };
+
+ it('exists', () => {
+ mountComponent({ routeParams });
+
+ expect(findPartialCleanupAlert().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ store.commit(SET_INITIAL_STATE, { ...config });
+
+ mountComponent({ routeParams });
+
+ expect(findPartialCleanupAlert().props()).toEqual({ ...config });
+ });
+
+ it('dismiss hides the component', async () => {
+ mountComponent({ routeParams });
+
+ expect(findPartialCleanupAlert().exists()).toBe(true);
+ findPartialCleanupAlert().vm.$emit('dismiss');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findPartialCleanupAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('when expiration_policy_started is null', () => {
+ it('the component is hidden', () => {
+ mountComponent();
+
+ expect(findPartialCleanupAlert().exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap
deleted file mode 100644
index 11393c89d06..00000000000
--- a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap
+++ /dev/null
@@ -1,7 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Registry Settings App renders 1`] = `
-<div>
- <settings-form-stub />
-</div>
-`;
diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js
index 9551ee72e51..01d6852e1e5 100644
--- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js
+++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js
@@ -1,28 +1,35 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
import component from '~/registry/settings/components/registry_settings_app.vue';
+import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
import SettingsForm from '~/registry/settings/components/settings_form.vue';
-import { createStore } from '~/registry/settings/store/';
-import { SET_SETTINGS, SET_INITIAL_STATE } from '~/registry/settings/store/mutation_types';
import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/registry/shared/constants';
import {
UNAVAILABLE_FEATURE_INTRO_TEXT,
UNAVAILABLE_USER_FEATURE_TEXT,
} from '~/registry/settings/constants';
-import { stringifiedFormOptions } from '../../shared/mock_data';
+import { expirationPolicyPayload } from '../mock_data';
+
+const localVue = createLocalVue();
describe('Registry Settings App', () => {
let wrapper;
- let store;
+ let fakeApollo;
+
+ const defaultProvidedValues = {
+ projectPath: 'path',
+ isAdmin: false,
+ adminSettingsPath: 'settingsPath',
+ enableHistoricEntries: false,
+ };
const findSettingsComponent = () => wrapper.find(SettingsForm);
const findAlert = () => wrapper.find(GlAlert);
- const mountComponent = ({ dispatchMock = 'mockResolvedValue' } = {}) => {
- const dispatchSpy = jest.spyOn(store, 'dispatch');
- dispatchSpy[dispatchMock]();
-
+ const mountComponent = (provide = defaultProvidedValues, config) => {
wrapper = shallowMount(component, {
stubs: {
GlSprintf,
@@ -32,71 +39,72 @@ describe('Registry Settings App', () => {
show: jest.fn(),
},
},
- store,
+ provide,
+ ...config,
});
};
- beforeEach(() => {
- store = createStore();
- });
+ const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [[expirationPolicyQuery, resolver]];
+
+ fakeApollo = createMockApollo(requestHandlers);
+ mountComponent(provide, {
+ localVue,
+ apolloProvider: fakeApollo,
+ });
+
+ return requestHandlers.map(request => request[1]);
+ };
afterEach(() => {
wrapper.destroy();
});
- it('renders', () => {
- store.commit(SET_SETTINGS, { foo: 'bar' });
- mountComponent();
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('call the store function to load the data on mount', () => {
- mountComponent();
- expect(store.dispatch).toHaveBeenCalledWith('fetchSettings');
- });
+ it('renders the setting form', async () => {
+ const requests = mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()),
+ });
+ await Promise.all(requests);
- it('renders the setting form', () => {
- store.commit(SET_SETTINGS, { foo: 'bar' });
- mountComponent();
expect(findSettingsComponent().exists()).toBe(true);
});
describe('the form is disabled', () => {
- beforeEach(() => {
- store.commit(SET_SETTINGS, undefined);
+ it('the form is hidden', () => {
mountComponent();
- });
- it('the form is hidden', () => {
expect(findSettingsComponent().exists()).toBe(false);
});
it('shows an alert', () => {
+ mountComponent();
+
const text = findAlert().text();
expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT);
expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT);
});
describe('an admin is visiting the page', () => {
- beforeEach(() => {
- store.commit(SET_INITIAL_STATE, {
- ...stringifiedFormOptions,
- isAdmin: true,
- adminSettingsPath: 'foo',
- });
- });
-
it('shows the admin part of the alert message', () => {
+ mountComponent({ ...defaultProvidedValues, isAdmin: true });
+
const sprintf = findAlert().find(GlSprintf);
expect(sprintf.text()).toBe('administration settings');
- expect(sprintf.find(GlLink).attributes('href')).toBe('foo');
+ expect(sprintf.find(GlLink).attributes('href')).toBe(
+ defaultProvidedValues.adminSettingsPath,
+ );
});
});
});
describe('fetchSettingsError', () => {
beforeEach(() => {
- mountComponent({ dispatchMock: 'mockRejectedValue' });
+ const requests = mountComponentWithApollo({
+ resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')),
+ });
+ return Promise.all(requests);
});
it('the form is hidden', () => {
diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js
index 6f9518808db..77fd71a22fc 100644
--- a/spec/frontend/registry/settings/components/settings_form_spec.js
+++ b/spec/frontend/registry/settings/components/settings_form_spec.js
@@ -1,30 +1,37 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Tracking from '~/tracking';
import component from '~/registry/settings/components/settings_form.vue';
import expirationPolicyFields from '~/registry/shared/components/expiration_policy_fields.vue';
-import { createStore } from '~/registry/settings/store/';
+import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.graphql';
+import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '~/registry/shared/constants';
-import { stringifiedFormOptions } from '../../shared/mock_data';
+import { GlCard, GlLoadingIcon } from '../../shared/stubs';
+import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data';
+
+const localVue = createLocalVue();
describe('Settings Form', () => {
let wrapper;
- let store;
- let dispatchSpy;
-
- const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' };
- const GlCard = {
- name: 'gl-card-stub',
- template: `
- <div>
- <slot name="header"></slot>
- <slot></slot>
- <slot name="footer"></slot>
- </div>
- `,
+ let fakeApollo;
+
+ const defaultProvidedValues = {
+ projectPath: 'path',
+ };
+
+ const {
+ data: {
+ project: { containerExpirationPolicy },
+ },
+ } = expirationPolicyPayload();
+
+ const defaultProps = {
+ value: { ...containerExpirationPolicy },
};
const trackingPayload = {
@@ -35,14 +42,21 @@ describe('Settings Form', () => {
const findFields = () => wrapper.find(expirationPolicyFields);
const findCancelButton = () => wrapper.find({ ref: 'cancel-button' });
const findSaveButton = () => wrapper.find({ ref: 'save-button' });
- const findLoadingIcon = (parent = wrapper) => parent.find(GlLoadingIcon);
- const mountComponent = (data = {}) => {
+ const mountComponent = ({
+ props = defaultProps,
+ data,
+ config,
+ provide = defaultProvidedValues,
+ mocks,
+ } = {}) => {
wrapper = shallowMount(component, {
stubs: {
GlCard,
GlLoadingIcon,
},
+ propsData: { ...props },
+ provide,
data() {
return {
...data,
@@ -52,15 +66,42 @@ describe('Settings Form', () => {
$toast: {
show: jest.fn(),
},
+ ...mocks,
},
- store,
+ ...config,
});
};
+ const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [
+ [updateContainerExpirationPolicyMutation, resolver],
+ [expirationPolicyQuery, jest.fn().mockResolvedValue(expirationPolicyPayload())],
+ ];
+
+ fakeApollo = createMockApollo(requestHandlers);
+
+ fakeApollo.defaultClient.cache.writeQuery({
+ query: expirationPolicyQuery,
+ variables: {
+ projectPath: provide.projectPath,
+ },
+ ...expirationPolicyPayload(),
+ });
+
+ mountComponent({
+ provide,
+ config: {
+ localVue,
+ apolloProvider: fakeApollo,
+ },
+ });
+
+ return requestHandlers.map(resolvers => resolvers[1]);
+ };
+
beforeEach(() => {
- store = createStore();
- store.dispatch('setInitialState', stringifiedFormOptions);
- dispatchSpy = jest.spyOn(store, 'dispatch');
jest.spyOn(Tracking, 'event');
});
@@ -72,12 +113,12 @@ describe('Settings Form', () => {
it('v-model change update the settings property', () => {
mountComponent();
findFields().vm.$emit('input', { newValue: 'foo' });
- expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' });
+ expect(wrapper.emitted('input')).toEqual([['foo']]);
});
it('v-model change update the api error property', () => {
const apiErrors = { baz: 'bar' };
- mountComponent({ apiErrors });
+ mountComponent({ data: { apiErrors } });
expect(findFields().props('apiErrors')).toEqual(apiErrors);
findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' });
expect(findFields().props('apiErrors')).toEqual({});
@@ -85,19 +126,14 @@ describe('Settings Form', () => {
});
describe('form', () => {
- let form;
- beforeEach(() => {
- mountComponent();
- form = findForm();
- dispatchSpy.mockReturnValue();
- });
-
describe('form reset event', () => {
beforeEach(() => {
- form.trigger('reset');
+ mountComponent();
+
+ findForm().trigger('reset');
});
it('calls the appropriate function', () => {
- expect(dispatchSpy).toHaveBeenCalledWith('resetSettings');
+ expect(wrapper.emitted('reset')).toEqual([[]]);
});
it('tracks the reset event', () => {
@@ -108,54 +144,96 @@ describe('Settings Form', () => {
describe('form submit event ', () => {
it('save has type submit', () => {
mountComponent();
+
expect(findSaveButton().attributes('type')).toBe('submit');
});
- it('dispatches the saveSettings action', () => {
- dispatchSpy.mockResolvedValue();
- form.trigger('submit');
- expect(dispatchSpy).toHaveBeenCalledWith('saveSettings');
+ it('dispatches the correct apollo mutation', async () => {
+ const [expirationPolicyMutationResolver] = mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+ await expirationPolicyMutationResolver();
+ expect(expirationPolicyMutationResolver).toHaveBeenCalled();
});
it('tracks the submit event', () => {
- dispatchSpy.mockResolvedValue();
- form.trigger('submit');
+ mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload);
});
it('show a success toast when submit succeed', async () => {
- dispatchSpy.mockResolvedValue();
- form.trigger('submit');
- await waitForPromises();
+ const handlers = mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+ await Promise.all(handlers);
+ await wrapper.vm.$nextTick();
+
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, {
type: 'success',
});
});
describe('when submit fails', () => {
- it('shows an error', async () => {
- dispatchSpy.mockRejectedValue({ response: {} });
- form.trigger('submit');
- await waitForPromises();
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, {
- type: 'error',
+ describe('user recoverable errors', () => {
+ it('when there is an error is shown in a toast', async () => {
+ const handlers = mountComponentWithApollo({
+ resolver: jest
+ .fn()
+ .mockResolvedValue(expirationPolicyMutationPayload({ errors: ['foo'] })),
+ });
+
+ findForm().trigger('submit');
+ await Promise.all(handlers);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo', {
+ type: 'error',
+ });
});
});
+ describe('global errors', () => {
+ it('shows an error', async () => {
+ const handlers = mountComponentWithApollo({
+ resolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+ await Promise.all(handlers);
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
- it('parses the error messages', async () => {
- dispatchSpy.mockRejectedValue({
- response: {
- data: {
- message: {
- foo: 'bar',
- 'container_expiration_policy.name': ['baz'],
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, {
+ type: 'error',
+ });
+ });
+
+ it('parses the error messages', async () => {
+ const mutate = jest.fn().mockRejectedValue({
+ graphQLErrors: [
+ {
+ extensions: {
+ problems: [{ path: ['name'], message: 'baz' }],
+ },
},
- },
- },
+ ],
+ });
+ mountComponent({ mocks: { $apollo: { mutate } } });
+
+ findForm().trigger('submit');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+
+ expect(findFields().props('apiErrors')).toEqual({ name: 'baz' });
});
- form.trigger('submit');
- await waitForPromises();
- expect(findFields().props('apiErrors')).toEqual({ name: 'baz' });
});
});
});
@@ -163,51 +241,78 @@ describe('Settings Form', () => {
describe('form actions', () => {
describe('cancel button', () => {
- beforeEach(() => {
- store.commit('SET_SETTINGS', { foo: 'bar' });
+ it('has type reset', () => {
mountComponent();
- });
- it('has type reset', () => {
expect(findCancelButton().attributes('type')).toBe('reset');
});
- it('is disabled when isEdited is false', () =>
- wrapper.vm.$nextTick().then(() => {
- expect(findCancelButton().attributes('disabled')).toBe('true');
- }));
-
- it('is disabled isLoading is true', () => {
- store.commit('TOGGLE_LOADING');
- store.commit('UPDATE_SETTINGS', { settings: { foo: 'baz' } });
- return wrapper.vm.$nextTick().then(() => {
- expect(findCancelButton().attributes('disabled')).toBe('true');
- store.commit('TOGGLE_LOADING');
- });
- });
+ it.each`
+ isLoading | isEdited | mutationLoading | isDisabled
+ ${true} | ${true} | ${true} | ${true}
+ ${false} | ${true} | ${true} | ${true}
+ ${false} | ${false} | ${true} | ${true}
+ ${true} | ${false} | ${false} | ${true}
+ ${false} | ${false} | ${false} | ${true}
+ ${false} | ${true} | ${false} | ${false}
+ `(
+ 'when isLoading is $isLoading and isEdited is $isEdited and mutationLoading is $mutationLoading is $isDisabled that the is disabled',
+ ({ isEdited, isLoading, mutationLoading, isDisabled }) => {
+ mountComponent({
+ props: { ...defaultProps, isEdited, isLoading },
+ data: { mutationLoading },
+ });
- it('is enabled when isLoading is false and isEdited is true', () => {
- store.commit('UPDATE_SETTINGS', { settings: { foo: 'baz' } });
- return wrapper.vm.$nextTick().then(() => {
- expect(findCancelButton().attributes('disabled')).toBe(undefined);
- });
- });
+ const expectation = isDisabled ? 'true' : undefined;
+ expect(findCancelButton().attributes('disabled')).toBe(expectation);
+ },
+ );
});
- describe('when isLoading is true', () => {
- beforeEach(() => {
- store.commit('TOGGLE_LOADING');
+ describe('submit button', () => {
+ it('has type submit', () => {
mountComponent();
- });
- afterEach(() => {
- store.commit('TOGGLE_LOADING');
- });
- it('submit button is disabled and shows a spinner', () => {
- const button = findSaveButton();
- expect(button.attributes('disabled')).toBeTruthy();
- expect(findLoadingIcon(button).exists()).toBe(true);
+ expect(findSaveButton().attributes('type')).toBe('submit');
});
+ it.each`
+ isLoading | fieldsAreValid | mutationLoading | isDisabled
+ ${true} | ${true} | ${true} | ${true}
+ ${false} | ${true} | ${true} | ${true}
+ ${false} | ${false} | ${true} | ${true}
+ ${true} | ${false} | ${false} | ${true}
+ ${false} | ${false} | ${false} | ${true}
+ ${false} | ${true} | ${false} | ${false}
+ `(
+ 'when isLoading is $isLoading and fieldsAreValid is $fieldsAreValid and mutationLoading is $mutationLoading is $isDisabled that the is disabled',
+ ({ fieldsAreValid, isLoading, mutationLoading, isDisabled }) => {
+ mountComponent({
+ props: { ...defaultProps, isLoading },
+ data: { mutationLoading, fieldsAreValid },
+ });
+
+ const expectation = isDisabled ? 'true' : undefined;
+ expect(findSaveButton().attributes('disabled')).toBe(expectation);
+ },
+ );
+
+ it.each`
+ isLoading | mutationLoading | showLoading
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ ${false} | ${false} | ${false}
+ `(
+ 'when isLoading is $isLoading and mutationLoading is $mutationLoading is $showLoading that the loading icon is shown',
+ ({ isLoading, mutationLoading, showLoading }) => {
+ mountComponent({
+ props: { ...defaultProps, isLoading },
+ data: { mutationLoading },
+ });
+
+ expect(findSaveButton().props('loading')).toBe(showLoading);
+ },
+ );
});
});
});
diff --git a/spec/frontend/registry/settings/graphql/cache_updated_spec.js b/spec/frontend/registry/settings/graphql/cache_updated_spec.js
new file mode 100644
index 00000000000..e5f69a08285
--- /dev/null
+++ b/spec/frontend/registry/settings/graphql/cache_updated_spec.js
@@ -0,0 +1,56 @@
+import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update';
+import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
+
+describe('Registry settings cache update', () => {
+ let client;
+
+ const payload = {
+ data: {
+ updateContainerExpirationPolicy: {
+ containerExpirationPolicy: {
+ enabled: true,
+ },
+ },
+ },
+ };
+
+ const cacheMock = {
+ project: {
+ containerExpirationPolicy: {
+ enabled: false,
+ },
+ },
+ };
+
+ const queryAndVariables = {
+ query: expirationPolicyQuery,
+ variables: { projectPath: 'foo' },
+ };
+
+ beforeEach(() => {
+ client = {
+ readQuery: jest.fn().mockReturnValue(cacheMock),
+ writeQuery: jest.fn(),
+ };
+ });
+ describe('Registry settings cache update', () => {
+ it('calls readQuery', () => {
+ updateContainerExpirationPolicy('foo')(client, payload);
+ expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables);
+ });
+
+ it('writes the correct result in the cache', () => {
+ updateContainerExpirationPolicy('foo')(client, payload);
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ ...queryAndVariables,
+ data: {
+ project: {
+ containerExpirationPolicy: {
+ enabled: true,
+ },
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/registry/settings/mock_data.js b/spec/frontend/registry/settings/mock_data.js
new file mode 100644
index 00000000000..6a936785b7c
--- /dev/null
+++ b/spec/frontend/registry/settings/mock_data.js
@@ -0,0 +1,32 @@
+export const expirationPolicyPayload = override => ({
+ data: {
+ project: {
+ containerExpirationPolicy: {
+ cadence: 'EVERY_DAY',
+ enabled: true,
+ keepN: 'TEN_TAGS',
+ nameRegex: 'asdasdssssdfdf',
+ nameRegexKeep: 'sss',
+ olderThan: 'FOURTEEN_DAYS',
+ ...override,
+ },
+ },
+ },
+});
+
+export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) => ({
+ data: {
+ updateContainerExpirationPolicy: {
+ containerExpirationPolicy: {
+ cadence: 'EVERY_DAY',
+ enabled: true,
+ keepN: 'TEN_TAGS',
+ nameRegex: 'asdasdssssdfdf',
+ nameRegexKeep: 'sss',
+ olderThan: 'FOURTEEN_DAYS',
+ ...override,
+ },
+ errors,
+ },
+ },
+});
diff --git a/spec/frontend/registry/settings/store/actions_spec.js b/spec/frontend/registry/settings/store/actions_spec.js
deleted file mode 100644
index 51b89f96ef2..00000000000
--- a/spec/frontend/registry/settings/store/actions_spec.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import testAction from 'helpers/vuex_action_helper';
-import Api from '~/api';
-import * as actions from '~/registry/settings/store/actions';
-import * as types from '~/registry/settings/store/mutation_types';
-
-describe('Actions Registry Store', () => {
- describe.each`
- actionName | mutationName | payload
- ${'setInitialState'} | ${types.SET_INITIAL_STATE} | ${'foo'}
- ${'updateSettings'} | ${types.UPDATE_SETTINGS} | ${'foo'}
- ${'toggleLoading'} | ${types.TOGGLE_LOADING} | ${undefined}
- ${'resetSettings'} | ${types.RESET_SETTINGS} | ${undefined}
- `(
- '$actionName invokes $mutationName with payload $payload',
- ({ actionName, mutationName, payload }) => {
- it('should set state', done => {
- testAction(actions[actionName], payload, {}, [{ type: mutationName, payload }], [], done);
- });
- },
- );
-
- describe('receiveSettingsSuccess', () => {
- it('calls SET_SETTINGS', () => {
- testAction(
- actions.receiveSettingsSuccess,
- 'foo',
- {},
- [{ type: types.SET_SETTINGS, payload: 'foo' }],
- [],
- );
- });
- });
-
- describe('fetchSettings', () => {
- const state = {
- projectId: 'bar',
- };
-
- const payload = {
- data: {
- container_expiration_policy: 'foo',
- },
- };
-
- it('should fetch the data from the API', done => {
- Api.project = jest.fn().mockResolvedValue(payload);
- testAction(
- actions.fetchSettings,
- null,
- state,
- [],
- [
- { type: 'toggleLoading' },
- { type: 'receiveSettingsSuccess', payload: payload.data.container_expiration_policy },
- { type: 'toggleLoading' },
- ],
- done,
- );
- });
- });
-
- describe('saveSettings', () => {
- const state = {
- projectId: 'bar',
- settings: 'baz',
- };
-
- const payload = {
- data: {
- tag_expiration_policies: 'foo',
- },
- };
-
- it('should fetch the data from the API', done => {
- Api.updateProject = jest.fn().mockResolvedValue(payload);
- testAction(
- actions.saveSettings,
- null,
- state,
- [],
- [
- { type: 'toggleLoading' },
- { type: 'receiveSettingsSuccess', payload: payload.data.container_expiration_policy },
- { type: 'toggleLoading' },
- ],
- done,
- );
- });
- });
-});
diff --git a/spec/frontend/registry/settings/store/getters_spec.js b/spec/frontend/registry/settings/store/getters_spec.js
deleted file mode 100644
index b781d09466c..00000000000
--- a/spec/frontend/registry/settings/store/getters_spec.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import * as getters from '~/registry/settings/store/getters';
-import * as utils from '~/registry/shared/utils';
-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`
- getter | variable | formOption
- ${'getCadence'} | ${'cadence'} | ${'cadence'}
- ${'getKeepN'} | ${'keep_n'} | ${'keepN'}
- ${'getOlderThan'} | ${'older_than'} | ${'olderThan'}
- `('Options getter', ({ getter, variable, formOption }) => {
- beforeEach(() => {
- utils.findDefaultOption = jest.fn();
- });
-
- it(`${getter} returns ${variable} when ${variable} exists in settings`, () => {
- expect(getters[getter]({ settings })).toBe(settings[variable]);
- });
-
- it(`${getter} calls findDefaultOption when ${variable} does not exists in settings`, () => {
- getters[getter]({ settings: {}, formOptions });
- expect(utils.findDefaultOption).toHaveBeenCalledWith(formOptions[formOption]);
- });
- });
-
- 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' };
- expect(getters.getIsEdited({ original: same, settings: same })).toBe(false);
- });
-
- it('returns true when original is different from settings', () => {
- expect(getters.getIsEdited({ original: { foo: 'bar' }, settings: { foo: 'baz' } })).toBe(
- true,
- );
- });
- });
-
- describe('getIsDisabled', () => {
- it.each`
- original | enableHistoricEntries | result
- ${undefined} | ${false} | ${true}
- ${{ foo: 'bar' }} | ${undefined} | ${false}
- ${{}} | ${false} | ${false}
- `(
- 'returns $result when original is $original and enableHistoricEntries is $enableHistoricEntries',
- ({ original, enableHistoricEntries, result }) => {
- expect(getters.getIsDisabled({ original, enableHistoricEntries })).toBe(result);
- },
- );
- });
-});
diff --git a/spec/frontend/registry/settings/store/mutations_spec.js b/spec/frontend/registry/settings/store/mutations_spec.js
deleted file mode 100644
index 1d85e38eb36..00000000000
--- a/spec/frontend/registry/settings/store/mutations_spec.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import mutations from '~/registry/settings/store/mutations';
-import * as types from '~/registry/settings/store/mutation_types';
-import createState from '~/registry/settings/store/state';
-import { formOptions, stringifiedFormOptions } from '../../shared/mock_data';
-
-describe('Mutations Registry Store', () => {
- let mockState;
-
- beforeEach(() => {
- mockState = createState();
- });
-
- describe('SET_INITIAL_STATE', () => {
- it('should set the initial state', () => {
- const payload = {
- projectId: 'foo',
- enableHistoricEntries: false,
- adminSettingsPath: 'foo',
- isAdmin: true,
- };
- const expectedState = { ...mockState, ...payload, formOptions };
- mutations[types.SET_INITIAL_STATE](mockState, {
- ...payload,
- ...stringifiedFormOptions,
- });
-
- expect(mockState).toEqual(expectedState);
- });
- });
-
- describe('UPDATE_SETTINGS', () => {
- it('should update the settings', () => {
- mockState.settings = { foo: 'bar' };
- const payload = { foo: 'baz' };
- const expectedState = { ...mockState, settings: payload };
- mutations[types.UPDATE_SETTINGS](mockState, { settings: payload });
- expect(mockState.settings).toEqual(expectedState.settings);
- });
- });
-
- describe('SET_SETTINGS', () => {
- it('should set the settings and original', () => {
- const payload = { foo: 'baz' };
- const expectedState = { ...mockState, settings: payload };
- mutations[types.SET_SETTINGS](mockState, payload);
- expect(mockState.settings).toEqual(expectedState.settings);
- expect(mockState.original).toEqual(expectedState.settings);
- });
-
- it('should keep the default state when settings is not present', () => {
- const originalSettings = { ...mockState.settings };
- mutations[types.SET_SETTINGS](mockState);
- expect(mockState.settings).toEqual(originalSettings);
- expect(mockState.original).toEqual(undefined);
- });
- });
-
- describe('RESET_SETTINGS', () => {
- it('should copy original over settings', () => {
- mockState.settings = { foo: 'bar' };
- mockState.original = { foo: 'baz' };
- mutations[types.RESET_SETTINGS](mockState);
- expect(mockState.settings).toEqual(mockState.original);
- });
-
- it('if original is undefined it should initialize to empty object', () => {
- mockState.settings = { foo: 'bar' };
- mockState.original = undefined;
- mutations[types.RESET_SETTINGS](mockState);
- expect(mockState.settings).toEqual({});
- });
- });
-
- describe('TOGGLE_LOADING', () => {
- it('should toggle the loading', () => {
- mutations[types.TOGGLE_LOADING](mockState);
- expect(mockState.isLoading).toEqual(true);
- });
- });
-});
diff --git a/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap
new file mode 100644
index 00000000000..032007bba51
--- /dev/null
+++ b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap
@@ -0,0 +1,101 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Utils formOptionsGenerator returns an object containing cadence 1`] = `
+Array [
+ Object {
+ "default": true,
+ "key": "EVERY_DAY",
+ "label": "Every day",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_WEEK",
+ "label": "Every week",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_TWO_WEEKS",
+ "label": "Every two weeks",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_MONTH",
+ "label": "Every month",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_THREE_MONTHS",
+ "label": "Every three months",
+ },
+]
+`;
+
+exports[`Utils formOptionsGenerator returns an object containing keepN 1`] = `
+Array [
+ Object {
+ "default": false,
+ "key": "ONE_TAG",
+ "label": "1 tag per image name",
+ "variable": 1,
+ },
+ Object {
+ "default": false,
+ "key": "FIVE_TAGS",
+ "label": "5 tags per image name",
+ "variable": 5,
+ },
+ Object {
+ "default": true,
+ "key": "TEN_TAGS",
+ "label": "10 tags per image name",
+ "variable": 10,
+ },
+ Object {
+ "default": false,
+ "key": "TWENTY_FIVE_TAGS",
+ "label": "25 tags per image name",
+ "variable": 25,
+ },
+ Object {
+ "default": false,
+ "key": "FIFTY_TAGS",
+ "label": "50 tags per image name",
+ "variable": 50,
+ },
+ Object {
+ "default": false,
+ "key": "ONE_HUNDRED_TAGS",
+ "label": "100 tags per image name",
+ "variable": 100,
+ },
+]
+`;
+
+exports[`Utils formOptionsGenerator returns an object containing olderThan 1`] = `
+Array [
+ Object {
+ "default": false,
+ "key": "SEVEN_DAYS",
+ "label": "7 days until tags are automatically removed",
+ "variable": 7,
+ },
+ Object {
+ "default": false,
+ "key": "FOURTEEN_DAYS",
+ "label": "14 days until tags are automatically removed",
+ "variable": 14,
+ },
+ Object {
+ "default": false,
+ "key": "THIRTY_DAYS",
+ "label": "30 days until tags are automatically removed",
+ "variable": 30,
+ },
+ Object {
+ "default": true,
+ "key": "NINETY_DAYS",
+ "label": "90 days until tags are automatically removed",
+ "variable": 90,
+ },
+]
+`;
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 ee765ffd1c0..bee9bca5369 100644
--- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
+++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
@@ -40,13 +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'}
- ${'keep-name'} | ${'name_regex_keep'} | ${'bar'} | ${'disabled'}
+ elementName | modelName | value | disabledByToggle
+ ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
+ ${'interval'} | ${'olderThan'} | ${'foo'} | ${'disabled'}
+ ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
+ ${'latest'} | ${'keepN'} | ${'foo'} | ${'disabled'}
+ ${'name-matching'} | ${'nameRegex'} | ${'foo'} | ${'disabled'}
+ ${'keep-name'} | ${'nameRegexKeep'} | ${'bar'} | ${'disabled'}
`(
`${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`,
({ elementName, modelName, value, disabledByToggle }) => {
@@ -128,9 +128,9 @@ describe('Expiration Policy Form', () => {
});
describe.each`
- modelName | elementName
- ${'name_regex'} | ${'name-matching'}
- ${'name_regex_keep'} | ${'keep-name'}
+ modelName | elementName
+ ${'nameRegex'} | ${'name-matching'}
+ ${'nameRegexKeep'} | ${'keep-name'}
`('regex textarea validation', ({ modelName, elementName }) => {
const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
diff --git a/spec/frontend/registry/shared/stubs.js b/spec/frontend/registry/shared/stubs.js
new file mode 100644
index 00000000000..f6b88d70e49
--- /dev/null
+++ b/spec/frontend/registry/shared/stubs.js
@@ -0,0 +1,11 @@
+export const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' };
+export const GlCard = {
+ name: 'gl-card-stub',
+ template: `
+<div>
+ <slot name="header"></slot>
+ <slot></slot>
+ <slot name="footer"></slot>
+</div>
+`,
+};
diff --git a/spec/frontend/registry/shared/utils_spec.js b/spec/frontend/registry/shared/utils_spec.js
new file mode 100644
index 00000000000..edb0c3261be
--- /dev/null
+++ b/spec/frontend/registry/shared/utils_spec.js
@@ -0,0 +1,37 @@
+import {
+ formOptionsGenerator,
+ optionLabelGenerator,
+ olderThanTranslationGenerator,
+} from '~/registry/shared/utils';
+
+describe('Utils', () => {
+ describe('optionLabelGenerator', () => {
+ it('returns an array with a set label', () => {
+ const result = optionLabelGenerator(
+ [{ variable: 1 }, { variable: 2 }],
+ olderThanTranslationGenerator,
+ );
+ expect(result).toEqual([
+ { variable: 1, label: '1 day until tags are automatically removed' },
+ { variable: 2, label: '2 days until tags are automatically removed' },
+ ]);
+ });
+ });
+
+ describe('formOptionsGenerator', () => {
+ it('returns an object containing olderThan', () => {
+ expect(formOptionsGenerator().olderThan).toBeDefined();
+ expect(formOptionsGenerator().olderThan).toMatchSnapshot();
+ });
+
+ it('returns an object containing cadence', () => {
+ expect(formOptionsGenerator().cadence).toBeDefined();
+ expect(formOptionsGenerator().cadence).toMatchSnapshot();
+ });
+
+ it('returns an object containing keepN', () => {
+ expect(formOptionsGenerator().keepN).toBeDefined();
+ expect(formOptionsGenerator().keepN).toMatchSnapshot();
+ });
+ });
+});
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
index 1b938c93df8..db33a9cdce1 100644
--- a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
+++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
@@ -19,9 +19,8 @@ describe('RelatedMergeRequests', () => {
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>`;
+ document.body.innerHTML = `<div id="js-issuable-app"></div>`;
+ document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(mockData);
mock = new MockAdapter(axios);
mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 });
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
index f56e296d106..84247e2a5a0 100644
--- a/spec/frontend/releases/__snapshots__/util_spec.js.snap
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -5,109 +5,123 @@ Object {
"data": Array [
Object {
"_links": Object {
- "editUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit",
- "issuesUrl": null,
- "mergeRequestsUrl": null,
- "self": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10",
- "selfUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10",
+ "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit",
+ "issuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened",
+ "mergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened",
+ "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
+ "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
},
"assets": Object {
- "count": 7,
+ "count": 8,
"links": Array [
Object {
- "directAssetUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-3",
"external": true,
- "id": "gid://gitlab/Releases::Link/69",
- "linkType": "other",
- "name": "An example link",
- "url": "https://example.com/link",
+ "id": "gid://gitlab/Releases::Link/13",
+ "linkType": "image",
+ "name": "Image",
+ "url": "https://example.com/image",
},
Object {
- "directAssetUrl": "https://example.com/package",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-2",
"external": true,
- "id": "gid://gitlab/Releases::Link/68",
+ "id": "gid://gitlab/Releases::Link/12",
"linkType": "package",
- "name": "An example package link",
+ "name": "Package",
"url": "https://example.com/package",
},
Object {
- "directAssetUrl": "https://example.com/image",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-1",
+ "external": false,
+ "id": "gid://gitlab/Releases::Link/11",
+ "linkType": "runbook",
+ "name": "Runbook",
+ "url": "http://localhost/releases-namespace/releases-project/runbook",
+ },
+ Object {
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/linux-amd64",
"external": true,
- "id": "gid://gitlab/Releases::Link/67",
- "linkType": "image",
- "name": "An example image",
- "url": "https://example.com/image",
+ "id": "gid://gitlab/Releases::Link/10",
+ "linkType": "other",
+ "name": "linux-amd64 binaries",
+ "url": "https://downloads.example.com/bin/gitlab-linux-amd64",
},
],
"sources": Array [
Object {
"format": "zip",
- "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.zip",
},
Object {
"format": "tar.gz",
- "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.gz",
},
Object {
"format": "tar.bz2",
- "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.bz2",
},
Object {
"format": "tar",
- "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar",
},
],
},
"author": Object {
- "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png",
- "username": "root",
- "webUrl": "http://0.0.0.0:3000/root",
+ "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon",
+ "username": "administrator",
+ "webUrl": "http://localhost/administrator",
},
"commit": Object {
- "shortId": "92e7ea2e",
- "title": "Testing a change.",
+ "shortId": "b83d6e39",
+ "title": "Merge branch 'branch-merged' into 'master'",
},
- "commitPath": "http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7",
- "descriptionHtml": "<p data-sourcepos=\\"1:1-1:24\\" dir=\\"auto\\">This is version <strong>1.0</strong>!</p>",
+ "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0",
+ "descriptionHtml": "<p data-sourcepos=\\"1:1-1:33\\" dir=\\"auto\\">Best. Release. <strong>Ever.</strong> <gl-emoji title=\\"rocket\\" data-name=\\"rocket\\" data-unicode-version=\\"6.0\\">🚀</gl-emoji></p>",
"evidences": Array [
Object {
- "collectedAt": "2020-08-21T20:15:19Z",
- "filepath": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json",
- "sha": "22bde8e8b93d870a29ddc339287a1fbb598f45d1396d",
+ "collectedAt": "2018-12-03T00:00:00Z",
+ "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json",
+ "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d",
},
],
"milestones": Array [
Object {
- "description": "",
- "id": "gid://gitlab/Milestone/60",
+ "description": "The 12.4 milestone",
+ "id": "gid://gitlab/Milestone/124",
"issueStats": Object {
- "closed": 0,
- "total": 0,
+ "closed": 1,
+ "total": 4,
},
"stats": undefined,
"title": "12.4",
"webPath": undefined,
- "webUrl": "/root/release-test/-/milestones/2",
+ "webUrl": "/releases-namespace/releases-project/-/milestones/2",
},
Object {
- "description": "Milestone 12.3",
- "id": "gid://gitlab/Milestone/59",
+ "description": "The 12.3 milestone",
+ "id": "gid://gitlab/Milestone/123",
"issueStats": Object {
- "closed": 1,
- "total": 2,
+ "closed": 3,
+ "total": 5,
},
"stats": undefined,
"title": "12.3",
"webPath": undefined,
- "webUrl": "/root/release-test/-/milestones/1",
+ "webUrl": "/releases-namespace/releases-project/-/milestones/1",
},
],
- "name": "Release 1.0",
- "releasedAt": "2020-08-21T20:15:18Z",
- "tagName": "v5.10",
- "tagPath": "/root/release-test/-/tags/v5.10",
- "upcomingRelease": false,
+ "name": "The first release",
+ "releasedAt": "2018-12-10T00:00:00Z",
+ "tagName": "v1.1",
+ "tagPath": "/releases-namespace/releases-project/-/tags/v1.1",
+ "upcomingRelease": true,
},
],
+ "paginationInfo": Object {
+ "endCursor": "eyJpZCI6IjEiLCJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyJ9",
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "eyJpZCI6IjEiLCJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyJ9",
+ },
}
`;
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index e9727801c1a..3367ca8ba3a 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -3,12 +3,15 @@ import { mount } from '@vue/test-utils';
import { merge } from 'lodash';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue';
-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';
+const originalRelease = getJSONFixture('api/releases/release.json');
+const originalMilestones = originalRelease.milestones;
+
describe('Release edit/new component', () => {
let wrapper;
let release;
diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js
index bcb87509cc3..9f1577c2f1e 100644
--- a/spec/frontend/releases/components/app_index_spec.js
+++ b/spec/frontend/releases/components/app_index_spec.js
@@ -2,27 +2,33 @@ import { range as rge } from 'lodash';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleasesApp from '~/releases/components/app_index.vue';
import createStore from '~/releases/stores';
import createListModule from '~/releases/stores/modules/list';
import api from '~/api';
-import {
- pageInfoHeadersWithoutPagination,
- pageInfoHeadersWithPagination,
- release2 as release,
- releases,
-} from '../mock_data';
+import { pageInfoHeadersWithoutPagination, pageInfoHeadersWithPagination } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import ReleasesPagination from '~/releases/components/releases_pagination.vue';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ ...jest.requireActual('~/lib/utils/common_utils'),
+ getParameterByName: jest.fn().mockImplementation(paramName => {
+ return `${paramName}_param_value`;
+ }),
+}));
const localVue = createLocalVue();
localVue.use(Vuex);
+const release = getJSONFixture('api/releases/release.json');
+const releases = [release];
+
describe('Releases App ', () => {
let wrapper;
let fetchReleaseSpy;
- const releasesPagination = rge(21).map(index => ({
+ const paginatedReleases = rge(21).map(index => ({
...convertObjectPropsToCamelCase(release, { deep: true }),
tagName: `${index}.00`,
}));
@@ -70,9 +76,13 @@ describe('Releases App ', () => {
createComponent();
});
- it('calls fetchRelease with the page parameter', () => {
+ it('calls fetchRelease with the page, before, and after parameters', () => {
expect(fetchReleaseSpy).toHaveBeenCalledTimes(1);
- expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { page: null });
+ expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), {
+ page: 'page_param_value',
+ before: 'before_param_value',
+ after: 'after_param_value',
+ });
});
});
@@ -91,7 +101,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(true);
expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(false);
- expect(wrapper.contains(TablePagination)).toBe(false);
+ expect(wrapper.contains(ReleasesPagination)).toBe(false);
});
});
@@ -108,7 +118,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(false);
expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(true);
- expect(wrapper.contains(TablePagination)).toBe(true);
+ expect(wrapper.contains(ReleasesPagination)).toBe(true);
});
});
@@ -116,7 +126,7 @@ describe('Releases App ', () => {
beforeEach(() => {
jest
.spyOn(api, 'releases')
- .mockResolvedValue({ data: releasesPagination, headers: pageInfoHeadersWithPagination });
+ .mockResolvedValue({ data: paginatedReleases, headers: pageInfoHeadersWithPagination });
createComponent();
});
@@ -125,7 +135,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(false);
expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(true);
- expect(wrapper.contains(TablePagination)).toBe(true);
+ expect(wrapper.contains(ReleasesPagination)).toBe(true);
});
});
@@ -154,7 +164,7 @@ describe('Releases App ', () => {
const newReleasePath = 'path/to/new/release';
beforeEach(() => {
- createComponent({ ...defaultInitialState, newReleasePath });
+ createComponent({ newReleasePath });
});
it('renders the "New release" button', () => {
@@ -174,4 +184,27 @@ describe('Releases App ', () => {
});
});
});
+
+ describe('when the back button is pressed', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(api, 'releases')
+ .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
+
+ createComponent();
+
+ fetchReleaseSpy.mockClear();
+
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+
+ it('calls fetchRelease with the page parameter', () => {
+ expect(fetchReleaseSpy).toHaveBeenCalledTimes(1);
+ expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), {
+ page: 'page_param_value',
+ before: 'before_param_value',
+ after: 'after_param_value',
+ });
+ });
+ });
});
diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js
index 502a1053663..181fa0150f1 100644
--- a/spec/frontend/releases/components/app_show_spec.js
+++ b/spec/frontend/releases/components/app_show_spec.js
@@ -1,11 +1,13 @@
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleaseShowApp from '~/releases/components/app_show.vue';
-import { release as originalRelease } from '../mock_data';
+import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release show component', () => {
let wrapper;
let release;
@@ -33,7 +35,7 @@ describe('Release show component', () => {
wrapper = shallowMount(ReleaseShowApp, { store });
};
- const findLoadingSkeleton = () => wrapper.find(GlSkeletonLoading);
+ const findLoadingSkeleton = () => wrapper.find(ReleaseSkeletonLoader);
const findReleaseBlock = () => wrapper.find(ReleaseBlock);
it('calls fetchRelease when the component is created', () => {
diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js
index 582c0b32716..e5b8ed267a0 100644
--- a/spec/frontend/releases/components/asset_links_form_spec.js
+++ b/spec/frontend/releases/components/asset_links_form_spec.js
@@ -1,7 +1,7 @@
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
+import { getJSONFixture } from 'helpers/fixtures';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
-import { release as originalRelease } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { ENTER_KEY } from '~/lib/utils/keys';
import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants';
@@ -9,6 +9,8 @@ import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release edit component', () => {
let wrapper;
let release;
@@ -223,10 +225,18 @@ describe('Release edit component', () => {
});
});
- it('selects the default asset type if no type was provided by the backend', () => {
- const selected = wrapper.find({ ref: 'typeSelect' }).element.value;
+ describe('when no link type was provided by the backend', () => {
+ beforeEach(() => {
+ delete release.assets.links[0].linkType;
+
+ factory({ release });
+ });
+
+ it('selects the default asset type', () => {
+ const selected = wrapper.find({ ref: 'typeSelect' }).element.value;
- expect(selected).toBe(DEFAULT_ASSET_LINK_TYPE);
+ expect(selected).toBe(DEFAULT_ASSET_LINK_TYPE);
+ });
});
});
diff --git a/spec/frontend/releases/components/evidence_block_spec.js b/spec/frontend/releases/components/evidence_block_spec.js
index ba60a79e464..b8c78f90fc2 100644
--- a/spec/frontend/releases/components/evidence_block_spec.js
+++ b/spec/frontend/releases/components/evidence_block_spec.js
@@ -1,11 +1,13 @@
import { mount } from '@vue/test-utils';
import { GlLink, GlIcon } from '@gitlab/ui';
+import { getJSONFixture } from 'helpers/fixtures';
import { truncateSha } from '~/lib/utils/text_utility';
-import { release as originalRelease } from '../mock_data';
import EvidenceBlock from '~/releases/components/evidence_block.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Evidence Block', () => {
let wrapper;
let release;
@@ -35,7 +37,7 @@ describe('Evidence Block', () => {
});
it('renders the title for the dowload link', () => {
- expect(wrapper.find(GlLink).text()).toBe('v1.1.2-evidences-1.json');
+ expect(wrapper.find(GlLink).text()).toBe(`v1.1-evidences-1.json`);
});
it('renders the correct hover text for the download', () => {
@@ -43,7 +45,7 @@ describe('Evidence Block', () => {
});
it('renders the correct file link for download', () => {
- expect(wrapper.find(GlLink).attributes().download).toBe('v1.1.2-evidences-1.json');
+ expect(wrapper.find(GlLink).attributes().download).toBe(`v1.1-evidences-1.json`);
});
describe('sha text', () => {
diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js
index 3453ecbf8ab..adccd9d87ef 100644
--- a/spec/frontend/releases/components/release_block_assets_spec.js
+++ b/spec/frontend/releases/components/release_block_assets_spec.js
@@ -1,10 +1,12 @@
import { mount } from '@vue/test-utils';
import { GlCollapse } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
-import { cloneDeep } from 'lodash';
+import { getJSONFixture } from 'helpers/fixtures';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue';
import { ASSET_LINK_TYPE } from '~/releases/constants';
-import { assets } from '../mock_data';
+
+const { assets } = getJSONFixture('api/releases/release.json');
describe('Release block assets', () => {
let wrapper;
@@ -31,7 +33,7 @@ describe('Release block assets', () => {
wrapper.findAll('h5').filter(h5 => h5.text() === sections[type]);
beforeEach(() => {
- defaultProps = { assets: cloneDeep(assets) };
+ defaultProps = { assets: convertObjectPropsToCamelCase(assets, { deep: true }) };
});
describe('with default props', () => {
@@ -43,7 +45,7 @@ describe('Release block assets', () => {
const accordionButton = findAccordionButton();
expect(accordionButton.exists()).toBe(true);
- expect(trimText(accordionButton.text())).toBe('Assets 5');
+ expect(trimText(accordionButton.text())).toBe('Assets 8');
});
it('renders the accordion as expanded by default', () => {
diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js
index bde01cc0e00..f1c0c24f8ca 100644
--- a/spec/frontend/releases/components/release_block_footer_spec.js
+++ b/spec/frontend/releases/components/release_block_footer_spec.js
@@ -1,11 +1,13 @@
import { mount } from '@vue/test-utils';
import { GlLink, GlIcon } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import { cloneDeep } from 'lodash';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
-import { release as originalRelease } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
const mockFutureDate = new Date(9999, 0, 0).toISOString();
let mockIsFutureRelease = false;
diff --git a/spec/frontend/releases/components/release_block_header_spec.js b/spec/frontend/releases/components/release_block_header_spec.js
index 9c6cbc86d3c..f2159871395 100644
--- a/spec/frontend/releases/components/release_block_header_spec.js
+++ b/spec/frontend/releases/components/release_block_header_spec.js
@@ -1,11 +1,13 @@
import { shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
import { GlLink } from '@gitlab/ui';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleaseBlockHeader from '~/releases/components/release_block_header.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { release as originalRelease } from '../mock_data';
import { BACK_URL_PARAM } from '~/releases/constants';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release block header', () => {
let wrapper;
let release;
@@ -49,7 +51,7 @@ describe('Release block header', () => {
});
it('renders the title as text', () => {
- expect(findHeader().text()).toBe(release.name);
+ expect(findHeader().text()).toContain(release.name);
expect(findHeaderLink().exists()).toBe(false);
});
});
diff --git a/spec/frontend/releases/components/release_block_metadata_spec.js b/spec/frontend/releases/components/release_block_metadata_spec.js
index 6f184e45600..9038553fc8e 100644
--- a/spec/frontend/releases/components/release_block_metadata_spec.js
+++ b/spec/frontend/releases/components/release_block_metadata_spec.js
@@ -1,10 +1,12 @@
import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import { cloneDeep } from 'lodash';
import ReleaseBlockMetadata from '~/releases/components/release_block_metadata.vue';
-import { release as originalRelease } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
const mockFutureDate = new Date(9999, 0, 0).toISOString();
let mockIsFutureRelease = false;
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 0e79c45b337..45f4eaa01a9 100644
--- a/spec/frontend/releases/components/release_block_milestone_info_spec.js
+++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js
@@ -1,11 +1,13 @@
import { mount } from '@vue/test-utils';
import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue';
-import { milestones as originalMilestones } from '../mock_data';
import { MAX_MILESTONES_TO_DISPLAY } from '~/releases/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const { milestones: originalMilestones } = getJSONFixture('api/releases/release.json');
+
describe('Release block milestone info', () => {
let wrapper;
let milestones;
@@ -35,7 +37,7 @@ describe('Release block milestone info', () => {
beforeEach(() => factory({ milestones }));
it('renders the correct percentage', () => {
- expect(milestoneProgressBarContainer().text()).toContain('41% complete');
+ expect(milestoneProgressBarContainer().text()).toContain('44% complete');
});
it('renders a progress bar that displays the correct percentage', () => {
@@ -44,14 +46,24 @@ describe('Release block milestone info', () => {
expect(progressBar.exists()).toBe(true);
expect(progressBar.attributes()).toEqual(
expect.objectContaining({
- value: '22',
- max: '54',
+ value: '4',
+ max: '9',
}),
);
});
it('renders a list of links to all associated milestones', () => {
- expect(trimText(milestoneListContainer().text())).toContain('Milestones 13.6 • 13.5');
+ // The API currently returns the milestones in a non-deterministic order,
+ // which causes the frontend fixture used by this test to return the
+ // milestones in one order locally and a different order in the CI pipeline.
+ // This is a bug and is tracked here: https://gitlab.com/gitlab-org/gitlab/-/issues/259012
+ // When this bug is fixed this expectation should be updated to
+ // assert the expected order.
+ const containerText = trimText(milestoneListContainer().text());
+ expect(
+ containerText.includes('Milestones 12.4 • 12.3') ||
+ containerText.includes('Milestones 12.3 • 12.4'),
+ ).toBe(true);
milestones.forEach((m, i) => {
const milestoneLink = milestoneListContainer()
@@ -65,7 +77,7 @@ describe('Release block milestone info', () => {
});
it('renders the "Issues" section with a total count of issues associated to the milestone(s)', () => {
- const totalIssueCount = 54;
+ const totalIssueCount = 9;
const issuesContainerText = trimText(issuesContainer().text());
expect(issuesContainerText).toContain(`Issues ${totalIssueCount}`);
@@ -73,7 +85,7 @@ describe('Release block milestone info', () => {
const badge = issuesContainer().find(GlBadge);
expect(badge.text()).toBe(totalIssueCount.toString());
- expect(issuesContainerText).toContain('Open: 32 • Closed: 22');
+ expect(issuesContainerText).toContain('Open: 5 • Closed: 4');
});
});
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
index a7f1388664b..af5e538b95e 100644
--- a/spec/frontend/releases/components/release_block_spec.js
+++ b/spec/frontend/releases/components/release_block_spec.js
@@ -1,15 +1,17 @@
import $ from 'jquery';
import { mount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
+import { getJSONFixture } from 'helpers/fixtures';
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';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { release as originalRelease } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
import * as urlUtility from '~/lib/utils/url_utility';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release block', () => {
let wrapper;
let release;
@@ -46,7 +48,7 @@ describe('Release block', () => {
beforeEach(() => factory(release));
it("renders the block with an id equal to the release's tag name", () => {
- expect(wrapper.attributes().id).toBe('v0.3');
+ expect(wrapper.attributes().id).toBe(release.tagName);
});
it(`renders an edit button that links to the "Edit release" page with a "${BACK_URL_PARAM}" parameter`, () => {
@@ -107,7 +109,7 @@ describe('Release block', () => {
});
it('does not render external label when link is not external', () => {
- expect(wrapper.find('.js-assets-list li:nth-child(2) a').text()).not.toContain(
+ expect(wrapper.find('.js-assets-list li:nth-child(3) a').text()).not.toContain(
'external source',
);
});
diff --git a/spec/frontend/releases/components/release_skeleton_loader_spec.js b/spec/frontend/releases/components/release_skeleton_loader_spec.js
new file mode 100644
index 00000000000..7fbf864568a
--- /dev/null
+++ b/spec/frontend/releases/components/release_skeleton_loader_spec.js
@@ -0,0 +1,15 @@
+import { mount } from '@vue/test-utils';
+import { GlSkeletonLoader } from '@gitlab/ui';
+import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
+
+describe('release_skeleton_loader.vue', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(ReleaseSkeletonLoader);
+ });
+
+ it('renders a GlSkeletonLoader', () => {
+ expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/releases/components/releases_pagination_graphql_spec.js b/spec/frontend/releases/components/releases_pagination_graphql_spec.js
index b01a28eb6c3..bba5e532e5e 100644
--- a/spec/frontend/releases/components/releases_pagination_graphql_spec.js
+++ b/spec/frontend/releases/components/releases_pagination_graphql_spec.js
@@ -29,7 +29,7 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
listModule.state.graphQlPageInfo = pageInfo;
- listModule.actions.fetchReleasesGraphQl = jest.fn();
+ listModule.actions.fetchReleases = jest.fn();
wrapper = mount(ReleasesPaginationGraphql, {
store: createStore({
@@ -141,8 +141,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
findNextButton().trigger('click');
});
- it('calls fetchReleasesGraphQl with the correct after cursor', () => {
- expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([
+ it('calls fetchReleases with the correct after cursor', () => {
+ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { after: cursors.endCursor }],
]);
});
@@ -159,8 +159,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
findPrevButton().trigger('click');
});
- it('calls fetchReleasesGraphQl with the correct before cursor', () => {
- expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([
+ it('calls fetchReleases with the correct before cursor', () => {
+ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { before: cursors.startCursor }],
]);
});
diff --git a/spec/frontend/releases/components/releases_pagination_rest_spec.js b/spec/frontend/releases/components/releases_pagination_rest_spec.js
index 4fd3e085fc9..59c0c31413a 100644
--- a/spec/frontend/releases/components/releases_pagination_rest_spec.js
+++ b/spec/frontend/releases/components/releases_pagination_rest_spec.js
@@ -20,9 +20,9 @@ describe('~/releases/components/releases_pagination_rest.vue', () => {
const createComponent = pageInfo => {
listModule = createListModule({ projectId });
- listModule.state.pageInfo = pageInfo;
+ listModule.state.restPageInfo = pageInfo;
- listModule.actions.fetchReleasesRest = jest.fn();
+ listModule.actions.fetchReleases = jest.fn();
wrapper = mount(ReleasesPaginationRest, {
store: createStore({
@@ -57,8 +57,8 @@ describe('~/releases/components/releases_pagination_rest.vue', () => {
findGlPagination().vm.$emit('input', newPage);
});
- it('calls fetchReleasesRest with the correct page', () => {
- expect(listModule.actions.fetchReleasesRest.mock.calls).toEqual([
+ it('calls fetchReleases with the correct page', () => {
+ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { page: newPage }],
]);
});
diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js
index 58cd69a2f6a..c89182faa44 100644
--- a/spec/frontend/releases/mock_data.js
+++ b/spec/frontend/releases/mock_data.js
@@ -1,139 +1,3 @@
-import { ASSET_LINK_TYPE } from '~/releases/constants';
-
-export const milestones = [
- {
- id: 50,
- iid: 2,
- project_id: 18,
- title: '13.6',
- description: 'The 13.6 milestone!',
- state: 'active',
- created_at: '2019-08-27T17:22:38.280Z',
- updated_at: '2019-08-27T17:22:38.280Z',
- due_date: '2019-09-19',
- start_date: '2019-08-31',
- web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/2',
- issue_stats: {
- total: 33,
- closed: 19,
- },
- },
- {
- id: 49,
- iid: 1,
- project_id: 18,
- title: '13.5',
- description: 'The 13.5 milestone!',
- state: 'active',
- created_at: '2019-08-26T17:55:48.643Z',
- updated_at: '2019-08-26T17:55:48.643Z',
- due_date: '2019-10-11',
- start_date: '2019-08-19',
- web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/1',
- issue_stats: {
- total: 21,
- closed: 3,
- },
- },
-];
-
-export const release = {
- name: 'New release',
- tag_name: 'v0.3',
- tag_path: '/root/release-test/-/tags/v0.3',
- description: 'A super nice release!',
- description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>',
- created_at: '2019-08-26T17:54:04.952Z',
- released_at: '2019-08-26T17:54:04.807Z',
- author: {
- id: 1,
- name: 'Administrator',
- username: 'root',
- state: 'active',
- avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- web_url: 'http://0.0.0.0:3001/root',
- },
- commit: {
- id: 'c22b0728d1b465f82898c884d32b01aa642f96c1',
- short_id: 'c22b0728',
- created_at: '2019-08-26T17:47:07.000Z',
- parent_ids: [],
- title: 'Initial commit',
- message: 'Initial commit',
- author_name: 'Administrator',
- author_email: 'admin@example.com',
- authored_date: '2019-08-26T17:47:07.000Z',
- committer_name: 'Administrator',
- committer_email: 'admin@example.com',
- committed_date: '2019-08-26T17:47:07.000Z',
- },
- commit_path: '/root/release-test/commit/c22b0728d1b465f82898c884d32b01aa642f96c1',
- upcoming_release: false,
- milestones,
- evidences: [
- {
- filepath:
- 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/1.json',
- sha: 'fb3a125fd69a0e5048ebfb0ba43eb32ce4911520dd8d',
- collected_at: '2018-10-19 15:43:20 +0200',
- },
- {
- filepath:
- 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/2.json',
- sha: '6ebd17a66e6a861175735416e49cf677678029805712dd71bb805c609e2d9108',
- collected_at: '2018-10-19 15:43:20 +0200',
- },
- {
- filepath:
- 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/3.json',
- sha: '2f65beaf275c3cb4b4e24fb01d481cc475d69c957830833f15338384816b5cba',
- collected_at: '2018-10-19 15:43:20 +0200',
- },
- ],
- assets: {
- count: 5,
- sources: [
- {
- format: 'zip',
- url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip',
- },
- {
- format: 'tar.gz',
- url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz',
- },
- {
- format: 'tar.bz2',
- url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2',
- },
- {
- format: 'tar',
- url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar',
- },
- ],
- links: [
- {
- id: 1,
- name: 'my link',
- url: 'https://google.com',
- direct_asset_url: 'https://redirected.google.com',
- external: true,
- },
- {
- id: 2,
- name: 'my second link',
- url:
- 'https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
- direct_asset_url: 'https://redirected.google.com',
- external: false,
- },
- ],
- },
- _links: {
- self: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3',
- edit_url: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit',
- },
-};
-
export const pageInfoHeadersWithoutPagination = {
'X-NEXT-PAGE': '',
'X-PAGE': '1',
@@ -151,202 +15,3 @@ export const pageInfoHeadersWithPagination = {
'X-TOTAL': '21',
'X-TOTAL-PAGES': '2',
};
-
-export const assets = {
- count: 5,
- sources: [
- {
- format: 'zip',
- url: 'https://example.gitlab.com/path/to/zip',
- },
- ],
- links: [
- {
- linkType: ASSET_LINK_TYPE.IMAGE,
- url: 'https://example.gitlab.com/path/to/image',
- directAssetUrl: 'https://example.gitlab.com/path/to/image',
- name: 'Example image link',
- },
- {
- linkType: ASSET_LINK_TYPE.PACKAGE,
- url: 'https://example.gitlab.com/path/to/package',
- directAssetUrl: 'https://example.gitlab.com/path/to/package',
- name: 'Example package link',
- },
- {
- linkType: ASSET_LINK_TYPE.RUNBOOK,
- url: 'https://example.gitlab.com/path/to/runbook',
- directAssetUrl: 'https://example.gitlab.com/path/to/runbook',
- name: 'Example runbook link',
- },
- {
- linkType: ASSET_LINK_TYPE.OTHER,
- url: 'https://example.gitlab.com/path/to/link',
- directAssetUrl: 'https://example.gitlab.com/path/to/link',
- name: 'Example link',
- },
- ],
-};
-
-export const release2 = {
- name: 'Bionic Beaver',
- tag_name: '18.04',
- description: '## changelog\n\n* line 1\n* line2',
- description_html: '<div><h2>changelog</h2><ul><li>line1</li<li>line 2</li></ul></div>',
- author_name: 'Release bot',
- author_email: 'release-bot@example.com',
- created_at: '2012-05-28T05:00:00-07:00',
- commit: {
- id: '2695effb5807a22ff3d138d593fd856244e155e7',
- short_id: '2695effb',
- title: 'Initial commit',
- created_at: '2017-07-26T11:08:53.000+02:00',
- parent_ids: ['2a4b78934375d7f53875269ffd4f45fd83a84ebe'],
- message: 'Initial commit',
- author: {
- avatar_url: 'uploads/-/system/user/avatar/johndoe/avatar.png',
- id: 482476,
- name: 'John Doe',
- path: '/johndoe',
- state: 'active',
- status_tooltip_html: null,
- username: 'johndoe',
- web_url: 'https://gitlab.com/johndoe',
- },
- authored_date: '2012-05-28T04:42:42-07:00',
- committer_name: 'Jack Smith',
- committer_email: 'jack@example.com',
- committed_date: '2012-05-28T04:42:42-07:00',
- },
- assets,
-};
-
-export const releases = [release, release2];
-
-export const graphqlReleasesResponse = {
- data: {
- project: {
- releases: {
- count: 39,
- nodes: [
- {
- name: 'Release 1.0',
- tagName: 'v5.10',
- tagPath: '/root/release-test/-/tags/v5.10',
- descriptionHtml:
- '<p data-sourcepos="1:1-1:24" dir="auto">This is version <strong>1.0</strong>!</p>',
- releasedAt: '2020-08-21T20:15:18Z',
- upcomingRelease: false,
- assets: {
- count: 7,
- sources: {
- nodes: [
- {
- format: 'zip',
- url:
- 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip',
- },
- {
- format: 'tar.gz',
- url:
- 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz',
- },
- {
- format: 'tar.bz2',
- url:
- 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2',
- },
- {
- format: 'tar',
- url:
- 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar',
- },
- ],
- },
- links: {
- nodes: [
- {
- id: 'gid://gitlab/Releases::Link/69',
- name: 'An example link',
- url: 'https://example.com/link',
- directAssetUrl:
- 'http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook',
- linkType: 'OTHER',
- external: true,
- },
- {
- id: 'gid://gitlab/Releases::Link/68',
- name: 'An example package link',
- url: 'https://example.com/package',
- directAssetUrl: 'https://example.com/package',
- linkType: 'PACKAGE',
- external: true,
- },
- {
- id: 'gid://gitlab/Releases::Link/67',
- name: 'An example image',
- url: 'https://example.com/image',
- directAssetUrl: 'https://example.com/image',
- linkType: 'IMAGE',
- external: true,
- },
- ],
- },
- },
- evidences: {
- nodes: [
- {
- filepath:
- 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json',
- collectedAt: '2020-08-21T20:15:19Z',
- sha: '22bde8e8b93d870a29ddc339287a1fbb598f45d1396d',
- },
- ],
- },
- links: {
- editUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit',
- issuesUrl: null,
- mergeRequestsUrl: null,
- selfUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10',
- },
- commit: {
- sha: '92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7',
- webUrl:
- 'http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7',
- title: 'Testing a change.',
- },
- author: {
- webUrl: 'http://0.0.0.0:3000/root',
- avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png',
- username: 'root',
- },
- milestones: {
- nodes: [
- {
- id: 'gid://gitlab/Milestone/60',
- title: '12.4',
- description: '',
- webPath: '/root/release-test/-/milestones/2',
- stats: {
- totalIssuesCount: 0,
- closedIssuesCount: 0,
- },
- },
- {
- id: 'gid://gitlab/Milestone/59',
- title: '12.3',
- description: 'Milestone 12.3',
- webPath: '/root/release-test/-/milestones/1',
- stats: {
- totalIssuesCount: 2,
- closedIssuesCount: 1,
- },
- },
- ],
- },
- },
- ],
- },
- },
- },
-};
diff --git a/spec/frontend/releases/stores/getters_spec.js b/spec/frontend/releases/stores/getters_spec.js
new file mode 100644
index 00000000000..01e10567cf0
--- /dev/null
+++ b/spec/frontend/releases/stores/getters_spec.js
@@ -0,0 +1,22 @@
+import * as getters from '~/releases/stores/getters';
+
+describe('~/releases/stores/getters.js', () => {
+ it.each`
+ graphqlReleaseData | graphqlReleasesPage | graphqlMilestoneStats | result
+ ${false} | ${false} | ${false} | ${false}
+ ${false} | ${false} | ${true} | ${false}
+ ${false} | ${true} | ${false} | ${false}
+ ${false} | ${true} | ${true} | ${false}
+ ${true} | ${false} | ${false} | ${false}
+ ${true} | ${false} | ${true} | ${false}
+ ${true} | ${true} | ${false} | ${false}
+ ${true} | ${true} | ${true} | ${true}
+ `(
+ 'returns $result with feature flag values graphqlReleaseData=$graphqlReleaseData, graphqlReleasesPage=$graphqlReleasesPage, and graphqlMilestoneStats=$graphqlMilestoneStats',
+ ({ result: expectedResult, ...featureFlags }) => {
+ const actualResult = getters.useGraphQLEndpoint({ featureFlags });
+
+ expect(actualResult).toBe(expectedResult);
+ },
+ );
+});
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index 1b2a705e8f4..955c761d35a 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -1,10 +1,10 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import { cloneDeep } from 'lodash';
import * as actions from '~/releases/stores/modules/detail/actions';
import * as types from '~/releases/stores/modules/detail/mutation_types';
-import { release as originalRelease } from '../../../mock_data';
import createState from '~/releases/stores/modules/detail/state';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
@@ -21,6 +21,8 @@ jest.mock('~/lib/utils/url_utility', () => ({
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
}));
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release detail actions', () => {
let state;
let release;
@@ -207,6 +209,15 @@ describe('Release detail actions', () => {
});
});
+ describe('updateReleaseGroupMilestones', () => {
+ it(`commits ${types.UPDATE_RELEASE_GROUP_MILESTONES} with the updated release group milestones`, () => {
+ const newReleaseGroupMilestones = ['v0.0', 'v0.1'];
+ return testAction(actions.updateReleaseGroupMilestones, newReleaseGroupMilestones, state, [
+ { type: types.UPDATE_RELEASE_GROUP_MILESTONES, payload: newReleaseGroupMilestones },
+ ]);
+ });
+ });
+
describe('addEmptyAssetLink', () => {
it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => {
return testAction(actions.addEmptyAssetLink, undefined, state, [
diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
index cd7c6b7d275..f3e84262754 100644
--- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
@@ -1,10 +1,12 @@
+import { getJSONFixture } from 'helpers/fixtures';
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';
-import { release as originalRelease } from '../../../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release detail mutations', () => {
let state;
let release;
@@ -30,6 +32,7 @@ describe('Release detail mutations', () => {
name: '',
description: '',
milestones: [],
+ groupMilestones: [],
assets: {
links: [],
},
@@ -112,6 +115,26 @@ describe('Release detail mutations', () => {
});
});
+ describe(`${types.UPDATE_RELEASE_MILESTONES}`, () => {
+ it("updates the release's milestones", () => {
+ state.release = release;
+ const newReleaseMilestones = ['v0.0', 'v0.1'];
+ mutations[types.UPDATE_RELEASE_MILESTONES](state, newReleaseMilestones);
+
+ expect(state.release.milestones).toBe(newReleaseMilestones);
+ });
+ });
+
+ describe(`${types.UPDATE_RELEASE_GROUP_MILESTONES}`, () => {
+ it("updates the release's group milestones", () => {
+ state.release = release;
+ const newReleaseGroupMilestones = ['v0.0', 'v0.1'];
+ mutations[types.UPDATE_RELEASE_GROUP_MILESTONES](state, newReleaseGroupMilestones);
+
+ expect(state.release.groupMilestones).toBe(newReleaseGroupMilestones);
+ });
+ });
+
describe(`${types.REQUEST_SAVE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_SAVE_RELEASE](state);
diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js
index 95e30659d6c..2068d7fee78 100644
--- a/spec/frontend/releases/stores/modules/list/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/list/actions_spec.js
@@ -1,31 +1,42 @@
import { cloneDeep } from 'lodash';
import testAction from 'helpers/vuex_action_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import {
- requestReleases,
fetchReleases,
- receiveReleasesSuccess,
+ fetchReleasesGraphQl,
+ fetchReleasesRest,
receiveReleasesError,
} from '~/releases/stores/modules/list/actions';
import createState from '~/releases/stores/modules/list/state';
import * as types from '~/releases/stores/modules/list/mutation_types';
import api from '~/api';
import { gqClient, convertGraphQLResponse } from '~/releases/util';
-import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
- pageInfoHeadersWithoutPagination,
- releases as originalReleases,
- graphqlReleasesResponse as originalGraphqlReleasesResponse,
-} from '../../../mock_data';
+ normalizeHeaders,
+ parseIntPagination,
+ convertObjectPropsToCamelCase,
+} from '~/lib/utils/common_utils';
+import { pageInfoHeadersWithoutPagination } from '../../../mock_data';
import allReleasesQuery from '~/releases/queries/all_releases.query.graphql';
+import { PAGE_SIZE } from '~/releases/constants';
+
+const originalRelease = getJSONFixture('api/releases/release.json');
+const originalReleases = [originalRelease];
+
+const originalGraphqlReleasesResponse = getJSONFixture(
+ 'graphql/releases/queries/all_releases.query.graphql.json',
+);
describe('Releases State actions', () => {
let mockedState;
- let pageInfo;
let releases;
let graphqlReleasesResponse;
const projectPath = 'root/test-project';
const projectId = 19;
+ const before = 'testBeforeCursor';
+ const after = 'testAfterCursor';
+ const page = 2;
beforeEach(() => {
mockedState = {
@@ -33,178 +44,261 @@ describe('Releases State actions', () => {
projectId,
projectPath,
}),
- featureFlags: {
- graphqlReleaseData: true,
- graphqlReleasesPage: true,
- graphqlMilestoneStats: true,
- },
};
- pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse);
});
- describe('requestReleases', () => {
- it('should commit REQUEST_RELEASES mutation', done => {
- testAction(requestReleases, null, mockedState, [{ type: types.REQUEST_RELEASES }], [], done);
+ describe('when all the necessary GraphQL feature flags are enabled', () => {
+ beforeEach(() => {
+ mockedState.useGraphQLEndpoint = true;
+ });
+
+ describe('fetchReleases', () => {
+ it('dispatches fetchReleasesGraphQl with before and after parameters', () => {
+ return testAction(
+ fetchReleases,
+ { before, after, page },
+ mockedState,
+ [],
+ [
+ {
+ type: 'fetchReleasesGraphQl',
+ payload: { before, after },
+ },
+ ],
+ );
+ });
});
});
- describe('fetchReleases', () => {
- describe('success', () => {
- it('dispatches requestReleases and receiveReleasesSuccess', done => {
- jest.spyOn(gqClient, 'query').mockImplementation(({ query, variables }) => {
- expect(query).toBe(allReleasesQuery);
- expect(variables).toEqual({
- fullPath: projectPath,
+ describe('when at least one of the GraphQL feature flags is disabled', () => {
+ beforeEach(() => {
+ mockedState.useGraphQLEndpoint = false;
+ });
+
+ describe('fetchReleases', () => {
+ it('dispatches fetchReleasesRest with a page parameter', () => {
+ return testAction(
+ fetchReleases,
+ { before, after, page },
+ mockedState,
+ [],
+ [
+ {
+ type: 'fetchReleasesRest',
+ payload: { page },
+ },
+ ],
+ );
+ });
+ });
+ });
+
+ describe('fetchReleasesGraphQl', () => {
+ describe('GraphQL query variables', () => {
+ let vuexParams;
+
+ beforeEach(() => {
+ jest.spyOn(gqClient, 'query');
+
+ vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
+ });
+
+ describe('when neither a before nor an after parameter is provided', () => {
+ beforeEach(() => {
+ fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined });
+ });
+
+ it('makes a GraphQl query with a first variable', () => {
+ expect(gqClient.query).toHaveBeenCalledWith({
+ query: allReleasesQuery,
+ variables: { fullPath: projectPath, first: PAGE_SIZE },
});
- return Promise.resolve(graphqlReleasesResponse);
});
+ });
- testAction(
- fetchReleases,
+ describe('when only a before parameter is provided', () => {
+ beforeEach(() => {
+ fetchReleasesGraphQl(vuexParams, { before, after: undefined });
+ });
+
+ it('makes a GraphQl query with last and before variables', () => {
+ expect(gqClient.query).toHaveBeenCalledWith({
+ query: allReleasesQuery,
+ variables: { fullPath: projectPath, last: PAGE_SIZE, before },
+ });
+ });
+ });
+
+ describe('when only an after parameter is provided', () => {
+ beforeEach(() => {
+ fetchReleasesGraphQl(vuexParams, { before: undefined, after });
+ });
+
+ it('makes a GraphQl query with first and after variables', () => {
+ expect(gqClient.query).toHaveBeenCalledWith({
+ query: allReleasesQuery,
+ variables: { fullPath: projectPath, first: PAGE_SIZE, after },
+ });
+ });
+ });
+
+ describe('when both before and after parameters are provided', () => {
+ it('throws an error', () => {
+ const callFetchReleasesGraphQl = () => {
+ fetchReleasesGraphQl(vuexParams, { before, after });
+ };
+
+ expect(callFetchReleasesGraphQl).toThrowError(
+ 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.',
+ );
+ });
+ });
+ });
+
+ describe('when the request is successful', () => {
+ beforeEach(() => {
+ jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse);
+ });
+
+ it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
+ const convertedResponse = convertGraphQLResponse(graphqlReleasesResponse);
+
+ return testAction(
+ fetchReleasesGraphQl,
{},
mockedState,
- [],
[
{
- type: 'requestReleases',
+ type: types.REQUEST_RELEASES,
},
{
- payload: convertGraphQLResponse(graphqlReleasesResponse),
- type: 'receiveReleasesSuccess',
+ type: types.RECEIVE_RELEASES_SUCCESS,
+ payload: {
+ data: convertedResponse.data,
+ graphQlPageInfo: convertedResponse.paginationInfo,
+ },
},
],
- done,
+ [],
);
});
});
- describe('error', () => {
- it('dispatches requestReleases and receiveReleasesError', done => {
- jest.spyOn(gqClient, 'query').mockRejectedValue();
+ describe('when the request fails', () => {
+ beforeEach(() => {
+ jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!'));
+ });
- testAction(
- fetchReleases,
+ it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
+ return testAction(
+ fetchReleasesGraphQl,
{},
mockedState,
- [],
[
{
- type: 'requestReleases',
+ type: types.REQUEST_RELEASES,
},
+ ],
+ [
{
type: 'receiveReleasesError',
},
],
- done,
);
});
});
+ });
+
+ describe('fetchReleasesRest', () => {
+ describe('REST query parameters', () => {
+ let vuexParams;
- describe('when the graphqlReleaseData feature flag is disabled', () => {
beforeEach(() => {
- mockedState.featureFlags.graphqlReleasesPage = false;
- });
+ jest
+ .spyOn(api, 'releases')
+ .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
- describe('success', () => {
- it('dispatches requestReleases and receiveReleasesSuccess', done => {
- jest.spyOn(api, 'releases').mockImplementation((id, options) => {
- expect(id).toBe(projectId);
- expect(options.page).toBe('1');
- return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
- });
+ vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
+ });
- testAction(
- fetchReleases,
- {},
- mockedState,
- [],
- [
- {
- type: 'requestReleases',
- },
- {
- payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
- type: 'receiveReleasesSuccess',
- },
- ],
- done,
- );
+ describe('when a page parameter is provided', () => {
+ beforeEach(() => {
+ fetchReleasesRest(vuexParams, { page: 2 });
});
- it('dispatches requestReleases and receiveReleasesSuccess on page two', done => {
- jest.spyOn(api, 'releases').mockImplementation((_, options) => {
- expect(options.page).toBe('2');
- return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
- });
-
- testAction(
- fetchReleases,
- { page: '2' },
- mockedState,
- [],
- [
- {
- type: 'requestReleases',
- },
- {
- payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
- type: 'receiveReleasesSuccess',
- },
- ],
- done,
- );
+ it('makes a REST query with a page query parameter', () => {
+ expect(api.releases).toHaveBeenCalledWith(projectId, { page });
});
});
+ });
- describe('error', () => {
- it('dispatches requestReleases and receiveReleasesError', done => {
- jest.spyOn(api, 'releases').mockReturnValue(Promise.reject());
+ describe('when the request is successful', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(api, 'releases')
+ .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
+ });
- testAction(
- fetchReleases,
- {},
- mockedState,
- [],
- [
- {
- type: 'requestReleases',
- },
- {
- type: 'receiveReleasesError',
+ it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
+ return testAction(
+ fetchReleasesRest,
+ {},
+ mockedState,
+ [
+ {
+ type: types.REQUEST_RELEASES,
+ },
+ {
+ type: types.RECEIVE_RELEASES_SUCCESS,
+ payload: {
+ data: convertObjectPropsToCamelCase(releases, { deep: true }),
+ restPageInfo: parseIntPagination(
+ normalizeHeaders(pageInfoHeadersWithoutPagination),
+ ),
},
- ],
- done,
- );
- });
+ },
+ ],
+ [],
+ );
});
});
- });
- describe('receiveReleasesSuccess', () => {
- it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => {
- testAction(
- receiveReleasesSuccess,
- { data: releases, headers: pageInfoHeadersWithoutPagination },
- mockedState,
- [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }],
- [],
- done,
- );
+ describe('when the request fails', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'releases').mockRejectedValue(new Error('Something went wrong!'));
+ });
+
+ it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
+ return testAction(
+ fetchReleasesRest,
+ {},
+ mockedState,
+ [
+ {
+ type: types.REQUEST_RELEASES,
+ },
+ ],
+ [
+ {
+ type: 'receiveReleasesError',
+ },
+ ],
+ );
+ });
});
});
describe('receiveReleasesError', () => {
- it('should commit RECEIVE_RELEASES_ERROR mutation', done => {
- testAction(
+ it('should commit RECEIVE_RELEASES_ERROR mutation', () => {
+ return testAction(
receiveReleasesError,
null,
mockedState,
[{ type: types.RECEIVE_RELEASES_ERROR }],
[],
- done,
);
});
});
diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js
index 27ad05846e7..914f69ec194 100644
--- a/spec/frontend/releases/stores/modules/list/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js
@@ -1,16 +1,29 @@
+import { getJSONFixture } from 'helpers/fixtures';
import createState from '~/releases/stores/modules/list/state';
import mutations from '~/releases/stores/modules/list/mutations';
import * as types from '~/releases/stores/modules/list/mutation_types';
-import { parseIntPagination } from '~/lib/utils/common_utils';
-import { pageInfoHeadersWithoutPagination, releases } from '../../../mock_data';
+import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { pageInfoHeadersWithoutPagination } from '../../../mock_data';
+import { convertGraphQLResponse } from '~/releases/util';
+
+const originalRelease = getJSONFixture('api/releases/release.json');
+const originalReleases = [originalRelease];
+
+const graphqlReleasesResponse = getJSONFixture(
+ 'graphql/releases/queries/all_releases.query.graphql.json',
+);
describe('Releases Store Mutations', () => {
let stateCopy;
- let pageInfo;
+ let restPageInfo;
+ let graphQlPageInfo;
+ let releases;
beforeEach(() => {
stateCopy = createState({});
- pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
+ restPageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
+ graphQlPageInfo = convertGraphQLResponse(graphqlReleasesResponse).paginationInfo;
+ releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
});
describe('REQUEST_RELEASES', () => {
@@ -23,7 +36,11 @@ describe('Releases Store Mutations', () => {
describe('RECEIVE_RELEASES_SUCCESS', () => {
beforeEach(() => {
- mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { pageInfo, data: releases });
+ mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
+ restPageInfo,
+ graphQlPageInfo,
+ data: releases,
+ });
});
it('sets is loading to false', () => {
@@ -38,18 +55,29 @@ describe('Releases Store Mutations', () => {
expect(stateCopy.releases).toEqual(releases);
});
- it('sets pageInfo', () => {
- expect(stateCopy.pageInfo).toEqual(pageInfo);
+ it('sets restPageInfo', () => {
+ expect(stateCopy.restPageInfo).toEqual(restPageInfo);
+ });
+
+ it('sets graphQlPageInfo', () => {
+ expect(stateCopy.graphQlPageInfo).toEqual(graphQlPageInfo);
});
});
describe('RECEIVE_RELEASES_ERROR', () => {
it('resets data', () => {
+ mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
+ restPageInfo,
+ graphQlPageInfo,
+ data: releases,
+ });
+
mutations[types.RECEIVE_RELEASES_ERROR](stateCopy);
expect(stateCopy.isLoading).toEqual(false);
expect(stateCopy.releases).toEqual([]);
- expect(stateCopy.pageInfo).toEqual({});
+ expect(stateCopy.restPageInfo).toEqual({});
+ expect(stateCopy.graphQlPageInfo).toEqual({});
});
});
});
diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js
index f40e5729188..a9d0b61695d 100644
--- a/spec/frontend/releases/util_spec.js
+++ b/spec/frontend/releases/util_spec.js
@@ -1,6 +1,10 @@
import { cloneDeep } from 'lodash';
+import { getJSONFixture } from 'helpers/fixtures';
import { releaseToApiJson, apiJsonToRelease, convertGraphQLResponse } from '~/releases/util';
-import { graphqlReleasesResponse as originalGraphqlReleasesResponse } from './mock_data';
+
+const originalGraphqlReleasesResponse = getJSONFixture(
+ 'graphql/releases/queries/all_releases.query.graphql.json',
+);
describe('releases/util.js', () => {
describe('releaseToApiJson', () => {
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 cf2e6b00800..aaa8bf168f2 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -77,24 +77,31 @@ exports[`Repository last commit component renders commit widget 1`] = `
</gl-link-stub>
</div>
- <div
- class="commit-sha-group d-flex"
+ <gl-button-group-stub
+ class="gl-ml-4 js-commit-sha-group"
>
- <div
- class="label label-monospace monospace"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="gl-font-monospace"
+ data-testid="last-commit-id-label"
+ icon=""
+ label="true"
+ size="medium"
+ variant="default"
>
-
- 12345678
-
- </div>
+ 12345678
+ </gl-button-stub>
<clipboard-button-stub
- cssclass="btn-default"
+ category="secondary"
+ class="input-group-text"
+ size="medium"
text="123456789"
title="Copy commit SHA"
- tooltipplacement="bottom"
+ tooltipplacement="top"
/>
- </div>
+ </gl-button-group-stub>
</div>
</div>
</div>
@@ -181,24 +188,31 @@ exports[`Repository last commit component renders the signature HTML as returned
</gl-link-stub>
</div>
- <div
- class="commit-sha-group d-flex"
+ <gl-button-group-stub
+ class="gl-ml-4 js-commit-sha-group"
>
- <div
- class="label label-monospace monospace"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="gl-font-monospace"
+ data-testid="last-commit-id-label"
+ icon=""
+ label="true"
+ size="medium"
+ variant="default"
>
-
- 12345678
-
- </div>
+ 12345678
+ </gl-button-stub>
<clipboard-button-stub
- cssclass="btn-default"
+ category="secondary"
+ class="input-group-text"
+ size="medium"
text="123456789"
title="Copy commit SHA"
- tooltipplacement="bottom"
+ tooltipplacement="top"
/>
- </div>
+ </gl-button-group-stub>
</div>
</div>
</div>
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index c14a7f0e061..ccba0982c26 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -78,7 +78,7 @@ describe('Repository last commit component', () => {
factory();
return vm.vm.$nextTick(() => {
- expect(vm.find('.label-monospace').text()).toEqual('12345678');
+ expect(vm.find('[data-testid="last-commit-id-label"]').text()).toEqual('12345678');
});
});
diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js
index 954424b5c8a..ddc95feccd6 100644
--- a/spec/frontend/repository/log_tree_spec.js
+++ b/spec/frontend/repository/log_tree_spec.js
@@ -84,6 +84,14 @@ describe('fetchLogsTree', () => {
expect(axios.get.mock.calls.length).toEqual(1);
}));
+ it('calls axios for each path', () =>
+ Promise.all([
+ fetchLogsTree(client, '', '0', resolver),
+ fetchLogsTree(client, '/test', '0', resolver),
+ ]).then(() => {
+ expect(axios.get.mock.calls.length).toEqual(2);
+ }));
+
it('calls entry resolver', () =>
fetchLogsTree(client, '', '0', resolver).then(() => {
expect(resolver.resolve).toHaveBeenCalledWith(
diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js
index d80d80152a5..3490a99afb4 100644
--- a/spec/frontend/right_sidebar_spec.js
+++ b/spec/frontend/right_sidebar_spec.js
@@ -6,7 +6,9 @@ import Sidebar from '~/right_sidebar';
let $aside = null;
let $toggle = null;
-let $icon = null;
+let $toggleContainer = null;
+let $expandIcon = null;
+let $collapseIcon = null;
let $page = null;
let $labelsIcon = null;
@@ -15,10 +17,11 @@ const assertSidebarState = state => {
const shouldBeCollapsed = state === 'collapsed';
expect($aside.hasClass('right-sidebar-expanded')).toBe(shouldBeExpanded);
expect($page.hasClass('right-sidebar-expanded')).toBe(shouldBeExpanded);
- expect($icon.hasClass('fa-angle-double-right')).toBe(shouldBeExpanded);
+ expect($toggleContainer.data('is-expanded')).toBe(shouldBeExpanded);
+ expect($expandIcon.hasClass('hidden')).toBe(shouldBeExpanded);
expect($aside.hasClass('right-sidebar-collapsed')).toBe(shouldBeCollapsed);
expect($page.hasClass('right-sidebar-collapsed')).toBe(shouldBeCollapsed);
- expect($icon.hasClass('fa-angle-double-left')).toBe(shouldBeCollapsed);
+ expect($collapseIcon.hasClass('hidden')).toBe(shouldBeCollapsed);
};
describe('RightSidebar', () => {
@@ -33,7 +36,9 @@ describe('RightSidebar', () => {
new Sidebar(); // eslint-disable-line no-new
$aside = $('.right-sidebar');
$page = $('.layout-page');
- $icon = $aside.find('i');
+ $toggleContainer = $('.js-sidebar-toggle-container');
+ $expandIcon = $aside.find('.js-sidebar-expand');
+ $collapseIcon = $aside.find('.js-sidebar-collapse');
$toggle = $aside.find('.js-sidebar-toggle');
$labelsIcon = $aside.find('.sidebar-collapsed-icon');
});
diff --git a/spec/frontend/search/components/state_filter_spec.js b/spec/frontend/search/components/dropdown_filter_spec.js
index 26344f2b592..ffac038e1c5 100644
--- a/spec/frontend/search/components/state_filter_spec.js
+++ b/spec/frontend/search/components/dropdown_filter_spec.js
@@ -1,11 +1,11 @@
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import StateFilter from '~/search/state_filter/components/state_filter.vue';
+import DropdownFilter from '~/search/components/dropdown_filter.vue';
import {
FILTER_STATES,
- SCOPES,
FILTER_STATES_BY_SCOPE,
- FILTER_TEXT,
+ FILTER_HEADER,
+ SCOPES,
} from '~/search/state_filter/constants';
import * as urlUtils from '~/lib/utils/url_utility';
@@ -15,14 +15,19 @@ jest.mock('~/lib/utils/url_utility', () => ({
}));
function createComponent(props = { scope: 'issues' }) {
- return shallowMount(StateFilter, {
+ return shallowMount(DropdownFilter, {
propsData: {
+ filtersArray: FILTER_STATES_BY_SCOPE.issues,
+ filters: FILTER_STATES,
+ header: FILTER_HEADER,
+ param: 'state',
+ supportedScopes: Object.values(SCOPES),
...props,
},
});
}
-describe('StateFilter', () => {
+describe('DropdownFilter', () => {
let wrapper;
beforeEach(() => {
@@ -41,7 +46,7 @@ describe('StateFilter', () => {
describe('template', () => {
describe.each`
- scope | showStateDropdown
+ scope | showDropdown
${'issues'} | ${true}
${'merge_requests'} | ${true}
${'projects'} | ${false}
@@ -50,26 +55,25 @@ describe('StateFilter', () => {
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
- `(`state dropdown`, ({ scope, showStateDropdown }) => {
+ `(`dropdown`, ({ scope, showDropdown }) => {
beforeEach(() => {
wrapper = createComponent({ scope });
});
- it(`does${showStateDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
- expect(findGlDropdown().exists()).toBe(showStateDropdown);
+ it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
+ expect(findGlDropdown().exists()).toBe(showDropdown);
});
});
describe.each`
- state | label
- ${FILTER_STATES.ANY.value} | ${FILTER_TEXT}
+ initialFilter | label
+ ${FILTER_STATES.ANY.value} | ${`Any ${FILTER_HEADER}`}
${FILTER_STATES.OPEN.value} | ${FILTER_STATES.OPEN.label}
${FILTER_STATES.CLOSED.value} | ${FILTER_STATES.CLOSED.label}
- ${FILTER_STATES.MERGED.value} | ${FILTER_STATES.MERGED.label}
- `(`filter text`, ({ state, label }) => {
- describe(`when state is ${state}`, () => {
+ `(`filter text`, ({ initialFilter, label }) => {
+ describe(`when initialFilter is ${initialFilter}`, () => {
beforeEach(() => {
- wrapper = createComponent({ scope: 'issues', state });
+ wrapper = createComponent({ scope: 'issues', initialFilter });
});
it(`sets dropdown label to ${label}`, () => {
diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
index f4ac2f57261..02d5ca6bdb3 100644
--- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
+++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
@@ -15,13 +15,16 @@ exports[`self monitor component When the self monitor project has not been creat
</h4>
- <gl-deprecated-button-stub
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
class="js-settings-toggle"
- size="md"
- variant="secondary"
+ icon=""
+ size="medium"
+ variant="default"
>
Expand
- </gl-deprecated-button-stub>
+ </gl-button-stub>
<p
class="js-section-sub-header"
@@ -56,6 +59,7 @@ exports[`self monitor component When the self monitor project has not been creat
<gl-modal-stub
cancel-title="Cancel"
+ category="primary"
modalclass=""
modalid="delete-self-monitor-modal"
ok-title="Delete project"
diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js
index ec5f7b0a394..618cc16cdf4 100644
--- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js
+++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue';
import { createStore } from '~/self_monitor/store';
@@ -42,7 +42,7 @@ describe('self monitor component', () => {
it('renders as an expand button by default', () => {
wrapper = shallowMount(SelfMonitor, { store });
- const button = wrapper.find(GlDeprecatedButton);
+ const button = wrapper.find(GlButton);
expect(button.text()).toBe('Expand');
});
diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
index 22689080063..6b3d65ff037 100644
--- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
+++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
@@ -11,7 +11,7 @@ exports[`EmptyStateComponent should render content 1`] = `
<p>In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub>
</p>
<div>
- <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" href=\\"/clusters\\">Install Knative</gl-button-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" href=\\"/clusters\\">Install Knative</gl-button-stub>
<!---->
</div>
</div>
diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js
index 9ca4a45dd5f..0bd2e96a068 100644
--- a/spec/frontend/serverless/components/missing_prometheus_spec.js
+++ b/spec/frontend/serverless/components/missing_prometheus_spec.js
@@ -1,4 +1,4 @@
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createStore } from '~/serverless/store';
import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue';
@@ -24,7 +24,7 @@ describe('missingPrometheusComponent', () => {
'Function invocation metrics require Prometheus to be installed first.',
);
- expect(wrapper.find(GlDeprecatedButton).attributes('variant')).toBe('success');
+ expect(wrapper.find(GlButton).attributes('variant')).toBe('success');
});
it('should render no prometheus data message', () => {
diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
index 2f11c6a07c2..8c868205295 100644
--- a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
+++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
import eventHub from '~/sidebar/event_hub';
@@ -56,11 +55,11 @@ describe('Edit Form Buttons', () => {
});
it('disables the toggle button', () => {
- expect(findConfidentialToggle().attributes('disabled')).toBe('disabled');
+ expect(findConfidentialToggle().props('disabled')).toBe(true);
});
- it('finds the GlLoadingIcon', () => {
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ it('sets loading on the toggle button', () => {
+ expect(findConfidentialToggle().props('loading')).toBe(true);
});
});
@@ -99,7 +98,7 @@ describe('Edit Form Buttons', () => {
describe('when succeeds', () => {
beforeEach(() => {
createComponent({ data: { isLoading: false }, props: { confidential: true } });
- findConfidentialToggle().trigger('click');
+ findConfidentialToggle().vm.$emit('click', new Event('click'));
});
it('dispatches the correct action', () => {
@@ -109,9 +108,9 @@ describe('Edit Form Buttons', () => {
});
});
- it('resets loading', () => {
+ it('resets loading on the toggle button', () => {
return waitForPromises().then(() => {
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(findConfidentialToggle().props('loading')).toBe(false);
});
});
@@ -135,7 +134,7 @@ describe('Edit Form Buttons', () => {
props: { confidential: true },
resolved: false,
});
- findConfidentialToggle().trigger('click');
+ findConfidentialToggle().vm.$emit('click', new Event('click'));
});
it('calls flash with the correct message', () => {
diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
index de1da3456f8..913646c8f8d 100644
--- a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
+++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
@@ -1,5 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
import eventHub from '~/sidebar/event_hub';
import { deprecatedCreateFlash as flash } from '~/flash';
@@ -22,7 +21,6 @@ describe('EditFormButtons', () => {
};
const findLockToggle = () => wrapper.find('[data-testid="lock-toggle"]');
- const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon);
const createComponent = ({ props = {}, data = {}, resolved = true }) => {
store = issuableType === ISSUABLE_TYPE_ISSUE ? createStore() : createMrStore();
@@ -33,7 +31,7 @@ describe('EditFormButtons', () => {
jest.spyOn(store, 'dispatch').mockRejectedValue();
}
- wrapper = shallowMount(EditFormButtons, {
+ wrapper = mount(EditFormButtons, {
store,
provide: {
fullPath: '',
@@ -78,8 +76,8 @@ describe('EditFormButtons', () => {
expect(findLockToggle().attributes('disabled')).toBe('disabled');
});
- it('displays the GlLoadingIcon', () => {
- expect(findGlLoadingIcon().exists()).toBe(true);
+ it('sets loading on the toggle button', () => {
+ expect(findLockToggle().props('loading')).toBe(true);
});
});
@@ -121,7 +119,7 @@ describe('EditFormButtons', () => {
it('resets loading', async () => {
await wrapper.vm.$nextTick().then(() => {
- expect(findGlLoadingIcon().exists()).toBe(false);
+ expect(findLockToggle().props('loading')).toBe(false);
});
});
@@ -156,7 +154,7 @@ describe('EditFormButtons', () => {
it('resets loading', async () => {
await wrapper.vm.$nextTick().then(() => {
- expect(findGlLoadingIcon().exists()).toBe(false);
+ expect(findLockToggle().props('loading')).toBe(false);
});
});
diff --git a/spec/frontend/sidebar/reviewer_title_spec.js b/spec/frontend/sidebar/reviewer_title_spec.js
new file mode 100644
index 00000000000..eae266688d5
--- /dev/null
+++ b/spec/frontend/sidebar/reviewer_title_spec.js
@@ -0,0 +1,116 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
+import Component from '~/sidebar/components/reviewers/reviewer_title.vue';
+
+describe('ReviewerTitle component', () => {
+ let wrapper;
+
+ const createComponent = props => {
+ return shallowMount(Component, {
+ propsData: {
+ numberOfReviewers: 0,
+ editable: false,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('reviewer title', () => {
+ it('renders reviewer', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 1,
+ editable: false,
+ });
+
+ expect(wrapper.vm.$el.innerText.trim()).toEqual('Reviewer');
+ });
+
+ it('renders 2 reviewers', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 2,
+ editable: false,
+ });
+
+ expect(wrapper.vm.$el.innerText.trim()).toEqual('2 Reviewers');
+ });
+ });
+
+ describe('gutter toggle', () => {
+ it('does not show toggle by default', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 2,
+ editable: false,
+ });
+
+ expect(wrapper.vm.$el.querySelector('.gutter-toggle')).toBeNull();
+ });
+
+ it('shows toggle when showToggle is true', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 2,
+ editable: false,
+ showToggle: true,
+ });
+
+ expect(wrapper.vm.$el.querySelector('.gutter-toggle')).toEqual(expect.any(Object));
+ });
+ });
+
+ it('does not render spinner by default', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 0,
+ editable: false,
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy();
+ });
+
+ it('renders spinner when loading', () => {
+ wrapper = createComponent({
+ loading: true,
+ numberOfReviewers: 0,
+ editable: false,
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBeTruthy();
+ });
+
+ it('does not render edit link when not editable', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 0,
+ editable: false,
+ });
+
+ expect(wrapper.vm.$el.querySelector('.edit-link')).toBeNull();
+ });
+
+ it('renders edit link when editable', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 0,
+ editable: true,
+ });
+
+ expect(wrapper.vm.$el.querySelector('.edit-link')).not.toBeNull();
+ });
+
+ it('tracks the event when edit is clicked', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 0,
+ editable: true,
+ });
+
+ const spy = mockTracking('_category_', wrapper.element, jest.spyOn);
+ triggerEvent('.js-sidebar-dropdown-toggle');
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
+ label: 'right_sidebar',
+ property: 'reviewer',
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/reviewers_spec.js b/spec/frontend/sidebar/reviewers_spec.js
new file mode 100644
index 00000000000..effcac266f0
--- /dev/null
+++ b/spec/frontend/sidebar/reviewers_spec.js
@@ -0,0 +1,169 @@
+import { mount } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import { GlIcon } from '@gitlab/ui';
+import Reviewer from '~/sidebar/components/reviewers/reviewers.vue';
+import UsersMock from './mock_data';
+import UsersMockHelper from '../helpers/user_mock_data_helper';
+
+describe('Reviewer component', () => {
+ const getDefaultProps = () => ({
+ rootPath: 'http://localhost:3000',
+ users: [],
+ editable: false,
+ });
+ let wrapper;
+
+ const createWrapper = (propsData = getDefaultProps()) => {
+ wrapper = mount(Reviewer, {
+ propsData,
+ });
+ };
+
+ const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('No reviewers/users', () => {
+ it('displays no reviewer icon when collapsed', () => {
+ createWrapper();
+ const collapsedChildren = findCollapsedChildren();
+ const userIcon = collapsedChildren.at(0).find(GlIcon);
+
+ expect(collapsedChildren.length).toBe(1);
+ expect(collapsedChildren.at(0).attributes('aria-label')).toBe('None');
+ expect(userIcon.exists()).toBe(true);
+ expect(userIcon.props('name')).toBe('user');
+ });
+ });
+
+ describe('One reviewer/user', () => {
+ it('displays one reviewer icon when collapsed', () => {
+ createWrapper({
+ ...getDefaultProps(),
+ users: [UsersMock.user],
+ });
+
+ const collapsedChildren = findCollapsedChildren();
+ const reviewer = collapsedChildren.at(0);
+
+ expect(collapsedChildren.length).toBe(1);
+ expect(reviewer.find('.avatar').attributes('src')).toBe(UsersMock.user.avatar);
+ expect(reviewer.find('.avatar').attributes('alt')).toBe(`${UsersMock.user.name}'s avatar`);
+
+ expect(trimText(reviewer.find('.author').text())).toBe(UsersMock.user.name);
+ });
+ });
+
+ describe('Two or more reviewers/users', () => {
+ it('displays two reviewer icons when collapsed', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(2);
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ });
+
+ const collapsedChildren = findCollapsedChildren();
+
+ expect(collapsedChildren.length).toBe(2);
+
+ const first = collapsedChildren.at(0);
+
+ expect(first.find('.avatar').attributes('src')).toBe(users[0].avatar_url);
+ expect(first.find('.avatar').attributes('alt')).toBe(`${users[0].name}'s avatar`);
+
+ expect(trimText(first.find('.author').text())).toBe(users[0].name);
+
+ const second = collapsedChildren.at(1);
+
+ expect(second.find('.avatar').attributes('src')).toBe(users[1].avatar_url);
+ expect(second.find('.avatar').attributes('alt')).toBe(`${users[1].name}'s avatar`);
+
+ expect(trimText(second.find('.author').text())).toBe(users[1].name);
+ });
+
+ it('displays one reviewer icon and counter when collapsed', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ });
+
+ const collapsedChildren = findCollapsedChildren();
+
+ expect(collapsedChildren.length).toBe(2);
+
+ const first = collapsedChildren.at(0);
+
+ expect(first.find('.avatar').attributes('src')).toBe(users[0].avatar_url);
+ expect(first.find('.avatar').attributes('alt')).toBe(`${users[0].name}'s avatar`);
+
+ expect(trimText(first.find('.author').text())).toBe(users[0].name);
+
+ const second = collapsedChildren.at(1);
+
+ expect(trimText(second.find('.avatar-counter').text())).toBe('+2');
+ });
+
+ it('Shows two reviewers', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(2);
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ editable: true,
+ });
+
+ expect(wrapper.findAll('.user-item').length).toBe(users.length);
+ expect(wrapper.find('.user-list-more').exists()).toBe(false);
+ });
+
+ it('shows sorted reviewer where "can merge" users are sorted first', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ users[0].can_merge = false;
+ users[1].can_merge = false;
+ users[2].can_merge = true;
+
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ editable: true,
+ });
+
+ expect(wrapper.vm.sortedReviewers[0].can_merge).toBe(true);
+ });
+
+ it('passes the sorted reviewers to the uncollapsed-reviewer-list', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ users[0].can_merge = false;
+ users[1].can_merge = false;
+ users[2].can_merge = true;
+
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ });
+
+ const userItems = wrapper.findAll('.user-list .user-item a');
+
+ expect(userItems.length).toBe(3);
+ expect(userItems.at(0).attributes('title')).toBe(users[2].name);
+ });
+
+ it('passes the sorted reviewers to the collapsed-reviewer-list', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ users[0].can_merge = false;
+ users[1].can_merge = false;
+ users[2].can_merge = true;
+
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ });
+
+ const collapsedButton = wrapper.find('.sidebar-collapsed-user button');
+
+ expect(trimText(collapsedButton.text())).toBe(users[2].name);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js
index 29333a344e1..9d59dc750fb 100644
--- a/spec/frontend/sidebar/sidebar_labels_spec.js
+++ b/spec/frontend/sidebar/sidebar_labels_spec.js
@@ -114,7 +114,7 @@ describe('sidebar labels', () => {
const expected = {
[defaultProps.issuableType]: {
- label_ids: [27, 28, 40],
+ label_ids: [27, 28, 29, 40],
},
};
diff --git a/spec/frontend/snippet/snippet_edit_spec.js b/spec/frontend/snippet/snippet_edit_spec.js
index 7c12c0cac03..42a55ac0d3e 100644
--- a/spec/frontend/snippet/snippet_edit_spec.js
+++ b/spec/frontend/snippet/snippet_edit_spec.js
@@ -5,6 +5,7 @@ import initSnippet from '~/snippet/snippet_bundle';
jest.mock('~/snippet/snippet_bundle');
jest.mock('~/snippets');
+jest.mock('~/gl_form');
describe('Snippet edit form initialization', () => {
const setFF = flag => {
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 1cf1ee74ddf..e742a6b9eaf 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
@@ -3,6 +3,7 @@
exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = `
<div
class="file-holder snippet"
+ data-qa-selector="file_holder_container"
>
<blob-header-edit-stub
candelete="true"
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index b6abb9f389a..c1fad8cebe6 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -148,17 +148,17 @@ describe('Snippet Edit app', () => {
// Ideally we wouldn't call this method directly, but we don't have a way to trigger
// apollo responses yet.
- const loadSnippet = (...edges) => {
- if (edges.length) {
+ const loadSnippet = (...nodes) => {
+ if (nodes.length) {
wrapper.setData({
- snippet: edges[0],
+ snippet: nodes[0],
});
}
wrapper.vm.onSnippetFetch({
data: {
snippets: {
- edges,
+ nodes,
},
},
});
diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js
index 9c4b2734a3f..1ccecd7b5ba 100644
--- a/spec/frontend/snippets/components/snippet_blob_view_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js
@@ -140,10 +140,10 @@ describe('Blob Embeddable', () => {
async ({ snippetBlobs, currentBlob, expectedContent }) => {
const apolloData = {
snippets: {
- edges: [
+ nodes: [
{
- node: {
- blobs: snippetBlobs,
+ blobs: {
+ nodes: snippetBlobs,
},
},
],
diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js
index d861f6c9cd7..0f2456cd9ea 100644
--- a/spec/frontend/static_site_editor/mock_data.js
+++ b/spec/frontend/static_site_editor/mock_data.js
@@ -23,7 +23,10 @@ export const username = 'gitlabuser';
export const projectId = '123456';
export const returnUrl = 'https://www.gitlab.com';
export const sourcePath = 'foobar.md.html';
-
+export const mergeRequestMeta = {
+ title: `Update ${sourcePath} file`,
+ description: 'Copy update',
+};
export const savedContentMeta = {
branch: {
label: 'foobar',
diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js
index 41f8a1075c0..10d34d9651c 100644
--- a/spec/frontend/static_site_editor/pages/home_spec.js
+++ b/spec/frontend/static_site_editor/pages/home_spec.js
@@ -1,4 +1,3 @@
-import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import Home from '~/static_site_editor/pages/home.vue';
@@ -7,6 +6,7 @@ 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 hasSubmittedChangesMutation from '~/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql';
import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants';
import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constants';
@@ -17,6 +17,7 @@ import {
sourceContentTitle as title,
sourcePath,
username,
+ mergeRequestMeta,
savedContentMeta,
submitChangesError,
trackingCategory,
@@ -24,8 +25,6 @@ import {
const localVue = createLocalVue();
-localVue.use(Vuex);
-
describe('static_site_editor/pages/home', () => {
let wrapper;
let store;
@@ -33,6 +32,19 @@ describe('static_site_editor/pages/home', () => {
let $router;
let mutateMock;
let trackingSpy;
+ const defaultAppData = {
+ isSupportedContent: true,
+ hasSubmittedChanges: false,
+ returnUrl,
+ project,
+ username,
+ sourcePath,
+ };
+ const hasSubmittedChangesMutationPayload = {
+ data: {
+ appData: { ...defaultAppData, hasSubmittedChanges: true },
+ },
+ };
const buildApollo = (queries = {}) => {
mutateMock = jest.fn();
@@ -64,7 +76,7 @@ describe('static_site_editor/pages/home', () => {
},
data() {
return {
- appData: { isSupportedContent: true, returnUrl, project, username, sourcePath },
+ appData: { ...defaultAppData },
sourceContent: { title, content },
...data,
};
@@ -152,8 +164,14 @@ describe('static_site_editor/pages/home', () => {
});
describe('when submitting changes fails', () => {
+ const setupMutateMock = () => {
+ mutateMock
+ .mockResolvedValueOnce(hasSubmittedChangesMutationPayload)
+ .mockRejectedValueOnce(new Error(submitChangesError));
+ };
+
beforeEach(() => {
- mutateMock.mockRejectedValue(new Error(submitChangesError));
+ setupMutateMock();
buildWrapper();
findEditArea().vm.$emit('submit', { content });
@@ -166,6 +184,8 @@ describe('static_site_editor/pages/home', () => {
});
it('retries submitting changes when retry button is clicked', () => {
+ setupMutateMock();
+
findSubmitChangesError().vm.$emit('retry');
expect(mutateMock).toHaveBeenCalled();
@@ -190,7 +210,11 @@ describe('static_site_editor/pages/home', () => {
const newContent = `new ${content}`;
beforeEach(() => {
- mutateMock.mockResolvedValueOnce({ data: { submitContentChanges: savedContentMeta } });
+ mutateMock.mockResolvedValueOnce(hasSubmittedChangesMutationPayload).mockResolvedValueOnce({
+ data: {
+ submitContentChanges: savedContentMeta,
+ },
+ });
buildWrapper();
findEditArea().vm.$emit('submit', { content: newContent });
@@ -198,8 +222,19 @@ describe('static_site_editor/pages/home', () => {
return wrapper.vm.$nextTick();
});
+ it('dispatches hasSubmittedChanges mutation', () => {
+ expect(mutateMock).toHaveBeenNthCalledWith(1, {
+ mutation: hasSubmittedChangesMutation,
+ variables: {
+ input: {
+ hasSubmittedChanges: true,
+ },
+ },
+ });
+ });
+
it('dispatches submitContentChanges mutation', () => {
- expect(mutateMock).toHaveBeenCalledWith({
+ expect(mutateMock).toHaveBeenNthCalledWith(2, {
mutation: submitContentChangesMutation,
variables: {
input: {
@@ -207,6 +242,8 @@ describe('static_site_editor/pages/home', () => {
project,
sourcePath,
username,
+ images: undefined,
+ mergeRequestMeta,
},
},
});
diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js
index 3e19e2413e7..3fc69dc4586 100644
--- a/spec/frontend/static_site_editor/pages/success_spec.js
+++ b/spec/frontend/static_site_editor/pages/success_spec.js
@@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils';
-import { GlEmptyState, GlButton } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import Success from '~/static_site_editor/pages/success.vue';
import { savedContentMeta, returnUrl, sourcePath } from '../mock_data';
import { HOME_ROUTE } from '~/static_site_editor/router/constants';
-describe('static_site_editor/pages/success', () => {
+describe('~/static_site_editor/pages/success.vue', () => {
const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg';
let wrapper;
let router;
@@ -15,14 +15,15 @@ describe('static_site_editor/pages/success', () => {
};
};
- const buildWrapper = (data = {}) => {
+ const buildWrapper = (data = {}, appData = {}) => {
wrapper = shallowMount(Success, {
mocks: {
$router: router,
},
stubs: {
- GlEmptyState,
GlButton,
+ GlEmptyState,
+ GlLoadingIcon,
},
propsData: {
mergeRequestsIllustrationPath,
@@ -33,6 +34,8 @@ describe('static_site_editor/pages/success', () => {
appData: {
returnUrl,
sourcePath,
+ hasSubmittedChanges: true,
+ ...appData,
},
...data,
};
@@ -40,8 +43,9 @@ describe('static_site_editor/pages/success', () => {
});
};
- const findEmptyState = () => wrapper.find(GlEmptyState);
const findReturnUrlButton = () => wrapper.find(GlButton);
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
buildRouter();
@@ -52,50 +56,76 @@ describe('static_site_editor/pages/success', () => {
wrapper = null;
});
- it('renders empty state with a link to the created merge request', () => {
- buildWrapper();
+ describe('when savedContentMeta is valid', () => {
+ it('renders empty state with a link to the created merge request', () => {
+ buildWrapper();
+
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEmptyState().props()).toMatchObject({
+ primaryButtonText: 'View merge request',
+ primaryButtonLink: savedContentMeta.mergeRequest.url,
+ title: 'Your merge request has been created',
+ svgPath: mergeRequestsIllustrationPath,
+ svgHeight: 146,
+ });
+ });
- expect(findEmptyState().exists()).toBe(true);
- expect(findEmptyState().props()).toMatchObject({
- primaryButtonText: 'View merge request',
- primaryButtonLink: savedContentMeta.mergeRequest.url,
- title: 'Your merge request has been created',
- svgPath: mergeRequestsIllustrationPath,
+ it('displays merge request instructions in the empty state', () => {
+ buildWrapper();
+
+ expect(findEmptyState().text()).toContain(
+ 'To see your changes live you will need to do the following things:',
+ );
+ expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.');
+ expect(findEmptyState().text()).toContain(
+ '2. Add a description to explain why the change is being made.',
+ );
+ expect(findEmptyState().text()).toContain(
+ '3. Assign a person to review and accept the merge request.',
+ );
});
- });
- it('displays merge request instructions in the empty state', () => {
- buildWrapper();
-
- expect(findEmptyState().text()).toContain(
- 'To see your changes live you will need to do the following things:',
- );
- expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.');
- expect(findEmptyState().text()).toContain(
- '2. Add a description to explain why the change is being made.',
- );
- expect(findEmptyState().text()).toContain(
- '3. Assign a person to review and accept the merge request.',
- );
- });
+ it('displays return to site button', () => {
+ buildWrapper();
+
+ expect(findReturnUrlButton().text()).toBe('Return to site');
+ expect(findReturnUrlButton().attributes().href).toBe(returnUrl);
+ });
- it('displays return to site button', () => {
- buildWrapper();
+ it('displays source path', () => {
+ buildWrapper();
- expect(findReturnUrlButton().text()).toBe('Return to site');
- expect(findReturnUrlButton().attributes().href).toBe(returnUrl);
+ expect(wrapper.text()).toContain(`Update ${sourcePath} file`);
+ });
});
- it('displays source path', () => {
- buildWrapper();
+ describe('when savedContentMeta is invalid', () => {
+ it('renders empty state with a loader', () => {
+ buildWrapper({ savedContentMeta: null });
- expect(wrapper.text()).toContain(`Update ${sourcePath} file`);
- });
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEmptyState().props()).toMatchObject({
+ title: 'Creating your merge request',
+ svgPath: mergeRequestsIllustrationPath,
+ });
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
- it('redirects to the HOME route when content has not been submitted', () => {
- buildWrapper({ savedContentMeta: null });
+ it('displays helper info in the empty state', () => {
+ buildWrapper({ savedContentMeta: null });
- expect(router.push).toHaveBeenCalledWith(HOME_ROUTE);
- expect(wrapper.html()).toBe('');
+ expect(findEmptyState().text()).toContain(
+ 'You can set an assignee to get your changes reviewed and deployed once your merge request is created',
+ );
+ expect(findEmptyState().text()).toContain(
+ 'A link to view the merge request will appear once ready',
+ );
+ });
+
+ it('redirects to the HOME route when content has not been submitted', () => {
+ buildWrapper({ savedContentMeta: null }, { hasSubmittedChanges: false });
+
+ expect(router.push).toHaveBeenCalledWith(HOME_ROUTE);
+ });
});
});
diff --git a/spec/frontend/static_site_editor/services/front_matterify_spec.js b/spec/frontend/static_site_editor/services/front_matterify_spec.js
new file mode 100644
index 00000000000..dbaedc30849
--- /dev/null
+++ b/spec/frontend/static_site_editor/services/front_matterify_spec.js
@@ -0,0 +1,47 @@
+import {
+ sourceContentYAML as content,
+ sourceContentHeaderObjYAML as yamlFrontMatterObj,
+ sourceContentSpacing as spacing,
+ sourceContentBody as body,
+} from '../mock_data';
+
+import { frontMatterify, stringify } from '~/static_site_editor/services/front_matterify';
+
+describe('static_site_editor/services/front_matterify', () => {
+ const frontMatterifiedContent = {
+ source: content,
+ matter: yamlFrontMatterObj,
+ spacing,
+ content: body,
+ delimiter: '---',
+ type: 'yaml',
+ };
+ const frontMatterifiedBody = {
+ source: body,
+ matter: null,
+ spacing: null,
+ content: body,
+ delimiter: null,
+ type: null,
+ };
+
+ describe('frontMatterify', () => {
+ it.each`
+ frontMatterified | target
+ ${frontMatterify(content)} | ${frontMatterifiedContent}
+ ${frontMatterify(body)} | ${frontMatterifiedBody}
+ `('returns $target from $frontMatterified', ({ frontMatterified, target }) => {
+ expect(frontMatterified).toEqual(target);
+ });
+ });
+
+ describe('stringify', () => {
+ it.each`
+ stringified | target
+ ${stringify(frontMatterifiedContent)} | ${content}
+ ${stringify(frontMatterifiedBody)} | ${body}
+ `('returns $target from $stringified', ({ stringified, target }) => {
+ expect(stringified).toBe(target);
+ });
+ });
+});
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 d464e6b1895..5018da7300b 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
@@ -19,6 +19,7 @@ import {
commitBranchResponse,
commitMultipleResponse,
createMergeRequestResponse,
+ mergeRequestMeta,
sourcePath,
sourceContentYAML as content,
trackingCategory,
@@ -28,11 +29,20 @@ import {
jest.mock('~/static_site_editor/services/generate_branch_name');
describe('submitContentChanges', () => {
- const mergeRequestTitle = `Update ${sourcePath} file`;
const branch = 'branch-name';
let trackingSpy;
let origPage;
+ const buildPayload = (overrides = {}) => ({
+ username,
+ projectId,
+ sourcePath,
+ content,
+ images,
+ mergeRequestMeta,
+ ...overrides,
+ });
+
beforeEach(() => {
jest.spyOn(Api, 'createBranch').mockResolvedValue({ data: commitBranchResponse });
jest.spyOn(Api, 'commitMultiple').mockResolvedValue({ data: commitMultipleResponse });
@@ -53,7 +63,7 @@ describe('submitContentChanges', () => {
});
it('creates a branch named after the username and target branch', () => {
- return submitContentChanges({ username, projectId }).then(() => {
+ return submitContentChanges(buildPayload()).then(() => {
expect(Api.createBranch).toHaveBeenCalledWith(projectId, {
ref: DEFAULT_TARGET_BRANCH,
branch,
@@ -64,16 +74,16 @@ describe('submitContentChanges', () => {
it('notifies error when branch could not be created', () => {
Api.createBranch.mockRejectedValueOnce();
- return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
+ return expect(submitContentChanges(buildPayload())).rejects.toThrow(
SUBMIT_CHANGES_BRANCH_ERROR,
);
});
it('commits the content changes to the branch when creating branch succeeds', () => {
- return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => {
+ return submitContentChanges(buildPayload()).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, {
branch,
- commit_message: mergeRequestTitle,
+ commit_message: mergeRequestMeta.title,
actions: [
{
action: 'update',
@@ -93,16 +103,11 @@ describe('submitContentChanges', () => {
it('does not commit an image if it has been removed from the content', () => {
const contentWithoutImages = '## Content without images';
- return submitContentChanges({
- username,
- projectId,
- sourcePath,
- content: contentWithoutImages,
- images,
- }).then(() => {
+ const payload = buildPayload({ content: contentWithoutImages });
+ return submitContentChanges(payload).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, {
branch,
- commit_message: mergeRequestTitle,
+ commit_message: mergeRequestMeta.title,
actions: [
{
action: 'update',
@@ -117,17 +122,19 @@ describe('submitContentChanges', () => {
it('notifies error when content could not be committed', () => {
Api.commitMultiple.mockRejectedValueOnce();
- return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow(
+ return expect(submitContentChanges(buildPayload())).rejects.toThrow(
SUBMIT_CHANGES_COMMIT_ERROR,
);
});
- it('creates a merge request when commiting changes succeeds', () => {
- return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => {
+ it('creates a merge request when committing changes succeeds', () => {
+ return submitContentChanges(buildPayload()).then(() => {
+ const { title, description } = mergeRequestMeta;
expect(Api.createProjectMergeRequest).toHaveBeenCalledWith(
projectId,
convertObjectPropsToSnakeCase({
- title: mergeRequestTitle,
+ title,
+ description,
targetBranch: DEFAULT_TARGET_BRANCH,
sourceBranch: branch,
}),
@@ -138,7 +145,7 @@ describe('submitContentChanges', () => {
it('notifies error when merge request could not be created', () => {
Api.createProjectMergeRequest.mockRejectedValueOnce();
- return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow(
+ return expect(submitContentChanges(buildPayload())).rejects.toThrow(
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
);
});
@@ -147,11 +154,9 @@ describe('submitContentChanges', () => {
let result;
beforeEach(() => {
- return submitContentChanges({ username, projectId, sourcePath, content, images }).then(
- _result => {
- result = _result;
- },
- );
+ return submitContentChanges(buildPayload()).then(_result => {
+ result = _result;
+ });
});
it('returns the branch name', () => {
@@ -179,7 +184,7 @@ describe('submitContentChanges', () => {
describe('sends the correct tracking event', () => {
beforeEach(() => {
- return submitContentChanges({ username, projectId, sourcePath, content, images });
+ return submitContentChanges(buildPayload());
});
it('for committing changes', () => {
diff --git a/spec/frontend/static_site_editor/services/templater_spec.js b/spec/frontend/static_site_editor/services/templater_spec.js
index 1e7ae872b7e..cb3a0a0c106 100644
--- a/spec/frontend/static_site_editor/services/templater_spec.js
+++ b/spec/frontend/static_site_editor/services/templater_spec.js
@@ -39,6 +39,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese
<p>Some paragraph...</p>
</div>
\`\`\`
+
+Below this line is a iframe that should be ignored and preserved
+
+<iframe></iframe>
`;
const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example.
@@ -87,6 +91,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese
<p>Some paragraph...</p>
</div>
\`\`\`
+
+Below this line is a iframe that should be ignored and preserved
+
+<iframe></iframe>
`;
it.each`
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 544c19da57b..eebec7de9d4 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import 'jquery';
+
import * as jqueryMatchers from 'custom-jquery-matchers';
import { config as testUtilsConfig } from '@vue/test-utils';
import Translate from '~/vue_shared/translate';
@@ -9,7 +11,6 @@ import customMatchers from './matchers';
import './helpers/dom_shims';
import './helpers/jquery';
-import '~/commons/jquery';
import '~/commons/bootstrap';
process.on('unhandledRejection', global.promiseRejectionHandler);
diff --git a/spec/frontend/user_lists/components/add_user_modal_spec.js b/spec/frontend/user_lists/components/add_user_modal_spec.js
new file mode 100644
index 00000000000..82ce195d7cd
--- /dev/null
+++ b/spec/frontend/user_lists/components/add_user_modal_spec.js
@@ -0,0 +1,50 @@
+import { mount } from '@vue/test-utils';
+import AddUserModal from '~/user_lists/components/add_user_modal.vue';
+
+describe('Add User Modal', () => {
+ let wrapper;
+
+ const click = testId => wrapper.find(`[data-testid="${testId}"]`).trigger('click');
+
+ beforeEach(() => {
+ wrapper = mount(AddUserModal, {
+ propsData: { visible: true },
+ });
+ });
+
+ it('should explain the format of user IDs to enter', () => {
+ expect(wrapper.find('[data-testid="add-userids-description"]').text()).toContain(
+ 'Enter a comma separated list of user IDs',
+ );
+ });
+
+ describe('events', () => {
+ beforeEach(() => {
+ wrapper.find('#add-user-ids').setValue('1, 2, 3, 4');
+ });
+
+ it('should emit the users entered when Add Users is clicked', () => {
+ click('confirm-add-user-ids');
+ expect(wrapper.emitted('addUsers')).toContainEqual(['1, 2, 3, 4']);
+ });
+
+ it('should clear the input after emitting', async () => {
+ click('confirm-add-user-ids');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('#add-user-ids').element.value).toBe('');
+ });
+
+ it('should not emit the users entered if cancel is clicked', () => {
+ click('cancel-add-user-ids');
+ expect(wrapper.emitted('addUsers')).toBeUndefined();
+ });
+
+ it('should clear the input after cancelling', async () => {
+ click('cancel-add-user-ids');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('#add-user-ids').element.value).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js
new file mode 100644
index 00000000000..51a38e12916
--- /dev/null
+++ b/spec/frontend/user_lists/components/edit_user_list_spec.js
@@ -0,0 +1,150 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { createLocalVue, mount } from '@vue/test-utils';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import Api from '~/api';
+import createStore from '~/user_lists/store/edit';
+import EditUserList from '~/user_lists/components/edit_user_list.vue';
+import UserListForm from '~/user_lists/components/user_list_form.vue';
+import { userList } from '../../feature_flags/mock_data';
+import { redirectTo } from '~/lib/utils/url_utility';
+
+jest.mock('~/api');
+jest.mock('~/lib/utils/url_utility');
+
+const localVue = createLocalVue(Vue);
+localVue.use(Vuex);
+
+describe('user_lists/components/edit_user_list', () => {
+ let wrapper;
+
+ const setInputValue = value => wrapper.find('[data-testid="user-list-name"]').setValue(value);
+
+ const click = button => wrapper.find(`[data-testid="${button}"]`).trigger('click');
+ const clickSave = () => click('save-user-list');
+
+ const destroy = () => wrapper?.destroy();
+
+ const factory = () => {
+ destroy();
+
+ wrapper = mount(EditUserList, {
+ localVue,
+ store: createStore({ projectId: '1', userListIid: '2' }),
+ provide: {
+ userListsDocsPath: '/docs/user_lists',
+ },
+ });
+ };
+
+ afterEach(() => {
+ destroy();
+ });
+
+ describe('loading', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockReturnValue(new Promise(() => {}));
+ factory();
+ });
+
+ it('should show a loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('loading error', () => {
+ const message = 'error creating list';
+ let alert;
+
+ beforeEach(async () => {
+ Api.fetchFeatureFlagUserList.mockRejectedValue({ message });
+ factory();
+ await waitForPromises();
+
+ alert = wrapper.find(GlAlert);
+ });
+
+ it('should show a flash with the error respopnse', () => {
+ expect(alert.text()).toContain(message);
+ });
+
+ it('should not be dismissible', async () => {
+ expect(alert.props('dismissible')).toBe(false);
+ });
+
+ it('should not show a user list form', () => {
+ expect(wrapper.find(UserListForm).exists()).toBe(false);
+ });
+ });
+
+ describe('update', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList });
+ factory();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should link to the documentation', () => {
+ const link = wrapper.find('[data-testid="user-list-docs-link"]');
+ expect(link.attributes('href')).toBe('/docs/user_lists');
+ });
+
+ it('should link the cancel button to the user list details path', () => {
+ const link = wrapper.find('[data-testid="user-list-cancel"]');
+ expect(link.attributes('href')).toBe(userList.path);
+ });
+
+ it('should show the user list name in the title', () => {
+ expect(wrapper.find('[data-testid="user-list-title"]').text()).toBe(`Edit ${userList.name}`);
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList });
+ setInputValue('test');
+ clickSave();
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should create a user list with the entered name', () => {
+ expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ name: 'test',
+ iid: userList.iid,
+ });
+ });
+
+ it('should redirect to the feature flag details page', () => {
+ expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ });
+ });
+
+ describe('error', () => {
+ let alert;
+ let message;
+
+ beforeEach(async () => {
+ message = 'error creating list';
+ Api.updateFeatureFlagUserList.mockRejectedValue({ message });
+ setInputValue('test');
+ clickSave();
+ await waitForPromises();
+
+ alert = wrapper.find(GlAlert);
+ });
+
+ it('should show a flash with the error respopnse', () => {
+ expect(alert.text()).toContain(message);
+ });
+
+ it('should dismiss the error if dismiss is clicked', async () => {
+ alert.find('button').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(alert.exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js
new file mode 100644
index 00000000000..62fb0ca0859
--- /dev/null
+++ b/spec/frontend/user_lists/components/new_user_list_spec.js
@@ -0,0 +1,93 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { GlAlert } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import Api from '~/api';
+import createStore from '~/user_lists/store/new';
+import NewUserList from '~/user_lists/components/new_user_list.vue';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { userList } from '../../feature_flags/mock_data';
+
+jest.mock('~/api');
+jest.mock('~/lib/utils/url_utility');
+
+const localVue = createLocalVue(Vue);
+localVue.use(Vuex);
+
+describe('user_lists/components/new_user_list', () => {
+ let wrapper;
+
+ const setInputValue = value => wrapper.find('[data-testid="user-list-name"]').setValue(value);
+
+ const click = button => wrapper.find(`[data-testid="${button}"]`).trigger('click');
+
+ beforeEach(() => {
+ wrapper = mount(NewUserList, {
+ localVue,
+ store: createStore({ projectId: '1' }),
+ provide: {
+ featureFlagsPath: '/feature_flags',
+ userListsDocsPath: '/docs/user_lists',
+ },
+ });
+ });
+
+ it('should link to the documentation', () => {
+ const link = wrapper.find('[data-testid="user-list-docs-link"]');
+ expect(link.attributes('href')).toBe('/docs/user_lists');
+ });
+
+ it('should link the cancel buton back to feature flags', () => {
+ const cancel = wrapper.find('[data-testid="user-list-cancel"');
+ expect(cancel.attributes('href')).toBe('/feature_flags');
+ });
+
+ describe('create', () => {
+ describe('success', () => {
+ beforeEach(() => {
+ Api.createFeatureFlagUserList.mockResolvedValue({ data: userList });
+ setInputValue('test');
+ click('save-user-list');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should create a user list with the entered name', () => {
+ expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ name: 'test',
+ user_xids: '',
+ });
+ });
+
+ it('should redirect to the feature flag details page', () => {
+ expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ });
+ });
+
+ describe('error', () => {
+ let alert;
+
+ beforeEach(async () => {
+ Api.createFeatureFlagUserList.mockRejectedValue({ message: 'error creating list' });
+ setInputValue('test');
+ click('save-user-list');
+
+ await waitForPromises();
+
+ alert = wrapper.find(GlAlert);
+ });
+
+ it('should show a flash with the error respopnse', () => {
+ expect(alert.text()).toContain('error creating list');
+ });
+
+ it('should dismiss the error when the dismiss button is clicked', async () => {
+ alert.find('button').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(alert.exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/components/user_list_form_spec.js b/spec/frontend/user_lists/components/user_list_form_spec.js
new file mode 100644
index 00000000000..42f7659600e
--- /dev/null
+++ b/spec/frontend/user_lists/components/user_list_form_spec.js
@@ -0,0 +1,40 @@
+import { mount } from '@vue/test-utils';
+import Form from '~/user_lists/components/user_list_form.vue';
+import { userList } from '../../feature_flags/mock_data';
+
+describe('user_lists/components/user_list_form', () => {
+ let wrapper;
+ let input;
+
+ beforeEach(() => {
+ wrapper = mount(Form, {
+ propsData: {
+ cancelPath: '/cancel',
+ saveButtonLabel: 'Save',
+ userListsDocsPath: '/docs',
+ userList,
+ },
+ });
+
+ input = wrapper.find('[data-testid="user-list-name"]');
+ });
+
+ it('should set the name to the name of the given user list', () => {
+ expect(input.element.value).toBe(userList.name);
+ });
+
+ it('should link to the user lists docs', () => {
+ expect(wrapper.find('[data-testid="user-list-docs-link"]').attributes('href')).toBe('/docs');
+ });
+
+ it('should emit an updated user list when save is clicked', () => {
+ input.setValue('test');
+ wrapper.find('[data-testid="save-user-list"]').trigger('click');
+
+ expect(wrapper.emitted('submit')).toEqual([[{ ...userList, name: 'test' }]]);
+ });
+
+ it('should set the cancel button to the passed url', () => {
+ expect(wrapper.find('[data-testid="user-list-cancel"]').attributes('href')).toBe('/cancel');
+ });
+});
diff --git a/spec/frontend/user_lists/components/user_list_spec.js b/spec/frontend/user_lists/components/user_list_spec.js
new file mode 100644
index 00000000000..5f9b7967846
--- /dev/null
+++ b/spec/frontend/user_lists/components/user_list_spec.js
@@ -0,0 +1,196 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { mount } from '@vue/test-utils';
+import { uniq } from 'lodash';
+import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import Api from '~/api';
+import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils';
+import createStore from '~/user_lists/store/show';
+import UserList from '~/user_lists/components/user_list.vue';
+import { userList } from '../../feature_flags/mock_data';
+
+jest.mock('~/api');
+
+Vue.use(Vuex);
+
+describe('User List', () => {
+ let wrapper;
+
+ const click = testId => wrapper.find(`[data-testid="${testId}"]`).trigger('click');
+
+ const findUserIds = () => wrapper.findAll('[data-testid="user-id"]');
+
+ const destroy = () => wrapper?.destroy();
+
+ const factory = () => {
+ destroy();
+
+ wrapper = mount(UserList, {
+ store: createStore({ projectId: '1', userListIid: '2' }),
+ propsData: {
+ emptyStatePath: '/empty_state.svg',
+ },
+ });
+ };
+
+ describe('loading', () => {
+ let resolveFn;
+
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockReturnValue(
+ new Promise(resolve => {
+ resolveFn = resolve;
+ }),
+ );
+ factory();
+ });
+
+ afterEach(() => {
+ resolveFn();
+ });
+
+ it('shows a loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('success', () => {
+ let userIds;
+
+ beforeEach(() => {
+ userIds = parseUserIds(userList.user_xids);
+ Api.fetchFeatureFlagUserList.mockResolvedValueOnce({ data: userList });
+ factory();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('requests the user list on mount', () => {
+ expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2');
+ });
+
+ it('shows the list name', () => {
+ expect(wrapper.find('h3').text()).toBe(userList.name);
+ });
+
+ it('shows an add users button', () => {
+ expect(wrapper.find('[data-testid="add-users"]').text()).toBe('Add Users');
+ });
+
+ it('shows an edit list button', () => {
+ expect(wrapper.find('[data-testid="edit-user-list"]').text()).toBe('Edit');
+ });
+
+ it('shows a row for every id', () => {
+ expect(wrapper.findAll('[data-testid="user-id-row"]')).toHaveLength(userIds.length);
+ });
+
+ it('shows one id on each row', () => {
+ findUserIds().wrappers.forEach((w, i) => expect(w.text()).toBe(userIds[i]));
+ });
+
+ it('shows a delete button for every row', () => {
+ expect(wrapper.findAll('[data-testid="delete-user-id"]')).toHaveLength(userIds.length);
+ });
+
+ describe('adding users', () => {
+ const newIds = ['user3', 'user4', 'user5', 'test', 'example', 'foo'];
+ let receivedUserIds;
+ let parsedReceivedUserIds;
+
+ beforeEach(async () => {
+ Api.updateFeatureFlagUserList.mockResolvedValue(userList);
+ click('add-users');
+ await wrapper.vm.$nextTick();
+ wrapper.find('#add-user-ids').setValue(`${stringifyUserIds(newIds)},`);
+ click('confirm-add-user-ids');
+ await wrapper.vm.$nextTick();
+ [[, { user_xids: receivedUserIds }]] = Api.updateFeatureFlagUserList.mock.calls;
+ parsedReceivedUserIds = parseUserIds(receivedUserIds);
+ });
+
+ it('should add user IDs to the user list', () => {
+ newIds.forEach(id => expect(receivedUserIds).toContain(id));
+ });
+
+ it('should not remove existing user ids', () => {
+ userIds.forEach(id => expect(receivedUserIds).toContain(id));
+ });
+
+ it('should not submit empty IDs', () => {
+ parsedReceivedUserIds.forEach(id => expect(id).not.toBe(''));
+ });
+
+ it('should not create duplicate entries', () => {
+ expect(uniq(parsedReceivedUserIds)).toEqual(parsedReceivedUserIds);
+ });
+
+ it('should display the new IDs', () => {
+ const userIdWrappers = findUserIds();
+ newIds.forEach(id => {
+ const userIdWrapper = userIdWrappers.wrappers.find(w => w.text() === id);
+ expect(userIdWrapper.exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('deleting users', () => {
+ let receivedUserIds;
+
+ beforeEach(async () => {
+ Api.updateFeatureFlagUserList.mockResolvedValue(userList);
+ click('delete-user-id');
+ await wrapper.vm.$nextTick();
+ [[, { user_xids: receivedUserIds }]] = Api.updateFeatureFlagUserList.mock.calls;
+ });
+
+ it('should remove the ID clicked', () => {
+ expect(receivedUserIds).not.toContain(userIds[0]);
+ });
+
+ it('should not display the deleted user', () => {
+ const userIdWrappers = findUserIds();
+ const userIdWrapper = userIdWrappers.wrappers.find(w => w.text() === userIds[0]);
+ expect(userIdWrapper).toBeUndefined();
+ });
+ });
+ });
+
+ describe('error', () => {
+ const findAlert = () => wrapper.find(GlAlert);
+
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockRejectedValue();
+ factory();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays the alert message', () => {
+ const alert = findAlert();
+ expect(alert.text()).toBe('Something went wrong on our end. Please try again!');
+ });
+
+ it('can dismiss the alert', async () => {
+ const alert = findAlert();
+ alert.find('button').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(alert.exists()).toBe(false);
+ });
+ });
+
+ describe('empty list', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockResolvedValueOnce({ data: { ...userList, user_xids: '' } });
+ factory();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays an empty state', () => {
+ expect(wrapper.find(GlEmptyState).exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/edit/actions_spec.js b/spec/frontend/user_lists/store/edit/actions_spec.js
new file mode 100644
index 00000000000..7f0fb8e5401
--- /dev/null
+++ b/spec/frontend/user_lists/store/edit/actions_spec.js
@@ -0,0 +1,121 @@
+import testAction from 'helpers/vuex_action_helper';
+import Api from '~/api';
+import createState from '~/user_lists/store/edit/state';
+import * as types from '~/user_lists/store/edit/mutation_types';
+import * as actions from '~/user_lists/store/edit/actions';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { userList } from '../../../feature_flags/mock_data';
+
+jest.mock('~/api');
+jest.mock('~/lib/utils/url_utility');
+
+describe('User Lists Edit Actions', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '1', userListIid: '2' });
+ });
+
+ describe('fetchUserList', () => {
+ describe('success', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList });
+ });
+
+ it('should commit RECEIVE_USER_LIST_SUCCESS', () => {
+ return testAction(
+ actions.fetchUserList,
+ undefined,
+ state,
+ [
+ { type: types.REQUEST_USER_LIST },
+ { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList },
+ ],
+ [],
+ () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'),
+ );
+ });
+ });
+
+ describe('error', () => {
+ let error;
+ beforeEach(() => {
+ error = { response: { data: { message: ['error'] } } };
+ Api.fetchFeatureFlagUserList.mockRejectedValue(error);
+ });
+
+ it('should commit RECEIVE_USER_LIST_ERROR', () => {
+ return testAction(
+ actions.fetchUserList,
+ undefined,
+ state,
+ [
+ { type: types.REQUEST_USER_LIST },
+ { type: types.RECEIVE_USER_LIST_ERROR, payload: ['error'] },
+ ],
+ [],
+ () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'),
+ );
+ });
+ });
+ });
+
+ describe('dismissErrorAlert', () => {
+ it('should commit DISMISS_ERROR_ALERT', () => {
+ return testAction(actions.dismissErrorAlert, undefined, state, [
+ { type: types.DISMISS_ERROR_ALERT },
+ ]);
+ });
+ });
+
+ describe('updateUserList', () => {
+ let updatedList;
+
+ beforeEach(() => {
+ updatedList = {
+ ...userList,
+ name: 'new',
+ };
+ });
+ describe('success', () => {
+ beforeEach(() => {
+ Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList });
+ state.userList = userList;
+ });
+
+ it('should commit RECEIVE_USER_LIST_SUCCESS', () => {
+ return testAction(actions.updateUserList, updatedList, state, [], [], () => {
+ expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ name: updatedList.name,
+ iid: updatedList.iid,
+ });
+ expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ });
+ });
+ });
+
+ describe('error', () => {
+ let error;
+
+ beforeEach(() => {
+ error = { message: 'error' };
+ Api.updateFeatureFlagUserList.mockRejectedValue(error);
+ });
+
+ it('should commit RECEIVE_USER_LIST_ERROR', () => {
+ return testAction(
+ actions.updateUserList,
+ updatedList,
+ state,
+ [{ type: types.RECEIVE_USER_LIST_ERROR, payload: ['error'] }],
+ [],
+ () =>
+ expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ name: updatedList.name,
+ iid: updatedList.iid,
+ }),
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js
new file mode 100644
index 00000000000..3d4d2a59717
--- /dev/null
+++ b/spec/frontend/user_lists/store/edit/mutations_spec.js
@@ -0,0 +1,61 @@
+import statuses from '~/user_lists/constants/edit';
+import createState from '~/user_lists/store/edit/state';
+import * as types from '~/user_lists/store/edit/mutation_types';
+import mutations from '~/user_lists/store/edit/mutations';
+import { userList } from '../../../feature_flags/mock_data';
+
+describe('User List Edit Mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '1', userListIid: '2' });
+ });
+
+ describe(types.REQUEST_USER_LIST, () => {
+ beforeEach(() => {
+ mutations[types.REQUEST_USER_LIST](state);
+ });
+
+ it('sets the state to loading', () => {
+ expect(state.status).toBe(statuses.LOADING);
+ });
+ });
+
+ describe(types.RECEIVE_USER_LIST_SUCCESS, () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](state, userList);
+ });
+
+ it('sets the state to success', () => {
+ expect(state.status).toBe(statuses.SUCCESS);
+ });
+
+ it('sets the user list to the one received', () => {
+ expect(state.userList).toEqual(userList);
+ });
+ });
+
+ describe(types.RECIEVE_USER_LIST_ERROR, () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_ERROR](state, ['network error']);
+ });
+
+ it('sets the state to error', () => {
+ expect(state.status).toBe(statuses.ERROR);
+ });
+
+ it('sets the error message to the recieved one', () => {
+ expect(state.errorMessage).toEqual(['network error']);
+ });
+ });
+
+ describe(types.DISMISS_ERROR_ALERT, () => {
+ beforeEach(() => {
+ mutations[types.DISMISS_ERROR_ALERT](state);
+ });
+
+ it('sets the state to error dismissed', () => {
+ expect(state.status).toBe(statuses.UNSYNCED);
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/new/actions_spec.js b/spec/frontend/user_lists/store/new/actions_spec.js
new file mode 100644
index 00000000000..9cc6212a125
--- /dev/null
+++ b/spec/frontend/user_lists/store/new/actions_spec.js
@@ -0,0 +1,69 @@
+import testAction from 'helpers/vuex_action_helper';
+import Api from '~/api';
+import createState from '~/user_lists/store/new/state';
+import * as types from '~/user_lists/store/new/mutation_types';
+import * as actions from '~/user_lists/store/new/actions';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { userList } from '../../../feature_flags/mock_data';
+
+jest.mock('~/api');
+jest.mock('~/lib/utils/url_utility');
+
+describe('User Lists Edit Actions', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '1' });
+ });
+
+ describe('dismissErrorAlert', () => {
+ it('should commit DISMISS_ERROR_ALERT', () => {
+ return testAction(actions.dismissErrorAlert, undefined, state, [
+ { type: types.DISMISS_ERROR_ALERT },
+ ]);
+ });
+ });
+
+ describe('createUserList', () => {
+ let createdList;
+
+ beforeEach(() => {
+ createdList = {
+ ...userList,
+ name: 'new',
+ };
+ });
+ describe('success', () => {
+ beforeEach(() => {
+ Api.createFeatureFlagUserList.mockResolvedValue({ data: userList });
+ });
+
+ it('should redirect to the user list page', () => {
+ return testAction(actions.createUserList, createdList, state, [], [], () => {
+ expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', createdList);
+ expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ });
+ });
+ });
+
+ describe('error', () => {
+ let error;
+
+ beforeEach(() => {
+ error = { message: 'error' };
+ Api.createFeatureFlagUserList.mockRejectedValue(error);
+ });
+
+ it('should commit RECEIVE_USER_LIST_ERROR', () => {
+ return testAction(
+ actions.createUserList,
+ createdList,
+ state,
+ [{ type: types.RECEIVE_CREATE_USER_LIST_ERROR, payload: ['error'] }],
+ [],
+ () => expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', createdList),
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/new/mutations_spec.js b/spec/frontend/user_lists/store/new/mutations_spec.js
new file mode 100644
index 00000000000..89e8a83eb25
--- /dev/null
+++ b/spec/frontend/user_lists/store/new/mutations_spec.js
@@ -0,0 +1,38 @@
+import createState from '~/user_lists/store/new/state';
+import * as types from '~/user_lists/store/new/mutation_types';
+import mutations from '~/user_lists/store/new/mutations';
+
+describe('User List Edit Mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '1' });
+ });
+
+ describe(types.RECIEVE_USER_LIST_ERROR, () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_CREATE_USER_LIST_ERROR](state, ['network error']);
+ });
+
+ it('sets the error message to the recieved one', () => {
+ expect(state.errorMessage).toEqual(['network error']);
+ });
+
+ it('sets the error message to the recevied API message if present', () => {
+ const message = ['name is blank', 'name is too short'];
+
+ mutations[types.RECEIVE_CREATE_USER_LIST_ERROR](state, message);
+ expect(state.errorMessage).toEqual(message);
+ });
+ });
+
+ describe(types.DISMISS_ERROR_ALERT, () => {
+ beforeEach(() => {
+ mutations[types.DISMISS_ERROR_ALERT](state);
+ });
+
+ it('clears the error message', () => {
+ expect(state.errorMessage).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/show/actions_spec.js b/spec/frontend/user_lists/store/show/actions_spec.js
new file mode 100644
index 00000000000..25a6b9ec0e4
--- /dev/null
+++ b/spec/frontend/user_lists/store/show/actions_spec.js
@@ -0,0 +1,117 @@
+import testAction from 'helpers/vuex_action_helper';
+import { userList } from 'jest/feature_flags/mock_data';
+import Api from '~/api';
+import { stringifyUserIds } from '~/user_lists/store/utils';
+import createState from '~/user_lists/store/show/state';
+import * as types from '~/user_lists/store/show/mutation_types';
+import * as actions from '~/user_lists/store/show/actions';
+
+jest.mock('~/api');
+
+describe('User Lists Show Actions', () => {
+ let mockState;
+
+ beforeEach(() => {
+ mockState = createState({ projectId: '1', userListIid: '2' });
+ });
+
+ describe('fetchUserList', () => {
+ it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_SUCCESS on success', () => {
+ Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList });
+ return testAction(
+ actions.fetchUserList,
+ undefined,
+ mockState,
+ [
+ { type: types.REQUEST_USER_LIST },
+ { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList },
+ ],
+ [],
+ () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'),
+ );
+ });
+
+ it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_ERROR on error', () => {
+ Api.fetchFeatureFlagUserList.mockRejectedValue({ message: 'fail' });
+ return testAction(
+ actions.fetchUserList,
+ undefined,
+ mockState,
+ [{ type: types.REQUEST_USER_LIST }, { type: types.RECEIVE_USER_LIST_ERROR }],
+ [],
+ );
+ });
+ });
+
+ describe('dismissErrorAlert', () => {
+ it('commits DISMISS_ERROR_ALERT', () => {
+ return testAction(
+ actions.dismissErrorAlert,
+ undefined,
+ mockState,
+ [{ type: types.DISMISS_ERROR_ALERT }],
+ [],
+ );
+ });
+ });
+
+ describe('addUserIds', () => {
+ it('adds the given IDs and tries to update the user list', () => {
+ return testAction(
+ actions.addUserIds,
+ '1,2,3',
+ mockState,
+ [{ type: types.ADD_USER_IDS, payload: '1,2,3' }],
+ [{ type: 'updateUserList' }],
+ );
+ });
+ });
+
+ describe('removeUserId', () => {
+ it('removes the given ID and tries to update the user list', () => {
+ return testAction(
+ actions.removeUserId,
+ 'user3',
+ mockState,
+ [{ type: types.REMOVE_USER_ID, payload: 'user3' }],
+ [{ type: 'updateUserList' }],
+ );
+ });
+ });
+
+ describe('updateUserList', () => {
+ beforeEach(() => {
+ mockState.userList = userList;
+ mockState.userIds = ['user1', 'user2', 'user3'];
+ });
+
+ it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_SUCCESS on success', () => {
+ Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList });
+ return testAction(
+ actions.updateUserList,
+ undefined,
+ mockState,
+ [
+ { type: types.REQUEST_USER_LIST },
+ { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList },
+ ],
+ [],
+ () =>
+ expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ ...userList,
+ user_xids: stringifyUserIds(mockState.userIds),
+ }),
+ );
+ });
+ it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_ERROR on error', () => {
+ Api.updateFeatureFlagUserList.mockRejectedValue({ message: 'fail' });
+ return testAction(
+ actions.updateUserList,
+ undefined,
+ mockState,
+ [{ type: types.REQUEST_USER_LIST }, { type: types.RECEIVE_USER_LIST_ERROR }],
+ [],
+ );
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/show/mutations_spec.js b/spec/frontend/user_lists/store/show/mutations_spec.js
new file mode 100644
index 00000000000..364cc6a0225
--- /dev/null
+++ b/spec/frontend/user_lists/store/show/mutations_spec.js
@@ -0,0 +1,86 @@
+import { uniq } from 'lodash';
+import { userList } from 'jest/feature_flags/mock_data';
+import createState from '~/user_lists/store/show/state';
+import mutations from '~/user_lists/store/show/mutations';
+import { states } from '~/user_lists/constants/show';
+import * as types from '~/user_lists/store/show/mutation_types';
+
+describe('User Lists Show Mutations', () => {
+ let mockState;
+
+ beforeEach(() => {
+ mockState = createState({ projectId: '1', userListIid: '2' });
+ });
+
+ describe(types.REQUEST_USER_LIST, () => {
+ it('puts us in the loading state', () => {
+ mutations[types.REQUEST_USER_LIST](mockState);
+
+ expect(mockState.state).toBe(states.LOADING);
+ });
+ });
+
+ describe(types.RECEIVE_USER_LIST_SUCCESS, () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList);
+ });
+
+ it('sets the state to LOADED', () => {
+ expect(mockState.state).toBe(states.SUCCESS);
+ });
+
+ it('sets the active user list', () => {
+ expect(mockState.userList).toEqual(userList);
+ });
+
+ it('splits the user IDs into an Array', () => {
+ expect(mockState.userIds).toEqual(userList.user_xids.split(','));
+ });
+
+ it('sets user IDs to an empty Array if an empty string is received', () => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, { ...userList, user_xids: '' });
+ expect(mockState.userIds).toEqual([]);
+ });
+ });
+ describe(types.RECEIVE_USER_LIST_ERROR, () => {
+ it('sets the state to error', () => {
+ mutations[types.RECEIVE_USER_LIST_ERROR](mockState);
+ expect(mockState.state).toBe(states.ERROR);
+ });
+ });
+ describe(types.ADD_USER_IDS, () => {
+ const newIds = ['user3', 'test1', '1', '3', ''];
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList);
+ mutations[types.ADD_USER_IDS](mockState, newIds.join(', '));
+ });
+
+ it('adds the new IDs to the state unless empty', () => {
+ newIds.filter(id => id).forEach(id => expect(mockState.userIds).toContain(id));
+ });
+
+ it('does not add duplicate IDs to the state', () => {
+ expect(mockState.userIds).toEqual(uniq(mockState.userIds));
+ });
+ });
+ describe(types.REMOVE_USER_ID, () => {
+ let userIds;
+ let removedId;
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList);
+ userIds = mockState.userIds;
+ removedId = 'user3';
+ mutations[types.REMOVE_USER_ID](mockState, removedId);
+ });
+
+ it('should remove the given id', () => {
+ expect(mockState).not.toContain(removedId);
+ });
+
+ it('should leave the rest of the IDs alone', () => {
+ userIds.filter(id => id !== removedId).forEach(id => expect(mockState.userIds).toContain(id));
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/utils_spec.js b/spec/frontend/user_lists/store/utils_spec.js
new file mode 100644
index 00000000000..9547b463eec
--- /dev/null
+++ b/spec/frontend/user_lists/store/utils_spec.js
@@ -0,0 +1,23 @@
+import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils';
+
+describe('User List Store Utils', () => {
+ describe('parseUserIds', () => {
+ it('should split comma-seperated user IDs into an array', () => {
+ expect(parseUserIds('1,2,3')).toEqual(['1', '2', '3']);
+ });
+
+ it('should filter whitespace before the comma', () => {
+ expect(parseUserIds('1\t,2 ,3')).toEqual(['1', '2', '3']);
+ });
+
+ it('should filter whitespace after the comma', () => {
+ expect(parseUserIds('1,\t2, 3')).toEqual(['1', '2', '3']);
+ });
+ });
+
+ describe('stringifyUserIds', () => {
+ it('should convert a list of user IDs into a comma-separated string', () => {
+ expect(stringifyUserIds(['1', '2', '3'])).toBe('1,2,3');
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
index caea9a757ae..015f8bbac51 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
@@ -130,7 +130,7 @@ describe('MRWidgetHeader', () => {
});
it('renders clipboard button', () => {
- expect(vm.$el.querySelector('.btn-clipboard')).not.toEqual(null);
+ expect(vm.$el.querySelector('[data-testid="mr-widget-copy-clipboard"]')).not.toEqual(null);
});
it('renders target branch', () => {
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
index 6ec30493f8b..9923434a7dd 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
@@ -6,6 +6,10 @@ import component from '~/vue_merge_request_widget/components/states/mr_widget_re
describe('Merge request widget rebase component', () => {
let Component;
let vm;
+
+ const findRebaseMessageEl = () => vm.$el.querySelector('[data-testid="rebase-message"]');
+ const findRebaseMessageElText = () => findRebaseMessageEl().textContent.trim();
+
beforeEach(() => {
Component = Vue.extend(component);
});
@@ -21,9 +25,7 @@ describe('Merge request widget rebase component', () => {
service: {},
});
- expect(
- vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(),
- ).toContain('Rebase in progress');
+ expect(findRebaseMessageElText()).toContain('Rebase in progress');
});
});
@@ -39,9 +41,7 @@ describe('Merge request widget rebase component', () => {
});
it('it should render rebase button and warning message', () => {
- const text = vm.$el
- .querySelector('.rebase-state-find-class-convention span')
- .textContent.trim();
+ const text = findRebaseMessageElText();
expect(text).toContain('Fast-forward merge is not possible.');
expect(text.replace(/\s\s+/g, ' ')).toContain(
@@ -53,9 +53,7 @@ describe('Merge request widget rebase component', () => {
vm.rebasingError = 'Something went wrong!';
Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(),
- ).toContain('Something went wrong!');
+ expect(findRebaseMessageElText()).toContain('Something went wrong!');
done();
});
});
@@ -72,9 +70,7 @@ describe('Merge request widget rebase component', () => {
service: {},
});
- const text = vm.$el
- .querySelector('.rebase-state-find-class-convention span')
- .textContent.trim();
+ const text = findRebaseMessageElText();
expect(text).toContain('Fast-forward merge is not possible.');
expect(text).toContain('Rebase the source branch onto');
@@ -93,7 +89,7 @@ describe('Merge request widget rebase component', () => {
service: {},
});
- const elem = vm.$el.querySelector('.rebase-state-find-class-convention span');
+ const elem = findRebaseMessageEl();
expect(elem.innerHTML).toContain(
`Fast-forward merge is not possible. Rebase the source branch onto <span class="label-branch">${targetBranch}</span> to allow this merge request to be merged.`,
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js
index 98af44b0975..aae9b8660e2 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js
@@ -1,12 +1,12 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import AutoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
describe('MRWidgetAutoMergeFailed', () => {
let wrapper;
const mergeError = 'This is the merge error';
- const findButton = () => wrapper.find('button');
+ const findButton = () => wrapper.find(GlButton);
const createComponent = (props = {}) => {
wrapper = shallowMount(AutoMergeFailedComponent, {
@@ -38,17 +38,13 @@ describe('MRWidgetAutoMergeFailed', () => {
it('emits event and shows loading icon when button is clicked', () => {
jest.spyOn(eventHub, '$emit');
- findButton().trigger('click');
+ findButton().vm.$emit('click');
expect(eventHub.$emit.mock.calls[0][0]).toBe('MRWidgetUpdateRequested');
return wrapper.vm.$nextTick(() => {
- expect(findButton().attributes('disabled')).toEqual('disabled');
- expect(
- findButton()
- .find(GlLoadingIcon)
- .exists(),
- ).toBe(true);
+ expect(findButton().attributes('disabled')).toBe('true');
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
index 5eb24315ca6..9057ffaea45 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -101,8 +101,6 @@ describe('ReadyToMerge', () => {
expect(vm.isMakingRequest).toBeFalsy();
expect(vm.isMergingImmediately).toBeFalsy();
expect(vm.commitMessage).toBe(vm.mr.commitMessage);
- expect(vm.successSvg).toBeDefined();
- expect(vm.warningSvg).toBeDefined();
});
});
@@ -494,19 +492,6 @@ describe('ReadyToMerge', () => {
});
});
- it('hides close button', done => {
- jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged'));
- jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {});
-
- vm.handleMergePolling(() => {}, () => {});
-
- setImmediate(() => {
- expect(document.querySelector('.btn-close').classList.contains('hidden')).toBeTruthy();
-
- done();
- });
- });
-
it('updates merge request count badge', done => {
jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged'));
jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {});
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
index 1711efb5512..13c0665f929 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
@@ -31,10 +31,7 @@ describe('DeploymentAction component', () => {
wrapper.destroy();
}
- wrapper = mount(DeploymentActions, {
- ...options,
- provide: { glFeatures: { deployFromFooter: true } },
- });
+ wrapper = mount(DeploymentActions, options);
};
const findStopButton = () => wrapper.find('.js-stop-env');
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
index ce395de3b5d..17d7fcc4bff 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
@@ -19,10 +19,7 @@ describe('Deployment component', () => {
if (wrapper && wrapper.destroy) {
wrapper.destroy();
}
- wrapper = mount(DeploymentComponent, {
- ...options,
- provide: { glFeatures: { deployFromFooter: true } },
- });
+ wrapper = mount(DeploymentComponent, options);
};
beforeEach(() => {
diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
index a2ade44b7c4..5fe8ff58d31 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'helpers/vue_mount_component_helper';
+import { withGonExperiment } from 'helpers/experimentation_helper';
import axios from '~/lib/utils/axios_utils';
import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
@@ -812,43 +813,61 @@ describe('mrWidgetOptions', () => {
});
});
- describe('given suggestPipeline feature flag is enabled', () => {
+ describe('suggestPipeline Experiment', () => {
beforeEach(() => {
mock.onAny().reply(200);
// This is needed because some grandchildren Bootstrap components throw warnings
// https://gitlab.com/gitlab-org/gitlab/issues/208458
jest.spyOn(console, 'warn').mockImplementation();
+ });
- gon.features = { suggestPipeline: true };
+ describe('given experiment is enabled', () => {
+ withGonExperiment('suggestPipeline');
- createComponent();
+ beforeEach(() => {
+ createComponent();
- vm.mr.hasCI = false;
- });
+ vm.mr.hasCI = false;
+ });
- it('should suggest pipelines when none exist', () => {
- expect(findSuggestPipeline()).toEqual(expect.any(Element));
- });
+ it('should suggest pipelines when none exist', () => {
+ expect(findSuggestPipeline()).toEqual(expect.any(Element));
+ });
- it.each([
- { isDismissedSuggestPipeline: true },
- { mergeRequestAddCiConfigPath: null },
- { hasCI: true },
- ])('with %s, should not suggest pipeline', async obj => {
- Object.assign(vm.mr, obj);
+ it.each([
+ { isDismissedSuggestPipeline: true },
+ { mergeRequestAddCiConfigPath: null },
+ { hasCI: true },
+ ])('with %s, should not suggest pipeline', async obj => {
+ Object.assign(vm.mr, obj);
- await vm.$nextTick();
+ await vm.$nextTick();
- expect(findSuggestPipeline()).toBeNull();
+ expect(findSuggestPipeline()).toBeNull();
+ });
+
+ it('should allow dismiss of the suggest pipeline message', async () => {
+ findSuggestPipelineButton().click();
+
+ await vm.$nextTick();
+
+ expect(findSuggestPipeline()).toBeNull();
+ });
});
- it('should allow dismiss of the suggest pipeline message', async () => {
- findSuggestPipelineButton().click();
+ describe('given suggestPipeline experiment is not enabled', () => {
+ withGonExperiment('suggestPipeline', false);
- await vm.$nextTick();
+ beforeEach(() => {
+ createComponent();
- expect(findSuggestPipeline()).toBeNull();
+ vm.mr.hasCI = false;
+ });
+
+ it('should not suggest pipelines when none exist', () => {
+ expect(findSuggestPipeline()).toBeNull();
+ });
});
});
});
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 dfd114a2d1c..ec4a81054db 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
@@ -39,6 +39,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="d-inline-flex"
data-clipboard-text="ssh://foo.bar"
@@ -80,6 +81,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="d-inline-flex"
data-clipboard-text="http://foo.bar"
diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js
index 9c38ccad8a7..dbdb7705d3c 100644
--- a/spec/frontend/vue_shared/components/alert_detail_table_spec.js
+++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js
@@ -1,5 +1,5 @@
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { GlTable, GlLoadingIcon } from '@gitlab/ui';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
const mockAlert = {
@@ -14,6 +14,7 @@ const mockAlert = {
assignees: { nodes: [] },
notes: { nodes: [] },
todos: { nodes: [] },
+ __typename: 'AlertManagementAlert',
};
describe('AlertDetails', () => {
@@ -35,6 +36,8 @@ describe('AlertDetails', () => {
});
const findTableComponent = () => wrapper.find(GlTable);
+ const findTableKeys = () => findTableComponent().findAll('tbody td:first-child');
+ const findTableField = (fields, fieldName) => fields.filter(row => row.text() === fieldName);
describe('Alert details', () => {
describe('empty state', () => {
@@ -58,8 +61,10 @@ describe('AlertDetails', () => {
});
describe('with table data', () => {
+ const environment = 'myEnvironment';
+ const environmentUrl = 'fake/url';
beforeEach(() => {
- mountComponent();
+ mountComponent({ alert: { ...mockAlert, environment, environmentUrl } });
});
it('renders a table', () => {
@@ -69,6 +74,26 @@ describe('AlertDetails', () => {
it('renders a cell based on alert data', () => {
expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token');
});
+
+ it('should show allowed alert fields', () => {
+ const fields = findTableKeys();
+
+ expect(findTableField(fields, 'Iid').exists()).toBe(true);
+ expect(findTableField(fields, 'Title').exists()).toBe(true);
+ expect(findTableField(fields, 'Severity').exists()).toBe(true);
+ expect(findTableField(fields, 'Status').exists()).toBe(true);
+ expect(findTableField(fields, 'Environment').exists()).toBe(true);
+ });
+
+ it('should not show disallowed alert fields', () => {
+ const fields = findTableKeys();
+
+ expect(findTableField(fields, 'Typename').exists()).toBe(false);
+ expect(findTableField(fields, 'Todos').exists()).toBe(false);
+ expect(findTableField(fields, 'Notes').exists()).toBe(false);
+ expect(findTableField(fields, 'Assignees').exists()).toBe(false);
+ expect(findTableField(fields, 'EnvironmentUrl').exists()).toBe(false);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js
index 7f0b7ba8cf8..51a2653befc 100644
--- a/spec/frontend/vue_shared/components/clipboard_button_spec.js
+++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
describe('clipboard button', () => {
@@ -26,9 +26,8 @@ describe('clipboard button', () => {
});
it('renders a button for clipboard', () => {
- expect(wrapper.find(GlDeprecatedButton).exists()).toBe(true);
+ expect(wrapper.find(GlButton).exists()).toBe(true);
expect(wrapper.attributes('data-clipboard-text')).toBe('copy me');
- expect(wrapper.find(GlIcon).props('name')).toBe('copy-to-clipboard');
});
it('should have a tooltip with default values', () => {
diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js
index 5d92af64de0..8456ca9d125 100644
--- a/spec/frontend/vue_shared/components/confirm_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js
@@ -86,6 +86,22 @@ describe('vue_shared/components/confirm_modal', () => {
expect(findForm().element.submit).not.toHaveBeenCalled();
});
+ describe('with handleSubmit prop', () => {
+ const handleSubmit = jest.fn();
+ beforeEach(() => {
+ createComponent({ handleSubmit });
+ findModal().vm.$emit('primary');
+ });
+
+ it('will call handleSubmit', () => {
+ expect(handleSubmit).toHaveBeenCalled();
+ });
+
+ it('does not submit the form', () => {
+ expect(findForm().element.submit).not.toHaveBeenCalled();
+ });
+ });
+
describe('when modal submitted', () => {
beforeEach(() => {
findModal().vm.$emit('primary');
diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
index 5470171a21e..3ff4c0917f2 100644
--- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js
+++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
@@ -12,7 +12,9 @@ describe('Local Storage Sync', () => {
};
afterEach(() => {
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ }
wrapper = null;
localStorage.clear();
});
@@ -45,23 +47,23 @@ describe('Local Storage Sync', () => {
expect(wrapper.emitted('input')).toBeFalsy();
});
- it('saves updated value to localStorage', () => {
- createComponent({
- props: {
- storageKey,
- value: 'ascending',
- },
- });
-
- const newValue = 'descending';
- wrapper.setProps({
- value: newValue,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(localStorage.getItem(storageKey)).toBe(newValue);
- });
- });
+ it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })(
+ 'saves updated value to localStorage',
+ newValue => {
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ },
+ });
+
+ wrapper.setProps({ value: newValue });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(localStorage.getItem(storageKey)).toBe(String(newValue));
+ });
+ },
+ );
it('does not save default value', () => {
const value = 'ascending';
@@ -125,4 +127,88 @@ describe('Local Storage Sync', () => {
});
});
});
+
+ describe('with "asJson" prop set to "true"', () => {
+ const storageKey = 'testStorageKey';
+
+ describe.each`
+ value | serializedValue
+ ${null} | ${'null'}
+ ${''} | ${'""'}
+ ${true} | ${'true'}
+ ${false} | ${'false'}
+ ${42} | ${'42'}
+ ${'42'} | ${'"42"'}
+ ${'{ foo: '} | ${'"{ foo: "'}
+ ${['test']} | ${'["test"]'}
+ ${{ foo: 'bar' }} | ${'{"foo":"bar"}'}
+ `('given $value', ({ value, serializedValue }) => {
+ describe('is a new value', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+
+ wrapper.setProps({ value });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('serializes the value correctly to localStorage', () => {
+ expect(localStorage.getItem(storageKey)).toBe(serializedValue);
+ });
+ });
+
+ describe('is already stored', () => {
+ beforeEach(() => {
+ localStorage.setItem(storageKey, serializedValue);
+
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+ });
+
+ it('emits an input event with the deserialized value', () => {
+ expect(wrapper.emitted('input')).toEqual([[value]]);
+ });
+ });
+ });
+
+ describe('with bad JSON in storage', () => {
+ const badJSON = '{ badJSON';
+
+ beforeEach(() => {
+ jest.spyOn(console, 'warn').mockImplementation();
+ localStorage.setItem(storageKey, badJSON);
+
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+ });
+
+ it('should console warn', () => {
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith(
+ `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`,
+ badJSON,
+ );
+ });
+
+ it('should not emit an input event', () => {
+ expect(wrapper.emitted('input')).toBeUndefined();
+ });
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
index cdd7a3ccaf0..b8a9143bc79 100644
--- a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
+++ b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
@@ -10,6 +10,7 @@ exports[`Suggestion Diff component matches snapshot 1`] = `
helppagepath="path_to_docs"
isapplyingbatch="true"
isbatched="true"
+ suggestionscount="0"
/>
<table
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index 3da0a35f05a..f1ead33ec68 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils';
const markdownPreviewPath = `${TEST_HOST}/preview`;
const markdownDocsPath = `${TEST_HOST}/docs`;
+const textareaValue = 'testing\n123';
function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) {
expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite);
@@ -20,23 +21,11 @@ function createComponent() {
markdownDocsPath,
markdownPreviewPath,
isSubmitting: false,
+ textareaValue,
},
slots: {
- textarea: '<textarea>testing\n123</textarea>',
+ textarea: `<textarea>${textareaValue}</textarea>`,
},
- template: `
- <field-component
- markdown-preview-path="${markdownPreviewPath}"
- markdown-docs-path="${markdownDocsPath}"
- :isSubmitting="false"
- >
- <textarea
- slot="textarea"
- v-model="text">
- <slot>this is a test</slot>
- </textarea>
- </field-component>
- `,
});
return wrapper;
}
diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
index a521668b15c..b19e74b5b11 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
@@ -57,7 +57,9 @@ describe('Suggestion Diff component', () => {
});
it('renders apply suggestion and add to batch buttons', () => {
- createComponent();
+ createComponent({
+ suggestionsCount: 2,
+ });
const applyBtn = findApplyButton();
const addToBatchBtn = findAddToBatchButton();
@@ -104,7 +106,9 @@ describe('Suggestion Diff component', () => {
describe('when add to batch is clicked', () => {
it('emits addToBatch', () => {
- createComponent();
+ createComponent({
+ suggestionsCount: 2,
+ });
findAddToBatchButton().vm.$emit('click');
diff --git a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js
new file mode 100644
index 00000000000..d6f5773295c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js
@@ -0,0 +1,46 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { GlAvatarLink } from '@gitlab/ui';
+import { group as member } from '../mock_data';
+import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const group = member.sharedWithGroup;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(GroupAvatar, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders link to group', () => {
+ const link = wrapper.find(GlAvatarLink);
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(group.webUrl);
+ });
+
+ it("renders group's full name", () => {
+ expect(getByText(group.fullName).exists()).toBe(true);
+ });
+
+ it("renders group's avatar", () => {
+ expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js
new file mode 100644
index 00000000000..7948da7eb40
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js
@@ -0,0 +1,38 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { invite as member } from '../mock_data';
+import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const { invite } = member;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(InviteAvatar, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders email as name', () => {
+ expect(getByText(invite.email).exists()).toBe(true);
+ });
+
+ it('renders avatar', () => {
+ expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js
new file mode 100644
index 00000000000..6c0ba8afede
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js
@@ -0,0 +1,85 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { GlAvatarLink, GlBadge } from '@gitlab/ui';
+import { member as memberMock, orphanedMember } from '../mock_data';
+import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
+
+describe('UserAvatar', () => {
+ let wrapper;
+
+ const { user } = memberMock;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(UserAvatar, {
+ propsData: {
+ member: memberMock,
+ isCurrentUser: false,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).findByText(text, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it("renders link to user's profile", () => {
+ createComponent();
+
+ const link = wrapper.find(GlAvatarLink);
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: user.webUrl,
+ 'data-user-id': `${user.id}`,
+ 'data-username': user.username,
+ });
+ });
+
+ it("renders user's name", () => {
+ createComponent();
+
+ expect(getByText(user.name).exists()).toBe(true);
+ });
+
+ it("renders user's username", () => {
+ createComponent();
+
+ expect(getByText(`@${user.username}`).exists()).toBe(true);
+ });
+
+ it("renders user's avatar", () => {
+ createComponent();
+
+ expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl);
+ });
+
+ describe('when user property does not exist', () => {
+ it('displays an orphaned user', () => {
+ createComponent({ member: orphanedMember });
+
+ expect(getByText('Orphaned member').exists()).toBe(true);
+ });
+ });
+
+ describe('badges', () => {
+ it.each`
+ member | badgeText
+ ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'}
+ ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'}
+ `('renders the "$badgeText" badge', ({ member, badgeText }) => {
+ createComponent({ member });
+
+ expect(wrapper.find(GlBadge).text()).toBe(badgeText);
+ });
+
+ it('renders the "It\'s you" badge when member is current user', () => {
+ createComponent({ isCurrentUser: true });
+
+ expect(getByText("It's you").exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js
new file mode 100644
index 00000000000..3195f04f202
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/mock_data.js
@@ -0,0 +1,61 @@
+export const member = {
+ requestedAt: null,
+ canUpdate: false,
+ canRemove: false,
+ canOverride: false,
+ accessLevel: { integerValue: 50, stringValue: 'Owner' },
+ source: {
+ id: 178,
+ name: 'Foo Bar',
+ webUrl: 'https://gitlab.com/groups/foo-bar',
+ },
+ user: {
+ id: 123,
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'https://gitlab.com/root',
+ avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
+ blocked: false,
+ twoFactorEnabled: false,
+ },
+ id: 238,
+ createdAt: '2020-07-17T16:22:46.923Z',
+ expiresAt: null,
+ usingLicense: false,
+ groupSso: false,
+ groupManagedAccount: false,
+};
+
+export const group = {
+ accessLevel: { integerValue: 10, stringValue: 'Guest' },
+ sharedWithGroup: {
+ id: 24,
+ name: 'Commit451',
+ avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40',
+ fullPath: 'parent-group/commit451',
+ fullName: 'Parent group / Commit451',
+ webUrl: 'https://gitlab.com/groups/parent-group/commit451',
+ },
+ id: 3,
+ createdAt: '2020-08-06T15:31:07.662Z',
+ expiresAt: null,
+};
+
+const { user, ...memberNoUser } = member;
+export const invite = {
+ ...memberNoUser,
+ invite: {
+ email: 'jewel@hudsonwalter.biz',
+ avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon',
+ canResend: true,
+ },
+};
+
+export const orphanedMember = memberNoUser;
+
+export const accessRequest = {
+ ...member,
+ requestedAt: '2020-07-17T16:22:46.923Z',
+};
+
+export const members = [member];
diff --git a/spec/frontend/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/vue_shared/components/members/table/created_at_spec.js
new file mode 100644
index 00000000000..cf3821baf44
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/created_at_spec.js
@@ -0,0 +1,61 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { useFakeDate } from 'helpers/fake_date';
+import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+describe('CreatedAt', () => {
+ // March 15th, 2020
+ useFakeDate(2020, 2, 15);
+
+ const date = '2020-03-01T00:00:00.000';
+ const dateTimeAgo = '2 weeks ago';
+
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(CreatedAt, {
+ propsData: {
+ date,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).getByText(text, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('created at text', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays created at text', () => {
+ expect(getByText(dateTimeAgo).exists()).toBe(true);
+ });
+
+ it('uses `TimeAgoTooltip` component to display tooltip', () => {
+ expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
+ });
+ });
+
+ describe('when `createdBy` prop is provided', () => {
+ it('displays a link to the user that created the member', () => {
+ createComponent({
+ createdBy: {
+ name: 'Administrator',
+ webUrl: 'https://gitlab.com/root',
+ },
+ });
+
+ const link = getByText('Administrator');
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe('https://gitlab.com/root');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js
new file mode 100644
index 00000000000..95ae251b0fd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js
@@ -0,0 +1,86 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { useFakeDate } from 'helpers/fake_date';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
+
+describe('ExpiresAt', () => {
+ // March 15th, 2020
+ useFakeDate(2020, 2, 15);
+
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(ExpiresAt, {
+ propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).getByText(text, options));
+
+ const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when no expiration date is set', () => {
+ it('displays "No expiration set"', () => {
+ createComponent({ date: null });
+
+ expect(getByText('No expiration set').exists()).toBe(true);
+ });
+ });
+
+ describe('when expiration date is in the past', () => {
+ let expiredText;
+
+ beforeEach(() => {
+ createComponent({ date: '2019-03-15T00:00:00.000' });
+
+ expiredText = getByText('Expired');
+ });
+
+ it('displays "Expired"', () => {
+ expect(expiredText.exists()).toBe(true);
+ expect(expiredText.classes()).toContain('gl-text-red-500');
+ });
+
+ it('displays tooltip with formatted date', () => {
+ const tooltipDirective = getTooltipDirective(expiredText);
+
+ expect(tooltipDirective).not.toBeUndefined();
+ expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000');
+ });
+ });
+
+ describe('when expiration date is in the future', () => {
+ it.each`
+ date | expected | warningColor
+ ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false}
+ ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true}
+ ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true}
+ ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true}
+ ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true}
+ ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true}
+ ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true}
+ ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true}
+ `('displays "$expected"', ({ date, expected, warningColor }) => {
+ createComponent({ date });
+
+ const expiredText = getByText(expected);
+
+ expect(expiredText.exists()).toBe(true);
+
+ if (warningColor) {
+ expect(expiredText.classes()).toContain('gl-text-orange-500');
+ } else {
+ expect(expiredText.classes()).not.toContain('gl-text-orange-500');
+ }
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js
new file mode 100644
index 00000000000..a171dd830c1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js
@@ -0,0 +1,39 @@
+import { shallowMount } from '@vue/test-utils';
+import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
+import { member as memberMock, group, invite, accessRequest } from '../mock_data';
+import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
+import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
+import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
+import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = shallowMount(MemberAvatar, {
+ propsData: {
+ isCurrentUser: false,
+ ...propsData,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ test.each`
+ memberType | member | expectedComponent | expectedComponentName
+ ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'}
+ ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'}
+ ${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'}
+ ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'}
+ `(
+ 'renders $expectedComponentName when `memberType` is $memberType',
+ ({ memberType, member, expectedComponent }) => {
+ createComponent({ memberType, member });
+
+ expect(wrapper.find(expectedComponent).exists()).toBe(true);
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/vue_shared/components/members/table/member_source_spec.js
new file mode 100644
index 00000000000..8b914d76674
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_source_spec.js
@@ -0,0 +1,71 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
+
+describe('MemberSource', () => {
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(MemberSource, {
+ propsData: {
+ memberSource: {
+ id: 102,
+ name: 'Foo bar',
+ webUrl: 'https://gitlab.com/groups/foo-bar',
+ },
+ ...propsData,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('direct member', () => {
+ it('displays "Direct member"', () => {
+ createComponent({
+ isDirectMember: true,
+ });
+
+ expect(getByText('Direct member').exists()).toBe(true);
+ });
+ });
+
+ describe('inherited member', () => {
+ let sourceGroupLink;
+
+ beforeEach(() => {
+ createComponent({
+ isDirectMember: false,
+ });
+
+ sourceGroupLink = getByText('Foo bar');
+ });
+
+ it('displays a link to source group', () => {
+ createComponent({
+ isDirectMember: false,
+ });
+
+ expect(sourceGroupLink.exists()).toBe(true);
+ expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar');
+ });
+
+ it('displays tooltip with "Inherited"', () => {
+ const tooltipDirective = getTooltipDirective(sourceGroupLink);
+
+ expect(tooltipDirective).not.toBeUndefined();
+ expect(sourceGroupLink.attributes('title')).toBe('Inherited');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js
new file mode 100644
index 00000000000..960d9bc164c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js
@@ -0,0 +1,130 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
+import { member as memberMock, group, invite, accessRequest } from '../mock_data';
+import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue';
+
+describe('MemberList', () => {
+ const WrappedComponent = {
+ props: {
+ memberType: {
+ type: String,
+ required: true,
+ },
+ isDirectMember: {
+ type: Boolean,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ render(createElement) {
+ return createElement('div', this.memberType);
+ },
+ };
+
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+ localVue.component('wrapped-component', WrappedComponent);
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ sourceId: 1,
+ currentUserId: 1,
+ ...state,
+ },
+ });
+ };
+
+ let wrapper;
+
+ const createComponent = (propsData, state = {}) => {
+ wrapper = mount(MembersTableCell, {
+ localVue,
+ propsData,
+ store: createStore(state),
+ scopedSlots: {
+ default: `
+ <wrapped-component
+ :member-type="props.memberType"
+ :is-direct-member="props.isDirectMember"
+ :is-current-user="props.isCurrentUser"
+ />
+ `,
+ },
+ });
+ };
+
+ const findWrappedComponent = () => wrapper.find(WrappedComponent);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ test.each`
+ member | expectedMemberType
+ ${memberMock} | ${MEMBER_TYPES.user}
+ ${group} | ${MEMBER_TYPES.group}
+ ${invite} | ${MEMBER_TYPES.invite}
+ ${accessRequest} | ${MEMBER_TYPES.accessRequest}
+ `(
+ 'sets scoped slot prop `memberType` to $expectedMemberType',
+ ({ member, expectedMemberType }) => {
+ createComponent({ member });
+
+ expect(findWrappedComponent().props('memberType')).toBe(expectedMemberType);
+ },
+ );
+
+ describe('isDirectMember', () => {
+ it('returns `true` when member source has same ID as `sourceId`', () => {
+ createComponent({
+ member: {
+ ...memberMock,
+ source: {
+ ...memberMock.source,
+ id: 1,
+ },
+ },
+ });
+
+ expect(findWrappedComponent().props('isDirectMember')).toBe(true);
+ });
+
+ it('returns `false` when member is inherited', () => {
+ createComponent({
+ member: memberMock,
+ });
+
+ expect(findWrappedComponent().props('isDirectMember')).toBe(false);
+ });
+ });
+
+ describe('isCurrentUser', () => {
+ it('returns `true` when `member.user` has the same ID as `currentUserId`', () => {
+ createComponent({
+ member: {
+ ...memberMock,
+ user: {
+ ...memberMock.user,
+ id: 1,
+ },
+ },
+ });
+
+ expect(findWrappedComponent().props('isCurrentUser')).toBe(true);
+ });
+
+ it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => {
+ createComponent({
+ member: memberMock,
+ });
+
+ expect(findWrappedComponent().props('isCurrentUser')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
new file mode 100644
index 00000000000..4979a7096ac
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
@@ -0,0 +1,104 @@
+import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
+import Vuex from 'vuex';
+import {
+ getByText as getByTextHelper,
+ getByTestId as getByTestIdHelper,
+} from '@testing-library/dom';
+import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
+import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
+import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
+import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
+import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
+import * as initUserPopovers from '~/user_popovers';
+import { member as memberMock, invite, accessRequest } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ members: [],
+ tableFields: [],
+ ...state,
+ },
+ });
+ };
+
+ const createComponent = state => {
+ wrapper = mount(MembersTable, {
+ localVue,
+ store: createStore(state),
+ stubs: ['member-avatar', 'member-source', 'expires-at', 'created-at'],
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ const getByTestId = (id, options) =>
+ createWrapper(getByTestIdHelper(wrapper.element, id, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('fields', () => {
+ it.each`
+ field | label | member | expectedComponent
+ ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
+ ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
+ ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
+ ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
+ ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
+ ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt}
+ ${'maxRole'} | ${'Max role'} | ${memberMock} | ${null}
+ ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null}
+ `('renders the $label field', ({ field, label, member, expectedComponent }) => {
+ createComponent({
+ members: [member],
+ tableFields: [field],
+ });
+
+ expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true);
+
+ if (expectedComponent) {
+ expect(
+ wrapper
+ .find(`[data-label="${label}"][role="cell"]`)
+ .find(expectedComponent)
+ .exists(),
+ ).toBe(true);
+ }
+ });
+
+ it('renders "Actions" field for screen readers', () => {
+ createComponent({ tableFields: ['actions'] });
+
+ const actionField = getByTestId('col-actions');
+
+ expect(actionField.exists()).toBe(true);
+ expect(actionField.classes('gl-sr-only')).toBe(true);
+ });
+ });
+
+ describe('when `members` is an empty array', () => {
+ it('displays a "No members found" message', () => {
+ createComponent();
+
+ expect(getByText('No members found').exists()).toBe(true);
+ });
+ });
+
+ it('initializes user popovers when mounted', () => {
+ const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default');
+
+ createComponent();
+
+ expect(initUserPopoversMock).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js
new file mode 100644
index 00000000000..f183abc08d6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/utils_spec.js
@@ -0,0 +1,29 @@
+import { generateBadges } from '~/vue_shared/components/members/utils';
+import { member as memberMock } from './mock_data';
+
+describe('Members Utils', () => {
+ describe('generateBadges', () => {
+ it('has correct properties for each badge', () => {
+ const badges = generateBadges(memberMock, true);
+
+ badges.forEach(badge => {
+ expect(badge).toEqual(
+ expect.objectContaining({
+ show: expect.any(Boolean),
+ text: expect.any(String),
+ variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/),
+ }),
+ );
+ });
+ });
+
+ it.each`
+ member | expected
+ ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }}
+ ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }}
+ ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }}
+ `('returns expected output for "$expected.text" badge', ({ member, expected }) => {
+ expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
index 16094a42668..27276faf333 100644
--- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
@@ -38,7 +38,8 @@ exports[`Package code instruction single line to match the default snapshot 1`]
data-testid="instruction-button"
>
<button
- class="btn input-group-text btn-secondary btn-md btn-default"
+ aria-label="Copy this value"
+ class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon"
data-clipboard-text="npm i @my-package"
title="Copy npm install command"
type="button"
@@ -53,6 +54,8 @@ exports[`Package code instruction single line to match the default snapshot 1`]
href="#copy-to-clipboard"
/>
</svg>
+
+ <!---->
</button>
</span>
</div>
diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js
index 6740d6097a4..a513f178f45 100644
--- a/spec/frontend/vue_shared/components/registry/title_area_spec.js
+++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js
@@ -1,4 +1,4 @@
-import { GlAvatar } from '@gitlab/ui';
+import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import component from '~/vue_shared/components/registry/title_area.vue';
@@ -10,10 +10,12 @@ describe('title area', () => {
const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`);
const findTitle = () => wrapper.find('[data-testid="title"]');
const findAvatar = () => wrapper.find(GlAvatar);
+ const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]');
const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => {
wrapper = shallowMount(component, {
propsData,
+ stubs: { GlSprintf },
slots: {
'sub-header': '<div data-testid="sub-header" />',
'right-actions': '<div data-testid="right-actions" />',
@@ -95,4 +97,33 @@ describe('title area', () => {
});
});
});
+
+ describe('info-messages', () => {
+ it('shows a message when the props contains one', () => {
+ mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } });
+
+ const messages = findInfoMessages();
+ expect(messages).toHaveLength(1);
+ expect(messages.at(0).text()).toBe('foo foo bar bar');
+ });
+
+ it('shows a link when the props contains one', () => {
+ mountComponent({
+ propsData: {
+ infoMessages: [{ text: 'foo %{docLinkStart}link%{docLinkEnd}', link: 'bar' }],
+ },
+ });
+
+ const message = findInfoMessages().at(0);
+
+ expect(message.find(GlLink).attributes('href')).toBe('bar');
+ expect(message.text()).toBe('foo link');
+ });
+
+ it('multiple messages generates multiple spans', () => {
+ mountComponent({ propsData: { infoMessages: [{ text: 'foo' }, { text: 'bar' }] } });
+
+ expect(findInfoMessages()).toHaveLength(2);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
index 16f60b5ff21..81d31a284df 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
@@ -9,9 +9,11 @@ import {
} from '~/vue_shared/components/rich_content_editor/services/editor_service';
import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer';
+import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html';
jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer');
jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer');
+jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html');
describe('Editor Service', () => {
let mockInstance;
@@ -143,5 +145,14 @@ describe('Editor Service', () => {
getEditorOptions(externalOptions);
expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers);
});
+
+ it('uses the internal sanitizeHTML service for HTML sanitization', () => {
+ const options = getEditorOptions();
+ const html = '<div></div>';
+
+ options.customHTMLSanitizer(html);
+
+ expect(sanitizeHTML).toHaveBeenCalledWith(html);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
index a6c712eeb31..b31684a400e 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
@@ -1,22 +1,21 @@
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block';
import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
-import { normalTextNode } from './mock_data';
+describe('rich_content_editor/services/renderers/render_html_block', () => {
+ const htmlBlockNode = {
+ literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
+ type: 'htmlBlock',
+ };
-const htmlBlockNode = {
- firstChild: null,
- literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
- type: 'htmlBlock',
-};
-
-describe('Render HTML renderer', () => {
describe('canRender', () => {
- it('should return true when the argument is an html block', () => {
- expect(renderer.canRender(htmlBlockNode)).toBe(true);
- });
-
- it('should return false when the argument is not an html block', () => {
- expect(renderer.canRender(normalTextNode)).toBe(false);
+ it.each`
+ input | result
+ ${htmlBlockNode} | ${true}
+ ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true}
+ ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false}
+ ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false}
+ `('returns $result when input=$input', ({ input, result }) => {
+ expect(renderer.canRender(input)).toBe(result);
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js
new file mode 100644
index 00000000000..f2182ef60d7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js
@@ -0,0 +1,11 @@
+import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html';
+
+describe('rich_content_editor/services/sanitize_html', () => {
+ it.each`
+ input | result
+ ${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'}
+ ${'<iframe src="https://gitlab.com"></iframe>'} | ${''}
+ `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => {
+ expect(sanitizeHTML(input)).toBe(result);
+ });
+});
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 589be0ad7a4..a9350bc059d 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
@@ -69,6 +69,16 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
});
+ it('returns matching labels with fuzzy filtering', () => {
+ wrapper.setData({
+ searchKey: 'bg',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(2);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ expect(wrapper.vm.visibleLabels[1].title).toBe('Boog');
+ });
+
it('returns all labels when `searchKey` is empty', () => {
wrapper.setData({
searchKey: '',
@@ -133,6 +143,19 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapper.vm.currentHighlightItem).toBe(2);
});
+ it('resets the search text when the Enter key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ searchKey: 'bug',
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.searchKey).toBe('');
+ });
+
it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
wrapper.setData({
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 e1008d13fc2..9697d6c30f2 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
@@ -24,6 +24,13 @@ export const mockLabels = [
color: '#FF0000',
textColor: '#FFFFFF',
},
+ {
+ id: 29,
+ title: 'Boog',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
];
export const mockConfig = {
diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/todo_button_spec.js
index 482b5de11f6..1f8a214d632 100644
--- a/spec/frontend/vue_shared/components/todo_button_spec.js
+++ b/spec/frontend/vue_shared/components/todo_button_spec.js
@@ -33,7 +33,7 @@ describe('Todo Button', () => {
it.each`
label | isTodo
${'Mark as done'} | ${true}
- ${'Add a To-Do'} | ${false}
+ ${'Add a To Do'} | ${false}
`('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => {
createComponent({ isTodo });
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
index 57f511903d9..5532a27b767 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -60,6 +60,7 @@ describe('Web IDE link component', () => {
it.each`
props | expectedActions
${{}} | ${[ACTION_WEB_IDE]}
+ ${{ webIdeIsFork: true }} | ${[{ ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' }]}
${{ needsToFork: true }} | ${[ACTION_WEB_IDE_FORK]}
${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }} | ${[ACTION_GITPOD]}
${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_GITPOD_ENABLE]}
diff --git a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js
deleted file mode 100644
index e57c730ecee..00000000000
--- a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import { mount } from '@vue/test-utils';
-
-import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue';
-
-const mockActions = [
- {
- title: 'Foo',
- description: 'Some foo action',
- },
- {
- title: 'Bar',
- description: 'Some bar action',
- },
-];
-
-const createComponent = ({
- size = '',
- dropdownClass = '',
- actions = mockActions,
- defaultAction = 0,
-}) =>
- mount(DroplabDropdownButton, {
- propsData: {
- size,
- dropdownClass,
- actions,
- defaultAction,
- },
- });
-
-describe('DroplabDropdownButton', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = createComponent({});
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('data', () => {
- it('contains `selectedAction` representing value of `defaultAction` prop', () => {
- expect(wrapper.vm.selectedAction).toBe(0);
- });
- });
-
- describe('computed', () => {
- describe('selectedActionTitle', () => {
- it('returns string containing title of selected action', () => {
- wrapper.setData({ selectedAction: 0 });
-
- expect(wrapper.vm.selectedActionTitle).toBe(mockActions[0].title);
-
- wrapper.setData({ selectedAction: 1 });
-
- expect(wrapper.vm.selectedActionTitle).toBe(mockActions[1].title);
- });
- });
-
- describe('buttonSizeClass', () => {
- it('returns string containing button sizing class based on `size` prop', done => {
- const wrapperWithSize = createComponent({
- size: 'sm',
- });
-
- wrapperWithSize.vm.$nextTick(() => {
- expect(wrapperWithSize.vm.buttonSizeClass).toBe('btn-sm');
-
- done();
- wrapperWithSize.destroy();
- });
- });
- });
- });
-
- describe('methods', () => {
- describe('handlePrimaryActionClick', () => {
- it('emits `onActionClick` event on component with selectedAction object as param', () => {
- jest.spyOn(wrapper.vm, '$emit');
-
- wrapper.setData({ selectedAction: 0 });
- wrapper.vm.handlePrimaryActionClick();
-
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionClick', mockActions[0]);
- });
- });
-
- describe('handleActionClick', () => {
- it('emits `onActionSelect` event on component with selectedAction index as param', () => {
- jest.spyOn(wrapper.vm, '$emit');
-
- wrapper.vm.handleActionClick(1);
-
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionSelect', 1);
- });
- });
- });
-
- describe('template', () => {
- it('renders default action button', () => {
- const defaultButton = wrapper.findAll('.btn').at(0);
-
- expect(defaultButton.text()).toBe(mockActions[0].title);
- });
-
- it('renders dropdown button', () => {
- const dropdownButton = wrapper.findAll('.dropdown-toggle').at(0);
-
- expect(dropdownButton.isVisible()).toBe(true);
- });
-
- it('renders dropdown actions', () => {
- const dropdownActions = wrapper.findAll('.dropdown-menu li button');
-
- Array(dropdownActions.length)
- .fill()
- .forEach((_, index) => {
- const actionContent = dropdownActions.at(index).find('.description');
-
- expect(actionContent.find('strong').text()).toBe(mockActions[index].title);
- expect(actionContent.find('p').text()).toBe(mockActions[index].description);
- });
- });
-
- it('renders divider between dropdown actions', () => {
- const dropdownDivider = wrapper.find('.dropdown-menu .divider');
-
- expect(dropdownDivider.isVisible()).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js
index 59d05f68fdd..157faa90efa 100644
--- a/spec/frontend/whats_new/components/app_spec.js
+++ b/spec/frontend/whats_new/components/app_spec.js
@@ -1,6 +1,7 @@
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlDrawer } from '@gitlab/ui';
+import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import App from '~/whats_new/components/app.vue';
const localVue = createLocalVue();
@@ -11,7 +12,8 @@ describe('App', () => {
let store;
let actions;
let state;
- let propsData = { features: '[ {"title":"Whats New Drawer"} ]' };
+ let propsData = { features: '[ {"title":"Whats New Drawer"} ]', storageKey: 'storage-key' };
+ let trackingSpy;
const buildWrapper = () => {
actions = {
@@ -36,11 +38,16 @@ describe('App', () => {
};
beforeEach(() => {
+ document.body.dataset.page = 'test-page';
+ document.body.dataset.namespaceId = 'namespace-840';
+
+ trackingSpy = mockTracking('_category_', null, jest.spyOn);
buildWrapper();
});
afterEach(() => {
wrapper.destroy();
+ unmockTracking();
});
const getDrawer = () => wrapper.find(GlDrawer);
@@ -50,7 +57,11 @@ describe('App', () => {
});
it('dispatches openDrawer when mounted', () => {
- expect(actions.openDrawer).toHaveBeenCalled();
+ expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
+ label: 'namespace_id',
+ value: 'namespace-840',
+ });
});
it('dispatches closeDrawer when clicking close', () => {
@@ -71,9 +82,30 @@ describe('App', () => {
});
it('handles bad json argument gracefully', () => {
- propsData = { features: 'this is not json' };
+ propsData = { features: 'this is not json', storageKey: 'storage-key' };
buildWrapper();
expect(getDrawer().exists()).toBe(true);
});
+
+ it('send an event when feature item is clicked', () => {
+ propsData = {
+ features: '[ {"title":"Whats New Drawer", "url": "www.url.com"} ]',
+ storageKey: 'storage-key',
+ };
+ buildWrapper();
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+
+ const link = wrapper.find('[data-testid="whats-new-title-link"]');
+ triggerEvent(link.element);
+
+ expect(trackingSpy.mock.calls[2]).toMatchObject([
+ '_category_',
+ 'click_whats_new_item',
+ {
+ label: 'Whats New Drawer',
+ property: 'www.url.com',
+ },
+ ]);
+ });
});
diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js
index d95453c9175..6f550222074 100644
--- a/spec/frontend/whats_new/store/actions_spec.js
+++ b/spec/frontend/whats_new/store/actions_spec.js
@@ -1,11 +1,16 @@
import testAction from 'helpers/vuex_action_helper';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import actions from '~/whats_new/store/actions';
import * as types from '~/whats_new/store/mutation_types';
describe('whats new actions', () => {
describe('openDrawer', () => {
+ useLocalStorageSpy();
+
it('should commit openDrawer', () => {
- testAction(actions.openDrawer, {}, {}, [{ type: types.OPEN_DRAWER }]);
+ testAction(actions.openDrawer, 'storage-key', {}, [{ type: types.OPEN_DRAWER }]);
+
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('storage-key', 'false');
});
});