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/__helpers__/mock_dom_observer.js22
-rw-r--r--spec/frontend/__helpers__/mock_window_location_helper.js2
-rw-r--r--spec/frontend/__helpers__/set_window_location_helper.js75
-rw-r--r--spec/frontend/__helpers__/set_window_location_helper_spec.js161
-rw-r--r--spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js67
-rw-r--r--spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js27
-rw-r--r--spec/frontend/admin/analytics/devops_score/mock_data.js2
-rw-r--r--spec/frontend/admin/signup_restrictions/components/signup_form_spec.js40
-rw-r--r--spec/frontend/admin/signup_restrictions/mock_data.js2
-rw-r--r--spec/frontend/admin/users/components/actions/actions_spec.js49
-rw-r--r--spec/frontend/admin/users/components/user_date_spec.js16
-rw-r--r--spec/frontend/admin/users/mock_data.js2
-rw-r--r--spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js2
-rw-r--r--spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js12
-rw-r--r--spec/frontend/authentication/two_factor_auth/index_spec.js4
-rw-r--r--spec/frontend/authentication/webauthn/error_spec.js16
-rw-r--r--spec/frontend/authentication/webauthn/register_spec.js8
-rw-r--r--spec/frontend/blob/components/blob_header_default_actions_spec.js12
-rw-r--r--spec/frontend/blob/components/blob_header_spec.js12
-rw-r--r--spec/frontend/blob/csv/csv_viewer_spec.js13
-rw-r--r--spec/frontend/blob/viewer/index_spec.js2
-rw-r--r--spec/frontend/boards/board_card_inner_spec.js24
-rw-r--r--spec/frontend/boards/board_list_helper.js6
-rw-r--r--spec/frontend/boards/board_list_spec.js116
-rw-r--r--spec/frontend/boards/components/board_card_spec.js1
-rw-r--r--spec/frontend/boards/components/board_filtered_search_spec.js6
-rw-r--r--spec/frontend/boards/components/board_form_spec.js9
-rw-r--r--spec/frontend/boards/components/board_new_issue_spec.js154
-rw-r--r--spec/frontend/boards/components/board_new_item_spec.js103
-rw-r--r--spec/frontend/boards/components/issue_board_filtered_search_spec.js31
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js2
-rw-r--r--spec/frontend/boards/mock_data.js47
-rw-r--r--spec/frontend/boards/stores/actions_spec.js274
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js40
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js23
-rw-r--r--spec/frontend/clusters/clusters_bundle_spec.js17
-rw-r--r--spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js50
-rw-r--r--spec/frontend/commit/mock_data.js117
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap2
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap17
-rw-r--r--spec/frontend/content_editor/components/content_editor_error_spec.js54
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js166
-rw-r--r--spec/frontend/content_editor/components/editor_state_observer_spec.js75
-rw-r--r--spec/frontend/content_editor/components/formatting_bubble_menu_spec.js80
-rw-r--r--spec/frontend/content_editor/components/toolbar_button_spec.js46
-rw-r--r--spec/frontend/content_editor/components/toolbar_image_button_spec.js22
-rw-r--r--spec/frontend/content_editor/components/toolbar_link_button_spec.js52
-rw-r--r--spec/frontend/content_editor/components/toolbar_table_button_spec.js34
-rw-r--r--spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js18
-rw-r--r--spec/frontend/content_editor/components/top_toolbar_spec.js35
-rw-r--r--spec/frontend/content_editor/extensions/attachment_spec.js235
-rw-r--r--spec/frontend/content_editor/extensions/code_block_highlight_spec.js3
-rw-r--r--spec/frontend/content_editor/extensions/emoji_spec.js57
-rw-r--r--spec/frontend/content_editor/extensions/hard_break_spec.js2
-rw-r--r--spec/frontend/content_editor/extensions/image_spec.js193
-rw-r--r--spec/frontend/content_editor/extensions/inline_diff_spec.js27
-rw-r--r--spec/frontend/content_editor/markdown_processing_spec.js2
-rw-r--r--spec/frontend/content_editor/services/build_serializer_config_spec.js38
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js68
-rw-r--r--spec/frontend/content_editor/services/create_content_editor_spec.js12
-rw-r--r--spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js8
-rw-r--r--spec/frontend/content_editor/services/upload_helpers_spec.js (renamed from spec/frontend/content_editor/services/upload_file_spec.js)4
-rw-r--r--spec/frontend/content_editor/test_utils.js7
-rw-r--r--spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap9
-rw-r--r--spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap28
-rw-r--r--spec/frontend/cycle_analytics/base_spec.js138
-rw-r--r--spec/frontend/cycle_analytics/mock_data.js154
-rw-r--r--spec/frontend/cycle_analytics/stage_nav_item_spec.js152
-rw-r--r--spec/frontend/cycle_analytics/stage_table_spec.js279
-rw-r--r--spec/frontend/cycle_analytics/store/actions_spec.js173
-rw-r--r--spec/frontend/cycle_analytics/store/getters_spec.js3
-rw-r--r--spec/frontend/cycle_analytics/store/mutations_spec.js104
-rw-r--r--spec/frontend/cycle_analytics/total_time_component_spec.js61
-rw-r--r--spec/frontend/cycle_analytics/utils_spec.js80
-rw-r--r--spec/frontend/cycle_analytics/value_stream_metrics_spec.js128
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js38
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap96
-rw-r--r--spec/frontend/design_management/components/upload/design_version_dropdown_spec.js11
-rw-r--r--spec/frontend/design_management/components/upload/mock_data/all_versions.js16
-rw-r--r--spec/frontend/design_management/mock_data/all_versions.js14
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js37
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap2
-rw-r--r--spec/frontend/diffs/components/app_spec.js52
-rw-r--r--spec/frontend/diffs/components/compare_versions_spec.js25
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js50
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js8
-rw-r--r--spec/frontend/diffs/store/actions_spec.js9
-rw-r--r--spec/frontend/diffs/store/getters_versions_dropdowns_spec.js10
-rw-r--r--spec/frontend/diffs/utils/queue_events_spec.js36
-rw-r--r--spec/frontend/editor/source_editor_extension_base_spec.js8
-rw-r--r--spec/frontend/editor/source_editor_markdown_ext_spec.js385
-rw-r--r--spec/frontend/editor/utils_spec.js85
-rw-r--r--spec/frontend/environment.js23
-rw-r--r--spec/frontend/environments/confirm_rollback_modal_spec.js138
-rw-r--r--spec/frontend/environments/edit_environment_spec.js104
-rw-r--r--spec/frontend/environments/environment_form_spec.js105
-rw-r--r--spec/frontend/environments/environment_item_spec.js125
-rw-r--r--spec/frontend/environments/environments_app_spec.js50
-rw-r--r--spec/frontend/environments/environments_detail_header_spec.js238
-rw-r--r--spec/frontend/environments/mock_data.js22
-rw-r--r--spec/frontend/environments/new_environment_spec.js100
-rw-r--r--spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js5
-rw-r--r--spec/frontend/feature_flags/mock_data.js2
-rw-r--r--spec/frontend/fixtures/analytics.rb71
-rw-r--r--spec/frontend/fixtures/api_markdown.rb9
-rw-r--r--spec/frontend/fixtures/api_markdown.yml31
-rw-r--r--spec/frontend/fixtures/startup_css.rb24
-rw-r--r--spec/frontend/graphql_shared/utils_spec.js15
-rw-r--r--spec/frontend/groups/components/group_item_spec.js5
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js10
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_spec.js120
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js (renamed from spec/frontend/import_entities/import_groups/components/import_table_row_spec.js)155
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js88
-rw-r--r--spec/frontend/integrations/edit/components/dynamic_field_spec.js13
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js6
-rw-r--r--spec/frontend/integrations/overrides/components/integration_overrides_spec.js146
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js198
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js54
-rw-r--r--spec/frontend/issue_show/components/app_spec.js3
-rw-r--r--spec/frontend/issue_show/components/fields/type_spec.js14
-rw-r--r--spec/frontend/issue_show/issue_spec.js3
-rw-r--r--spec/frontend/issues_list/components/issuables_list_app_spec.js31
-rw-r--r--spec/frontend/issues_list/components/issue_card_time_info_spec.js21
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js66
-rw-r--r--spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js6
-rw-r--r--spec/frontend/issues_list/mock_data.js11
-rw-r--r--spec/frontend/issues_list/utils_spec.js21
-rw-r--r--spec/frontend/jira_connect/branches/components/new_branch_form_spec.js236
-rw-r--r--spec/frontend/jira_connect/branches/pages/index_spec.js65
-rw-r--r--spec/frontend/jira_connect/subscriptions/api_spec.js (renamed from spec/frontend/jira_connect/api_spec.js)6
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap (renamed from spec/frontend/jira_connect/components/__snapshots__/group_item_name_spec.js.snap)0
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/app_spec.js (renamed from spec/frontend/jira_connect/components/app_spec.js)8
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js (renamed from spec/frontend/jira_connect/components/group_item_name_spec.js)2
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js (renamed from spec/frontend/jira_connect/components/groups_list_item_spec.js)10
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js (renamed from spec/frontend/jira_connect/components/groups_list_spec.js)10
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js (renamed from spec/frontend/jira_connect/components/subscriptions_list_spec.js)12
-rw-r--r--spec/frontend/jira_connect/subscriptions/index_spec.js (renamed from spec/frontend/jira_connect/index_spec.js)4
-rw-r--r--spec/frontend/jira_connect/subscriptions/mock_data.js (renamed from spec/frontend/jira_connect/mock_data.js)0
-rw-r--r--spec/frontend/jira_connect/subscriptions/store/mutations_spec.js (renamed from spec/frontend/jira_connect/store/mutations_spec.js)4
-rw-r--r--spec/frontend/jira_connect/subscriptions/utils_spec.js (renamed from spec/frontend/jira_connect/utils_spec.js)4
-rw-r--r--spec/frontend/jobs/components/log/mock_data.js9
-rw-r--r--spec/frontend/jobs/components/stages_dropdown_spec.js13
-rw-r--r--spec/frontend/jobs/store/utils_spec.js16
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js115
-rw-r--r--spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js2
-rw-r--r--spec/frontend/members/components/action_buttons/remove_member_button_spec.js27
-rw-r--r--spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js42
-rw-r--r--spec/frontend/members/components/filter_sort/sort_dropdown_spec.js30
-rw-r--r--spec/frontend/members/components/members_tabs_spec.js33
-rw-r--r--spec/frontend/members/components/modals/remove_member_modal_spec.js (renamed from spec/frontend/vue_shared/components/remove_member_modal_spec.js)87
-rw-r--r--spec/frontend/members/components/table/members_table_spec.js12
-rw-r--r--spec/frontend/members/mock_data.js9
-rw-r--r--spec/frontend/members/store/actions_spec.js32
-rw-r--r--spec/frontend/members/store/mutations_spec.js30
-rw-r--r--spec/frontend/members/utils_spec.js17
-rw-r--r--spec/frontend/monitoring/components/dashboard_actions_menu_spec.js3
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js40
-rw-r--r--spec/frontend/monitoring/utils_spec.js2
-rw-r--r--spec/frontend/nav/components/responsive_app_spec.js50
-rw-r--r--spec/frontend/notes/components/comment_field_layout_spec.js14
-rw-r--r--spec/frontend/notes/components/noteable_note_spec.js27
-rw-r--r--spec/frontend/packages/details/components/app_spec.js10
-rw-r--r--spec/frontend/packages/list/components/packages_list_app_spec.js15
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap36
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/dependency_row_spec.js.snap36
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap30
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap135
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap36
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap36
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap197
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap48
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap101
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js130
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js448
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js118
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js65
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js69
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js33
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js58
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js64
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js213
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js122
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js75
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js272
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js122
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js202
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js80
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js89
-rw-r--r--spec/frontend/packages_and_registries/package_registry/mock_data.js251
-rw-r--r--spec/frontend/packages_and_registries/package_registry/utils_spec.js23
-rw-r--r--spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js58
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js68
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_form_spec.js7
-rw-r--r--spec/frontend/persistent_user_callout_spec.js6
-rw-r--r--spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js8
-rw-r--r--spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js9
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_app_spec.js8
-rw-r--r--spec/frontend/pipelines/components/pipelines_filtered_search_spec.js11
-rw-r--r--spec/frontend/pipelines/graph/graph_component_legacy_spec.js300
-rw-r--r--spec/frontend/pipelines/graph/graph_component_spec.js4
-rw-r--r--spec/frontend/pipelines/graph/graph_component_wrapper_spec.js183
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js40
-rw-r--r--spec/frontend/pipelines/graph/mock_data_legacy.js261
-rw-r--r--spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js130
-rw-r--r--spec/frontend/pipelines/graph_shared/links_inner_spec.js2
-rw-r--r--spec/frontend/pipelines/graph_shared/links_layer_spec.js145
-rw-r--r--spec/frontend/pipelines/header_component_spec.js18
-rw-r--r--spec/frontend/pipelines/mock_data.js22
-rw-r--r--spec/frontend/pipelines/parsing_utils_spec.js6
-rw-r--r--spec/frontend/pipelines/pipeline_details_mediator_spec.js36
-rw-r--r--spec/frontend/pipelines/pipeline_multi_actions_spec.js8
-rw-r--r--spec/frontend/pipelines/pipeline_store_spec.js27
-rw-r--r--spec/frontend/pipelines/pipeline_url_spec.js1
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js27
-rw-r--r--spec/frontend/pipelines/pipelines_table_spec.js4
-rw-r--r--spec/frontend/pipelines/stores/pipeline_store_spec.js135
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_source_token_spec.js50
-rw-r--r--spec/frontend/popovers/components/popovers_spec.js9
-rw-r--r--spec/frontend/profile/preferences/components/profile_preferences_spec.js17
-rw-r--r--spec/frontend/projects/compare/components/app_legacy_spec.js159
-rw-r--r--spec/frontend/projects/terraform_notification/terraform_notification_spec.js27
-rw-r--r--spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js53
-rw-r--r--spec/frontend/registry/explorer/components/details_page/details_header_spec.js28
-rw-r--r--spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js4
-rw-r--r--spec/frontend/registry/explorer/mock_data.js1
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js4
-rw-r--r--spec/frontend/registry/explorer/stubs.js5
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js7
-rw-r--r--spec/frontend/releases/components/release_block_header_spec.js8
-rw-r--r--spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js28
-rw-r--r--spec/frontend/reports/codequality_report/store/actions_spec.js80
-rw-r--r--spec/frontend/reports/codequality_report/store/getters_spec.js7
-rw-r--r--spec/frontend/reports/codequality_report/store/mutations_spec.js17
-rw-r--r--spec/frontend/repository/components/blob_button_group_spec.js5
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js124
-rw-r--r--spec/frontend/repository/components/blob_edit_spec.js22
-rw-r--r--spec/frontend/repository/components/breadcrumbs_spec.js29
-rw-r--r--spec/frontend/repository/components/delete_blob_modal_spec.js119
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js (renamed from spec/frontend/runner/runner_list/runner_list_app_spec.js)29
-rw-r--r--spec/frontend/runner/components/runner_registration_token_reset_spec.js73
-rw-r--r--spec/frontend/runner/components/runner_type_alert_spec.js8
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js34
-rw-r--r--spec/frontend/runner/runner_search_utils_spec.js (renamed from spec/frontend/runner/runner_list/runner_search_utils_spec.js)2
-rw-r--r--spec/frontend/search/index_spec.js22
-rw-r--r--spec/frontend/search/mock_data.js15
-rw-r--r--spec/frontend/search/store/actions_spec.js79
-rw-r--r--spec/frontend/search/store/utils_spec.js86
-rw-r--r--spec/frontend/search/topbar/components/app_spec.js19
-rw-r--r--spec/frontend/search/topbar/components/group_filter_spec.js34
-rw-r--r--spec/frontend/search/topbar/components/project_filter_spec.js34
-rw-r--r--spec/frontend/security_configuration/app_spec.js27
-rw-r--r--spec/frontend/security_configuration/components/app_spec.js (renamed from spec/frontend/security_configuration/components/redesigned_app_spec.js)94
-rw-r--r--spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js46
-rw-r--r--spec/frontend/security_configuration/components/feature_card_spec.js34
-rw-r--r--spec/frontend/security_configuration/components/upgrade_banner_spec.js4
-rw-r--r--spec/frontend/security_configuration/configuration_table_spec.js52
-rw-r--r--spec/frontend/security_configuration/upgrade_spec.js30
-rw-r--r--spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap19
-rw-r--r--spec/frontend/self_monitor/components/self_monitor_form_spec.js4
-rw-r--r--spec/frontend/self_monitor/store/actions_spec.js4
-rw-r--r--spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js27
-rw-r--r--spec/frontend/sidebar/components/severity/sidebar_severity_spec.js57
-rw-r--r--spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js14
-rw-r--r--spec/frontend/sidebar/components/time_tracking/mock_data.js10
-rw-r--r--spec/frontend/sidebar/components/time_tracking/report_spec.js2
-rw-r--r--spec/frontend/sidebar/mock_data.js55
-rw-r--r--spec/frontend/snippets/components/show_spec.js4
-rw-r--r--spec/frontend/snippets/components/snippet_header_spec.js162
-rw-r--r--spec/frontend/syntax_highlight_spec.js63
-rw-r--r--spec/frontend/terraform/components/empty_state_spec.js19
-rw-r--r--spec/frontend/terraform/components/init_command_modal_spec.js79
-rw-r--r--spec/frontend/terraform/components/states_table_actions_spec.js28
-rw-r--r--spec/frontend/test_setup.js11
-rw-r--r--spec/frontend/token_access/token_access_spec.js17
-rw-r--r--spec/frontend/tooltips/components/tooltips_spec.js9
-rw-r--r--spec/frontend/tracking_spec.js41
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js101
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js21
-rw-r--r--spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap136
-rw-r--r--spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap3
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js42
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js23
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js41
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js3
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js6
-rw-r--r--spec/frontend/vue_mr_widget/mock_data.js8
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js19
-rw-r--r--spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js97
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js28
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js42
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/papa_parse_alert_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js434
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js52
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js25
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js88
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js52
-rw-r--r--spec/frontend/vue_shared/components/url_sync_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js8
-rw-r--r--spec/frontend/vue_shared/directives/autofocusonshow_spec.js3
-rw-r--r--spec/frontend/vue_shared/security_reports/security_reports_app_spec.js2
318 files changed, 11352 insertions, 4947 deletions
diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js
index 1b93b81535d..dd26b594ad9 100644
--- a/spec/frontend/__helpers__/mock_dom_observer.js
+++ b/spec/frontend/__helpers__/mock_dom_observer.js
@@ -52,7 +52,7 @@ class MockIntersectionObserver extends MockObserver {
* const { trigger: triggerMutate } = useMockMutationObserver();
*
* it('test', () => {
- * trigger(el, { options: { childList: true }, entry: { } });
+ * triggerMutate(el, { options: { childList: true }, entry: { } });
* });
* })
* ```
@@ -60,33 +60,31 @@ class MockIntersectionObserver extends MockObserver {
* @param {String} key
*/
const useMockObserver = (key, createMock) => {
- let mockObserver;
+ let mockObservers = [];
let origObserver;
beforeEach(() => {
origObserver = global[key];
global[key] = jest.fn().mockImplementation((...args) => {
- mockObserver = createMock(...args);
+ const mockObserver = createMock(...args);
+ mockObservers.push(mockObserver);
return mockObserver;
});
});
afterEach(() => {
- mockObserver = null;
+ mockObservers.forEach((x) => x.disconnect());
+ mockObservers = [];
global[key] = origObserver;
});
const trigger = (...args) => {
- if (!mockObserver) {
- return;
- }
-
- mockObserver.$_triggerObserve(...args);
+ mockObservers.forEach((observer) => {
+ observer.$_triggerObserve(...args);
+ });
};
- const observersCount = () => mockObserver.$_observers.length;
-
- return { trigger, observersCount };
+ return { trigger };
};
export const useMockIntersectionObserver = () =>
diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js
index 08a28fbbbd6..3755778e5c1 100644
--- a/spec/frontend/__helpers__/mock_window_location_helper.js
+++ b/spec/frontend/__helpers__/mock_window_location_helper.js
@@ -10,7 +10,7 @@
*/
const useMockLocation = (fn) => {
const origWindowLocation = window.location;
- let currentWindowLocation;
+ let currentWindowLocation = origWindowLocation;
Object.defineProperty(window, 'location', {
get: () => currentWindowLocation,
diff --git a/spec/frontend/__helpers__/set_window_location_helper.js b/spec/frontend/__helpers__/set_window_location_helper.js
index a94e73762c9..573a089f111 100644
--- a/spec/frontend/__helpers__/set_window_location_helper.js
+++ b/spec/frontend/__helpers__/set_window_location_helper.js
@@ -1,40 +1,53 @@
/**
- * setWindowLocation allows for setting `window.location`
- * (doing so directly is causing an error in jsdom)
+ * setWindowLocation allows for setting `window.location` within Jest.
*
- * Example usage:
- * assert(window.location.hash === undefined);
- * setWindowLocation('http://example.com#foo')
- * assert(window.location.hash === '#foo');
+ * The jsdom environment at the time of writing does not support changing the
+ * current location (see
+ * https://github.com/jsdom/jsdom/blob/16.4.0/lib/jsdom/living/window/navigation.js#L76),
+ * hence this helper.
*
- * More information:
- * https://github.com/facebook/jest/issues/890
+ * This helper mutates the current `window.location` very similarly to how
+ * a direct assignment to `window.location.href` would in a browser (but
+ * without the navigation/reload behaviour). For instance:
*
- * @param url
+ * - Set the full href by passing an absolute URL, e.g.:
+ *
+ * setWindowLocation('https://gdk.test');
+ * // window.location.href is now 'https://gdk.test'
+ *
+ * - Set the path, search and/or hash components by passing a relative URL:
+ *
+ * setWindowLocation('/foo/bar');
+ * // window.location.href is now 'http://test.host/foo/bar'
+ *
+ * setWindowLocation('?foo=bar');
+ * // window.location.href is now 'http://test.host/?foo=bar'
+ *
+ * setWindowLocation('#foo');
+ * // window.location.href is now 'http://test.host/#foo'
+ *
+ * setWindowLocation('/a/b/foo.html?bar=1#qux');
+ * // window.location.href is now 'http://test.host/a/b/foo.html?bar=1#qux
+ *
+ * Both approaches also automatically update the rest of the properties on
+ * `window.locaton`. For instance:
+ *
+ * setWindowLocation('http://test.host/a/b/foo.html?bar=1#qux');
+ * // window.location.origin is now 'http://test.host'
+ * // window.location.pathname is now '/a/b/foo.html'
+ * // window.location.search is now '?bar=1'
+ * // window.location.searchParams is now { bar: 1 }
+ * // window.location.hash is now '#qux'
+ *
+ * @param {string} url A string representing an absolute or relative URL.
+ * @returns {undefined}
*/
export default function setWindowLocation(url) {
- const parsedUrl = new URL(url);
+ if (typeof url !== 'string') {
+ throw new TypeError(`Expected string; got ${url} (${typeof url})`);
+ }
- const newLocationValue = [
- 'hash',
- 'host',
- 'hostname',
- 'href',
- 'origin',
- 'pathname',
- 'port',
- 'protocol',
- 'search',
- ].reduce(
- (location, prop) => ({
- ...location,
- [prop]: parsedUrl[prop],
- }),
- {},
- );
+ const newUrl = new URL(url, window.location.href);
- Object.defineProperty(window, 'location', {
- value: newLocationValue,
- writable: true,
- });
+ global.jsdom.reconfigure({ url: newUrl.href });
}
diff --git a/spec/frontend/__helpers__/set_window_location_helper_spec.js b/spec/frontend/__helpers__/set_window_location_helper_spec.js
index 98f26854822..c0f3debddbc 100644
--- a/spec/frontend/__helpers__/set_window_location_helper_spec.js
+++ b/spec/frontend/__helpers__/set_window_location_helper_spec.js
@@ -1,40 +1,133 @@
import setWindowLocation from './set_window_location_helper';
+import { TEST_HOST } from './test_constants';
-describe('setWindowLocation', () => {
- const originalLocation = window.location;
+describe('helpers/set_window_location_helper', () => {
+ const originalLocation = window.location.href;
- afterEach(() => {
- window.location = originalLocation;
+ beforeEach(() => {
+ setWindowLocation(originalLocation);
});
- it.each`
- url | property | value
- ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'}
- ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'}
- ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'}
- ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'}
- ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'}
- ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'}
- ${'https://gitlab.com'} | ${'protocol'} | ${'https:'}
- ${'http://gitlab.com#foo'} | ${'protocol'} | ${'http:'}
- ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'}
- ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'}
- `(
- 'sets "window.location.$property" to be "$value" when called with: "$url"',
- ({ url, property, value }) => {
- expect(window.location).toBe(originalLocation);
-
- setWindowLocation(url);
-
- expect(window.location[property]).toBe(value);
- },
- );
-
- it.each([null, 1, undefined, false, '', 'gitlab.com'])(
- 'throws an error when called with an invalid url: "%s"',
- (invalidUrl) => {
- expect(() => setWindowLocation(invalidUrl)).toThrow(/Invalid URL/);
- expect(window.location).toBe(originalLocation);
- },
- );
+ describe('setWindowLocation', () => {
+ describe('given a complete URL', () => {
+ it.each`
+ url | property | value
+ ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'}
+ ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'}
+ ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'}
+ ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'}
+ ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'}
+ ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'}
+ ${'https://gitlab.com'} | ${'protocol'} | ${'https:'}
+ ${'ftp://gitlab.com#foo'} | ${'protocol'} | ${'ftp:'}
+ ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'}
+ ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'}
+ `(
+ 'sets "window.location.$property" to be "$value" when called with: "$url"',
+ ({ url, property, value }) => {
+ expect(window.location.href).toBe(originalLocation);
+
+ setWindowLocation(url);
+
+ expect(window.location[property]).toBe(value);
+ },
+ );
+ });
+
+ describe('given a partial URL', () => {
+ it.each`
+ partialURL | href
+ ${'//foo.test:3000/'} | ${'http://foo.test:3000/'}
+ ${'/foo/bar'} | ${`${originalLocation}foo/bar`}
+ ${'foo/bar'} | ${`${originalLocation}foo/bar`}
+ ${'?foo=bar'} | ${`${originalLocation}?foo=bar`}
+ ${'#a-thing'} | ${`${originalLocation}#a-thing`}
+ `('$partialURL sets location.href to $href', ({ partialURL, href }) => {
+ expect(window.location.href).toBe(originalLocation);
+
+ setWindowLocation(partialURL);
+
+ expect(window.location.href).toBe(href);
+ });
+ });
+
+ describe('relative path', () => {
+ describe.each`
+ initialHref | path | newHref
+ ${'https://gdk.test/foo/bar'} | ${'/qux'} | ${'https://gdk.test/qux'}
+ ${'https://gdk.test/foo/bar/'} | ${'/qux'} | ${'https://gdk.test/qux'}
+ ${'https://gdk.test/foo/bar'} | ${'qux'} | ${'https://gdk.test/foo/qux'}
+ ${'https://gdk.test/foo/bar/'} | ${'qux'} | ${'https://gdk.test/foo/bar/qux'}
+ ${'https://gdk.test/foo/bar'} | ${'../qux'} | ${'https://gdk.test/qux'}
+ ${'https://gdk.test/foo/bar/'} | ${'../qux'} | ${'https://gdk.test/foo/qux'}
+ `('when location is $initialHref', ({ initialHref, path, newHref }) => {
+ beforeEach(() => {
+ setWindowLocation(initialHref);
+ });
+
+ it(`${path} sets window.location.href to ${newHref}`, () => {
+ expect(window.location.href).toBe(initialHref);
+
+ setWindowLocation(path);
+
+ expect(window.location.href).toBe(newHref);
+ });
+ });
+ });
+
+ it.each([null, 1, undefined, false, 'https://', 'https:', { foo: 1 }, []])(
+ 'throws an error when called with an invalid url: "%s"',
+ (invalidUrl) => {
+ expect(() => setWindowLocation(invalidUrl)).toThrow();
+ expect(window.location.href).toBe(originalLocation);
+ },
+ );
+
+ describe('affects links', () => {
+ it.each`
+ url | hrefAttr | expectedHref
+ ${'http://gitlab.com/'} | ${'foo'} | ${'http://gitlab.com/foo'}
+ ${'http://gitlab.com/bar/'} | ${'foo'} | ${'http://gitlab.com/bar/foo'}
+ ${'http://gitlab.com/bar/'} | ${'/foo'} | ${'http://gitlab.com/foo'}
+ ${'http://gdk.test:3000/?foo=bar'} | ${'?qux=1'} | ${'http://gdk.test:3000/?qux=1'}
+ ${'https://gdk.test:3000/?foo=bar'} | ${'//other.test'} | ${'https://other.test/'}
+ `(
+ 'given $url, <a href="$hrefAttr"> points to $expectedHref',
+ ({ url, hrefAttr, expectedHref }) => {
+ setWindowLocation(url);
+
+ const link = document.createElement('a');
+ link.setAttribute('href', hrefAttr);
+
+ expect(link.href).toBe(expectedHref);
+ },
+ );
+ });
+ });
+
+ // This set of tests relies on Jest executing tests in source order, which is
+ // at the time of writing the only order they will execute, by design.
+ // See https://github.com/facebook/jest/issues/4386 for more details.
+ describe('window.location resetting by global beforeEach', () => {
+ const overridden = 'https://gdk.test:1234/';
+ const initial = `${TEST_HOST}/`;
+
+ it('works before an override', () => {
+ expect(window.location.href).toBe(initial);
+ });
+
+ describe('overriding', () => {
+ beforeEach(() => {
+ setWindowLocation(overridden);
+ });
+
+ it('works', () => {
+ expect(window.location.href).toBe(overridden);
+ });
+ });
+
+ it('works after an override', () => {
+ expect(window.location.href).toBe(initial);
+ });
+ });
});
diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js
new file mode 100644
index 00000000000..ee14e002f1b
--- /dev/null
+++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_callout_spec.js
@@ -0,0 +1,67 @@
+import { GlBanner } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import DevopsScoreCallout from '~/analytics/devops_report/components/devops_score_callout.vue';
+import { INTRO_COOKIE_KEY } from '~/analytics/devops_report/constants';
+import * as utils from '~/lib/utils/common_utils';
+import { devopsReportDocsPath, devopsScoreIntroImagePath } from '../mock_data';
+
+describe('DevopsScoreCallout', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(DevopsScoreCallout, {
+ provide: {
+ devopsReportDocsPath,
+ devopsScoreIntroImagePath,
+ },
+ });
+ };
+
+ const findBanner = () => wrapper.findComponent(GlBanner);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with no cookie set', () => {
+ beforeEach(() => {
+ utils.setCookie = jest.fn();
+
+ createComponent();
+ });
+
+ it('displays the banner', () => {
+ expect(findBanner().exists()).toBe(true);
+ });
+
+ it('does not call setCookie', () => {
+ expect(utils.setCookie).not.toHaveBeenCalled();
+ });
+
+ describe('when the close button is clicked', () => {
+ beforeEach(() => {
+ findBanner().vm.$emit('close');
+ });
+
+ it('sets the dismissed cookie', () => {
+ expect(utils.setCookie).toHaveBeenCalledWith(INTRO_COOKIE_KEY, 'true');
+ });
+
+ it('hides the banner', () => {
+ expect(findBanner().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with the dismissed cookie set', () => {
+ beforeEach(() => {
+ jest.spyOn(utils, 'getCookie').mockReturnValue('true');
+
+ createComponent();
+ });
+
+ it('hides the banner', () => {
+ expect(findBanner().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js
index 7c20bbe21c8..8f8dac977de 100644
--- a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js
+++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js
@@ -1,14 +1,10 @@
-import { GlTable, GlBadge, GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlTable, GlBadge, GlEmptyState } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DevopsScore from '~/analytics/devops_report/components/devops_score.vue';
-import {
- devopsScoreMetricsData,
- devopsReportDocsPath,
- noDataImagePath,
- devopsScoreTableHeaders,
-} from '../mock_data';
+import DevopsScoreCallout from '~/analytics/devops_report/components/devops_score_callout.vue';
+import { devopsScoreMetricsData, noDataImagePath, devopsScoreTableHeaders } from '../mock_data';
describe('DevopsScore', () => {
let wrapper;
@@ -18,7 +14,6 @@ describe('DevopsScore', () => {
mount(DevopsScore, {
provide: {
devopsScoreMetrics,
- devopsReportDocsPath,
noDataImagePath,
},
}),
@@ -30,12 +25,19 @@ describe('DevopsScore', () => {
const findCol = (testId) => findTable().find(`[data-testid="${testId}"]`);
const findUsageCol = () => findCol('usageCol');
const findDevopsScoreApp = () => wrapper.findByTestId('devops-score-app');
+ const bannerExists = () => wrapper.findComponent(DevopsScoreCallout).exists();
+ const findDocsLink = () =>
+ wrapper.findByRole('link', { name: 'See example DevOps Score page in our documentation.' });
describe('with no data', () => {
beforeEach(() => {
createComponent({ devopsScoreMetrics: {} });
});
+ it('includes the DevopsScoreCallout component ', () => {
+ expect(bannerExists()).toBe(true);
+ });
+
describe('empty state', () => {
it('displays the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
@@ -48,7 +50,10 @@ describe('DevopsScore', () => {
});
it('contains a link to the feature documentation', () => {
- expect(wrapper.findComponent(GlLink).exists()).toBe(true);
+ expect(findDocsLink().exists()).toBe(true);
+ expect(findDocsLink().attributes('href')).toBe(
+ '/help/user/admin_area/analytics/dev_ops_report',
+ );
});
});
@@ -62,6 +67,10 @@ describe('DevopsScore', () => {
createComponent();
});
+ it('includes the DevopsScoreCallout component ', () => {
+ expect(bannerExists()).toBe(true);
+ });
+
it('does not display the empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
diff --git a/spec/frontend/admin/analytics/devops_score/mock_data.js b/spec/frontend/admin/analytics/devops_score/mock_data.js
index ae0c01a2661..e8f8b778ffa 100644
--- a/spec/frontend/admin/analytics/devops_score/mock_data.js
+++ b/spec/frontend/admin/analytics/devops_score/mock_data.js
@@ -44,3 +44,5 @@ export const devopsScoreMetricsData = {
export const devopsReportDocsPath = 'docs-path';
export const noDataImagePath = 'image-path';
+
+export const devopsScoreIntroImagePath = 'image-path';
diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
index 18339164d5a..4bb22feb913 100644
--- a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
+++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
@@ -192,22 +192,27 @@ describe('Signup Form', () => {
describe('form submit button confirmation modal for side-effect of adding possibly unwanted new users', () => {
it.each`
- requireAdminApprovalAction | userCapAction | buttonEffect
- ${'unchanged from true'} | ${'unchanged'} | ${'submits form'}
- ${'unchanged from false'} | ${'unchanged'} | ${'submits form'}
- ${'toggled off'} | ${'unchanged'} | ${'shows confirmation modal'}
- ${'toggled on'} | ${'unchanged'} | ${'submits form'}
- ${'unchanged from false'} | ${'increased'} | ${'shows confirmation modal'}
- ${'unchanged from true'} | ${'increased'} | ${'shows confirmation modal'}
- ${'toggled off'} | ${'increased'} | ${'shows confirmation modal'}
- ${'toggled on'} | ${'increased'} | ${'shows confirmation modal'}
- ${'toggled on'} | ${'decreased'} | ${'submits form'}
- ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${'shows confirmation modal'}
- ${'unchanged from false'} | ${'changed from unlimited to limited'} | ${'submits form'}
- ${'unchanged from false'} | ${'unchanged from unlimited'} | ${'submits form'}
+ requireAdminApprovalAction | userCapAction | pendingUserCount | buttonEffect
+ ${'unchanged from true'} | ${'unchanged'} | ${0} | ${'submits form'}
+ ${'unchanged from false'} | ${'unchanged'} | ${0} | ${'submits form'}
+ ${'toggled off'} | ${'unchanged'} | ${1} | ${'shows confirmation modal'}
+ ${'toggled off'} | ${'unchanged'} | ${0} | ${'submits form'}
+ ${'toggled on'} | ${'unchanged'} | ${0} | ${'submits form'}
+ ${'unchanged from false'} | ${'increased'} | ${1} | ${'shows confirmation modal'}
+ ${'unchanged from true'} | ${'increased'} | ${0} | ${'submits form'}
+ ${'toggled off'} | ${'increased'} | ${1} | ${'shows confirmation modal'}
+ ${'toggled off'} | ${'increased'} | ${0} | ${'submits form'}
+ ${'toggled on'} | ${'increased'} | ${1} | ${'shows confirmation modal'}
+ ${'toggled on'} | ${'increased'} | ${0} | ${'submits form'}
+ ${'toggled on'} | ${'decreased'} | ${0} | ${'submits form'}
+ ${'toggled on'} | ${'decreased'} | ${1} | ${'submits form'}
+ ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${1} | ${'shows confirmation modal'}
+ ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${0} | ${'submits form'}
+ ${'unchanged from false'} | ${'changed from unlimited to limited'} | ${0} | ${'submits form'}
+ ${'unchanged from false'} | ${'unchanged from unlimited'} | ${0} | ${'submits form'}
`(
- '$buttonEffect if require admin approval for new sign-ups is $requireAdminApprovalAction and the user cap is $userCapAction',
- async ({ requireAdminApprovalAction, userCapAction, buttonEffect }) => {
+ '$buttonEffect if require admin approval for new sign-ups is $requireAdminApprovalAction and the user cap is $userCapAction and pending user count is $pendingUserCount',
+ async ({ requireAdminApprovalAction, userCapAction, pendingUserCount, buttonEffect }) => {
let isModalDisplayed;
switch (buttonEffect) {
@@ -224,7 +229,9 @@ describe('Signup Form', () => {
const isFormSubmittedWhenClickingFormSubmitButton = !isModalDisplayed;
- const injectedProps = {};
+ const injectedProps = {
+ pendingUserCount,
+ };
const USER_CAP_DEFAULT = 5;
@@ -310,6 +317,7 @@ describe('Signup Form', () => {
await mountComponent({
injectedProps: {
newUserSignupsCap: INITIAL_USER_CAP,
+ pendingUserCount: 5,
},
stubs: { GlButton, GlModal: stubComponent(GlModal) },
});
diff --git a/spec/frontend/admin/signup_restrictions/mock_data.js b/spec/frontend/admin/signup_restrictions/mock_data.js
index 624a5614c9c..135fc8caae0 100644
--- a/spec/frontend/admin/signup_restrictions/mock_data.js
+++ b/spec/frontend/admin/signup_restrictions/mock_data.js
@@ -17,6 +17,7 @@ export const rawMockData = {
supportedSyntaxLinkUrl: '/supported/syntax/link',
emailRestrictions: 'user1@domain.com, user2@domain.com',
afterSignUpText: 'Congratulations on your successful sign-up!',
+ pendingUserCount: '0',
};
export const mockData = {
@@ -38,4 +39,5 @@ export const mockData = {
supportedSyntaxLinkUrl: '/supported/syntax/link',
emailRestrictions: 'user1@domain.com, user2@domain.com',
afterSignUpText: 'Congratulations on your successful sign-up!',
+ pendingUserCount: '0',
};
diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js
index 67d9bac8580..fd05b08a3fb 100644
--- a/spec/frontend/admin/users/components/actions/actions_spec.js
+++ b/spec/frontend/admin/users/components/actions/actions_spec.js
@@ -5,8 +5,8 @@ import { nextTick } from 'vue';
import Actions from '~/admin/users/components/actions';
import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants';
+import { paths } from '../../mock_data';
describe('Action components', () => {
let wrapper;
@@ -47,32 +47,33 @@ describe('Action components', () => {
describe('DELETE_ACTION_COMPONENTS', () => {
const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }];
- it.each(DELETE_ACTIONS)('renders a dropdown item for "%s"', async (action) => {
- initComponent({
- component: Actions[capitalizeFirstCharacter(action)],
- props: {
- username: 'John Doe',
- paths: {
- delete: '/delete',
- block: '/block',
+
+ it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))(
+ 'renders a dropdown item for "%s"',
+ async (action, expectedPath) => {
+ initComponent({
+ component: Actions[capitalizeFirstCharacter(action)],
+ props: {
+ username: 'John Doe',
+ paths,
+ oncallSchedules,
},
- oncallSchedules,
- },
- stubs: { SharedDeleteAction },
- });
+ stubs: { SharedDeleteAction },
+ });
- await nextTick();
+ await nextTick();
- const sharedAction = wrapper.find(SharedDeleteAction);
+ const sharedAction = wrapper.find(SharedDeleteAction);
- expect(sharedAction.attributes('data-block-user-url')).toBe('/block');
- expect(sharedAction.attributes('data-delete-user-url')).toBe('/delete');
- expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
- expect(sharedAction.attributes('data-username')).toBe('John Doe');
- expect(sharedAction.attributes('data-oncall-schedules')).toBe(
- JSON.stringify(oncallSchedules),
- );
- expect(findDropdownItem().exists()).toBe(true);
- });
+ expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block);
+ expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath);
+ expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
+ expect(sharedAction.attributes('data-username')).toBe('John Doe');
+ expect(sharedAction.attributes('data-oncall-schedules')).toBe(
+ JSON.stringify(oncallSchedules),
+ );
+ expect(findDropdownItem().exists()).toBe(true);
+ },
+ );
});
});
diff --git a/spec/frontend/admin/users/components/user_date_spec.js b/spec/frontend/admin/users/components/user_date_spec.js
index 1a2f2938db5..af262c6d3f0 100644
--- a/spec/frontend/admin/users/components/user_date_spec.js
+++ b/spec/frontend/admin/users/components/user_date_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import UserDate from '~/vue_shared/components/user_date.vue';
+import { ISO_SHORT_FORMAT } from '~/vue_shared/constants';
import { users } from '../mock_data';
const mockDate = users[0].createdAt;
@@ -22,12 +23,15 @@ describe('FormatDate component', () => {
});
it.each`
- date | output
- ${mockDate} | ${'13 Nov, 2020'}
- ${null} | ${'Never'}
- ${undefined} | ${'Never'}
- `('renders $date as $output', ({ date, output }) => {
- initComponent({ date });
+ date | dateFormat | output
+ ${mockDate} | ${undefined} | ${'13 Nov, 2020'}
+ ${null} | ${undefined} | ${'Never'}
+ ${undefined} | ${undefined} | ${'Never'}
+ ${mockDate} | ${ISO_SHORT_FORMAT} | ${'2020-11-13'}
+ ${null} | ${ISO_SHORT_FORMAT} | ${'Never'}
+ ${undefined} | ${ISO_SHORT_FORMAT} | ${'Never'}
+ `('renders $date as $output', ({ date, dateFormat, output }) => {
+ initComponent({ date, dateFormat });
expect(wrapper.text()).toBe(output);
});
diff --git a/spec/frontend/admin/users/mock_data.js b/spec/frontend/admin/users/mock_data.js
index ded3e6f7edf..73fa73c0b47 100644
--- a/spec/frontend/admin/users/mock_data.js
+++ b/spec/frontend/admin/users/mock_data.js
@@ -30,7 +30,7 @@ export const paths = {
activate: '/admin/users/id/activate',
unlock: '/admin/users/id/unlock',
delete: '/admin/users/id',
- deleteWithContributions: '/admin/users/id',
+ deleteWithContributions: '/admin/users/id?hard_delete=true',
adminUser: '/admin/users/id',
ban: '/admin/users/id/ban',
unban: '/admin/users/id/unban',
diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
index 1c4dde39585..e6a6e01c41c 100644
--- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
@@ -6,7 +6,6 @@ import VueApollo from 'vue-apollo';
import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue';
@@ -57,7 +56,6 @@ describe('AlertsSettingsWrapper', () => {
let wrapper;
let fakeApollo;
let destroyIntegrationHandler;
- useMockIntersectionObserver();
const httpMappingData = {
payloadExample: '{"test: : "field"}',
diff --git a/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js b/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js
index 75ef9d9db94..c5c40e9a360 100644
--- a/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js
+++ b/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js
@@ -1,6 +1,6 @@
import { GlEmptyState, GlSprintf } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import ServicePingDisabled from '~/analytics/devops_report/components/service_ping_disabled.vue';
describe('~/analytics/devops_report/components/service_ping_disabled.vue', () => {
@@ -11,21 +11,19 @@ describe('~/analytics/devops_report/components/service_ping_disabled.vue', () =>
});
const createWrapper = ({ isAdmin = false } = {}) => {
- wrapper = shallowMountExtended(ServicePingDisabled, {
+ wrapper = mountExtended(ServicePingDisabled, {
provide: {
isAdmin,
svgPath: TEST_HOST,
- docsLink: TEST_HOST,
primaryButtonPath: TEST_HOST,
},
- stubs: { GlEmptyState, GlSprintf },
});
};
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findMessageForRegularUsers = () => wrapper.findComponent(GlSprintf);
- const findDocsLink = () => wrapper.findByTestId('docs-link');
- const findPowerOnButton = () => wrapper.findByTestId('power-on-button');
+ const findDocsLink = () => wrapper.findByRole('link', { name: 'service ping' });
+ const findPowerOnButton = () => wrapper.findByRole('link', { name: 'Turn on service ping' });
it('renders empty state with provided SVG path', () => {
createWrapper();
@@ -45,7 +43,7 @@ describe('~/analytics/devops_report/components/service_ping_disabled.vue', () =>
it('renders docs link', () => {
expect(findDocsLink().exists()).toBe(true);
- expect(findDocsLink().attributes('href')).toBe(TEST_HOST);
+ expect(findDocsLink().attributes('href')).toBe('/help/development/service_ping/index.md');
});
});
diff --git a/spec/frontend/authentication/two_factor_auth/index_spec.js b/spec/frontend/authentication/two_factor_auth/index_spec.js
index f5345139021..0ff9d60f409 100644
--- a/spec/frontend/authentication/two_factor_auth/index_spec.js
+++ b/spec/frontend/authentication/two_factor_auth/index_spec.js
@@ -1,5 +1,6 @@
import { getByTestId, fireEvent } from '@testing-library/dom';
import { createWrapper } from '@vue/test-utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { initRecoveryCodes, initClose2faSuccessMessage } from '~/authentication/two_factor_auth';
import RecoveryCodes from '~/authentication/two_factor_auth/components/recovery_codes.vue';
import * as urlUtils from '~/lib/utils/url_utility';
@@ -53,8 +54,7 @@ describe('initClose2faSuccessMessage', () => {
describe('when alert is closed', () => {
beforeEach(() => {
- delete window.location;
- window.location = new URL(
+ setWindowLocation(
'https://localhost/-/profile/account?two_factor_auth_enabled_successfully=true',
);
diff --git a/spec/frontend/authentication/webauthn/error_spec.js b/spec/frontend/authentication/webauthn/error_spec.js
index 26f1ca5e27d..9b71f77dde2 100644
--- a/spec/frontend/authentication/webauthn/error_spec.js
+++ b/spec/frontend/authentication/webauthn/error_spec.js
@@ -1,3 +1,4 @@
+import setWindowLocation from 'helpers/set_window_location_helper';
import WebAuthnError from '~/authentication/webauthn/error';
describe('WebAuthnError', () => {
@@ -17,19 +18,8 @@ describe('WebAuthnError', () => {
});
describe('SecurityError', () => {
- const { location } = window;
-
- beforeEach(() => {
- delete window.location;
- window.location = {};
- });
-
- afterEach(() => {
- window.location = location;
- });
-
it('returns a descriptive error if https is disabled', () => {
- window.location.protocol = 'http:';
+ setWindowLocation('http://localhost');
const expectedMessage =
'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.';
@@ -39,7 +29,7 @@ describe('WebAuthnError', () => {
});
it('returns a generic error if https is enabled', () => {
- window.location.protocol = 'https:';
+ setWindowLocation('https://localhost');
const expectedMessage = 'There was a problem communicating with your device.';
expect(
diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js
index 43cd3d7ca34..0f8ea2b635f 100644
--- a/spec/frontend/authentication/webauthn/register_spec.js
+++ b/spec/frontend/authentication/webauthn/register_spec.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import setWindowLocation from 'helpers/set_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WebAuthnRegister from '~/authentication/webauthn/register';
import MockWebAuthnDevice from './mock_webauthn_device';
@@ -50,17 +51,14 @@ describe('WebAuthnRegister', () => {
});
describe('when unsupported', () => {
- const { location, PublicKeyCredential } = window;
+ const { PublicKeyCredential } = window;
beforeEach(() => {
- delete window.location;
delete window.credentials;
- window.location = {};
window.PublicKeyCredential = undefined;
});
afterEach(() => {
- window.location = location;
window.PublicKeyCredential = PublicKeyCredential;
});
@@ -69,7 +67,7 @@ describe('WebAuthnRegister', () => {
${false} | ${'WebAuthn only works with HTTPS-enabled websites'}
${true} | ${'Please use a supported browser, e.g. Chrome (67+) or Firefox'}
`('when https is $httpsEnabled', ({ httpsEnabled, expectedText }) => {
- window.location.protocol = httpsEnabled ? 'https:' : 'http:';
+ setWindowLocation(`${httpsEnabled ? 'https:' : 'http:'}//localhost`);
component.start();
expect(findMessage().text()).toContain(expectedText);
diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js
index bce65899c43..e321bb41774 100644
--- a/spec/frontend/blob/components/blob_header_default_actions_spec.js
+++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js
@@ -39,6 +39,9 @@ describe('Blob Header Default Actions', () => {
});
describe('renders', () => {
+ const findCopyButton = () => wrapper.find('[data-testid="copyContentsButton"]');
+ const findViewRawButton = () => wrapper.find('[data-testid="viewRawButton"]');
+
it('gl-button-group component', () => {
expect(btnGroup.exists()).toBe(true);
});
@@ -76,7 +79,14 @@ describe('Blob Header Default Actions', () => {
hasRenderError: true,
});
- expect(wrapper.find('[data-testid="copyContentsButton"]').exists()).toBe(false);
+ expect(findCopyButton().exists()).toBe(false);
+ });
+
+ it('does not render the copy and view raw button if isBinary is set to true', () => {
+ createComponent({ isBinary: true });
+
+ expect(findCopyButton().exists()).toBe(false);
+ expect(findViewRawButton().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js
index 865e8ab1124..f841785be42 100644
--- a/spec/frontend/blob/components/blob_header_spec.js
+++ b/spec/frontend/blob/components/blob_header_spec.js
@@ -29,6 +29,8 @@ describe('Blob Header Default Actions', () => {
});
describe('rendering', () => {
+ const findDefaultActions = () => wrapper.find(DefaultActions);
+
const slots = {
prepend: 'Foo Prepend',
actions: 'Actions Bar',
@@ -42,7 +44,7 @@ describe('Blob Header Default Actions', () => {
it('renders all components', () => {
createComponent();
expect(wrapper.find(ViewerSwitcher).exists()).toBe(true);
- expect(wrapper.find(DefaultActions).exists()).toBe(true);
+ expect(findDefaultActions().exists()).toBe(true);
expect(wrapper.find(BlobFilepath).exists()).toBe(true);
});
@@ -100,7 +102,13 @@ describe('Blob Header Default Actions', () => {
hasRenderError: true,
},
);
- expect(wrapper.find(DefaultActions).props('hasRenderError')).toBe(true);
+ expect(findDefaultActions().props('hasRenderError')).toBe(true);
+ });
+
+ it('passes the correct isBinary value to default actions when viewing a binary file', () => {
+ createComponent({}, {}, { isBinary: true });
+
+ expect(findDefaultActions().props('isBinary')).toBe(true);
});
});
diff --git a/spec/frontend/blob/csv/csv_viewer_spec.js b/spec/frontend/blob/csv/csv_viewer_spec.js
index abb914b8f57..17973c709c1 100644
--- a/spec/frontend/blob/csv/csv_viewer_spec.js
+++ b/spec/frontend/blob/csv/csv_viewer_spec.js
@@ -1,8 +1,9 @@
-import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { getAllByRole } from '@testing-library/dom';
import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import CSVViewer from '~/blob/csv/csv_viewer.vue';
+import CsvViewer from '~/blob/csv/csv_viewer.vue';
+import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue';
const validCsv = 'one,two,three';
const brokenCsv = '{\n "json": 1,\n "key": [1, 2, 3]\n}';
@@ -11,7 +12,7 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => {
let wrapper;
const createComponent = ({ csv = validCsv, mountFunction = shallowMount } = {}) => {
- wrapper = mountFunction(CSVViewer, {
+ wrapper = mountFunction(CsvViewer, {
propsData: {
csv,
},
@@ -20,7 +21,7 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => {
const findCsvTable = () => wrapper.findComponent(GlTable);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findAlert = () => wrapper.findComponent(GlAlert);
+ const findAlert = () => wrapper.findComponent(PapaParseAlert);
afterEach(() => {
wrapper.destroy();
@@ -35,12 +36,12 @@ describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => {
});
describe('when the CSV contains errors', () => {
- it('should render alert', async () => {
+ it('should render alert with correct props', async () => {
createComponent({ csv: brokenCsv });
await nextTick;
expect(findAlert().props()).toMatchObject({
- variant: 'danger',
+ papaParseErrors: [{ code: 'UndetectableDelimiter' }],
});
});
});
diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js
index 6a24b76abc8..705c4630a68 100644
--- a/spec/frontend/blob/viewer/index_spec.js
+++ b/spec/frontend/blob/viewer/index_spec.js
@@ -3,7 +3,7 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import { setTestTimeout } from 'helpers/timeout';
-import BlobViewer from '~/blob/viewer/index';
+import { BlobViewer } from '~/blob/viewer/index';
import axios from '~/lib/utils/axios_utils';
const execImmediately = (callback) => {
diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js
index 87f9a68f5dd..7d3ecc773a6 100644
--- a/spec/frontend/boards/board_card_inner_spec.js
+++ b/spec/frontend/boards/board_card_inner_spec.js
@@ -1,6 +1,7 @@
import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { range } from 'lodash';
import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue';
@@ -8,7 +9,7 @@ import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import defaultStore from '~/boards/stores';
import { updateHistory } from '~/lib/utils/url_utility';
-import { mockLabelList, mockIssue } from './mock_data';
+import { mockLabelList, mockIssue, mockIssueFullPath } from './mock_data';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/boards/eventhub');
@@ -44,7 +45,7 @@ describe('Board card component', () => {
const findEpicCountablesTotalWeight = () => wrapper.findByTestId('epic-countables-total-weight');
const findEpicProgressTooltip = () => wrapper.findByTestId('epic-progress-tooltip-content');
- const createStore = ({ isEpicBoard = false } = {}) => {
+ const createStore = ({ isEpicBoard = false, isProjectBoard = false } = {}) => {
store = new Vuex.Store({
...defaultStore,
state: {
@@ -54,7 +55,7 @@ describe('Board card component', () => {
getters: {
isGroupBoard: () => true,
isEpicBoard: () => isEpicBoard,
- isProjectBoard: () => false,
+ isProjectBoard: () => isProjectBoard,
},
});
};
@@ -133,6 +134,17 @@ describe('Board card component', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
});
+ it('does not render item reference path', () => {
+ createStore({ isProjectBoard: true });
+ createWrapper();
+
+ expect(wrapper.find('.board-card-number').text()).not.toContain(mockIssueFullPath);
+ });
+
+ it('renders item reference path', () => {
+ expect(wrapper.find('.board-card-number').text()).toContain(mockIssueFullPath);
+ });
+
describe('blocked', () => {
it('renders blocked icon if issue is blocked', async () => {
createWrapper({
@@ -363,8 +375,6 @@ describe('Board card component', () => {
describe('filterByLabel method', () => {
beforeEach(() => {
- delete window.location;
-
wrapper.setProps({
updateFilters: true,
});
@@ -373,7 +383,7 @@ describe('Board card component', () => {
describe('when selected label is not in the filter', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(() => {});
- window.location = { search: '' };
+ setWindowLocation('?');
wrapper.vm.filterByLabel(label1);
});
@@ -394,7 +404,7 @@ describe('Board card component', () => {
describe('when selected label is already in the filter', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(() => {});
- window.location = { search: '?label_name[]=testing%20123' };
+ setWindowLocation('?label_name[]=testing%20123');
wrapper.vm.filterByLabel(label1);
});
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index c440c110094..811f0043a01 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -4,8 +4,9 @@ import Vuex from 'vuex';
import BoardCard from '~/boards/components/board_card.vue';
import BoardList from '~/boards/components/board_list.vue';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
+import BoardNewItem from '~/boards/components/board_new_item.vue';
import defaultState from '~/boards/stores/state';
-import { mockList, mockIssuesByListId, issues } from './mock_data';
+import { mockList, mockIssuesByListId, issues, mockGroupProjects } from './mock_data';
export default function createComponent({
listIssueProps = {},
@@ -17,6 +18,7 @@ export default function createComponent({
state = defaultState,
stubs = {
BoardNewIssue,
+ BoardNewItem,
BoardCard,
},
} = {}) {
@@ -25,6 +27,7 @@ export default function createComponent({
const store = new Vuex.Store({
state: {
+ selectedProject: mockGroupProjects[0],
boardItemsByListId: mockIssuesByListId,
boardItems: issues,
pageInfoByListId: {
@@ -77,6 +80,7 @@ export default function createComponent({
provide: {
groupId: null,
rootPath: '/',
+ boardId: '1',
weightFeatureAvailable: false,
boardWeight: null,
canAdminList: true,
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index a3b1810ab80..6f623eab1af 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -1,3 +1,5 @@
+import Draggable from 'vuedraggable';
+import { DraggableItemTypes } from 'ee_else_ce/boards/constants';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import createComponent from 'jest/boards/board_list_helper';
import BoardCard from '~/boards/components/board_card.vue';
@@ -10,6 +12,23 @@ describe('Board list component', () => {
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]');
+ const findDraggable = () => wrapper.findComponent(Draggable);
+
+ const startDrag = (
+ params = {
+ item: {
+ dataset: {
+ draggableItemType: DraggableItemTypes.card,
+ },
+ },
+ },
+ ) => {
+ findByTestId('tree-root-wrapper').vm.$emit('start', params);
+ };
+
+ const endDrag = (params) => {
+ findByTestId('tree-root-wrapper').vm.$emit('end', params);
+ };
useFakeRequestAnimationFrame();
@@ -155,40 +174,89 @@ describe('Board list component', () => {
});
describe('drag & drop issue', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
+ describe('when dragging is allowed', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ componentProps: {
+ disabled: false,
+ },
+ });
+ });
- describe('handleDragOnStart', () => {
- it('adds a class `is-dragging` to document body', () => {
- expect(document.body.classList.contains('is-dragging')).toBe(false);
+ it('Draggable is used', () => {
+ expect(findDraggable().exists()).toBe(true);
+ });
+
+ describe('handleDragOnStart', () => {
+ it('adds a class `is-dragging` to document body', () => {
+ expect(document.body.classList.contains('is-dragging')).toBe(false);
- findByTestId('tree-root-wrapper').vm.$emit('start');
+ startDrag();
- expect(document.body.classList.contains('is-dragging')).toBe(true);
+ expect(document.body.classList.contains('is-dragging')).toBe(true);
+ });
});
- });
- describe('handleDragOnEnd', () => {
- it('removes class `is-dragging` from document body', () => {
- jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {});
- document.body.classList.add('is-dragging');
+ describe('handleDragOnEnd', () => {
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {});
+
+ startDrag();
+ });
+
+ it('removes class `is-dragging` from document body', () => {
+ document.body.classList.add('is-dragging');
+
+ endDrag({
+ oldIndex: 1,
+ newIndex: 0,
+ item: {
+ dataset: {
+ draggableItemType: DraggableItemTypes.card,
+ itemId: mockIssues[0].id,
+ itemIid: mockIssues[0].iid,
+ itemPath: mockIssues[0].referencePath,
+ },
+ },
+ to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
+ from: { dataset: { listId: 'gid://gitlab/List/2' } },
+ });
- findByTestId('tree-root-wrapper').vm.$emit('end', {
- oldIndex: 1,
- newIndex: 0,
- item: {
- dataset: {
- itemId: mockIssues[0].id,
- itemIid: mockIssues[0].iid,
- itemPath: mockIssues[0].referencePath,
+ expect(document.body.classList.contains('is-dragging')).toBe(false);
+ });
+
+ it(`should not handle the event if the dragged item is not a "${DraggableItemTypes.card}"`, () => {
+ endDrag({
+ oldIndex: 1,
+ newIndex: 0,
+ item: {
+ dataset: {
+ draggableItemType: DraggableItemTypes.list,
+ itemId: mockIssues[0].id,
+ itemIid: mockIssues[0].iid,
+ itemPath: mockIssues[0].referencePath,
+ },
},
+ to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
+ from: { dataset: { listId: 'gid://gitlab/List/2' } },
+ });
+
+ expect(document.body.classList.contains('is-dragging')).toBe(true);
+ });
+ });
+ });
+
+ describe('when dragging is not allowed', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ componentProps: {
+ disabled: true,
},
- to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
- from: { dataset: { listId: 'gid://gitlab/List/2' } },
});
+ });
- expect(document.body.classList.contains('is-dragging')).toBe(false);
+ it('Draggable is not used', () => {
+ expect(findDraggable().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index 9a9ce7b8dc1..25ec568e48d 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -31,6 +31,7 @@ describe('Board card', () => {
actions: mockActions,
getters: {
isEpicBoard: () => false,
+ isProjectBoard: () => false,
},
});
};
diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js
index 6ac5d16e5a3..50f86e92adb 100644
--- a/spec/frontend/boards/components/board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/board_filtered_search_spec.js
@@ -115,6 +115,9 @@ describe('BoardFilteredSearch', () => {
{ type: 'author_username', value: { data: 'root', operator: '=' } },
{ type: 'label_name', value: { data: 'label', operator: '=' } },
{ type: 'label_name', value: { data: 'label2', operator: '=' } },
+ { type: 'milestone_title', value: { data: 'New Milestone', operator: '=' } },
+ { type: 'types', value: { data: 'INCIDENT', operator: '=' } },
+ { type: 'weight', value: { data: '2', operator: '=' } },
];
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', mockFilters);
@@ -122,7 +125,8 @@ describe('BoardFilteredSearch', () => {
expect(urlUtility.updateHistory).toHaveBeenCalledWith({
title: '',
replace: true,
- url: 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2',
+ url:
+ 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&milestone_title=New+Milestone&types=INCIDENT&weight=2',
});
});
});
diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js
index 3966c3e6b87..52f1907654a 100644
--- a/spec/frontend/boards/components/board_form_spec.js
+++ b/spec/frontend/boards/components/board_form_spec.js
@@ -1,5 +1,6 @@
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
@@ -75,10 +76,6 @@ describe('BoardForm', () => {
});
};
- beforeEach(() => {
- delete window.location;
- });
-
afterEach(() => {
wrapper.destroy();
wrapper = null;
@@ -244,7 +241,7 @@ describe('BoardForm', () => {
updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } },
},
});
- window.location = new URL('https://test/boards/1');
+ setWindowLocation('https://test/boards/1');
createComponent({ canAdminBoard: true, currentPage: formType.edit });
findInput().trigger('keyup.enter', { metaKey: true });
@@ -270,7 +267,7 @@ describe('BoardForm', () => {
updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } },
},
});
- window.location = new URL('https://test/boards/1?group_by=epic');
+ setWindowLocation('https://test/boards/1?group_by=epic');
createComponent({ canAdminBoard: true, currentPage: formType.edit });
findInput().trigger('keyup.enter', { metaKey: true });
diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js
index e6405bbcff3..57ccebf3676 100644
--- a/spec/frontend/boards/components/board_new_issue_spec.js
+++ b/spec/frontend/boards/components/board_new_issue_spec.js
@@ -1,6 +1,9 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
+import BoardNewItem from '~/boards/components/board_new_item.vue';
+import ProjectSelect from '~/boards/components/project_select.vue';
+import eventHub from '~/boards/eventhub';
import { mockList, mockGroupProjects } from '../mock_data';
@@ -8,107 +11,104 @@ const localVue = createLocalVue();
localVue.use(Vuex);
+const addListNewIssuesSpy = jest.fn().mockResolvedValue();
+const mockActions = { addListNewIssue: addListNewIssuesSpy };
+
+const createComponent = ({
+ state = { selectedProject: mockGroupProjects[0], fullPath: mockGroupProjects[0].fullPath },
+ actions = mockActions,
+ getters = { isGroupBoard: () => true, isProjectBoard: () => false },
+} = {}) =>
+ shallowMount(BoardNewIssue, {
+ localVue,
+ store: new Vuex.Store({
+ state,
+ actions,
+ getters,
+ }),
+ propsData: {
+ list: mockList,
+ },
+ provide: {
+ groupId: 1,
+ weightFeatureAvailable: false,
+ boardWeight: null,
+ },
+ stubs: {
+ BoardNewItem,
+ },
+ });
+
describe('Issue boards new issue form', () => {
let wrapper;
- let vm;
-
- const addListNewIssuesSpy = jest.fn();
-
- const findSubmitButton = () => wrapper.find({ ref: 'submitButton' });
- const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
- const findSubmitForm = () => wrapper.find({ ref: 'submitForm' });
-
- const submitIssue = () => {
- const dummySubmitEvent = {
- preventDefault() {},
- };
- return findSubmitForm().trigger('submit', dummySubmitEvent);
- };
-
- beforeEach(() => {
- const store = new Vuex.Store({
- state: { selectedProject: mockGroupProjects[0] },
- actions: { addListNewIssue: addListNewIssuesSpy },
- getters: { isGroupBoard: () => false, isProjectBoard: () => true },
- });
-
- wrapper = shallowMount(BoardNewIssue, {
- propsData: {
- disabled: false,
- list: mockList,
- },
- store,
- localVue,
- provide: {
- groupId: null,
- weightFeatureAvailable: false,
- boardWeight: null,
- },
- });
+ const findBoardNewItem = () => wrapper.findComponent(BoardNewItem);
- vm = wrapper.vm;
+ beforeEach(async () => {
+ wrapper = createComponent();
- return vm.$nextTick();
+ await wrapper.vm.$nextTick();
});
afterEach(() => {
wrapper.destroy();
});
- it('calls submit if submit button is clicked', async () => {
- jest.spyOn(wrapper.vm, 'submit').mockImplementation();
- wrapper.setData({ title: 'Testing Title' });
-
- await vm.$nextTick();
- await submitIssue();
- expect(wrapper.vm.submit).toHaveBeenCalled();
- });
-
- it('disables submit button if title is empty', () => {
- expect(findSubmitButton().props().disabled).toBe(true);
+ it('renders board-new-item component', () => {
+ const boardNewItem = findBoardNewItem();
+ expect(boardNewItem.exists()).toBe(true);
+ expect(boardNewItem.props()).toEqual({
+ list: mockList,
+ formEventPrefix: 'toggle-issue-form-',
+ submitButtonTitle: 'Create issue',
+ disableSubmit: false,
+ });
});
- it('enables submit button if title is not empty', async () => {
- wrapper.setData({ title: 'Testing Title' });
-
- await vm.$nextTick();
- expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title');
- expect(findSubmitButton().props().disabled).toBe(false);
+ it('calls addListNewIssue action when `board-new-item` emits form-submit event', async () => {
+ findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' });
+
+ await wrapper.vm.$nextTick();
+ expect(addListNewIssuesSpy).toHaveBeenCalledWith(expect.any(Object), {
+ list: mockList,
+ issueInput: {
+ title: 'Foo',
+ labelIds: [],
+ assigneeIds: [],
+ milestoneId: undefined,
+ projectPath: mockGroupProjects[0].fullPath,
+ },
+ });
});
- it('clears title after clicking cancel', async () => {
- findCancelButton().trigger('click');
+ it('emits event `toggle-issue-form` with current list Id suffix on eventHub when `board-new-item` emits form-cancel event', async () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+ findBoardNewItem().vm.$emit('form-cancel');
- await vm.$nextTick();
- expect(vm.title).toBe('');
+ await wrapper.vm.$nextTick();
+ expect(eventHub.$emit).toHaveBeenCalledWith(`toggle-issue-form-${mockList.id}`);
});
- describe('submit success', () => {
- it('creates new issue', async () => {
- wrapper.setData({ title: 'create issue' });
+ describe('when in group issue board', () => {
+ it('renders project-select component within board-new-item component', () => {
+ const projectSelect = findBoardNewItem().findComponent(ProjectSelect);
- await vm.$nextTick();
- await submitIssue();
- expect(addListNewIssuesSpy).toHaveBeenCalled();
+ expect(projectSelect.exists()).toBe(true);
+ expect(projectSelect.props('list')).toEqual(mockList);
});
+ });
- it('enables button after submit', async () => {
- jest.spyOn(wrapper.vm, 'submit').mockImplementation();
- wrapper.setData({ title: 'create issue' });
-
- await vm.$nextTick();
- await submitIssue();
- expect(findSubmitButton().props().disabled).toBe(false);
+ describe('when in project issue board', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ getters: { isGroupBoard: () => false, isProjectBoard: () => true },
+ });
});
- it('clears title after submit', async () => {
- wrapper.setData({ title: 'create issue' });
+ it('does not render project-select component within board-new-item component', () => {
+ const projectSelect = findBoardNewItem().findComponent(ProjectSelect);
- await vm.$nextTick();
- await submitIssue();
- await vm.$nextTick();
- expect(vm.title).toBe('');
+ expect(projectSelect.exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/boards/components/board_new_item_spec.js b/spec/frontend/boards/components/board_new_item_spec.js
new file mode 100644
index 00000000000..0151d9c1c14
--- /dev/null
+++ b/spec/frontend/boards/components/board_new_item_spec.js
@@ -0,0 +1,103 @@
+import { GlForm, GlFormInput, GlButton } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+import BoardNewItem from '~/boards/components/board_new_item.vue';
+import eventHub from '~/boards/eventhub';
+
+import { mockList } from '../mock_data';
+
+const createComponent = ({
+ list = mockList,
+ formEventPrefix = 'toggle-issue-form-',
+ disabledSubmit = false,
+ submitButtonTitle = 'Create item',
+} = {}) =>
+ mountExtended(BoardNewItem, {
+ propsData: {
+ list,
+ formEventPrefix,
+ disabledSubmit,
+ submitButtonTitle,
+ },
+ slots: {
+ default: '<div id="default-slot"></div>',
+ },
+ stubs: {
+ GlForm,
+ },
+ });
+
+describe('BoardNewItem', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders gl-form component', () => {
+ expect(wrapper.findComponent(GlForm).exists()).toBe(true);
+ });
+
+ it('renders field label', () => {
+ expect(wrapper.find('label').exists()).toBe(true);
+ expect(wrapper.find('label').text()).toBe('Title');
+ });
+
+ it('renders gl-form-input field', () => {
+ expect(wrapper.findComponent(GlFormInput).exists()).toBe(true);
+ });
+
+ it('renders default slot contents', () => {
+ expect(wrapper.find('#default-slot').exists()).toBe(true);
+ });
+
+ it('renders submit and cancel buttons', () => {
+ const buttons = wrapper.findAllComponents(GlButton);
+ expect(buttons).toHaveLength(2);
+ expect(buttons.at(0).text()).toBe('Create item');
+ expect(buttons.at(1).text()).toBe('Cancel');
+ });
+
+ describe('events', () => {
+ const glForm = () => wrapper.findComponent(GlForm);
+ const titleInput = () => wrapper.find('input[name="issue_title"]');
+
+ it('emits `form-submit` event with title value when `submit` is triggered on gl-form', async () => {
+ titleInput().setValue('Foo');
+ await glForm().trigger('submit');
+
+ expect(wrapper.emitted('form-submit')).toBeTruthy();
+ expect(wrapper.emitted('form-submit')[0]).toEqual([
+ {
+ title: 'Foo',
+ list: mockList,
+ },
+ ]);
+ });
+
+ it('emits `scroll-board-list-` event with list.id on eventHub when `submit` is triggered on gl-form', async () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+ await glForm().trigger('submit');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(`scroll-board-list-${mockList.id}`);
+ });
+
+ it('emits `form-cancel` event and clears title value when `reset` is triggered on gl-form', async () => {
+ titleInput().setValue('Foo');
+
+ await wrapper.vm.$nextTick();
+ expect(titleInput().element.value).toBe('Foo');
+
+ await glForm().trigger('reset');
+
+ expect(titleInput().element.value).toBe('');
+ expect(wrapper.emitted('form-cancel')).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
index 0e3cf59901e..b6de46f8db8 100644
--- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
@@ -1,16 +1,16 @@
import { shallowMount } from '@vue/test-utils';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import IssueBoardFilteredSpec from '~/boards/components/issue_board_filtered_search.vue';
-import { BoardType } from '~/boards/constants';
import issueBoardFilters from '~/boards/issue_board_filters';
import { mockTokens } from '../mock_data';
+jest.mock('~/boards/issue_board_filters');
+
describe('IssueBoardFilter', () => {
let wrapper;
- const createComponent = ({ initialFilterParams = {} } = {}) => {
+ const createComponent = () => {
wrapper = shallowMount(IssueBoardFilteredSpec, {
- provide: { initialFilterParams },
props: { fullPath: '', boardType: '' },
});
};
@@ -20,7 +20,17 @@ describe('IssueBoardFilter', () => {
});
describe('default', () => {
+ let fetchAuthorsSpy;
+ let fetchLabelsSpy;
beforeEach(() => {
+ fetchAuthorsSpy = jest.fn();
+ fetchLabelsSpy = jest.fn();
+
+ issueBoardFilters.mockReturnValue({
+ fetchAuthors: fetchAuthorsSpy,
+ fetchLabels: fetchLabelsSpy,
+ });
+
createComponent();
});
@@ -28,17 +38,10 @@ describe('IssueBoardFilter', () => {
expect(wrapper.find(BoardFilteredSearch).exists()).toBe(true);
});
- it.each([[BoardType.group], [BoardType.project]])(
- 'when boardType is %s we pass the correct tokens to BoardFilteredSearch',
- (boardType) => {
- const { fetchAuthors, fetchLabels } = issueBoardFilters({}, '', boardType);
+ it('passes the correct tokens to BoardFilteredSearch', () => {
+ const tokens = mockTokens(fetchLabelsSpy, fetchAuthorsSpy, wrapper.vm.fetchMilestones);
- const tokens = mockTokens(fetchLabels, fetchAuthors);
-
- expect(wrapper.find(BoardFilteredSearch).props('tokens').toString()).toBe(
- tokens.toString(),
- );
- },
- );
+ expect(wrapper.find(BoardFilteredSearch).props('tokens')).toEqual(tokens);
+ });
});
});
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
index 8992a5780f3..60474767f2d 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
@@ -97,6 +97,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
addLabelIds: TEST_LABELS.map((label) => label.id),
projectPath: TEST_ISSUE_FULLPATH,
removeLabelIds: [],
+ iid: null,
});
});
});
@@ -121,6 +122,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
addLabelIds: [5, 7],
removeLabelIds: [6],
projectPath: TEST_ISSUE_FULLPATH,
+ iid: null,
});
});
});
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 6ac4db8cdaa..106f7b04c4b 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -1,5 +1,6 @@
/* global List */
+import { GlFilteredSearchToken } from '@gitlab/ui';
import { keyBy } from 'lodash';
import Vue from 'vue';
import '~/boards/models/list';
@@ -8,6 +9,8 @@ import boardsStore from '~/boards/stores/boards_store';
import { __ } from '~/locale';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
+import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
+import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
export const boardObj = {
id: 1,
@@ -101,6 +104,17 @@ export const mockMilestone = {
due_date: '2019-12-31',
};
+export const mockMilestones = [
+ {
+ id: 'gid://gitlab/Milestone/1',
+ title: 'Milestone 1',
+ },
+ {
+ id: 'gid://gitlab/Milestone/2',
+ title: 'Milestone 2',
+ },
+];
+
export const assignees = [
{
id: 'gid://gitlab/User/2',
@@ -531,7 +545,7 @@ export const mockMoveData = {
...mockMoveIssueParams,
};
-export const mockTokens = (fetchLabels, fetchAuthors) => [
+export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [
{
icon: 'labels',
title: __('Label'),
@@ -557,6 +571,7 @@ export const mockTokens = (fetchLabels, fetchAuthors) => [
token: AuthorToken,
unique: true,
fetchAuthors,
+ preloadedAuthors: [],
},
{
icon: 'user',
@@ -569,5 +584,35 @@ export const mockTokens = (fetchLabels, fetchAuthors) => [
token: AuthorToken,
unique: true,
fetchAuthors,
+ preloadedAuthors: [],
+ },
+ {
+ icon: 'issues',
+ title: __('Type'),
+ type: 'types',
+ operators: [{ value: '=', description: 'is' }],
+ token: GlFilteredSearchToken,
+ unique: true,
+ options: [
+ { icon: 'issue-type-issue', value: 'ISSUE', title: 'Issue' },
+ { icon: 'issue-type-incident', value: 'INCIDENT', title: 'Incident' },
+ ],
+ },
+ {
+ icon: 'clock',
+ title: __('Milestone'),
+ symbol: '%',
+ type: 'milestone_title',
+ token: MilestoneToken,
+ unique: true,
+ defaultMilestones: [],
+ fetchMilestones,
+ },
+ {
+ icon: 'weight',
+ title: __('Weight'),
+ type: 'weight',
+ token: WeightToken,
+ unique: true,
},
];
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 5e16e389ddc..1272a573d2f 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -1,4 +1,7 @@
import * as Sentry from '@sentry/browser';
+import { cloneDeep } from 'lodash';
+import Vue from 'vue';
+import Vuex from 'vuex';
import {
inactiveId,
ISSUABLE,
@@ -6,6 +9,7 @@ import {
issuableTypes,
BoardType,
listsQuery,
+ DraggableItemTypes,
} from 'ee_else_ce/boards/constants';
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import testAction from 'helpers/vuex_action_helper';
@@ -21,6 +25,7 @@ import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutati
import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
+import mutations from '~/boards/stores/mutations';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
@@ -37,6 +42,7 @@ import {
mockMoveState,
mockMoveData,
mockList,
+ mockMilestones,
} from '../mock_data';
jest.mock('~/flash');
@@ -45,6 +51,8 @@ jest.mock('~/flash');
// subgroups when the movIssue action is called.
const getProjectPath = (path) => path.split('#')[0];
+Vue.use(Vuex);
+
beforeEach(() => {
window.gon = { features: {} };
});
@@ -260,6 +268,87 @@ describe('fetchLists', () => {
);
});
+describe('fetchMilestones', () => {
+ const queryResponse = {
+ data: {
+ project: {
+ milestones: {
+ nodes: mockMilestones,
+ },
+ },
+ },
+ };
+
+ const queryErrors = {
+ data: {
+ project: {
+ errors: ['You cannot view these milestones'],
+ milestones: {},
+ },
+ },
+ };
+
+ function createStore({
+ state = {
+ boardType: 'project',
+ fullPath: 'gitlab-org/gitlab',
+ milestones: [],
+ milestonesLoading: false,
+ },
+ } = {}) {
+ return new Vuex.Store({
+ state,
+ mutations,
+ });
+ }
+
+ it('throws error if state.boardType is not group or project', () => {
+ const store = createStore({
+ state: {
+ boardType: 'invalid',
+ },
+ });
+
+ expect(() => actions.fetchMilestones(store)).toThrow(new Error('Unknown board type'));
+ });
+
+ it('sets milestonesLoading to true', async () => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
+
+ const store = createStore();
+
+ actions.fetchMilestones(store);
+
+ expect(store.state.milestonesLoading).toBe(true);
+ });
+
+ describe('success', () => {
+ it('sets state.milestones from query result', async () => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
+
+ const store = createStore();
+
+ await actions.fetchMilestones(store);
+
+ expect(store.state.milestonesLoading).toBe(false);
+ expect(store.state.milestones).toBe(mockMilestones);
+ });
+ });
+
+ describe('failure', () => {
+ it('sets state.milestones from query result', async () => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors);
+
+ const store = createStore();
+
+ await expect(actions.fetchMilestones(store)).rejects.toThrow();
+
+ expect(store.state.milestonesLoading).toBe(false);
+ expect(store.state.error).toBe('Failed to load milestones.');
+ });
+ });
+});
+
describe('createList', () => {
it('should dispatch createIssueList action', () => {
testAction({
@@ -419,75 +508,114 @@ describe('fetchLabels', () => {
});
describe('moveList', () => {
- it('should commit MOVE_LIST mutation and dispatch updateList action', (done) => {
- const initialBoardListsState = {
- 'gid://gitlab/List/1': mockLists[0],
- 'gid://gitlab/List/2': mockLists[1],
- };
+ const backlogListId = 'gid://1';
+ const closedListId = 'gid://5';
- const state = {
- fullPath: 'gitlab-org',
- fullBoardId: 'gid://gitlab/Board/1',
- boardType: 'group',
- disabled: false,
- boardLists: initialBoardListsState,
- };
+ const boardLists1 = {
+ 'gid://3': { listType: '', position: 0 },
+ 'gid://4': { listType: '', position: 1 },
+ 'gid://5': { listType: '', position: 2 },
+ };
- testAction(
- actions.moveList,
- {
- listId: 'gid://gitlab/List/1',
- replacedListId: 'gid://gitlab/List/2',
- newIndex: 1,
- adjustmentValue: 1,
- },
- state,
- [
- {
- type: types.MOVE_LIST,
- payload: { movedList: mockLists[0], listAtNewIndex: mockLists[1] },
- },
- ],
- [
- {
- type: 'updateList',
- payload: {
- listId: 'gid://gitlab/List/1',
- position: 0,
- backupList: initialBoardListsState,
- },
+ const boardLists2 = {
+ [backlogListId]: { listType: ListType.backlog, position: -Infinity },
+ [closedListId]: { listType: ListType.closed, position: Infinity },
+ ...cloneDeep(boardLists1),
+ };
+
+ const movableListsOrder = ['gid://3', 'gid://4', 'gid://5'];
+ const allListsOrder = [backlogListId, ...movableListsOrder, closedListId];
+
+ it(`should not handle the event if the dragged item is not a "${DraggableItemTypes.list}"`, () => {
+ return testAction({
+ action: actions.moveList,
+ payload: {
+ item: { dataset: { listId: '', draggableItemType: DraggableItemTypes.card } },
+ to: {
+ children: [],
},
- ],
- done,
- );
+ },
+ state: {},
+ expectedMutations: [],
+ expectedActions: [],
+ });
});
- it('should not commit MOVE_LIST or dispatch updateList if listId and replacedListId are the same', () => {
- const initialBoardListsState = {
- 'gid://gitlab/List/1': mockLists[0],
- 'gid://gitlab/List/2': mockLists[1],
- };
+ describe.each`
+ draggableFrom | draggableTo | boardLists | boardListsOrder | expectedMovableListsOrder
+ ${0} | ${2} | ${boardLists1} | ${movableListsOrder} | ${['gid://4', 'gid://5', 'gid://3']}
+ ${2} | ${0} | ${boardLists1} | ${movableListsOrder} | ${['gid://5', 'gid://3', 'gid://4']}
+ ${0} | ${1} | ${boardLists1} | ${movableListsOrder} | ${['gid://4', 'gid://3', 'gid://5']}
+ ${1} | ${2} | ${boardLists1} | ${movableListsOrder} | ${['gid://3', 'gid://5', 'gid://4']}
+ ${2} | ${1} | ${boardLists1} | ${movableListsOrder} | ${['gid://3', 'gid://5', 'gid://4']}
+ ${1} | ${3} | ${boardLists2} | ${allListsOrder} | ${['gid://4', 'gid://5', 'gid://3']}
+ ${3} | ${1} | ${boardLists2} | ${allListsOrder} | ${['gid://5', 'gid://3', 'gid://4']}
+ ${1} | ${2} | ${boardLists2} | ${allListsOrder} | ${['gid://4', 'gid://3', 'gid://5']}
+ ${2} | ${3} | ${boardLists2} | ${allListsOrder} | ${['gid://3', 'gid://5', 'gid://4']}
+ ${3} | ${2} | ${boardLists2} | ${allListsOrder} | ${['gid://3', 'gid://5', 'gid://4']}
+ `(
+ 'when moving a list from position $draggableFrom to $draggableTo with lists $boardListsOrder',
+ ({ draggableFrom, draggableTo, boardLists, boardListsOrder, expectedMovableListsOrder }) => {
+ const movedListId = boardListsOrder[draggableFrom];
+ const displacedListId = boardListsOrder[draggableTo];
+ const buildDraggablePayload = () => {
+ return {
+ item: {
+ dataset: {
+ listId: boardListsOrder[draggableFrom],
+ draggableItemType: DraggableItemTypes.list,
+ },
+ },
+ newIndex: draggableTo,
+ to: {
+ children: boardListsOrder.map((listId) => ({ dataset: { listId } })),
+ },
+ };
+ };
- const state = {
- fullPath: 'gitlab-org',
- fullBoardId: 'gid://gitlab/Board/1',
- boardType: 'group',
- disabled: false,
- boardLists: initialBoardListsState,
- };
+ it('should commit MOVE_LIST mutations and dispatch updateList action with correct payloads', () => {
+ return testAction({
+ action: actions.moveList,
+ payload: buildDraggablePayload(),
+ state: { boardLists },
+ expectedMutations: [
+ {
+ type: types.MOVE_LISTS,
+ payload: expectedMovableListsOrder.map((listId, i) => ({ listId, position: i })),
+ },
+ ],
+ expectedActions: [
+ {
+ type: 'updateList',
+ payload: {
+ listId: movedListId,
+ position: movableListsOrder.findIndex((i) => i === displacedListId),
+ },
+ },
+ ],
+ });
+ });
+ },
+ );
- testAction(
- actions.moveList,
- {
- listId: 'gid://gitlab/List/1',
- replacedListId: 'gid://gitlab/List/1',
- newIndex: 1,
- adjustmentValue: 1,
- },
- state,
- [],
- [],
- );
+ describe('when moving from and to the same position', () => {
+ it('should not commit MOVE_LIST and should not dispatch updateList', () => {
+ const listId = 'gid://1000';
+
+ return testAction({
+ action: actions.moveList,
+ payload: {
+ item: { dataset: { listId, draggbaleItemType: DraggableItemTypes.list } },
+ newIndex: 0,
+ to: {
+ children: [{ dataset: { listId } }],
+ },
+ },
+ state: { boardLists: { [listId]: { position: 0 } } },
+ expectedMutations: [],
+ expectedActions: [],
+ });
+ });
});
});
@@ -549,7 +677,7 @@ describe('updateList', () => {
});
});
- it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', (done) => {
+ it('should dispatch handleUpdateListFailure when API returns an error', () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
updateBoardList: {
@@ -559,17 +687,31 @@ describe('updateList', () => {
},
});
- testAction(
+ return testAction(
actions.updateList,
{ listId: 'gid://gitlab/List/1', position: 1 },
createState(),
- [{ type: types.UPDATE_LIST_FAILURE }],
[],
- done,
+ [{ type: 'handleUpdateListFailure' }],
);
});
});
+describe('handleUpdateListFailure', () => {
+ it('should dispatch fetchLists action and commit SET_ERROR mutation', async () => {
+ await testAction({
+ action: actions.handleUpdateListFailure,
+ expectedMutations: [
+ {
+ type: types.SET_ERROR,
+ payload: 'An error occurred while updating the board list. Please try again.',
+ },
+ ],
+ expectedActions: [{ type: 'fetchLists' }],
+ });
+ });
+});
+
describe('toggleListCollapsed', () => {
it('should commit TOGGLE_LIST_COLLAPSED mutation', async () => {
const payload = { listId: 'gid://gitlab/List/1', collapsed: true };
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index 37f0969a39a..a2ba1e9eb5e 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -165,40 +165,26 @@ describe('Board Store Mutations', () => {
});
});
- describe('MOVE_LIST', () => {
- it('updates boardLists state with reordered lists', () => {
+ describe('MOVE_LISTS', () => {
+ it('updates the positions of board lists', () => {
state = {
...state,
boardLists: initialBoardListsState,
};
- mutations.MOVE_LIST(state, {
- movedList: mockLists[0],
- listAtNewIndex: mockLists[1],
- });
-
- expect(state.boardLists).toEqual({
- 'gid://gitlab/List/2': mockLists[1],
- 'gid://gitlab/List/1': mockLists[0],
- });
- });
- });
-
- describe('UPDATE_LIST_FAILURE', () => {
- it('updates boardLists state with previous order and sets error message', () => {
- state = {
- ...state,
- boardLists: {
- 'gid://gitlab/List/2': mockLists[1],
- 'gid://gitlab/List/1': mockLists[0],
+ mutations.MOVE_LISTS(state, [
+ {
+ listId: mockLists[0].id,
+ position: 1,
},
- error: undefined,
- };
-
- mutations.UPDATE_LIST_FAILURE(state, initialBoardListsState);
+ {
+ listId: mockLists[1].id,
+ position: 0,
+ },
+ ]);
- expect(state.boardLists).toEqual(initialBoardListsState);
- expect(state.error).toEqual('An error occurred while updating the list. Please try again.');
+ expect(state.boardLists[mockLists[0].id].position).toBe(1);
+ expect(state.boardLists[mockLists[1].id].position).toBe(0);
});
});
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 eb18147fcef..5c7404c1175 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
@@ -123,6 +123,29 @@ describe('Ci variable modal', () => {
});
});
+ describe.each`
+ value | secret | rendered
+ ${'value'} | ${'secret_value'} | ${false}
+ ${'dollar$ign'} | ${'dollar$ign'} | ${true}
+ `('Adding a new variable', ({ value, secret, rendered }) => {
+ beforeEach(() => {
+ const [variable] = mockData.mockVariables;
+ const invalidKeyVariable = {
+ ...variable,
+ key: 'key',
+ value,
+ secret_value: secret,
+ };
+ createComponent(mount);
+ store.state.variable = invalidKeyVariable;
+ });
+
+ it(`${rendered ? 'renders' : 'does not render'} the variable reference warning`, () => {
+ const warning = wrapper.find(`[data-testid='contains-variable-reference']`);
+ expect(warning.exists()).toBe(rendered);
+ });
+ });
+
describe('Editing a variable', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js
index 42990334f0a..2a0610b1b0a 100644
--- a/spec/frontend/clusters/clusters_bundle_spec.js
+++ b/spec/frontend/clusters/clusters_bundle_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import { loadHTMLFixture } from 'helpers/fixtures';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { setTestTimeout } from 'helpers/timeout';
import Clusters from '~/clusters/clusters_bundle';
import axios from '~/lib/utils/axios_utils';
@@ -8,6 +9,8 @@ import initProjectSelectDropdown from '~/project_select';
jest.mock('~/lib/utils/poll');
jest.mock('~/project_select');
+useMockLocationHelper();
+
describe('Clusters', () => {
setTestTimeout(1000);
@@ -55,20 +58,6 @@ describe('Clusters', () => {
});
describe('updateContainer', () => {
- const { location } = window;
-
- beforeEach(() => {
- delete window.location;
- window.location = {
- reload: jest.fn(),
- hash: location.hash,
- };
- });
-
- afterEach(() => {
- window.location = location;
- });
-
describe('when creating cluster', () => {
it('should show the creating container', () => {
cluster.updateContainer(null, 'creating');
diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
new file mode 100644
index 00000000000..1a2e188e7ae
--- /dev/null
+++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
@@ -0,0 +1,50 @@
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue';
+import { mockStages } from './mock_data';
+
+describe('Commit box pipeline mini graph', () => {
+ let wrapper;
+
+ const findMiniGraph = () => wrapper.findByTestId('commit-box-mini-graph');
+ const findUpstream = () => wrapper.findByTestId('commit-box-mini-graph-upstream');
+ const findDownstream = () => wrapper.findByTestId('commit-box-mini-graph-downstream');
+
+ const createComponent = () => {
+ wrapper = extendedWrapper(
+ shallowMount(CommitBoxPipelineMiniGraph, {
+ propsData: {
+ stages: mockStages,
+ },
+ mocks: {
+ $apollo: {
+ queries: {
+ pipeline: {
+ loading: false,
+ },
+ },
+ },
+ },
+ }),
+ );
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('linked pipelines', () => {
+ it('should display the mini pipeine graph', () => {
+ expect(findMiniGraph().exists()).toBe(true);
+ });
+
+ it('should not display linked pipelines', () => {
+ expect(findUpstream().exists()).toBe(false);
+ expect(findDownstream().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js
new file mode 100644
index 00000000000..ef018a4fbd7
--- /dev/null
+++ b/spec/frontend/commit/mock_data.js
@@ -0,0 +1,117 @@
+export const mockStages = [
+ {
+ name: 'build',
+ title: 'build: passed',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/root/ci-project/-/pipelines/611#build',
+ illustration: null,
+ favicon:
+ '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
+ },
+ path: '/root/ci-project/-/pipelines/611#build',
+ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=build',
+ },
+ {
+ name: 'test',
+ title: 'test: passed',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/root/ci-project/-/pipelines/611#test',
+ illustration: null,
+ favicon:
+ '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
+ },
+ path: '/root/ci-project/-/pipelines/611#test',
+ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=test',
+ },
+ {
+ name: 'test_two',
+ title: 'test_two: passed',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/root/ci-project/-/pipelines/611#test_two',
+ illustration: null,
+ favicon:
+ '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
+ },
+ path: '/root/ci-project/-/pipelines/611#test_two',
+ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=test_two',
+ },
+ {
+ name: 'manual',
+ title: 'manual: skipped',
+ status: {
+ icon: 'status_skipped',
+ text: 'skipped',
+ label: 'skipped',
+ group: 'skipped',
+ tooltip: 'skipped',
+ has_details: true,
+ details_path: '/root/ci-project/-/pipelines/611#manual',
+ illustration: null,
+ favicon:
+ '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
+ action: {
+ icon: 'play',
+ title: 'Play all manual',
+ path: '/root/ci-project/-/pipelines/611/stages/manual/play_manual',
+ method: 'post',
+ button_title: 'Play all manual',
+ },
+ },
+ path: '/root/ci-project/-/pipelines/611#manual',
+ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=manual',
+ },
+ {
+ name: 'deploy',
+ title: 'deploy: passed',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/root/ci-project/-/pipelines/611#deploy',
+ illustration: null,
+ favicon:
+ '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
+ },
+ path: '/root/ci-project/-/pipelines/611#deploy',
+ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=deploy',
+ },
+ {
+ name: 'qa',
+ title: 'qa: passed',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/root/ci-project/-/pipelines/611#qa',
+ illustration: null,
+ favicon:
+ '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
+ },
+ path: '/root/ci-project/-/pipelines/611#qa',
+ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=qa',
+ },
+];
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
index 35c02911e27..e508cddd6f9 100644
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
+++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`content_editor/components/toolbar_button displays tertiary, small button with a provided label and icon 1`] = `
-"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-mx-2 gl-button btn-default-tertiary btn-icon\\">
+"<b-button-stub size=\\"sm\\" variant=\\"default\\" type=\\"button\\" tag=\\"button\\" aria-label=\\"Bold\\" title=\\"Bold\\" class=\\"gl-button btn-default-tertiary btn-icon\\">
<!---->
<gl-icon-stub name=\\"bold\\" size=\\"16\\" class=\\"gl-button-icon\\"></gl-icon-stub>
<!---->
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
index e56c37b0dc9..3c88c05a4b4 100644
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
+++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
@@ -26,8 +26,21 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen
</div>
</form>
</li>
- <!---->
- <!---->
+ <li role=\\"presentation\\" class=\\"gl-new-dropdown-divider\\">
+ <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\">
+ </li>
+ <li role=\\"presentation\\" class=\\"gl-new-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\">
+ <!---->
+ <!---->
+ <!---->
+ <div class=\\"gl-new-dropdown-item-text-wrapper\\">
+ <p class=\\"gl-new-dropdown-item-text-primary\\">
+ Upload file
+ </p>
+ <!---->
+ </div>
+ <!---->
+ </button></li> <input type=\\"file\\" name=\\"content_editor_attachment\\" class=\\"gl-display-none\\">
</div>
<!---->
</div>
diff --git a/spec/frontend/content_editor/components/content_editor_error_spec.js b/spec/frontend/content_editor/components/content_editor_error_spec.js
new file mode 100644
index 00000000000..8723fb5a338
--- /dev/null
+++ b/spec/frontend/content_editor/components/content_editor_error_spec.js
@@ -0,0 +1,54 @@
+import { GlAlert } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ContentEditorError from '~/content_editor/components/content_editor_error.vue';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
+import { createTestEditor, emitEditorEvent } from '../test_utils';
+
+describe('content_editor/components/content_editor_error', () => {
+ let wrapper;
+ let tiptapEditor;
+
+ const findErrorAlert = () => wrapper.findComponent(GlAlert);
+
+ const createWrapper = async () => {
+ tiptapEditor = createTestEditor();
+
+ wrapper = shallowMountExtended(ContentEditorError, {
+ provide: {
+ tiptapEditor,
+ },
+ stubs: {
+ EditorStateObserver,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders error when content editor emits an error event', async () => {
+ const error = 'error message';
+
+ createWrapper();
+
+ await emitEditorEvent({ tiptapEditor, event: 'error', params: { error } });
+
+ expect(findErrorAlert().text()).toBe(error);
+ });
+
+ it('allows dismissing the error', async () => {
+ const error = 'error message';
+
+ createWrapper();
+
+ await emitEditorEvent({ tiptapEditor, event: 'error', params: { error } });
+
+ findErrorAlert().vm.$emit('dismiss');
+
+ await nextTick();
+
+ expect(findErrorAlert().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 563e80e04c1..d516baf6f0f 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -1,91 +1,175 @@
-import { GlAlert } from '@gitlab/ui';
+import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent } from '@tiptap/vue-2';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditor from '~/content_editor/components/content_editor.vue';
+import ContentEditorError from '~/content_editor/components/content_editor_error.vue';
+import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
-import { createContentEditor } from '~/content_editor/services/create_content_editor';
+import {
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+} from '~/content_editor/constants';
+import { emitEditorEvent } from '../test_utils';
+
+jest.mock('~/emoji');
describe('ContentEditor', () => {
let wrapper;
- let editor;
+ let contentEditor;
+ let renderMarkdown;
+ const uploadsPath = '/uploads';
const findEditorElement = () => wrapper.findByTestId('content-editor');
- const findErrorAlert = () => wrapper.findComponent(GlAlert);
+ const findEditorContent = () => wrapper.findComponent(EditorContent);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const createWrapper = (propsData = {}) => {
+ renderMarkdown = jest.fn();
- const createWrapper = async (contentEditor) => {
wrapper = shallowMountExtended(ContentEditor, {
propsData: {
- contentEditor,
+ renderMarkdown,
+ uploadsPath,
+ ...propsData,
+ },
+ stubs: {
+ EditorStateObserver,
+ ContentEditorProvider,
+ },
+ listeners: {
+ initialized(editor) {
+ contentEditor = editor;
+ },
},
});
};
- beforeEach(() => {
- editor = createContentEditor({ renderMarkdown: () => true });
- });
-
afterEach(() => {
wrapper.destroy();
});
- it('renders editor content component and attaches editor instance', () => {
- createWrapper(editor);
+ it('triggers initialized event and provides contentEditor instance as event data', () => {
+ createWrapper();
- const editorContent = wrapper.findComponent(EditorContent);
+ expect(contentEditor).not.toBeFalsy();
+ });
+
+ it('renders EditorContent component and provides tiptapEditor instance', () => {
+ createWrapper();
+
+ const editorContent = findEditorContent();
- expect(editorContent.props().editor).toBe(editor.tiptapEditor);
+ expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor);
expect(editorContent.classes()).toContain('md');
});
- it('renders top toolbar component and attaches editor instance', () => {
- createWrapper(editor);
+ it('renders ContentEditorProvider component', () => {
+ createWrapper();
- expect(wrapper.findComponent(TopToolbar).props().contentEditor).toBe(editor);
+ expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true);
});
- it.each`
- isFocused | classes
- ${true} | ${['md-area', 'is-focused']}
- ${false} | ${['md-area']}
- `(
- 'has $classes class selectors when tiptapEditor.isFocused = $isFocused',
- ({ isFocused, classes }) => {
- editor.tiptapEditor.isFocused = isFocused;
- createWrapper(editor);
+ it('renders top toolbar component', () => {
+ createWrapper();
+
+ expect(wrapper.findComponent(TopToolbar).exists()).toBe(true);
+ });
- expect(findEditorElement().classes()).toStrictEqual(classes);
- },
- );
+ it('adds is-focused class when focus event is emitted', async () => {
+ createWrapper();
- it('adds isFocused class when tiptapEditor is focused', () => {
- editor.tiptapEditor.isFocused = true;
- createWrapper(editor);
+ await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
expect(findEditorElement().classes()).toContain('is-focused');
});
- describe('displaying error', () => {
- const error = 'Content Editor error';
+ it('removes is-focused class when blur event is emitted', async () => {
+ createWrapper();
+
+ await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
+ await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' });
+
+ expect(findEditorElement().classes()).not.toContain('is-focused');
+ });
+
+ it('emits change event when document is updated', async () => {
+ createWrapper();
+
+ await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' });
+
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ empty: contentEditor.empty,
+ },
+ ],
+ ]);
+ });
+
+ it('renders content_editor_error component', () => {
+ createWrapper();
+
+ expect(wrapper.findComponent(ContentEditorError).exists()).toBe(true);
+ });
+ describe('when loading content', () => {
beforeEach(async () => {
- createWrapper(editor);
+ createWrapper();
- editor.tiptapEditor.emit('error', error);
+ contentEditor.emit(LOADING_CONTENT_EVENT);
await nextTick();
});
- it('displays error notifications from the tiptap editor', () => {
- expect(findErrorAlert().text()).toBe(error);
+ it('displays loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
});
- it('allows dismissing an error alert', async () => {
- findErrorAlert().vm.$emit('dismiss');
+ it('hides EditorContent component', () => {
+ expect(findEditorContent().exists()).toBe(false);
+ });
+ });
+
+ describe('when loading content succeeds', () => {
+ beforeEach(async () => {
+ createWrapper();
+
+ contentEditor.emit(LOADING_CONTENT_EVENT);
+ await nextTick();
+ contentEditor.emit(LOADING_SUCCESS_EVENT);
+ await nextTick();
+ });
+
+ it('hides loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ it('displays EditorContent component', () => {
+ expect(findEditorContent().exists()).toBe(true);
+ });
+ });
+
+ describe('when loading content fails', () => {
+ const error = 'error';
+
+ beforeEach(async () => {
+ createWrapper();
+
+ contentEditor.emit(LOADING_CONTENT_EVENT);
+ await nextTick();
+ contentEditor.emit(LOADING_ERROR_EVENT, error);
await nextTick();
+ });
+
+ it('hides loading indicator', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
- expect(findErrorAlert().exists()).toBe(false);
+ it('displays EditorContent component', () => {
+ expect(findEditorContent().exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/content_editor/components/editor_state_observer_spec.js b/spec/frontend/content_editor/components/editor_state_observer_spec.js
new file mode 100644
index 00000000000..5e4bb348e1f
--- /dev/null
+++ b/spec/frontend/content_editor/components/editor_state_observer_spec.js
@@ -0,0 +1,75 @@
+import { shallowMount } from '@vue/test-utils';
+import { each } from 'lodash';
+import EditorStateObserver, {
+ tiptapToComponentMap,
+} from '~/content_editor/components/editor_state_observer.vue';
+import { createTestEditor } from '../test_utils';
+
+describe('content_editor/components/editor_state_observer', () => {
+ let tiptapEditor;
+ let wrapper;
+ let onDocUpdateListener;
+ let onSelectionUpdateListener;
+ let onTransactionListener;
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor();
+ jest.spyOn(tiptapEditor, 'on');
+ };
+
+ const buildWrapper = () => {
+ wrapper = shallowMount(EditorStateObserver, {
+ provide: { tiptapEditor },
+ listeners: {
+ docUpdate: onDocUpdateListener,
+ selectionUpdate: onSelectionUpdateListener,
+ transaction: onTransactionListener,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ onDocUpdateListener = jest.fn();
+ onSelectionUpdateListener = jest.fn();
+ onTransactionListener = jest.fn();
+ buildEditor();
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when editor content changes', () => {
+ it('emits update, selectionUpdate, and transaction events', () => {
+ const content = '<p>My paragraph</p>';
+
+ tiptapEditor.commands.insertContent(content);
+
+ expect(onDocUpdateListener).toHaveBeenCalledWith(
+ expect.objectContaining({ editor: tiptapEditor }),
+ );
+ expect(onSelectionUpdateListener).toHaveBeenCalledWith(
+ expect.objectContaining({ editor: tiptapEditor }),
+ );
+ expect(onSelectionUpdateListener).toHaveBeenCalledWith(
+ expect.objectContaining({ editor: tiptapEditor }),
+ );
+ });
+ });
+
+ describe('when component is destroyed', () => {
+ it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
+ jest.spyOn(tiptapEditor, 'off');
+
+ wrapper.destroy();
+
+ each(tiptapToComponentMap, (_, tiptapEvent) => {
+ expect(tiptapEditor.off).toHaveBeenCalledWith(
+ tiptapEvent,
+ tiptapEditor.on.mock.calls.find(([eventName]) => eventName === tiptapEvent)[1],
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
new file mode 100644
index 00000000000..e44a7fa4ddb
--- /dev/null
+++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
@@ -0,0 +1,80 @@
+import { BubbleMenu } from '@tiptap/vue-2';
+import { mockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue';
+
+import {
+ BUBBLE_MENU_TRACKING_ACTION,
+ CONTENT_EDITOR_TRACKING_LABEL,
+} from '~/content_editor/constants';
+import { createTestEditor } from '../test_utils';
+
+describe('content_editor/components/top_toolbar', () => {
+ let wrapper;
+ let trackingSpy;
+ let tiptapEditor;
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor();
+
+ jest.spyOn(tiptapEditor, 'isActive');
+ };
+
+ const buildWrapper = () => {
+ wrapper = shallowMountExtended(FormattingBubbleMenu, {
+ provide: {
+ tiptapEditor,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, null, jest.spyOn);
+ buildEditor();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders bubble menu component', () => {
+ buildWrapper();
+ const bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ expect(bubbleMenu.props().editor).toBe(tiptapEditor);
+ expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']);
+ });
+
+ describe.each`
+ testId | controlProps
+ ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'primary' }}
+ ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'primary' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'primary' }}
+ ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'primary' }}
+ `('given a $testId toolbar control', ({ testId, controlProps }) => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders the toolbar control with the provided properties', () => {
+ expect(wrapper.findByTestId(testId).exists()).toBe(true);
+
+ Object.keys(controlProps).forEach((propName) => {
+ expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]);
+ });
+ });
+
+ it('tracks the execution of toolbar controls', () => {
+ const eventData = { contentType: 'italic', value: 1 };
+ const { contentType, value } = eventData;
+
+ wrapper.findByTestId(testId).vm.$emit('execute', eventData);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, BUBBLE_MENU_TRACKING_ACTION, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property: contentType,
+ value,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js
index d848adcbff8..60263c46bdd 100644
--- a/spec/frontend/content_editor/components/toolbar_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_button_spec.js
@@ -1,7 +1,8 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
-import { createTestEditor, mockChainedCommands } from '../test_utils';
+import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_button', () => {
let wrapper;
@@ -20,9 +21,12 @@ describe('content_editor/components/toolbar_button', () => {
wrapper = shallowMount(ToolbarButton, {
stubs: {
GlButton,
+ EditorStateObserver,
},
- propsData: {
+ provide: {
tiptapEditor,
+ },
+ propsData: {
contentType: CONTENT_TYPE,
iconName: ICON_NAME,
label: LABEL,
@@ -46,19 +50,43 @@ describe('content_editor/components/toolbar_button', () => {
expect(findButton().html()).toMatchSnapshot();
});
+ it('allows customizing the variant, category, size of the button', () => {
+ const variant = 'danger';
+ const category = 'secondary';
+ const size = 'medium';
+
+ buildWrapper({
+ variant,
+ category,
+ size,
+ });
+
+ expect(findButton().props()).toMatchObject({
+ variant,
+ category,
+ size,
+ });
+ });
+
it.each`
editorState | outcomeDescription | outcome
${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true}
${{ isActive: false, isFocused: true }} | ${'button is not active'} | ${false}
${{ isActive: true, isFocused: false }} | ${'button is not active '} | ${false}
- `('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => {
- tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive);
- tiptapEditor.isFocused = editorState.isFocused;
- buildWrapper();
+ `(
+ '$outcomeDescription when when editor state is $editorState',
+ async ({ editorState, outcome }) => {
+ tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive);
+ tiptapEditor.isFocused = editorState.isFocused;
- expect(findButton().classes().includes('active')).toBe(outcome);
- expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
- });
+ buildWrapper();
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(findButton().classes().includes('active')).toBe(outcome);
+ expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
+ },
+ );
describe('when button is clicked', () => {
it('executes the content type command when executeCommand = true', async () => {
diff --git a/spec/frontend/content_editor/components/toolbar_image_button_spec.js b/spec/frontend/content_editor/components/toolbar_image_button_spec.js
index 701dcf83476..dab7e67d7c5 100644
--- a/spec/frontend/content_editor/components/toolbar_image_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_image_button_spec.js
@@ -1,7 +1,8 @@
import { GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue';
-import { configure as configureImageExtension } from '~/content_editor/extensions/image';
+import Attachment from '~/content_editor/extensions/attachment';
+import Image from '~/content_editor/extensions/image';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_image_button', () => {
@@ -10,7 +11,7 @@ describe('content_editor/components/toolbar_image_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarImageButton, {
- propsData: {
+ provide: {
tiptapEditor: editor,
},
});
@@ -29,13 +30,14 @@ describe('content_editor/components/toolbar_image_button', () => {
};
beforeEach(() => {
- const { tiptapExtension: Image } = configureImageExtension({
- renderMarkdown: jest.fn(),
- uploadsPath: '/uploads/',
- });
-
editor = createTestEditor({
- extensions: [Image],
+ extensions: [
+ Image,
+ Attachment.configure({
+ renderMarkdown: jest.fn(),
+ uploadsPath: '/uploads/',
+ }),
+ ],
});
buildWrapper();
@@ -64,13 +66,13 @@ describe('content_editor/components/toolbar_image_button', () => {
});
it('uploads the selected image when file input changes', async () => {
- const commands = mockChainedCommands(editor, ['focus', 'uploadImage', 'run']);
+ const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
const file = new File(['foo'], 'foo.png', { type: 'image/png' });
await selectFile(file);
expect(commands.focus).toHaveBeenCalled();
- expect(commands.uploadImage).toHaveBeenCalledWith({ file });
+ expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]);
diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js
index 576a2912f72..0cf488260bd 100644
--- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js
@@ -1,9 +1,9 @@
-import { GlDropdown, GlDropdownDivider, GlButton, GlFormInputGroup } from '@gitlab/ui';
+import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
-import { tiptapExtension as Link } from '~/content_editor/extensions/link';
+import Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
-import { createTestEditor, mockChainedCommands } from '../test_utils';
+import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
jest.mock('~/content_editor/services/utils');
@@ -13,21 +13,26 @@ describe('content_editor/components/toolbar_link_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarLinkButton, {
- propsData: {
+ provide: {
tiptapEditor: editor,
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]');
const findApplyLinkButton = () => wrapper.findComponent(GlButton);
const findRemoveLinkButton = () => wrapper.findByText('Remove link');
+ const selectFile = async (file) => {
+ const input = wrapper.find({ ref: 'fileSelector' });
+
+ // override the property definition because `input.files` isn't directly modifyable
+ Object.defineProperty(input.element, 'files', { value: [file], writable: true });
+ await input.trigger('change');
+ };
+
beforeEach(() => {
- editor = createTestEditor({
- extensions: [Link],
- });
+ editor = createTestEditor();
});
afterEach(() => {
@@ -45,14 +50,19 @@ describe('content_editor/components/toolbar_link_button', () => {
beforeEach(async () => {
jest.spyOn(editor, 'isActive').mockReturnValueOnce(true);
buildWrapper();
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
});
it('sets dropdown as active when link extension is active', () => {
expect(findDropdown().props('toggleClass')).toEqual({ active: true });
});
+ it('does not display the upload file option', () => {
+ expect(wrapper.findByText('Upload file').exists()).toBe(false);
+ });
+
it('displays a remove link dropdown option', () => {
- expect(findDropdownDivider().exists()).toBe(true);
expect(wrapper.findByText('Remove link').exists()).toBe(true);
});
@@ -90,7 +100,7 @@ describe('content_editor/components/toolbar_link_button', () => {
href: '/username/my-project/uploads/abcdefgh133535/my-file.zip',
});
- await editor.emit('selectionUpdate', { editor });
+ await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip');
});
@@ -100,14 +110,14 @@ describe('content_editor/components/toolbar_link_button', () => {
href: 'https://gitlab.com',
});
- await editor.emit('selectionUpdate', { editor });
+ await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
expect(findLinkURLInput().element.value).toEqual('https://gitlab.com');
});
});
});
- describe('when there is not an active link', () => {
+ describe('when there is no active link', () => {
beforeEach(() => {
jest.spyOn(editor, 'isActive');
editor.isActive.mockReturnValueOnce(false);
@@ -118,8 +128,11 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(findDropdown().props('toggleClass')).toEqual({ active: false });
});
+ it('displays the upload file option', () => {
+ expect(wrapper.findByText('Upload file').exists()).toBe(true);
+ });
+
it('does not display a remove link dropdown option', () => {
- expect(findDropdownDivider().exists()).toBe(false);
expect(wrapper.findByText('Remove link').exists()).toBe(false);
});
@@ -138,6 +151,19 @@ describe('content_editor/components/toolbar_link_button', () => {
expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
});
+
+ it('uploads the selected image when file input changes', async () => {
+ const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']);
+ const file = new File(['foo'], 'foo.png', { type: 'image/png' });
+
+ await selectFile(file);
+
+ expect(commands.focus).toHaveBeenCalled();
+ expect(commands.uploadAttachment).toHaveBeenCalledWith({ file });
+ expect(commands.run).toHaveBeenCalled();
+
+ expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]);
+ });
});
describe('when the user displays the dropdown', () => {
diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
index 237b2848246..056e5e04e1f 100644
--- a/spec/frontend/content_editor/components/toolbar_table_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
@@ -1,10 +1,6 @@
import { GlDropdown, GlButton } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue';
-import { tiptapExtension as Table } from '~/content_editor/extensions/table';
-import { tiptapExtension as TableCell } from '~/content_editor/extensions/table_cell';
-import { tiptapExtension as TableHeader } from '~/content_editor/extensions/table_header';
-import { tiptapExtension as TableRow } from '~/content_editor/extensions/table_row';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_table_button', () => {
@@ -13,7 +9,7 @@ describe('content_editor/components/toolbar_table_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarTableButton, {
- propsData: {
+ provide: {
tiptapEditor: editor,
},
});
@@ -23,9 +19,7 @@ describe('content_editor/components/toolbar_table_button', () => {
const getNumButtons = () => findDropdown().findAllComponents(GlButton).length;
beforeEach(() => {
- editor = createTestEditor({
- extensions: [Table, TableCell, TableRow, TableHeader],
- });
+ editor = createTestEditor();
buildWrapper();
});
@@ -35,17 +29,17 @@ describe('content_editor/components/toolbar_table_button', () => {
wrapper.destroy();
});
- it('renders a grid of 3x3 buttons to create a table', () => {
- expect(getNumButtons()).toBe(9); // 3 x 3
+ it('renders a grid of 5x5 buttons to create a table', () => {
+ expect(getNumButtons()).toBe(25); // 5x5
});
describe.each`
row | col | numButtons | tableSize
- ${1} | ${2} | ${9} | ${'1x2'}
- ${2} | ${2} | ${9} | ${'2x2'}
- ${2} | ${3} | ${12} | ${'2x3'}
- ${3} | ${2} | ${12} | ${'3x2'}
- ${3} | ${3} | ${16} | ${'3x3'}
+ ${3} | ${4} | ${25} | ${'3x4'}
+ ${4} | ${4} | ${25} | ${'4x4'}
+ ${4} | ${5} | ${30} | ${'4x5'}
+ ${5} | ${4} | ${30} | ${'5x4'}
+ ${5} | ${5} | ${36} | ${'5x5'}
`('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => {
describe('on mouse over', () => {
beforeEach(async () => {
@@ -56,9 +50,7 @@ describe('content_editor/components/toolbar_table_button', () => {
it('marks all rows and cols before it as active', () => {
const prevRow = Math.max(1, row - 1);
const prevCol = Math.max(1, col - 1);
- expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass(
- 'gl-bg-blue-50!',
- );
+ expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass('active');
});
it('shows a help text indicating the size of the table being inserted', () => {
@@ -95,8 +87,8 @@ describe('content_editor/components/toolbar_table_button', () => {
});
});
- it('does not create more buttons than a 8x8 grid', async () => {
- for (let i = 3; i < 8; i += 1) {
+ it('does not create more buttons than a 10x10 grid', async () => {
+ for (let i = 5; i < 10; i += 1) {
expect(getNumButtons()).toBe(i * i);
// eslint-disable-next-line no-await-in-loop
@@ -104,6 +96,6 @@ describe('content_editor/components/toolbar_table_button', () => {
expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`);
}
- expect(getNumButtons()).toBe(64); // 8x8 (and not 9x9)
+ expect(getNumButtons()).toBe(100); // 10x10 (and not 11x11)
});
});
diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
index 9a46e27404f..65c1c8c8310 100644
--- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js
@@ -1,11 +1,12 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
-import { tiptapExtension as Heading } from '~/content_editor/extensions/heading';
-import { createTestEditor, mockChainedCommands } from '../test_utils';
+import Heading from '~/content_editor/extensions/heading';
+import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
-describe('content_editor/components/toolbar_headings_dropdown', () => {
+describe('content_editor/components/toolbar_text_style_dropdown', () => {
let wrapper;
let tiptapEditor;
@@ -22,9 +23,12 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
stubs: {
GlDropdown,
GlDropdownItem,
+ EditorStateObserver,
},
- propsData: {
+ provide: {
tiptapEditor,
+ },
+ propsData: {
...propsData,
},
});
@@ -50,7 +54,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
describe('when there is an active item ', () => {
let activeTextStyle;
- beforeEach(() => {
+ beforeEach(async () => {
[, activeTextStyle] = TEXT_STYLE_DROPDOWN_ITEMS;
tiptapEditor.isActive.mockImplementation(
@@ -59,6 +63,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
);
buildWrapper();
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
it('displays the active text style label as the dropdown toggle text ', () => {
@@ -79,9 +84,10 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
});
describe('when there isn’t an active item', () => {
- beforeEach(() => {
+ beforeEach(async () => {
tiptapEditor.isActive.mockReturnValue(false);
buildWrapper();
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
it('sets dropdown as disabled', () => {
diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js
index 5411793cd5e..a5df3d73289 100644
--- a/spec/frontend/content_editor/components/top_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/top_toolbar_spec.js
@@ -1,39 +1,23 @@
-import { shallowMount } from '@vue/test-utils';
import { mockTracking } from 'helpers/tracking_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import {
TOOLBAR_CONTROL_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
-import { createContentEditor } from '~/content_editor/services/create_content_editor';
describe('content_editor/components/top_toolbar', () => {
let wrapper;
- let contentEditor;
let trackingSpy;
- const buildEditor = () => {
- contentEditor = createContentEditor({ renderMarkdown: () => true });
- };
const buildWrapper = () => {
- wrapper = extendedWrapper(
- shallowMount(TopToolbar, {
- propsData: {
- contentEditor,
- },
- }),
- );
+ wrapper = shallowMountExtended(TopToolbar);
};
beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
});
- beforeEach(() => {
- buildEditor();
- });
-
afterEach(() => {
wrapper.destroy();
});
@@ -58,18 +42,17 @@ describe('content_editor/components/top_toolbar', () => {
});
it('renders the toolbar control with the provided properties', () => {
- expect(wrapper.findByTestId(testId).props()).toEqual({
- ...controlProps,
- tiptapEditor: contentEditor.tiptapEditor,
+ expect(wrapper.findByTestId(testId).exists()).toBe(true);
+
+ Object.keys(controlProps).forEach((propName) => {
+ expect(wrapper.findByTestId(testId).props(propName)).toBe(controlProps[propName]);
});
});
- it.each`
- eventData
- ${{ contentType: 'bold' }}
- ${{ contentType: 'blockquote', value: 1 }}
- `('tracks the execution of toolbar controls', ({ eventData }) => {
+ it('tracks the execution of toolbar controls', () => {
+ const eventData = { contentType: 'blockquote', value: 1 };
const { contentType, value } = eventData;
+
wrapper.findByTestId(testId).vm.$emit('execute', eventData);
expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, {
diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js
new file mode 100644
index 00000000000..1334b1ddaad
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/attachment_spec.js
@@ -0,0 +1,235 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { once } from 'lodash';
+import waitForPromises from 'helpers/wait_for_promises';
+import Attachment from '~/content_editor/extensions/attachment';
+import Image from '~/content_editor/extensions/image';
+import Link from '~/content_editor/extensions/link';
+import Loading from '~/content_editor/extensions/loading';
+import httpStatus from '~/lib/utils/http_status';
+import { loadMarkdownApiResult } from '../markdown_processing_examples';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+describe('content_editor/extensions/attachment', () => {
+ let tiptapEditor;
+ let eq;
+ let doc;
+ let p;
+ let image;
+ let loading;
+ let link;
+ let renderMarkdown;
+ let mock;
+
+ const uploadsPath = '/uploads/';
+ const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
+ const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' });
+
+ beforeEach(() => {
+ renderMarkdown = jest.fn();
+
+ tiptapEditor = createTestEditor({
+ extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })],
+ });
+
+ ({
+ builders: { doc, p, image, loading, link },
+ eq,
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ loading: { markType: Loading.name },
+ image: { nodeType: Image.name },
+ link: { nodeType: Link.name },
+ },
+ }));
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.reset();
+ });
+
+ it.each`
+ eventType | propName | eventData | output
+ ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [attachmentFile] } }} | ${true}
+ ${'paste'} | ${'handlePaste'} | ${{ clipboardData: { files: [] } }} | ${undefined}
+ ${'drop'} | ${'handleDrop'} | ${{ dataTransfer: { files: [attachmentFile] } }} | ${true}
+ `('handles $eventType properly', ({ eventType, propName, eventData, output }) => {
+ const event = Object.assign(new Event(eventType), eventData);
+ const handled = tiptapEditor.view.someProp(propName, (eventHandler) => {
+ return eventHandler(tiptapEditor.view, event);
+ });
+
+ expect(handled).toBe(output);
+ });
+
+ describe('uploadAttachment command', () => {
+ let initialDoc;
+ beforeEach(() => {
+ initialDoc = doc(p(''));
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ });
+
+ describe('when the file has image mime type', () => {
+ const base64EncodedFile = 'data:image/png;base64,Zm9v';
+
+ beforeEach(() => {
+ renderMarkdown.mockResolvedValue(
+ loadMarkdownApiResult('project_wiki_attachment_image').body,
+ );
+ });
+
+ describe('when uploading succeeds', () => {
+ const successResponse = {
+ link: {
+ markdown: '![test-file](test-file.png)',
+ },
+ };
+
+ beforeEach(() => {
+ mock.onPost().reply(httpStatus.OK, successResponse);
+ });
+
+ it('inserts an image with src set to the encoded image file and uploading true', (done) => {
+ const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile })));
+
+ tiptapEditor.on(
+ 'update',
+ once(() => {
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ done();
+ }),
+ );
+
+ tiptapEditor.commands.uploadAttachment({ file: imageFile });
+ });
+
+ it('updates the inserted image with canonicalSrc when upload is successful', async () => {
+ const expectedDoc = doc(
+ p(
+ image({
+ canonicalSrc: 'test-file.png',
+ src: base64EncodedFile,
+ alt: 'test-file',
+ uploading: false,
+ }),
+ ),
+ );
+
+ tiptapEditor.commands.uploadAttachment({ file: imageFile });
+
+ await waitForPromises();
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+ });
+
+ describe('when uploading request fails', () => {
+ beforeEach(() => {
+ mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
+ });
+
+ it('resets the doc to orginal state', async () => {
+ const expectedDoc = doc(p(''));
+
+ tiptapEditor.commands.uploadAttachment({ file: imageFile });
+
+ await waitForPromises();
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+
+ it('emits an error event that includes an error message', (done) => {
+ tiptapEditor.commands.uploadAttachment({ file: imageFile });
+
+ tiptapEditor.on('error', ({ error }) => {
+ expect(error).toBe('An error occurred while uploading the image. Please try again.');
+ done();
+ });
+ });
+ });
+ });
+
+ describe('when the file has a zip (or any other attachment) mime type', () => {
+ const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body;
+
+ beforeEach(() => {
+ renderMarkdown.mockResolvedValue(markdownApiResult);
+ });
+
+ describe('when uploading succeeds', () => {
+ const successResponse = {
+ link: {
+ markdown: '[test-file](test-file.zip)',
+ },
+ };
+
+ beforeEach(() => {
+ mock.onPost().reply(httpStatus.OK, successResponse);
+ });
+
+ it('inserts a loading mark', (done) => {
+ const expectedDoc = doc(p(loading({ label: 'test-file' })));
+
+ tiptapEditor.on(
+ 'update',
+ once(() => {
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ done();
+ }),
+ );
+
+ tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+ });
+
+ it('updates the loading mark with a link with canonicalSrc and href attrs', async () => {
+ const [, group, project] = markdownApiResult.match(/\/(group[0-9]+)\/(project[0-9]+)\//);
+ const expectedDoc = doc(
+ p(
+ link(
+ {
+ canonicalSrc: 'test-file.zip',
+ href: `/${group}/${project}/-/wikis/test-file.zip`,
+ },
+ 'test-file',
+ ),
+ ),
+ );
+
+ tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+
+ await waitForPromises();
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+ });
+
+ describe('when uploading request fails', () => {
+ beforeEach(() => {
+ mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
+ });
+
+ it('resets the doc to orginal state', async () => {
+ const expectedDoc = doc(p(''));
+
+ tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+
+ await waitForPromises();
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+
+ it('emits an error event that includes an error message', (done) => {
+ tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+
+ tiptapEditor.on('error', ({ error }) => {
+ expect(error).toBe('An error occurred while uploading the file. Please try again.');
+ done();
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
index cc695ffe241..188e6580dc6 100644
--- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
+++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
@@ -1,4 +1,4 @@
-import { tiptapExtension as CodeBlockHighlight } from '~/content_editor/extensions/code_block_highlight';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import { loadMarkdownApiResult } from '../markdown_processing_examples';
import { createTestEditor } from '../test_utils';
@@ -25,7 +25,6 @@ describe('content_editor/extensions/code_block_highlight', () => {
expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
language,
- params: language,
});
});
diff --git a/spec/frontend/content_editor/extensions/emoji_spec.js b/spec/frontend/content_editor/extensions/emoji_spec.js
new file mode 100644
index 00000000000..c1b8dc9bdbb
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/emoji_spec.js
@@ -0,0 +1,57 @@
+import { initEmojiMock } from 'helpers/emoji';
+import Emoji from '~/content_editor/extensions/emoji';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+describe('content_editor/extensions/emoji', () => {
+ let tiptapEditor;
+ let doc;
+ let p;
+ let emoji;
+ let eq;
+
+ beforeEach(async () => {
+ await initEmojiMock();
+ });
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({ extensions: [Emoji] });
+ ({
+ builders: { doc, p, emoji },
+ eq,
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ loading: { nodeType: Emoji.name },
+ },
+ }));
+ });
+
+ describe('when typing a valid emoji input rule', () => {
+ it('inserts an emoji node', () => {
+ const { view } = tiptapEditor;
+ const { selection } = view.state;
+ const expectedDoc = doc(
+ p(
+ ' ',
+ emoji({ moji: '❤', name: 'heart', title: 'heavy black heart', unicodeVersion: '1.1' }),
+ ),
+ );
+ // Triggers the event handler that input rules listen to
+ view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, ':heart:'));
+
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+ });
+
+ describe('when typing a invalid emoji input rule', () => {
+ it('does not insert an emoji node', () => {
+ const { view } = tiptapEditor;
+ const { selection } = view.state;
+ const invalidEmoji = ':invalid:';
+ const expectedDoc = doc(p());
+ // Triggers the event handler that input rules listen to
+ view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, invalidEmoji));
+ expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/hard_break_spec.js b/spec/frontend/content_editor/extensions/hard_break_spec.js
index ebd58e60b0c..9e2e28b6e72 100644
--- a/spec/frontend/content_editor/extensions/hard_break_spec.js
+++ b/spec/frontend/content_editor/extensions/hard_break_spec.js
@@ -1,4 +1,4 @@
-import { tiptapExtension as HardBreak } from '~/content_editor/extensions/hard_break';
+import HardBreak from '~/content_editor/extensions/hard_break';
import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/extensions/hard_break', () => {
diff --git a/spec/frontend/content_editor/extensions/image_spec.js b/spec/frontend/content_editor/extensions/image_spec.js
deleted file mode 100644
index 922966b813a..00000000000
--- a/spec/frontend/content_editor/extensions/image_spec.js
+++ /dev/null
@@ -1,193 +0,0 @@
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
-import { once } from 'lodash';
-import waitForPromises from 'helpers/wait_for_promises';
-import * as Image from '~/content_editor/extensions/image';
-import httpStatus from '~/lib/utils/http_status';
-import { loadMarkdownApiResult } from '../markdown_processing_examples';
-import { createTestEditor, createDocBuilder } from '../test_utils';
-
-describe('content_editor/extensions/image', () => {
- let tiptapEditor;
- let eq;
- let doc;
- let p;
- let image;
- let renderMarkdown;
- let mock;
- const uploadsPath = '/uploads/';
- const validFile = new File(['foo'], 'foo.png', { type: 'image/png' });
- const invalidFile = new File(['foo'], 'bar.html', { type: 'text/html' });
-
- beforeEach(() => {
- renderMarkdown = jest
- .fn()
- .mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image').body);
-
- const { tiptapExtension } = Image.configure({ renderMarkdown, uploadsPath });
-
- tiptapEditor = createTestEditor({ extensions: [tiptapExtension] });
-
- ({
- builders: { doc, p, image },
- eq,
- } = createDocBuilder({
- tiptapEditor,
- names: { image: { nodeType: tiptapExtension.name } },
- }));
-
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.reset();
- });
-
- it.each`
- file | valid | description
- ${validFile} | ${true} | ${'handles paste event when mime type is valid'}
- ${invalidFile} | ${false} | ${'does not handle paste event when mime type is invalid'}
- `('$description', ({ file, valid }) => {
- const pasteEvent = Object.assign(new Event('paste'), {
- clipboardData: {
- files: [file],
- },
- });
- let handled;
-
- tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
- handled = eventHandler(tiptapEditor.view, pasteEvent);
- });
-
- expect(handled).toBe(valid);
- });
-
- it.each`
- file | valid | description
- ${validFile} | ${true} | ${'handles drop event when mime type is valid'}
- ${invalidFile} | ${false} | ${'does not handle drop event when mime type is invalid'}
- `('$description', ({ file, valid }) => {
- const dropEvent = Object.assign(new Event('drop'), {
- dataTransfer: {
- files: [file],
- },
- });
- let handled;
-
- tiptapEditor.view.someProp('handleDrop', (eventHandler) => {
- handled = eventHandler(tiptapEditor.view, dropEvent);
- });
-
- expect(handled).toBe(valid);
- });
-
- it('handles paste event when mime type is correct', () => {
- const pasteEvent = Object.assign(new Event('paste'), {
- clipboardData: {
- files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
- },
- });
- const handled = tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
- return eventHandler(tiptapEditor.view, pasteEvent);
- });
-
- expect(handled).toBe(true);
- });
-
- describe('uploadImage command', () => {
- describe('when file has correct mime type', () => {
- let initialDoc;
- const base64EncodedFile = 'data:image/png;base64,Zm9v';
-
- beforeEach(() => {
- initialDoc = doc(p(''));
- tiptapEditor.commands.setContent(initialDoc.toJSON());
- });
-
- describe('when uploading image succeeds', () => {
- const successResponse = {
- link: {
- markdown: '[image](/uploads/25265/image.png)',
- },
- };
-
- beforeEach(() => {
- mock.onPost().reply(httpStatus.OK, successResponse);
- });
-
- it('inserts an image with src set to the encoded image file and uploading true', (done) => {
- const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile })));
-
- tiptapEditor.on(
- 'update',
- once(() => {
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
- done();
- }),
- );
-
- tiptapEditor.commands.uploadImage({ file: validFile });
- });
-
- it('updates the inserted image with canonicalSrc when upload is successful', async () => {
- const expectedDoc = doc(
- p(
- image({
- canonicalSrc: 'test-file.png',
- src: base64EncodedFile,
- alt: 'test file',
- uploading: false,
- }),
- ),
- );
-
- tiptapEditor.commands.uploadImage({ file: validFile });
-
- await waitForPromises();
-
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
- });
- });
-
- describe('when uploading image request fails', () => {
- beforeEach(() => {
- mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
- });
-
- it('resets the doc to orginal state', async () => {
- const expectedDoc = doc(p(''));
-
- tiptapEditor.commands.uploadImage({ file: validFile });
-
- await waitForPromises();
-
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
- });
-
- it('emits an error event that includes an error message', (done) => {
- tiptapEditor.commands.uploadImage({ file: validFile });
-
- tiptapEditor.on('error', (message) => {
- expect(message).toBe('An error occurred while uploading the image. Please try again.');
- done();
- });
- });
- });
- });
-
- describe('when file does not have correct mime type', () => {
- let initialDoc;
-
- beforeEach(() => {
- initialDoc = doc(p(''));
- tiptapEditor.commands.setContent(initialDoc.toJSON());
- });
-
- it('does not start the upload image process', () => {
- tiptapEditor.commands.uploadImage({ file: invalidFile });
-
- expect(eq(tiptapEditor.state.doc, initialDoc)).toBe(true);
- });
- });
- });
-});
diff --git a/spec/frontend/content_editor/extensions/inline_diff_spec.js b/spec/frontend/content_editor/extensions/inline_diff_spec.js
new file mode 100644
index 00000000000..63cdf665e7f
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/inline_diff_spec.js
@@ -0,0 +1,27 @@
+import { inputRegexAddition, inputRegexDeletion } from '~/content_editor/extensions/inline_diff';
+
+describe('content_editor/extensions/inline_diff', () => {
+ describe.each`
+ inputRegex | description | input | matches
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+world+}'} | ${true}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+ world +}'} | ${true}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello {+ world+}'} | ${true}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello world +}'} | ${true}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello with \nnewline+}'} | ${false}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+open only'} | ${false}
+ ${inputRegexAddition} | ${'inputRegexAddition'} | ${'close only+}'} | ${false}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{-world-}'} | ${true}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{- world -}'} | ${true}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello {- world-}'} | ${true}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-hello world -}'} | ${true}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{+hello with \nnewline+}'} | ${false}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-open only'} | ${false}
+ ${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'close only-}'} | ${false}
+ `('$description', ({ inputRegex, input, matches }) => {
+ it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => {
+ const match = new RegExp(inputRegex).test(input);
+
+ expect(match).toBe(matches);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js
index 028cd6a8271..da3f6e64db8 100644
--- a/spec/frontend/content_editor/markdown_processing_spec.js
+++ b/spec/frontend/content_editor/markdown_processing_spec.js
@@ -1,6 +1,8 @@
import { createContentEditor } from '~/content_editor';
import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples';
+jest.mock('~/emoji');
+
describe('markdown processing', () => {
// Ensure we generate same markdown that was provided to Markdown API.
it.each(loadMarkdownApiExamples())(
diff --git a/spec/frontend/content_editor/services/build_serializer_config_spec.js b/spec/frontend/content_editor/services/build_serializer_config_spec.js
deleted file mode 100644
index 532e0493830..00000000000
--- a/spec/frontend/content_editor/services/build_serializer_config_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as Blockquote from '~/content_editor/extensions/blockquote';
-import * as Bold from '~/content_editor/extensions/bold';
-import * as Dropcursor from '~/content_editor/extensions/dropcursor';
-import * as Paragraph from '~/content_editor/extensions/paragraph';
-
-import buildSerializerConfig from '~/content_editor/services/build_serializer_config';
-
-describe('content_editor/services/build_serializer_config', () => {
- describe('given one or more content editor extensions', () => {
- it('creates a serializer config that collects all extension serializers by type', () => {
- const extensions = [Bold, Blockquote, Paragraph];
- const serializerConfig = buildSerializerConfig(extensions);
-
- extensions.forEach(({ tiptapExtension, serializer }) => {
- const { name, type } = tiptapExtension;
- expect(serializerConfig[`${type}s`][name]).toBe(serializer);
- });
- });
- });
-
- describe('given an extension without serializer', () => {
- it('does not include the extension in the serializer config', () => {
- const serializerConfig = buildSerializerConfig([Dropcursor]);
-
- expect(serializerConfig.marks[Dropcursor.tiptapExtension.name]).toBe(undefined);
- expect(serializerConfig.nodes[Dropcursor.tiptapExtension.name]).toBe(undefined);
- });
- });
-
- describe('given no extensions', () => {
- it('creates an empty serializer config', () => {
- expect(buildSerializerConfig()).toStrictEqual({
- marks: {},
- nodes: {},
- });
- });
- });
-});
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
new file mode 100644
index 00000000000..e48687f1548
--- /dev/null
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -0,0 +1,68 @@
+import {
+ LOADING_CONTENT_EVENT,
+ LOADING_SUCCESS_EVENT,
+ LOADING_ERROR_EVENT,
+} from '~/content_editor/constants';
+import { ContentEditor } from '~/content_editor/services/content_editor';
+
+import { createTestEditor } from '../test_utils';
+
+describe('content_editor/services/content_editor', () => {
+ let contentEditor;
+ let serializer;
+
+ beforeEach(() => {
+ const tiptapEditor = createTestEditor();
+ jest.spyOn(tiptapEditor, 'destroy');
+
+ serializer = { deserialize: jest.fn() };
+ contentEditor = new ContentEditor({ tiptapEditor, serializer });
+ });
+
+ describe('.dispose', () => {
+ it('destroys the tiptapEditor', () => {
+ expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled();
+
+ contentEditor.dispose();
+
+ expect(contentEditor.tiptapEditor.destroy).toHaveBeenCalled();
+ });
+ });
+
+ describe('when setSerializedContent succeeds', () => {
+ beforeEach(() => {
+ serializer.deserialize.mockResolvedValueOnce('');
+ });
+
+ it('emits loadingContent and loadingSuccess event', () => {
+ let loadingContentEmitted = false;
+
+ contentEditor.on(LOADING_CONTENT_EVENT, () => {
+ loadingContentEmitted = true;
+ });
+ contentEditor.on(LOADING_SUCCESS_EVENT, () => {
+ expect(loadingContentEmitted).toBe(true);
+ });
+
+ contentEditor.setSerializedContent('**bold text**');
+ });
+ });
+
+ describe('when setSerializedContent fails', () => {
+ const error = 'error';
+
+ beforeEach(() => {
+ serializer.deserialize.mockRejectedValueOnce(error);
+ });
+
+ it('emits loadingError event', async () => {
+ contentEditor.on(LOADING_ERROR_EVENT, (e) => {
+ expect(e).toBe('error');
+ });
+
+ await expect(() => contentEditor.setSerializedContent('**bold text**')).rejects.toEqual(
+ error,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js
index b614efd954a..6b2f28b3306 100644
--- a/spec/frontend/content_editor/services/create_content_editor_spec.js
+++ b/spec/frontend/content_editor/services/create_content_editor_spec.js
@@ -2,7 +2,9 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants
import { createContentEditor } from '~/content_editor/services/create_content_editor';
import { createTestContentEditorExtension } from '../test_utils';
-describe('content_editor/services/create_editor', () => {
+jest.mock('~/emoji');
+
+describe('content_editor/services/create_content_editor', () => {
let renderMarkdown;
let editor;
const uploadsPath = '/uploads';
@@ -32,13 +34,15 @@ describe('content_editor/services/create_editor', () => {
it('allows providing external content editor extensions', async () => {
const labelReference = 'this is a ~group::editor';
+ const { tiptapExtension, serializer } = createTestContentEditorExtension();
renderMarkdown.mockReturnValueOnce(
'<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>',
);
editor = createContentEditor({
renderMarkdown,
- extensions: [createTestContentEditorExtension()],
+ extensions: [tiptapExtension],
+ serializerConfig: { nodes: { [tiptapExtension.name]: serializer } },
});
await editor.setSerializedContent(labelReference);
@@ -50,9 +54,9 @@ describe('content_editor/services/create_editor', () => {
expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
});
- it('provides uploadsPath and renderMarkdown function to Image extension', () => {
+ it('provides uploadsPath and renderMarkdown function to Attachment extension', () => {
expect(
- editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'image').options,
+ editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'attachment').options,
).toMatchObject({
uploadsPath,
renderMarkdown,
diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
index 64f3d8df6e0..afe09a75f16 100644
--- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
+++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js
@@ -4,10 +4,10 @@ import {
INPUT_RULE_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
-import { tiptapExtension as BulletList } from '~/content_editor/extensions/bullet_list';
-import { tiptapExtension as CodeBlockLowlight } from '~/content_editor/extensions/code_block_highlight';
-import { tiptapExtension as Heading } from '~/content_editor/extensions/heading';
-import { tiptapExtension as ListItem } from '~/content_editor/extensions/list_item';
+import BulletList from '~/content_editor/extensions/bullet_list';
+import CodeBlockLowlight from '~/content_editor/extensions/code_block_highlight';
+import Heading from '~/content_editor/extensions/heading';
+import ListItem from '~/content_editor/extensions/list_item';
import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_rules_and_shortcuts';
import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys';
import { createTestEditor } from '../test_utils';
diff --git a/spec/frontend/content_editor/services/upload_file_spec.js b/spec/frontend/content_editor/services/upload_helpers_spec.js
index 87c5298079e..ee9333232db 100644
--- a/spec/frontend/content_editor/services/upload_file_spec.js
+++ b/spec/frontend/content_editor/services/upload_helpers_spec.js
@@ -1,9 +1,9 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { uploadFile } from '~/content_editor/services/upload_file';
+import { uploadFile } from '~/content_editor/services/upload_helpers';
import httpStatus from '~/lib/utils/http_status';
-describe('content_editor/services/upload_file', () => {
+describe('content_editor/services/upload_helpers', () => {
const uploadsPath = '/uploads';
const file = new File(['content'], 'file.txt');
// TODO: Replace with automated fixture
diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js
index 090e1d92218..b5a2abc2389 100644
--- a/spec/frontend/content_editor/test_utils.js
+++ b/spec/frontend/content_editor/test_utils.js
@@ -4,6 +4,7 @@ import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Editor } from '@tiptap/vue-2';
import { builders, eq } from 'prosemirror-test-builder';
+import { nextTick } from 'vue';
export const createDocBuilder = ({ tiptapEditor, names = {} }) => {
const docBuilders = builders(tiptapEditor.schema, {
@@ -14,6 +15,12 @@ export const createDocBuilder = ({ tiptapEditor, names = {} }) => {
return { eq, builders: docBuilders };
};
+export const emitEditorEvent = ({ tiptapEditor, event, params = {} }) => {
+ tiptapEditor.emit(event, { editor: tiptapEditor, ...params });
+
+ return nextTick();
+};
+
/**
* Creates an instance of the Tiptap Editor class
* with a minimal configuration for testing purposes.
diff --git a/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap
deleted file mode 100644
index 1af612ed029..00000000000
--- a/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap
+++ /dev/null
@@ -1,9 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Value stream analytics component isEmptyStage = true renders the empty stage with \`Not enough data\` message 1`] = `"<gl-empty-state-stub title=\\"We don't have enough data to show this stage.\\" svgpath=\\"path/to/no/data\\" description=\\"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
-
-exports[`Value stream analytics component isEmptyStage = true with a selectedStageError renders the empty stage with \`There is too much data to calculate\` message 1`] = `"<gl-empty-state-stub title=\\"There is too much data to calculate\\" svgpath=\\"path/to/no/data\\" description=\\"\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
-
-exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`;
-
-exports[`Value stream analytics component without enough permissions renders the empty stage with \`You need permission\` message 1`] = `"<gl-empty-state-stub title=\\"You need permission.\\" svgpath=\\"path/to/no/access\\" description=\\"Want to see the data? Please ask an administrator for access.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
diff --git a/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap
new file mode 100644
index 00000000000..e688df8f281
--- /dev/null
+++ b/spec/frontend/cycle_analytics/__snapshots__/total_time_component_spec.js.snap
@@ -0,0 +1,28 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TotalTimeComponent with a blank object should render -- 1`] = `"<span class=\\"total-time\\"> -- </span>"`;
+
+exports[`TotalTimeComponent with a valid time object with {"days": 3, "mins": 47, "seconds": 3} 1`] = `
+"<span class=\\"total-time\\">
+ 3 <span>days</span></span>"
+`;
+
+exports[`TotalTimeComponent with a valid time object with {"hours": 7, "mins": 20, "seconds": 10} 1`] = `
+"<span class=\\"total-time\\">
+ 7 <span>hrs</span></span>"
+`;
+
+exports[`TotalTimeComponent with a valid time object with {"hours": 23, "mins": 10} 1`] = `
+"<span class=\\"total-time\\">
+ 23 <span>hrs</span></span>"
+`;
+
+exports[`TotalTimeComponent with a valid time object with {"mins": 47, "seconds": 3} 1`] = `
+"<span class=\\"total-time\\">
+ 47 <span>mins</span></span>"
+`;
+
+exports[`TotalTimeComponent with a valid time object with {"seconds": 35} 1`] = `
+"<span class=\\"total-time\\">
+ 35 <span>s</span></span>"
+`;
diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js
index 2f85cc04051..71830eed3ef 100644
--- a/spec/frontend/cycle_analytics/base_spec.js
+++ b/spec/frontend/cycle_analytics/base_spec.js
@@ -5,62 +5,89 @@ import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BaseComponent from '~/cycle_analytics/components/base.vue';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
+import StageTable from '~/cycle_analytics/components/stage_table.vue';
+import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
+import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants';
import initState from '~/cycle_analytics/store/state';
-import { selectedStage, convertedEvents as selectedStageEvents } from './mock_data';
-
+import {
+ permissions,
+ transformedProjectStagePathData,
+ selectedStage,
+ issueEvents,
+ createdBefore,
+ createdAfter,
+ currentGroup,
+ stageCounts,
+} from './mock_data';
+
+const selectedStageEvents = issueEvents.events;
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
+const selectedStageCount = stageCounts[selectedStage.id];
+const fullPath = 'full/path/to/foo';
Vue.use(Vuex);
let wrapper;
-function createStore({ initialState = {} }) {
+const defaultState = {
+ permissions,
+ currentGroup,
+ createdBefore,
+ createdAfter,
+ stageCounts,
+ endpoints: { fullPath },
+};
+
+function createStore({ initialState = {}, initialGetters = {} }) {
return new Vuex.Store({
state: {
...initState(),
- permissions: {
- [selectedStage.id]: true,
- },
+ ...defaultState,
...initialState,
},
getters: {
- pathNavigationData: () => [],
+ pathNavigationData: () => transformedProjectStagePathData,
+ filterParams: () => ({
+ created_after: createdAfter,
+ created_before: createdBefore,
+ }),
+ ...initialGetters,
},
});
}
-function createComponent({ initialState } = {}) {
+function createComponent({ initialState, initialGetters } = {}) {
return extendedWrapper(
shallowMount(BaseComponent, {
- store: createStore({ initialState }),
+ store: createStore({ initialState, initialGetters }),
propsData: {
noDataSvgPath,
noAccessSvgPath,
},
+ stubs: {
+ StageTable,
+ },
}),
);
}
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
-const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
-const findStageTable = () => wrapper.findByTestId('vsa-stage-table');
-const findEmptyStage = () => wrapper.findComponent(GlEmptyState);
-const findStageEvents = () => wrapper.findByTestId('stage-table-events');
+const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics);
+const findStageTable = () => wrapper.findComponent(StageTable);
+const findStageEvents = () => findStageTable().props('stageEvents');
+const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title');
+
+const hasMetricsRequests = (reqs) => {
+ const foundReqs = findOverviewMetrics().props('requests');
+ expect(foundReqs.length).toEqual(reqs.length);
+ expect(foundReqs.map(({ name }) => name)).toEqual(reqs);
+};
describe('Value stream analytics component', () => {
beforeEach(() => {
- wrapper = createComponent({
- initialState: {
- isLoading: false,
- isLoadingStage: false,
- isEmptyStage: false,
- selectedStageEvents,
- selectedStage,
- selectedStageError: '',
- },
- });
+ wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } });
});
afterEach(() => {
@@ -72,23 +99,44 @@ describe('Value stream analytics component', () => {
expect(findPathNavigation().exists()).toBe(true);
});
+ it('receives the stages formatted for the path navigation', () => {
+ expect(findPathNavigation().props('stages')).toBe(transformedProjectStagePathData);
+ });
+
it('renders the overview metrics', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
+ it('passes requests prop to the metrics component', () => {
+ hasMetricsRequests(['recent activity']);
+ });
+
it('renders the stage table', () => {
expect(findStageTable().exists()).toBe(true);
});
+ it('passes the selected stage count to the stage table', () => {
+ expect(findStageTable().props('stageCount')).toBe(selectedStageCount);
+ });
+
it('renders the stage table events', () => {
- expect(findEmptyStage().exists()).toBe(false);
- expect(findStageEvents().exists()).toBe(true);
+ expect(findStageEvents()).toEqual(selectedStageEvents);
});
it('does not render the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
+ describe('with `cycleAnalyticsForGroups=true` license', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } });
+ });
+
+ it('passes requests prop to the metrics component', () => {
+ hasMetricsRequests(['time summary', 'recent activity']);
+ });
+ });
+
describe('isLoading = true', () => {
beforeEach(() => {
wrapper = createComponent({
@@ -97,17 +145,17 @@ describe('Value stream analytics component', () => {
});
it('renders the path navigation component with prop `loading` set to true', () => {
- expect(findPathNavigation().html()).toMatchSnapshot();
- });
-
- it('does not render the overview metrics', () => {
- expect(findOverviewMetrics().exists()).toBe(false);
+ expect(findPathNavigation().props('loading')).toBe(true);
});
it('does not render the stage table', () => {
expect(findStageTable().exists()).toBe(false);
});
+ it('renders the overview metrics', () => {
+ expect(findOverviewMetrics().exists()).toBe(true);
+ });
+
it('renders the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
@@ -125,32 +173,37 @@ describe('Value stream analytics component', () => {
expect(tableWrapper.exists()).toBe(true);
expect(tableWrapper.find(GlLoadingIcon).exists()).toBe(true);
});
+
+ it('renders the path navigation loading state', () => {
+ expect(findPathNavigation().props('loading')).toBe(true);
+ });
});
describe('isEmptyStage = true', () => {
+ const emptyStageParams = {
+ isEmptyStage: true,
+ selectedStage: { ...selectedStage, emptyStageText: 'This stage is empty' },
+ };
beforeEach(() => {
- wrapper = createComponent({
- initialState: { selectedStage, isEmptyStage: true },
- });
+ wrapper = createComponent({ initialState: emptyStageParams });
});
it('renders the empty stage with `Not enough data` message', () => {
- expect(findEmptyStage().html()).toMatchSnapshot();
+ expect(findEmptyStageTitle()).toBe(NOT_ENOUGH_DATA_ERROR);
});
describe('with a selectedStageError', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
- selectedStage,
- isEmptyStage: true,
+ ...emptyStageParams,
selectedStageError: 'There is too much data to calculate',
},
});
});
it('renders the empty stage with `There is too much data to calculate` message', () => {
- expect(findEmptyStage().html()).toMatchSnapshot();
+ expect(findEmptyStageTitle()).toBe('There is too much data to calculate');
});
});
});
@@ -159,21 +212,24 @@ describe('Value stream analytics component', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
+ selectedStage,
permissions: {
+ ...permissions,
[selectedStage.id]: false,
},
},
});
});
- it('renders the empty stage with `You need permission` message', () => {
- expect(findEmptyStage().html()).toMatchSnapshot();
+ it('renders the empty stage with `You need permission.` message', () => {
+ expect(findEmptyStageTitle()).toBe('You need permission.');
});
});
describe('without a selected stage', () => {
beforeEach(() => {
wrapper = createComponent({
+ initialGetters: { pathNavigationData: () => [] },
initialState: { selectedStage: null, isEmptyStage: true },
});
});
@@ -182,12 +238,12 @@ describe('Value stream analytics component', () => {
expect(findStageTable().exists()).toBe(true);
});
- it('does not render the path navigation component', () => {
+ it('does not render the path navigation', () => {
expect(findPathNavigation().exists()).toBe(false);
});
it('does not render the stage table events', () => {
- expect(findStageEvents().exists()).toBe(false);
+ expect(findStageEvents()).toHaveLength(0);
});
it('does not render the loading icon', () => {
diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js
index 4e6471d5f7b..d9659d5d4c3 100644
--- a/spec/frontend/cycle_analytics/mock_data.js
+++ b/spec/frontend/cycle_analytics/mock_data.js
@@ -1,3 +1,4 @@
+import { getJSONFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import { DEFAULT_VALUE_STREAM, DEFAULT_DAYS_IN_PAST } from '~/cycle_analytics/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
@@ -6,11 +7,33 @@ import { getDateInPast } from '~/lib/utils/datetime_utility';
export const createdBefore = new Date(2019, 0, 14);
export const createdAfter = getDateInPast(createdBefore, DEFAULT_DAYS_IN_PAST);
+export const deepCamelCase = (obj) => convertObjectPropsToCamelCase(obj, { deep: true });
+
export const getStageByTitle = (stages, title) =>
stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {};
+const fixtureEndpoints = {
+ customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages',
+ stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`,
+ metricsData: 'projects/analytics/value_stream_analytics/summary',
+};
+
+export const metricsData = getJSONFixture(fixtureEndpoints.metricsData);
+
+export const customizableStagesAndEvents = getJSONFixture(
+ fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents,
+);
+
export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging'];
+const stageFixtures = defaultStages.reduce((acc, stage) => {
+ const events = getJSONFixture(fixtureEndpoints.stageEvents(stage));
+ return {
+ ...acc,
+ [stage]: events,
+ };
+}, {});
+
export const summary = [
{ value: '20', title: 'New Issues' },
{ value: null, title: 'Commits' },
@@ -18,7 +41,7 @@ export const summary = [
{ value: null, title: 'Deployment Frequency', unit: 'per day' },
];
-const issueStage = {
+export const issueStage = {
id: 'issue',
title: 'Issue',
name: 'issue',
@@ -27,7 +50,7 @@ const issueStage = {
value: null,
};
-const planStage = {
+export const planStage = {
id: 'plan',
title: 'Plan',
name: 'plan',
@@ -36,7 +59,7 @@ const planStage = {
value: 75600,
};
-const codeStage = {
+export const codeStage = {
id: 'code',
title: 'Code',
name: 'code',
@@ -45,7 +68,7 @@ const codeStage = {
value: 172800,
};
-const testStage = {
+export const testStage = {
id: 'test',
title: 'Test',
name: 'test',
@@ -54,7 +77,7 @@ const testStage = {
value: 17550,
};
-const reviewStage = {
+export const reviewStage = {
id: 'review',
title: 'Review',
name: 'review',
@@ -63,7 +86,7 @@ const reviewStage = {
value: null,
};
-const stagingStage = {
+export const stagingStage = {
id: 'staging',
title: 'Staging',
name: 'staging',
@@ -79,7 +102,7 @@ export const selectedStage = {
isUserAllowed: true,
emptyStageText:
'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
- component: 'stage-issue-component',
+
slug: 'issue',
};
@@ -109,53 +132,30 @@ export const convertedData = {
],
};
-export const rawEvents = [
- {
- title: 'Brockfunc-1617160796',
- author: {
- id: 275,
- name: 'VSM User4',
- username: 'vsm-user-4-1617160796',
- state: 'active',
- avatar_url:
- 'https://www.gravatar.com/avatar/6a6f5480ae582ba68982a34169420747?s=80&d=identicon',
- web_url: 'http://gdk.test:3001/vsm-user-4-1617160796',
- show_status: false,
- path: '/vsm-user-4-1617160796',
- },
- iid: '16',
- total_time: { days: 1, hours: 9 },
- created_at: 'about 1 month ago',
- url: 'http://gdk.test:3001/vsa-life/ror-project-vsa/-/issues/16',
- short_sha: 'some_sha',
- commit_url: 'some_commit_url',
- },
- {
- title: 'Subpod-1617160796',
- author: {
- id: 274,
- name: 'VSM User3',
- username: 'vsm-user-3-1617160796',
- state: 'active',
- avatar_url:
- 'https://www.gravatar.com/avatar/fde853fc3ab7dc552e649dcb4fcf5f7f?s=80&d=identicon',
- web_url: 'http://gdk.test:3001/vsm-user-3-1617160796',
- show_status: false,
- path: '/vsm-user-3-1617160796',
- },
- iid: '20',
- total_time: { days: 2, hours: 18 },
- created_at: 'about 1 month ago',
- url: 'http://gdk.test:3001/vsa-life/ror-project-vsa/-/issues/20',
- },
-];
-
-export const convertedEvents = rawEvents.map((ev) =>
- convertObjectPropsToCamelCase(ev, { deep: true }),
-);
+export const rawIssueEvents = stageFixtures.issue;
+export const issueEvents = deepCamelCase(rawIssueEvents);
+export const reviewEvents = deepCamelCase(stageFixtures.review);
export const pathNavIssueMetric = 172800;
+export const rawStageCounts = [
+ { id: 'issue', count: 6 },
+ { id: 'plan', count: 6 },
+ { id: 'code', count: 1 },
+ { id: 'test', count: 5 },
+ { id: 'review', count: 12 },
+ { id: 'staging', count: 3 },
+];
+
+export const stageCounts = {
+ code: 1,
+ issue: 6,
+ plan: 6,
+ review: 12,
+ staging: 3,
+ test: 5,
+};
+
export const rawStageMedians = [
{ id: 'issue', value: 172800 },
{ id: 'plan', value: 86400 },
@@ -189,7 +189,7 @@ export const transformedProjectStagePathData = [
{
metric: 172800,
selected: true,
- stageCount: undefined,
+ stageCount: 6,
icon: null,
id: 'issue',
title: 'Issue',
@@ -201,7 +201,7 @@ export const transformedProjectStagePathData = [
{
metric: 86400,
selected: false,
- stageCount: undefined,
+ stageCount: 6,
icon: null,
id: 'plan',
title: 'Plan',
@@ -213,7 +213,7 @@ export const transformedProjectStagePathData = [
{
metric: 129600,
selected: false,
- stageCount: undefined,
+ stageCount: 1,
icon: null,
id: 'code',
title: 'Code',
@@ -251,46 +251,8 @@ export const selectedProjects = [
},
];
-export const rawValueStreamStages = [
- {
- title: 'Issue',
- hidden: false,
- legend: '',
- description: 'Time before an issue gets scheduled',
- id: 'issue',
- custom: false,
- start_event_html_description:
- '\u003cp data-sourcepos="1:1-1:13" dir="auto"\u003eIssue created\u003c/p\u003e',
- end_event_html_description:
- '\u003cp data-sourcepos="1:1-1:71" dir="auto"\u003eIssue first associated with a milestone or issue first added to a board\u003c/p\u003e',
- },
- {
- title: 'Plan',
- hidden: false,
- legend: '',
- description: 'Time before an issue starts implementation',
- id: 'plan',
- custom: false,
- start_event_html_description:
- '\u003cp data-sourcepos="1:1-1:71" dir="auto"\u003eIssue first associated with a milestone or issue first added to a board\u003c/p\u003e',
- end_event_html_description:
- '\u003cp data-sourcepos="1:1-1:33" dir="auto"\u003eIssue first mentioned in a commit\u003c/p\u003e',
- },
- {
- title: 'Code',
- hidden: false,
- legend: '',
- description: 'Time until first merge request',
- id: 'code',
- custom: false,
- start_event_html_description:
- '\u003cp data-sourcepos="1:1-1:33" dir="auto"\u003eIssue first mentioned in a commit\u003c/p\u003e',
- end_event_html_description:
- '\u003cp data-sourcepos="1:1-1:21" dir="auto"\u003eMerge request created\u003c/p\u003e',
- },
-];
+export const rawValueStreamStages = customizableStagesAndEvents.stages;
-export const valueStreamStages = rawValueStreamStages.map((s) => ({
- ...convertObjectPropsToCamelCase(s, { deep: true }),
- component: `stage-${s.id}-component`,
-}));
+export const valueStreamStages = rawValueStreamStages.map((s) =>
+ convertObjectPropsToCamelCase(s, { deep: true }),
+);
diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js
deleted file mode 100644
index d577d0b602a..00000000000
--- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js
+++ /dev/null
@@ -1,152 +0,0 @@
-import { mount, shallowMount } from '@vue/test-utils';
-import StageNavItem from '~/cycle_analytics/components/stage_nav_item.vue';
-
-describe('StageNavItem', () => {
- let wrapper = null;
- const title = 'Cool stage';
- const value = '1 day';
-
- function createComponent(props, shallow = true) {
- const func = shallow ? shallowMount : mount;
- return func(StageNavItem, {
- propsData: {
- isActive: false,
- isUserAllowed: false,
- isDefaultStage: true,
- title,
- value,
- ...props,
- },
- });
- }
-
- function hasStageName() {
- const stageName = wrapper.find('.stage-name');
- expect(stageName.exists()).toBe(true);
- expect(stageName.text()).toEqual(title);
- }
-
- it('renders stage name', () => {
- wrapper = createComponent({ isUserAllowed: true });
- hasStageName();
- wrapper.destroy();
- });
-
- describe('User has access', () => {
- describe('with a value', () => {
- beforeEach(() => {
- wrapper = createComponent({ isUserAllowed: true });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
- it('renders the value for median value', () => {
- expect(wrapper.find('.stage-empty').exists()).toBe(false);
- expect(wrapper.find('.not-available').exists()).toBe(false);
- expect(wrapper.find('.stage-median').text()).toEqual(value);
- });
- });
-
- describe('without a value', () => {
- beforeEach(() => {
- wrapper = createComponent({ isUserAllowed: true, value: null });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('has the stage-empty class', () => {
- expect(wrapper.find('.stage-empty').exists()).toBe(true);
- });
-
- it('renders Not enough data for the median value', () => {
- expect(wrapper.find('.stage-median').text()).toEqual('Not enough data');
- });
- });
- });
-
- describe('is active', () => {
- beforeEach(() => {
- wrapper = createComponent({ isActive: true }, false);
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
- it('has the active class', () => {
- expect(wrapper.find('.stage-nav-item').classes('active')).toBe(true);
- });
- });
-
- describe('is not active', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
- it('emits the `select` event when clicked', () => {
- expect(wrapper.emitted().select).toBeUndefined();
- wrapper.trigger('click');
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.emitted().select.length).toBe(1);
- });
- });
- });
-
- describe('User does not have access', () => {
- beforeEach(() => {
- wrapper = createComponent({ isUserAllowed: false }, false);
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
- it('renders stage name', () => {
- hasStageName();
- });
-
- it('has class not-available', () => {
- expect(wrapper.find('.stage-empty').exists()).toBe(false);
- expect(wrapper.find('.not-available').exists()).toBe(true);
- });
-
- it('renders Not available for the median value', () => {
- expect(wrapper.find('.stage-median').text()).toBe('Not available');
- });
- it('does not render options menu', () => {
- expect(wrapper.find('[data-testid="more-actions-toggle"]').exists()).toBe(false);
- });
- });
-
- describe('User can edit stages', () => {
- beforeEach(() => {
- wrapper = createComponent({ isUserAllowed: true }, false);
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
- it('renders stage name', () => {
- hasStageName();
- });
-
- it('does not render options menu', () => {
- expect(wrapper.find('[data-testid="more-actions-toggle"]').exists()).toBe(false);
- });
-
- it('can not edit the stage', () => {
- expect(wrapper.text()).not.toContain('Edit stage');
- });
- it('can not remove the stage', () => {
- expect(wrapper.text()).not.toContain('Remove stage');
- });
-
- it('can not hide the stage', () => {
- expect(wrapper.text()).not.toContain('Hide stage');
- });
- });
-});
diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js
new file mode 100644
index 00000000000..47a2ce4444b
--- /dev/null
+++ b/spec/frontend/cycle_analytics/stage_table_spec.js
@@ -0,0 +1,279 @@
+import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import StageTable from '~/cycle_analytics/components/stage_table.vue';
+import { PAGINATION_SORT_FIELD_DURATION } from '~/cycle_analytics/constants';
+import { issueEvents, issueStage, reviewStage, reviewEvents } from './mock_data';
+
+let wrapper = null;
+let trackingSpy = null;
+
+const noDataSvgPath = 'path/to/no/data';
+const emptyStateTitle = 'Too much data';
+const notEnoughDataError = "We don't have enough data to show this stage.";
+const issueEventItems = issueEvents.events;
+const reviewEventItems = reviewEvents.events;
+const [firstIssueEvent] = issueEventItems;
+const [firstReviewEvent] = reviewEventItems;
+const pagination = { page: 1, hasNextPage: true };
+
+const findStageEvents = () => wrapper.findAllByTestId('vsa-stage-event');
+const findPagination = () => wrapper.findByTestId('vsa-stage-pagination');
+const findTable = () => wrapper.findComponent(GlTable);
+const findTableHead = () => wrapper.find('thead');
+const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title');
+const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time');
+const findIcon = (name) => wrapper.findByTestId(`${name}-icon`);
+
+function createComponent(props = {}, shallow = false) {
+ const func = shallow ? shallowMount : mount;
+ return extendedWrapper(
+ func(StageTable, {
+ propsData: {
+ isLoading: false,
+ stageEvents: issueEventItems,
+ noDataSvgPath,
+ selectedStage: issueStage,
+ pagination,
+ ...props,
+ },
+ stubs: {
+ GlLoadingIcon,
+ GlEmptyState,
+ },
+ }),
+ );
+}
+
+describe('StageTable', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('is loaded with data', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('will render the correct events', () => {
+ const evs = findStageEvents();
+ expect(evs).toHaveLength(issueEventItems.length);
+
+ const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(titles[index]).toBe(ev.title);
+ });
+ });
+
+ it('will not display the default data message', () => {
+ expect(wrapper.html()).not.toContain(notEnoughDataError);
+ });
+ });
+
+ describe('with minimal stage data', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ currentStage: { title: 'New stage title' } });
+ });
+
+ it('will render the correct events', () => {
+ const evs = findStageEvents();
+ expect(evs).toHaveLength(issueEventItems.length);
+
+ const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(titles[index]).toBe(ev.title);
+ });
+ });
+ });
+
+ describe('default event', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ stageEvents: [{ ...firstIssueEvent }],
+ selectedStage: { ...issueStage, custom: false },
+ });
+ });
+
+ it('will render the event title', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-title').text()).toBe(firstIssueEvent.title);
+ });
+
+ it('will set the workflow title to "Issues"', () => {
+ expect(findTableHead().text()).toContain('Issues');
+ });
+
+ it('does not render the fork icon', () => {
+ expect(findIcon('fork').exists()).toBe(false);
+ });
+
+ it('does not render the branch icon', () => {
+ expect(findIcon('commit').exists()).toBe(false);
+ });
+
+ it('will render the total time', () => {
+ const createdAt = firstIssueEvent.createdAt.replace(' ago', '');
+ expect(findStageTime().text()).toBe(createdAt);
+ });
+
+ it('will render the author', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-author').text()).toContain(
+ firstIssueEvent.author.name,
+ );
+ });
+
+ it('will render the created at date', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-date').text()).toContain(
+ firstIssueEvent.createdAt,
+ );
+ });
+ });
+
+ describe('merge request event', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ stageEvents: [{ ...firstReviewEvent }],
+ selectedStage: { ...reviewStage, custom: false },
+ });
+ });
+
+ it('will set the workflow title to "Merge requests"', () => {
+ expect(findTableHead().text()).toContain('Merge requests');
+ expect(findTableHead().text()).not.toContain('Issues');
+ });
+ });
+
+ describe('isLoading = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ isLoading: true }, true);
+ });
+
+ it('will display the loading icon', () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('will not display pagination', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('with no stageEvents', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stageEvents: [] });
+ });
+
+ it('will render the empty state', () => {
+ expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true);
+ });
+
+ it('will display the default no data message', () => {
+ expect(wrapper.html()).toContain(notEnoughDataError);
+ });
+
+ it('will not display the pagination component', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('emptyStateTitle set', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stageEvents: [], emptyStateTitle });
+ });
+
+ it('will display the custom message', () => {
+ expect(wrapper.html()).not.toContain(notEnoughDataError);
+ expect(wrapper.html()).toContain(emptyStateTitle);
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ wrapper.destroy();
+ });
+
+ it('will display the pagination component', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('clicking prev or next will emit an event', async () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+
+ findPagination().vm.$emit('input', 2);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([{ page: 2 }]);
+ });
+
+ it('clicking prev or next will send tracking information', () => {
+ findPagination().vm.$emit('input', 2);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { label: 'pagination' });
+ });
+
+ describe('with `hasNextPage=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ pagination: { page: 1, hasNextPage: false } });
+ });
+
+ it('will not display the pagination component', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Sorting', () => {
+ const triggerTableSort = (sortDesc = true) =>
+ findTable().vm.$emit('sort-changed', {
+ sortBy: PAGINATION_SORT_FIELD_DURATION,
+ sortDesc,
+ });
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ wrapper.destroy();
+ });
+
+ it('clicking a table column will send tracking information', () => {
+ triggerTableSort();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'sort_duration_desc',
+ });
+ });
+
+ it('clicking a table column will update the sort field', () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+ triggerTableSort();
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
+ {
+ direction: 'desc',
+ sort: 'duration',
+ },
+ ]);
+ });
+
+ it('with sortDesc=false will toggle the direction field', async () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+ triggerTableSort(false);
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
+ {
+ direction: 'asc',
+ sort: 'duration',
+ },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js
index 8a8dd374f8e..915a828ff19 100644
--- a/spec/frontend/cycle_analytics/store/actions_spec.js
+++ b/spec/frontend/cycle_analytics/store/actions_spec.js
@@ -2,39 +2,23 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/cycle_analytics/store/actions';
+import * as getters from '~/cycle_analytics/store/getters';
import httpStatusCodes from '~/lib/utils/http_status';
import { allowedStages, selectedStage, selectedValueStream } from '../mock_data';
const mockRequestPath = 'some/cool/path';
const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams';
const mockStartDate = 30;
-const mockRequestedDataActions = ['fetchValueStreams', 'fetchCycleAnalyticsData'];
-const mockInitializeActionCommit = {
- payload: { requestPath: mockRequestPath },
- type: 'INITIALIZE_VSA',
-};
+const mockEndpoints = { fullPath: mockFullPath, requestPath: mockRequestPath };
const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' };
-const mockRequestedDataMutations = [
- {
- payload: true,
- type: 'SET_LOADING',
- },
- {
- payload: false,
- type: 'SET_LOADING',
- },
-];
-
-const features = {
- cycleAnalyticsForGroups: true,
-};
+
+const defaultState = { ...getters, selectedValueStream };
describe('Project Value Stream Analytics actions', () => {
let state;
let mock;
beforeEach(() => {
- state = {};
mock = new MockAdapter(axios);
});
@@ -45,28 +29,62 @@ describe('Project Value Stream Analytics actions', () => {
const mutationTypes = (arr) => arr.map(({ type }) => type);
+ const mockFetchStageDataActions = [
+ { type: 'setLoading', payload: true },
+ { type: 'fetchCycleAnalyticsData' },
+ { type: 'fetchStageData' },
+ { type: 'fetchStageMedians' },
+ { type: 'setLoading', payload: false },
+ ];
+
describe.each`
- action | payload | expectedActions | expectedMutations
- ${'initializeVsa'} | ${{ requestPath: mockRequestPath }} | ${mockRequestedDataActions} | ${[mockInitializeActionCommit, ...mockRequestedDataMutations]}
- ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockRequestedDataActions} | ${[mockSetDateActionCommit, ...mockRequestedDataMutations]}
- ${'setSelectedStage'} | ${{ selectedStage }} | ${['fetchStageData']} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]}
- ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${['fetchValueStreamStages']} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]}
+ action | payload | expectedActions | expectedMutations
+ ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]}
+ ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]}
+ ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]}
+ ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]}
+ ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]}
`('$action', ({ action, payload, expectedActions, expectedMutations }) => {
const types = mutationTypes(expectedMutations);
-
it(`will dispatch ${expectedActions} and commit ${types}`, () =>
testAction({
action: actions[action],
state,
payload,
expectedMutations,
- expectedActions: expectedActions.map((a) => ({ type: a })),
+ expectedActions,
}));
});
+ describe('initializeVsa', () => {
+ let mockDispatch;
+ let mockCommit;
+ const payload = { endpoints: mockEndpoints };
+
+ beforeEach(() => {
+ mockDispatch = jest.fn(() => Promise.resolve());
+ mockCommit = jest.fn();
+ });
+
+ it('will dispatch the setLoading and fetchValueStreams actions and commit INITIALIZE_VSA', async () => {
+ await actions.initializeVsa(
+ {
+ ...state,
+ dispatch: mockDispatch,
+ commit: mockCommit,
+ },
+ payload,
+ );
+ expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints });
+ expect(mockDispatch).toHaveBeenCalledWith('setLoading', true);
+ expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams');
+ expect(mockDispatch).toHaveBeenCalledWith('setLoading', false);
+ });
+ });
+
describe('fetchCycleAnalyticsData', () => {
beforeEach(() => {
- state = { requestPath: mockRequestPath };
+ state = { endpoints: mockEndpoints };
mock = new MockAdapter(axios);
mock.onGet(mockRequestPath).reply(httpStatusCodes.OK);
});
@@ -85,7 +103,7 @@ describe('Project Value Stream Analytics actions', () => {
describe('with a failing request', () => {
beforeEach(() => {
- state = { requestPath: mockRequestPath };
+ state = { endpoints: mockEndpoints };
mock = new MockAdapter(axios);
mock.onGet(mockRequestPath).reply(httpStatusCodes.BAD_REQUEST);
});
@@ -105,11 +123,12 @@ describe('Project Value Stream Analytics actions', () => {
});
describe('fetchStageData', () => {
- const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}`;
+ const mockStagePath = /value_streams\/\w+\/stages\/\w+\/records/;
beforeEach(() => {
state = {
- requestPath: mockRequestPath,
+ ...defaultState,
+ endpoints: mockEndpoints,
startDate: mockStartDate,
selectedStage,
};
@@ -131,7 +150,8 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
- requestPath: mockRequestPath,
+ ...defaultState,
+ endpoints: mockEndpoints,
startDate: mockStartDate,
selectedStage,
};
@@ -155,7 +175,8 @@ describe('Project Value Stream Analytics actions', () => {
describe('with a failing request', () => {
beforeEach(() => {
state = {
- requestPath: mockRequestPath,
+ ...defaultState,
+ endpoints: mockEndpoints,
startDate: mockStartDate,
selectedStage,
};
@@ -179,8 +200,7 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
- features,
- fullPath: mockFullPath,
+ endpoints: mockEndpoints,
};
mock = new MockAdapter(axios);
mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
@@ -196,29 +216,10 @@ describe('Project Value Stream Analytics actions', () => {
{ type: 'receiveValueStreamsSuccess' },
{ type: 'setSelectedStage' },
{ type: 'fetchStageMedians' },
+ { type: 'fetchStageCountValues' },
],
}));
- describe('with cycleAnalyticsForGroups=false', () => {
- beforeEach(() => {
- state = {
- features: { cycleAnalyticsForGroups: false },
- fullPath: mockFullPath,
- };
- mock = new MockAdapter(axios);
- mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
- });
-
- it("does not dispatch the 'fetchStageMedians' request", () =>
- testAction({
- action: actions.fetchValueStreams,
- state,
- payload: {},
- expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }],
- expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }],
- }));
- });
-
describe('with a failing request', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -271,7 +272,7 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
- fullPath: mockFullPath,
+ endpoints: mockEndpoints,
selectedValueStream,
};
mock = new MockAdapter(axios);
@@ -364,4 +365,64 @@ describe('Project Value Stream Analytics actions', () => {
}));
});
});
+
+ describe('fetchStageCountValues', () => {
+ const mockValueStreamPath = /count/;
+ const stageCountsPayload = [
+ { id: 'issue', count: 1 },
+ { id: 'plan', count: 2 },
+ { id: 'code', count: 3 },
+ ];
+
+ const stageCountError = new Error(
+ `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`,
+ );
+
+ beforeEach(() => {
+ state = {
+ fullPath: mockFullPath,
+ selectedValueStream,
+ stages: allowedStages,
+ };
+ mock = new MockAdapter(axios);
+ mock
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 1 })
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 2 })
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 3 });
+ });
+
+ it(`commits the 'REQUEST_STAGE_COUNTS' and 'RECEIVE_STAGE_COUNTS_SUCCESS' mutations`, () =>
+ testAction({
+ action: actions.fetchStageCountValues,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_COUNTS' },
+ { type: 'RECEIVE_STAGE_COUNTS_SUCCESS', payload: stageCountsPayload },
+ ],
+ expectedActions: [],
+ }));
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_STAGE_COUNTS_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchStageCountValues,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_COUNTS' },
+ { type: 'RECEIVE_STAGE_COUNTS_ERROR', payload: stageCountError },
+ ],
+ expectedActions: [],
+ }));
+ });
+ });
});
diff --git a/spec/frontend/cycle_analytics/store/getters_spec.js b/spec/frontend/cycle_analytics/store/getters_spec.js
index 5745e9d7902..c47a30a5f79 100644
--- a/spec/frontend/cycle_analytics/store/getters_spec.js
+++ b/spec/frontend/cycle_analytics/store/getters_spec.js
@@ -4,12 +4,13 @@ import {
stageMedians,
transformedProjectStagePathData,
selectedStage,
+ stageCounts,
} from '../mock_data';
describe('Value stream analytics getters', () => {
describe('pathNavigationData', () => {
it('returns the transformed data', () => {
- const state = { stages: allowedStages, medians: stageMedians, selectedStage };
+ const state = { stages: allowedStages, medians: stageMedians, selectedStage, stageCounts };
expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData);
});
});
diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js
index 77b19280517..7fcfef98547 100644
--- a/spec/frontend/cycle_analytics/store/mutations_spec.js
+++ b/spec/frontend/cycle_analytics/store/mutations_spec.js
@@ -4,30 +4,29 @@ import * as types from '~/cycle_analytics/store/mutation_types';
import mutations from '~/cycle_analytics/store/mutations';
import {
selectedStage,
- rawEvents,
- convertedEvents,
- rawData,
- convertedData,
+ rawIssueEvents,
+ issueEvents,
selectedValueStream,
rawValueStreamStages,
valueStreamStages,
rawStageMedians,
formattedStageMedians,
+ rawStageCounts,
+ stageCounts,
} from '../mock_data';
let state;
+const rawEvents = rawIssueEvents.events;
+const convertedEvents = issueEvents.events;
const mockRequestPath = 'fake/request/path';
const mockCreatedAfter = '2020-06-18';
const mockCreatedBefore = '2020-07-18';
-const features = {
- cycleAnalyticsForGroups: true,
-};
describe('Project Value Stream Analytics mutations', () => {
useFakeDate(2020, 6, 18);
beforeEach(() => {
- state = { features };
+ state = {};
});
afterEach(() => {
@@ -58,26 +57,48 @@ describe('Project Value Stream Analytics mutations', () => {
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
+ ${types.REQUEST_STAGE_COUNTS} | ${'stageCounts'} | ${{}}
+ ${types.RECEIVE_STAGE_COUNTS_ERROR} | ${'stageCounts'} | ${{}}
`('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => {
- mutations[mutation](state, {});
+ mutations[mutation](state);
expect(state).toMatchObject({ [stateKey]: value });
});
+ const mockInitialPayload = {
+ endpoints: { requestPath: mockRequestPath },
+ currentGroup: { title: 'cool-group' },
+ id: 1337,
+ };
+ const mockInitializedObj = {
+ endpoints: { requestPath: mockRequestPath },
+ createdAfter: mockCreatedAfter,
+ createdBefore: mockCreatedBefore,
+ };
+
it.each`
- mutation | payload | stateKey | value
- ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath}
- ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'startDate'} | ${DEFAULT_DAYS_TO_DISPLAY}
- ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdAfter'} | ${mockCreatedAfter}
- ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdBefore'} | ${mockCreatedBefore}
- ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
- ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
- ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
- ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
- ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
- ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
- ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
- ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
+ mutation | stateKey | value
+ ${types.INITIALIZE_VSA} | ${'endpoints'} | ${{ requestPath: mockRequestPath }}
+ ${types.INITIALIZE_VSA} | ${'createdAfter'} | ${mockCreatedAfter}
+ ${types.INITIALIZE_VSA} | ${'createdBefore'} | ${mockCreatedBefore}
+ `('$mutation will set $stateKey', ({ mutation, stateKey, value }) => {
+ mutations[mutation](state, { ...mockInitialPayload });
+
+ expect(state).toMatchObject({ ...mockInitializedObj, [stateKey]: value });
+ });
+
+ it.each`
+ mutation | payload | stateKey | value
+ ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
+ ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
+ ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
+ ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
+ ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
+ ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
+ ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
+ ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
+ ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
+ ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
@@ -95,41 +116,10 @@ describe('Project Value Stream Analytics mutations', () => {
});
it.each`
- mutation | payload | stateKey | value
- ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: [] }} | ${'isEmptyStage'} | ${true}
- ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'selectedStageEvents'} | ${convertedEvents}
- ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'isEmptyStage'} | ${false}
- `(
- '$mutation with $payload will set $stateKey to $value',
- ({ mutation, payload, stateKey, value }) => {
- mutations[mutation](state, payload);
-
- expect(state).toMatchObject({ [stateKey]: value });
- },
- );
- });
-
- describe('with cycleAnalyticsForGroups=false', () => {
- useFakeDate(2020, 6, 18);
-
- beforeEach(() => {
- state = { features: { cycleAnalyticsForGroups: false } };
- });
-
- const formattedMedians = {
- code: '2d',
- issue: '-',
- plan: '21h',
- review: '-',
- staging: '2d',
- test: '4h',
- };
-
- it.each`
- mutation | payload | stateKey | value
- ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'medians'} | ${formattedMedians}
- ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${{}} | ${'medians'} | ${{}}
- ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${{}} | ${'medians'} | ${{}}
+ mutation | payload | stateKey | value
+ ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${[]} | ${'isEmptyStage'} | ${true}
+ ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'selectedStageEvents'} | ${convertedEvents}
+ ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'isEmptyStage'} | ${false}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
diff --git a/spec/frontend/cycle_analytics/total_time_component_spec.js b/spec/frontend/cycle_analytics/total_time_component_spec.js
index e831bc311ed..9003c0330c0 100644
--- a/spec/frontend/cycle_analytics/total_time_component_spec.js
+++ b/spec/frontend/cycle_analytics/total_time_component_spec.js
@@ -1,11 +1,11 @@
-import { shallowMount } from '@vue/test-utils';
-import TotalTime from '~/cycle_analytics/components/total_time_component.vue';
+import { mount } from '@vue/test-utils';
+import TotalTimeComponent from '~/cycle_analytics/components/total_time_component.vue';
-describe('Total time component', () => {
- let wrapper;
+describe('TotalTimeComponent', () => {
+ let wrapper = null;
const createComponent = (propsData) => {
- wrapper = shallowMount(TotalTime, {
+ return mount(TotalTimeComponent, {
propsData,
});
};
@@ -14,45 +14,32 @@ describe('Total time component', () => {
wrapper.destroy();
});
- describe('With data', () => {
- it('should render information for days and hours', () => {
- createComponent({
- time: {
- days: 3,
- hours: 4,
- },
+ describe('with a valid time object', () => {
+ it.each`
+ time
+ ${{ seconds: 35 }}
+ ${{ mins: 47, seconds: 3 }}
+ ${{ days: 3, mins: 47, seconds: 3 }}
+ ${{ hours: 23, mins: 10 }}
+ ${{ hours: 7, mins: 20, seconds: 10 }}
+ `('with $time', ({ time }) => {
+ wrapper = createComponent({
+ time,
});
- expect(wrapper.text()).toMatchInterpolatedText('3 days 4 hrs');
- });
-
- it('should render information for hours and minutes', () => {
- createComponent({
- time: {
- hours: 4,
- mins: 35,
- },
- });
-
- expect(wrapper.text()).toMatchInterpolatedText('4 hrs 35 mins');
+ expect(wrapper.html()).toMatchSnapshot();
});
+ });
- it('should render information for seconds', () => {
- createComponent({
- time: {
- seconds: 45,
- },
+ describe('with a blank object', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ time: {},
});
-
- expect(wrapper.text()).toMatchInterpolatedText('45 s');
});
- });
-
- describe('Without data', () => {
- it('should render no information', () => {
- createComponent();
- expect(wrapper.text()).toBe('--');
+ it('should render --', () => {
+ expect(wrapper.html()).toMatchSnapshot();
});
});
});
diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js
index 1fecdfc0539..69fed879fd8 100644
--- a/spec/frontend/cycle_analytics/utils_spec.js
+++ b/spec/frontend/cycle_analytics/utils_spec.js
@@ -1,70 +1,24 @@
import { useFakeDate } from 'helpers/fake_date';
import {
- decorateEvents,
- decorateData,
transformStagesForPathNavigation,
timeSummaryForPathNavigation,
medianTimeToParsedSeconds,
formatMedianValues,
filterStagesByHiddenStatus,
calculateFormattedDayInPast,
+ prepareTimeMetricsData,
} from '~/cycle_analytics/utils';
+import { slugify } from '~/lib/utils/text_utility';
import {
selectedStage,
- rawData,
- convertedData,
- rawEvents,
allowedStages,
stageMedians,
pathNavIssueMetric,
rawStageMedians,
+ metricsData,
} from './mock_data';
describe('Value stream analytics utils', () => {
- describe('decorateEvents', () => {
- const [result] = decorateEvents(rawEvents, selectedStage);
- const eventKeys = Object.keys(result);
- const authorKeys = Object.keys(result.author);
- it('will return the same number of events', () => {
- expect(decorateEvents(rawEvents, selectedStage).length).toBe(rawEvents.length);
- });
-
- it('will set all the required event fields', () => {
- ['totalTime', 'author', 'createdAt', 'shortSha', 'commitUrl'].forEach((key) => {
- expect(eventKeys).toContain(key);
- });
- ['webUrl', 'avatarUrl'].forEach((key) => {
- expect(authorKeys).toContain(key);
- });
- });
-
- it('will remove unused fields', () => {
- ['total_time', 'created_at', 'short_sha', 'commit_url'].forEach((key) => {
- expect(eventKeys).not.toContain(key);
- });
-
- ['web_url', 'avatar_url'].forEach((key) => {
- expect(authorKeys).not.toContain(key);
- });
- });
- });
-
- describe('decorateData', () => {
- const result = decorateData(rawData);
- it('returns the summary data', () => {
- expect(result.summary).toEqual(convertedData.summary);
- });
-
- it('returns `-` for summary data that has no value', () => {
- const singleSummaryResult = decorateData({
- stats: [],
- permissions: { issue: true },
- summary: [{ value: null, title: 'Commits' }],
- });
- expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]);
- });
- });
-
describe('transformStagesForPathNavigation', () => {
const stages = allowedStages;
const response = transformStagesForPathNavigation({
@@ -159,4 +113,32 @@ describe('Value stream analytics utils', () => {
expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' });
});
});
+
+ describe('prepareTimeMetricsData', () => {
+ let prepared;
+ const [first, second] = metricsData;
+ const firstKey = slugify(first.title);
+ const secondKey = slugify(second.title);
+
+ beforeEach(() => {
+ prepared = prepareTimeMetricsData([first, second], {
+ [firstKey]: { description: 'Is a value that is good' },
+ });
+ });
+
+ it('will add a `key` based on the title', () => {
+ expect(prepared).toMatchObject([{ key: firstKey }, { key: secondKey }]);
+ });
+
+ it('will add a `label` key', () => {
+ expect(prepared).toMatchObject([{ label: 'New Issues' }, { label: 'Commits' }]);
+ });
+
+ it('will add a popover description using the key if it is provided', () => {
+ expect(prepared).toMatchObject([
+ { description: 'Is a value that is good' },
+ { description: '' },
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
new file mode 100644
index 00000000000..ffdb49a828c
--- /dev/null
+++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
@@ -0,0 +1,128 @@
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
+import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
+import createFlash from '~/flash';
+import { group, metricsData } from './mock_data';
+
+jest.mock('~/flash');
+
+describe('ValueStreamMetrics', () => {
+ let wrapper;
+ let mockGetValueStreamSummaryMetrics;
+
+ const { full_path: requestPath } = group;
+ const fakeReqName = 'Mock metrics';
+ const metricsRequestFactory = () => ({
+ request: mockGetValueStreamSummaryMetrics,
+ endpoint: METRIC_TYPE_SUMMARY,
+ name: fakeReqName,
+ });
+
+ const createComponent = ({ requestParams = {} } = {}) => {
+ return shallowMount(ValueStreamMetrics, {
+ propsData: {
+ requestPath,
+ requestParams,
+ requests: [metricsRequestFactory()],
+ },
+ });
+ };
+
+ const findMetrics = () => wrapper.findAllComponents(GlSingleStat);
+
+ const expectToHaveRequest = (fields) => {
+ expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
+ endpoint: METRIC_TYPE_SUMMARY,
+ requestPath,
+ ...fields,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('with successful requests', () => {
+ beforeEach(() => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
+ wrapper = createComponent();
+ });
+
+ it('will display a loader with pending requests', async () => {
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
+ });
+
+ describe('with data loaded', () => {
+ beforeEach(async () => {
+ await waitForPromises();
+ });
+
+ it('fetches data from the value stream analytics endpoint', () => {
+ expectToHaveRequest({ params: {} });
+ });
+
+ it.each`
+ index | value | title | unit
+ ${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit}
+ ${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit}
+ ${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit}
+ ${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit}
+ `(
+ 'renders a single stat component for the $title with value and unit',
+ ({ index, value, title, unit }) => {
+ const metric = findMetrics().at(index);
+ expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' });
+ },
+ );
+
+ it('will not display a loading icon', () => {
+ expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
+ });
+
+ describe('with additional params', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ requestParams: {
+ 'project_ids[]': [1],
+ created_after: '2020-01-01',
+ created_before: '2020-02-01',
+ },
+ });
+
+ await waitForPromises();
+ });
+
+ it('fetches data for the `getValueStreamSummaryMetrics` request', () => {
+ expectToHaveRequest({
+ params: {
+ 'project_ids[]': [1],
+ created_after: '2020-01-01',
+ created_before: '2020-02-01',
+ },
+ });
+ });
+ });
+ });
+ });
+
+ describe('with a request failing', () => {
+ beforeEach(async () => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue();
+ wrapper = createComponent();
+
+ await waitForPromises();
+ });
+
+ it('it should render an error message', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: `There was an error while fetching value stream analytics ${fakeReqName} data.`,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
index efadb9b717d..9335d800a16 100644
--- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
@@ -17,6 +17,8 @@ const defaultMockDiscussion = {
notes,
};
+const DEFAULT_TODO_COUNT = 2;
+
describe('Design discussions component', () => {
let wrapper;
@@ -41,8 +43,14 @@ describe('Design discussions component', () => {
},
};
const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } });
+ const readQuery = jest.fn().mockReturnValue({
+ project: {
+ issue: { designCollection: { designs: { nodes: [{ currentUserTodos: { nodes: [] } }] } } },
+ },
+ });
const $apollo = {
mutate,
+ provider: { clients: { defaultClient: { readQuery } } },
};
function createComponent(props = {}, data = {}) {
@@ -69,6 +77,12 @@ describe('Design discussions component', () => {
$apollo,
$route: {
hash: '#note_1',
+ params: {
+ id: 1,
+ },
+ query: {
+ version: null,
+ },
},
},
});
@@ -138,7 +152,13 @@ describe('Design discussions component', () => {
});
describe('when discussion is resolved', () => {
+ let dispatchEventSpy;
+
beforeEach(() => {
+ dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
+ jest.spyOn(document, 'querySelector').mockReturnValue({
+ innerText: DEFAULT_TODO_COUNT,
+ });
createComponent({
discussion: {
...defaultMockDiscussion,
@@ -174,6 +194,24 @@ describe('Design discussions component', () => {
expect(findResolveIcon().props('name')).toBe('check-circle-filled');
});
+ it('emit todo:toggle when discussion is resolved', async () => {
+ createComponent(
+ { discussionWithOpenForm: defaultMockDiscussion.id },
+ { discussionComment: 'test', isFormRendered: true },
+ );
+ findResolveButton().trigger('click');
+ findReplyForm().vm.$emit('submitForm');
+
+ await mutate();
+ await wrapper.vm.$nextTick();
+
+ const dispatchedEvent = dispatchEventSpy.mock.calls[0][0];
+
+ expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
+ expect(dispatchedEvent.detail).toEqual({ count: DEFAULT_TODO_COUNT });
+ expect(dispatchedEvent.type).toBe('todo:toggle');
+ });
+
describe('when replies are expanded', () => {
beforeEach(() => {
findRepliesWidget().vm.$emit('toggle');
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
index 63afc3f000d..637f22457c4 100644
--- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
@@ -17,13 +17,31 @@ exports[`Design management design version dropdown component renders design vers
iconname=""
iconrightarialabel=""
iconrightname=""
+ ischeckcentered="true"
ischecked="true"
ischeckitem="true"
secondarytext=""
>
- Version
- 2
- (latest)
+ <strong>
+ Version
+ 2
+ (latest)
+ </strong>
+
+ <div
+ class="gl-text-gray-600 gl-mt-1"
+ >
+ <div>
+ Adminstrator
+ </div>
+
+ <time-ago-stub
+ class="text-1"
+ cssclass=""
+ time="2021-08-09T06:05:00Z"
+ tooltipplacement="bottom"
+ />
+ </div>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
@@ -31,12 +49,30 @@ exports[`Design management design version dropdown component renders design vers
iconname=""
iconrightarialabel=""
iconrightname=""
+ ischeckcentered="true"
ischeckitem="true"
secondarytext=""
>
- Version
- 1
-
+ <strong>
+ Version
+ 1
+
+ </strong>
+
+ <div
+ class="gl-text-gray-600 gl-mt-1"
+ >
+ <div>
+ Adminstrator
+ </div>
+
+ <time-ago-stub
+ class="text-1"
+ cssclass=""
+ time="2021-08-09T06:05:00Z"
+ tooltipplacement="bottom"
+ />
+ </div>
</gl-dropdown-item-stub>
</gl-dropdown-stub>
`;
@@ -58,13 +94,31 @@ exports[`Design management design version dropdown component renders design vers
iconname=""
iconrightarialabel=""
iconrightname=""
+ ischeckcentered="true"
ischecked="true"
ischeckitem="true"
secondarytext=""
>
- Version
- 2
- (latest)
+ <strong>
+ Version
+ 2
+ (latest)
+ </strong>
+
+ <div
+ class="gl-text-gray-600 gl-mt-1"
+ >
+ <div>
+ Adminstrator
+ </div>
+
+ <time-ago-stub
+ class="text-1"
+ cssclass=""
+ time="2021-08-09T06:05:00Z"
+ tooltipplacement="bottom"
+ />
+ </div>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
@@ -72,12 +126,30 @@ exports[`Design management design version dropdown component renders design vers
iconname=""
iconrightarialabel=""
iconrightname=""
+ ischeckcentered="true"
ischeckitem="true"
secondarytext=""
>
- Version
- 1
-
+ <strong>
+ Version
+ 1
+
+ </strong>
+
+ <div
+ class="gl-text-gray-600 gl-mt-1"
+ >
+ <div>
+ Adminstrator
+ </div>
+
+ <time-ago-stub
+ class="text-1"
+ cssclass=""
+ time="2021-08-09T06:05:00Z"
+ tooltipplacement="bottom"
+ />
+ </div>
</gl-dropdown-item-stub>
</gl-dropdown-stub>
`;
diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
index 1b01a363688..ebfe27eaa71 100644
--- a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
+++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
@@ -1,9 +1,10 @@
import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import mockAllVersions from './mock_data/all_versions';
-const LATEST_VERSION_ID = 3;
+const LATEST_VERSION_ID = 1;
const PREVIOUS_VERSION_ID = 2;
const designRouteFactory = (versionId) => ({
@@ -110,5 +111,13 @@ describe('Design management design version dropdown component', () => {
expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length);
});
});
+
+ it('should render TimeAgo', async () => {
+ createComponent();
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findAllComponents(TimeAgo)).toHaveLength(wrapper.vm.allVersions.length);
+ });
});
});
diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
index 237e1654f9b..24c59ce1a75 100644
--- a/spec/frontend/design_management/components/upload/mock_data/all_versions.js
+++ b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
@@ -1,10 +1,20 @@
export default [
{
- id: 'gid://gitlab/DesignManagement::Version/3',
- sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55',
+ id: 'gid://gitlab/DesignManagement::Version/1',
+ sha: 'b389071a06c153509e11da1f582005b316667001',
+ createdAt: '2021-08-09T06:05:00Z',
+ author: {
+ id: 'gid://gitlab/User/1',
+ name: 'Adminstrator',
+ },
},
{
id: 'gid://gitlab/DesignManagement::Version/2',
- sha: '5b063fef0cd7213b312db65b30e24f057df21b20',
+ sha: 'b389071a06c153509e11da1f582005b316667021',
+ createdAt: '2021-08-09T06:05:00Z',
+ author: {
+ id: 'gid://gitlab/User/1',
+ name: 'Adminstrator',
+ },
},
];
diff --git a/spec/frontend/design_management/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js
index 2b216574e27..f4026da7dfd 100644
--- a/spec/frontend/design_management/mock_data/all_versions.js
+++ b/spec/frontend/design_management/mock_data/all_versions.js
@@ -2,5 +2,19 @@ export default [
{
id: 'gid://gitlab/DesignManagement::Version/1',
sha: 'b389071a06c153509e11da1f582005b316667001',
+ createdAt: '2021-08-09T06:05:00Z',
+ author: {
+ id: 'gid://gitlab/User/1',
+ name: 'Adminstrator',
+ },
+ },
+ {
+ id: 'gid://gitlab/DesignManagement::Version/1',
+ sha: 'b389071a06c153509e11da1f582005b316667021',
+ createdAt: '2021-08-09T06:05:00Z',
+ author: {
+ id: 'gid://gitlab/User/1',
+ name: 'Adminstrator',
+ },
},
];
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index e53ad2e6afe..cdd07a16e90 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -172,3 +172,40 @@ export const moveDesignMutationResponseWithErrors = {
},
},
};
+
+export const resolveCommentMutationResponse = {
+ discussionToggleResolve: {
+ discussion: {
+ noteable: {
+ id: 'gid://gitlab/DesignManagement::Design/1',
+ currentUserTodos: {
+ nodes: [],
+ __typename: 'TodoConnection',
+ },
+ __typename: 'Design',
+ },
+ __typename: 'Discussion',
+ },
+ errors: [],
+ __typename: 'DiscussionToggleResolvePayload',
+ },
+};
+
+export const getDesignQueryResponse = {
+ project: {
+ issue: {
+ designCollection: {
+ designs: {
+ nodes: [
+ {
+ id: 'gid://gitlab/DesignManagement::Design/1',
+ currentUserTodos: {
+ nodes: [{ id: 'gid://gitlab/Todo::1' }],
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+};
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 03ae77d4977..57023c55878 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
@@ -61,6 +61,7 @@ exports[`Design management design index page renders design index 1`] = `
<participants-stub
class="gl-mb-4"
+ lazy="true"
numberoflessparticipants="7"
participants="[object Object]"
/>
@@ -221,6 +222,7 @@ exports[`Design management design index page with error GlAlert is rendered in c
<participants-stub
class="gl-mb-4"
+ lazy="true"
numberoflessparticipants="7"
participants="[object Object]"
/>
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index b5eb3e1713c..1464dd84666 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter';
import Mousetrap from 'mousetrap';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'spec/test_constants';
import App from '~/diffs/components/app.vue';
import CommitWidget from '~/diffs/components/commit_widget.vue';
@@ -16,7 +17,6 @@ import TreeList from '~/diffs/components/tree_list.vue';
/* You know what: sometimes alphabetical isn't the best order */
import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue';
import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
-import MergeConflictWarning from '~/diffs/components/merge_conflict_warning.vue';
/* eslint-enable import/order */
import axios from '~/lib/utils/axios_utils';
@@ -258,6 +258,8 @@ describe('diffs/components/app', () => {
});
it('marks current diff file based on currently highlighted row', async () => {
+ window.location.hash = 'ABC_123';
+
createComponent({
shouldShow: true,
});
@@ -428,12 +430,9 @@ describe('diffs/components/app', () => {
jest.spyOn(wrapper.vm, 'refetchDiffData').mockImplementation(() => {});
jest.spyOn(wrapper.vm, 'adjustView').mockImplementation(() => {});
};
- let location;
- beforeAll(() => {
- location = window.location;
- delete window.location;
- window.location = COMMIT_URL;
+ beforeEach(() => {
+ setWindowLocation(COMMIT_URL);
document.title = 'My Title';
});
@@ -441,10 +440,6 @@ describe('diffs/components/app', () => {
jest.spyOn(urlUtils, 'updateHistory');
});
- afterAll(() => {
- window.location = location;
- });
-
it('when the commit changes and the app is not loading it should update the history, refetch the diff data, and update the view', async () => {
createComponent({}, ({ state }) => {
state.diffs.commit = { ...state.diffs.commit, id: 'OLD' };
@@ -546,43 +541,6 @@ describe('diffs/components/app', () => {
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false);
});
});
-
- describe('merge conflicts', () => {
- it('should render the merge conflicts banner if viewing the whole changeset and there are conflicts', () => {
- createComponent({}, ({ state }) => {
- Object.assign(state.diffs, {
- latestDiff: true,
- startVersion: null,
- hasConflicts: true,
- canMerge: false,
- conflictResolutionPath: 'path',
- });
- });
-
- expect(wrapper.find(MergeConflictWarning).exists()).toBe(true);
- });
-
- it.each`
- prop | value
- ${'latestDiff'} | ${false}
- ${'startVersion'} | ${'notnull'}
- ${'hasConflicts'} | ${false}
- `(
- "should not render if any of the MR properties aren't correct - like $prop: $value",
- ({ prop, value }) => {
- createComponent({}, ({ state }) => {
- Object.assign(state.diffs, {
- latestDiff: true,
- startVersion: null,
- hasConflicts: true,
- [prop]: value,
- });
- });
-
- expect(wrapper.find(MergeConflictWarning).exists()).toBe(false);
- },
- );
- });
});
it('should display commit widget if store has a commit', () => {
diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js
index 80a51ee137a..1c0cb1193fa 100644
--- a/spec/frontend/diffs/components/compare_versions_spec.js
+++ b/spec/frontend/diffs/components/compare_versions_spec.js
@@ -1,5 +1,6 @@
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { trimText } from 'helpers/text_helper';
import CompareVersionsComponent from '~/diffs/components/compare_versions.vue';
@@ -13,6 +14,10 @@ localVue.use(Vuex);
const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`;
const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`;
+beforeEach(() => {
+ setWindowLocation(TEST_HOST);
+});
+
describe('CompareVersions', () => {
let wrapper;
let store;
@@ -215,15 +220,7 @@ describe('CompareVersions', () => {
describe('prev commit', () => {
beforeAll(() => {
- global.jsdom.reconfigure({
- url: `${TEST_HOST}?commit_id=${mrCommit.id}`,
- });
- });
-
- afterAll(() => {
- global.jsdom.reconfigure({
- url: TEST_HOST,
- });
+ setWindowLocation(`?commit_id=${mrCommit.id}`);
});
beforeEach(() => {
@@ -258,15 +255,7 @@ describe('CompareVersions', () => {
describe('next commit', () => {
beforeAll(() => {
- global.jsdom.reconfigure({
- url: `${TEST_HOST}?commit_id=${mrCommit.id}`,
- });
- });
-
- afterAll(() => {
- global.jsdom.reconfigure({
- url: TEST_HOST,
- });
+ setWindowLocation(`?commit_id=${mrCommit.id}`);
});
beforeEach(() => {
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index 99dda8d5deb..3dec56f2fe3 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -521,4 +521,54 @@ describe('DiffFile', () => {
expect(button.attributes('href')).toBe('/file/view/path');
});
});
+
+ it('loads collapsed file on mounted when single file mode is enabled', async () => {
+ wrapper.destroy();
+
+ const file = {
+ ...getReadableFile(),
+ load_collapsed_diff_url: '/diff_for_path',
+ highlighted_diff_lines: [],
+ parallel_diff_lines: [],
+ viewer: { name: 'collapsed', automaticallyCollapsed: true },
+ };
+
+ axiosMock.onGet(file.load_collapsed_diff_url).reply(httpStatus.OK, getReadableFile());
+
+ ({ wrapper, store } = createComponent({ file, props: { viewDiffsFileByFile: true } }));
+
+ await wrapper.vm.$nextTick();
+
+ expect(findLoader(wrapper).exists()).toBe(true);
+ });
+
+ describe('merge conflicts', () => {
+ beforeEach(() => {
+ wrapper.destroy();
+ });
+
+ it('does not render conflict alert', () => {
+ const file = {
+ ...getReadableFile(),
+ conflict_type: null,
+ renderIt: true,
+ };
+
+ ({ wrapper, store } = createComponent({ file }));
+
+ expect(wrapper.find('[data-testid="conflictsAlert"]').exists()).toBe(false);
+ });
+
+ it('renders conflict alert when conflict_type is present', () => {
+ const file = {
+ ...getReadableFile(),
+ conflict_type: 'both_modified',
+ renderIt: true,
+ };
+
+ ({ wrapper, store } = createComponent({ file }));
+
+ expect(wrapper.find('[data-testid="conflictsAlert"]').exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js
index 43b9c5871a6..2dd35519464 100644
--- a/spec/frontend/diffs/components/settings_dropdown_spec.js
+++ b/spec/frontend/diffs/components/settings_dropdown_spec.js
@@ -48,13 +48,17 @@ describe('Diff settings dropdown component', () => {
it('list view button dispatches setRenderTreeList with false', () => {
wrapper.find('.js-list-view').trigger('click');
- expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', false);
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', {
+ renderTreeList: false,
+ });
});
it('tree view button dispatches setRenderTreeList with true', () => {
wrapper.find('.js-tree-view').trigger('click');
- expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', true);
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', {
+ renderTreeList: true,
+ });
});
it('sets list button as selected when renderTreeList is false', () => {
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index c2e5d07bcfd..6d005b868a9 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -874,6 +874,7 @@ describe('DiffsStoreActions', () => {
describe('scrollToFile', () => {
let commit;
+ const getters = { isVirtualScrollingEnabled: false };
beforeEach(() => {
commit = jest.fn();
@@ -888,7 +889,7 @@ describe('DiffsStoreActions', () => {
},
};
- scrollToFile({ state, commit }, 'path');
+ scrollToFile({ state, commit, getters }, 'path');
expect(document.location.hash).toBe('#test');
});
@@ -902,7 +903,7 @@ describe('DiffsStoreActions', () => {
},
};
- scrollToFile({ state, commit }, 'path');
+ scrollToFile({ state, commit, getters }, 'path');
expect(commit).toHaveBeenCalledWith(types.VIEW_DIFF_FILE, 'test');
});
@@ -1000,7 +1001,7 @@ describe('DiffsStoreActions', () => {
it('commits SET_RENDER_TREE_LIST', (done) => {
testAction(
setRenderTreeList,
- true,
+ { renderTreeList: true },
{},
[{ type: types.SET_RENDER_TREE_LIST, payload: true }],
[],
@@ -1009,7 +1010,7 @@ describe('DiffsStoreActions', () => {
});
it('sets localStorage', () => {
- setRenderTreeList({ commit() {} }, true);
+ setRenderTreeList({ commit() {} }, { renderTreeList: true });
expect(localStorage.setItem).toHaveBeenCalledWith('mr_diff_tree_list', true);
});
diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
index 99f13a1c84c..6ea8f691c3c 100644
--- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
+++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js
@@ -1,3 +1,4 @@
+import setWindowLocation from 'helpers/set_window_location_helper';
import {
DIFF_COMPARE_BASE_VERSION_INDEX,
DIFF_COMPARE_HEAD_VERSION_INDEX,
@@ -47,15 +48,12 @@ describe('Compare diff version dropdowns', () => {
let expectedFirstVersion;
let expectedBaseVersion;
let expectedHeadVersion;
- const originalLocation = window.location;
+ const originalLocation = window.location.href;
const setupTest = (includeDiffHeadParam) => {
const diffHeadParam = includeDiffHeadParam ? '?diff_head=true' : '';
- Object.defineProperty(window, 'location', {
- writable: true,
- value: { search: diffHeadParam },
- });
+ setWindowLocation(diffHeadParam);
expectedFirstVersion = {
...diffsMockData[1],
@@ -91,7 +89,7 @@ describe('Compare diff version dropdowns', () => {
};
afterEach(() => {
- window.location = originalLocation;
+ setWindowLocation(originalLocation);
});
it('base version selected', () => {
diff --git a/spec/frontend/diffs/utils/queue_events_spec.js b/spec/frontend/diffs/utils/queue_events_spec.js
new file mode 100644
index 00000000000..007748d8b2c
--- /dev/null
+++ b/spec/frontend/diffs/utils/queue_events_spec.js
@@ -0,0 +1,36 @@
+import api from '~/api';
+import { DEFER_DURATION } from '~/diffs/constants';
+import { queueRedisHllEvents } from '~/diffs/utils/queue_events';
+
+jest.mock('~/api', () => ({
+ trackRedisHllUserEvent: jest.fn(),
+}));
+
+describe('diffs events queue', () => {
+ describe('queueRedisHllEvents', () => {
+ it('does not dispatch the event immediately', () => {
+ queueRedisHllEvents(['know_event']);
+ expect(api.trackRedisHllUserEvent).not.toHaveBeenCalled();
+ });
+
+ it('does dispatch the event after the defer duration', () => {
+ queueRedisHllEvents(['know_event']);
+ jest.advanceTimersByTime(DEFER_DURATION + 1);
+ expect(api.trackRedisHllUserEvent).toHaveBeenCalled();
+ });
+
+ it('increase defer duration based on the provided events count', () => {
+ let deferDuration = DEFER_DURATION + 1;
+ const events = ['know_event_a', 'know_event_b', 'know_event_c'];
+ queueRedisHllEvents(events);
+
+ expect(api.trackRedisHllUserEvent).not.toHaveBeenCalled();
+
+ events.forEach((event, index) => {
+ jest.advanceTimersByTime(deferDuration);
+ expect(api.trackRedisHllUserEvent).toHaveBeenLastCalledWith(event);
+ deferDuration *= index + 1;
+ });
+ });
+ });
+});
diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js
index 352db9d0d51..2c06ae03892 100644
--- a/spec/frontend/editor/source_editor_extension_base_spec.js
+++ b/spec/frontend/editor/source_editor_extension_base_spec.js
@@ -1,5 +1,6 @@
import { Range } from 'monaco-editor';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
+import setWindowLocation from 'helpers/set_window_location_helper';
import {
ERROR_INSTANCE_REQUIRED_FOR_EXTENSION,
EDITOR_TYPE_CODE,
@@ -152,12 +153,7 @@ describe('The basis for an Source Editor extension', () => {
useFakeRequestAnimationFrame();
beforeEach(() => {
- delete window.location;
- window.location = new URL(`https://localhost`);
- });
-
- afterEach(() => {
- window.location.hash = '';
+ setWindowLocation('https://localhost');
});
it.each`
diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js
index 943e21250b4..48ccc10e486 100644
--- a/spec/frontend/editor/source_editor_markdown_ext_spec.js
+++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js
@@ -1,16 +1,36 @@
-import { Range, Position } from 'monaco-editor';
+import MockAdapter from 'axios-mock-adapter';
+import { Range, Position, editor as monacoEditor } from 'monaco-editor';
+import waitForPromises from 'helpers/wait_for_promises';
+import {
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
+ EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
+ EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
+ EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
+} from '~/editor/constants';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import SourceEditor from '~/editor/source_editor';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import syntaxHighlight from '~/syntax_highlight';
+
+jest.mock('~/syntax_highlight');
+jest.mock('~/flash');
describe('Markdown Extension for Source Editor', () => {
let editor;
let instance;
let editorEl;
+ let panelSpy;
+ let mockAxios;
+ const projectPath = 'fooGroup/barProj';
const firstLine = 'This is a';
const secondLine = 'multiline';
const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
- const filePath = 'foo.md';
+ const plaintextPath = 'foo.txt';
+ const markdownPath = 'foo.md';
+ const responseData = '<div>FooBar</div>';
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
@@ -22,21 +42,378 @@ describe('Markdown Extension for Source Editor', () => {
const selectionToString = () => instance.getSelection().toString();
const positionToString = () => instance.getPosition().toString();
+ const togglePreview = async () => {
+ instance.togglePreview();
+ await waitForPromises();
+ };
+
beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
setFixtures('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
editor = new SourceEditor();
instance = editor.createInstance({
el: editorEl,
- blobPath: filePath,
+ blobPath: markdownPath,
blobContent: text,
});
- editor.use(new EditorMarkdownExtension());
+ editor.use(new EditorMarkdownExtension({ instance, projectPath }));
+ panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
});
afterEach(() => {
instance.dispose();
editorEl.remove();
+ mockAxios.restore();
+ });
+
+ it('sets up the instance', () => {
+ expect(instance.preview).toEqual({
+ el: undefined,
+ action: expect.any(Object),
+ shown: false,
+ modelChangeListener: undefined,
+ });
+ expect(instance.projectPath).toBe(projectPath);
+ });
+
+ describe('model language changes listener', () => {
+ let cleanupSpy;
+ let actionSpy;
+
+ beforeEach(async () => {
+ cleanupSpy = jest.spyOn(instance, 'cleanup');
+ actionSpy = jest.spyOn(instance, 'setupPreviewAction');
+ await togglePreview();
+ });
+
+ it('cleans up when switching away from markdown', () => {
+ expect(instance.cleanup).not.toHaveBeenCalled();
+ expect(instance.setupPreviewAction).not.toHaveBeenCalled();
+
+ instance.updateModelLanguage(plaintextPath);
+
+ expect(cleanupSpy).toHaveBeenCalled();
+ expect(actionSpy).not.toHaveBeenCalled();
+ });
+
+ it.each`
+ oldLanguage | newLanguage | setupCalledTimes
+ ${'plaintext'} | ${'markdown'} | ${1}
+ ${'markdown'} | ${'markdown'} | ${0}
+ ${'markdown'} | ${'plaintext'} | ${0}
+ ${'markdown'} | ${undefined} | ${0}
+ ${undefined} | ${'markdown'} | ${1}
+ `(
+ 'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage',
+ ({ oldLanguage, newLanguage, setupCalledTimes } = {}) => {
+ expect(actionSpy).not.toHaveBeenCalled();
+ instance.updateModelLanguage(oldLanguage);
+ instance.updateModelLanguage(newLanguage);
+ expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
+ },
+ );
+ });
+
+ describe('model change listener', () => {
+ let cleanupSpy;
+ let actionSpy;
+
+ beforeEach(() => {
+ cleanupSpy = jest.spyOn(instance, 'cleanup');
+ actionSpy = jest.spyOn(instance, 'setupPreviewAction');
+ instance.togglePreview();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('does not do anything if there is no model', () => {
+ instance.setModel(null);
+
+ expect(cleanupSpy).not.toHaveBeenCalled();
+ expect(actionSpy).not.toHaveBeenCalled();
+ });
+
+ it('cleans up the preview when the model changes', () => {
+ instance.setModel(monacoEditor.createModel('foo'));
+ expect(cleanupSpy).toHaveBeenCalled();
+ });
+
+ it.each`
+ language | setupCalledTimes
+ ${'markdown'} | ${1}
+ ${'plaintext'} | ${0}
+ ${undefined} | ${0}
+ `(
+ 'correctly handles actions when the new model is $language',
+ ({ language, setupCalledTimes } = {}) => {
+ instance.setModel(monacoEditor.createModel('foo', language));
+
+ expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
+ },
+ );
+ });
+
+ describe('cleanup', () => {
+ beforeEach(async () => {
+ mockAxios.onPost().reply(200, { body: responseData });
+ await togglePreview();
+ });
+
+ it('disposes the modelChange listener and does not fetch preview on content changes', () => {
+ expect(instance.preview.modelChangeListener).toBeDefined();
+ jest.spyOn(instance, 'fetchPreview');
+
+ instance.cleanup();
+ instance.setValue('Foo Bar');
+ jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY);
+
+ expect(instance.fetchPreview).not.toHaveBeenCalled();
+ });
+
+ it('removes the contextual menu action', () => {
+ expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
+
+ instance.cleanup();
+
+ expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null);
+ });
+
+ it('toggles the `shown` flag', () => {
+ expect(instance.preview.shown).toBe(true);
+ instance.cleanup();
+ expect(instance.preview.shown).toBe(false);
+ });
+
+ it('toggles the panel only if the preview is visible', () => {
+ const { el: previewEl } = instance.preview;
+ const parentEl = previewEl.parentElement;
+
+ expect(previewEl).toBeVisible();
+ expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true);
+
+ instance.cleanup();
+ expect(previewEl).toBeHidden();
+ expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
+ false,
+ );
+
+ instance.cleanup();
+ expect(previewEl).toBeHidden();
+ expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
+ false,
+ );
+ });
+
+ it('toggles the layout only if the preview is visible', () => {
+ const { width } = instance.getLayoutInfo();
+
+ expect(instance.preview.shown).toBe(true);
+
+ instance.cleanup();
+
+ const { width: newWidth } = instance.getLayoutInfo();
+ expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
+
+ instance.cleanup();
+ expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
+ });
+ });
+
+ describe('fetchPreview', () => {
+ const group = 'foo';
+ const project = 'bar';
+ const setData = (path, g, p) => {
+ instance.projectPath = path;
+ document.body.setAttribute('data-group', g);
+ document.body.setAttribute('data-project', p);
+ };
+ const fetchPreview = async () => {
+ instance.fetchPreview();
+ await waitForPromises();
+ };
+
+ beforeEach(() => {
+ mockAxios.onPost().reply(200, { body: responseData });
+ });
+
+ it('correctly fetches preview based on projectPath', async () => {
+ setData(projectPath, group, project);
+ await fetchPreview();
+ expect(mockAxios.history.post[0].url).toBe(`/${projectPath}/preview_markdown`);
+ expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text }));
+ });
+
+ it('correctly fetches preview based on group and project data attributes', async () => {
+ setData(undefined, group, project);
+ await fetchPreview();
+ expect(mockAxios.history.post[0].url).toBe(`/${group}/${project}/preview_markdown`);
+ expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text }));
+ });
+
+ it('puts the fetched content into the preview DOM element', async () => {
+ instance.preview.el = editorEl.parentElement;
+ await fetchPreview();
+ expect(instance.preview.el.innerHTML).toEqual(responseData);
+ });
+
+ it('applies syntax highlighting to the preview content', async () => {
+ instance.preview.el = editorEl.parentElement;
+ await fetchPreview();
+ expect(syntaxHighlight).toHaveBeenCalled();
+ });
+
+ it('catches the errors when fetching the preview', async () => {
+ mockAxios.onPost().reply(500);
+
+ await fetchPreview();
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+
+ describe('setupPreviewAction', () => {
+ it('adds the contextual menu action', () => {
+ expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
+ });
+
+ it('does not set up action if one already exists', () => {
+ jest.spyOn(instance, 'addAction').mockImplementation();
+
+ instance.setupPreviewAction();
+ expect(instance.addAction).not.toHaveBeenCalled();
+ });
+
+ it('toggles preview when the action is triggered', () => {
+ jest.spyOn(instance, 'togglePreview').mockImplementation();
+
+ expect(instance.togglePreview).not.toHaveBeenCalled();
+
+ const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID);
+ action.run();
+
+ expect(instance.togglePreview).toHaveBeenCalled();
+ });
+ });
+
+ describe('togglePreview', () => {
+ beforeEach(() => {
+ mockAxios.onPost().reply(200, { body: responseData });
+ });
+
+ it('toggles preview flag on instance', () => {
+ expect(instance.preview.shown).toBe(false);
+
+ instance.togglePreview();
+ expect(instance.preview.shown).toBe(true);
+
+ instance.togglePreview();
+ expect(instance.preview.shown).toBe(false);
+ });
+
+ describe('panel DOM element set up', () => {
+ it('sets up an element to contain the preview and stores it on instance', () => {
+ expect(instance.preview.el).toBeUndefined();
+
+ instance.togglePreview();
+
+ expect(instance.preview.el).toBeDefined();
+ expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
+ true,
+ );
+ });
+
+ it('re-uses existing preview DOM element on repeated calls', () => {
+ instance.togglePreview();
+ const origPreviewEl = instance.preview.el;
+ instance.togglePreview();
+
+ expect(instance.preview.el).toBe(origPreviewEl);
+ });
+
+ it('hides the preview DOM element by default', () => {
+ panelSpy.mockImplementation();
+ instance.togglePreview();
+ expect(instance.preview.el.style.display).toBe('none');
+ });
+ });
+
+ describe('preview layout setup', () => {
+ it('sets correct preview layout', () => {
+ jest.spyOn(instance, 'layout');
+ const { width, height } = instance.getLayoutInfo();
+
+ instance.togglePreview();
+
+ expect(instance.layout).toHaveBeenCalledWith({
+ width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
+ height,
+ });
+ });
+ });
+
+ describe('preview panel', () => {
+ it('toggles preview CSS class on the editor', () => {
+ expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
+ false,
+ );
+ instance.togglePreview();
+ expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
+ true,
+ );
+ instance.togglePreview();
+ expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
+ false,
+ );
+ });
+
+ it('toggles visibility of the preview DOM element', async () => {
+ await togglePreview();
+ expect(instance.preview.el.style.display).toBe('block');
+ await togglePreview();
+ expect(instance.preview.el.style.display).toBe('none');
+ });
+
+ describe('hidden preview DOM element', () => {
+ it('listens to model changes and re-fetches preview', async () => {
+ expect(mockAxios.history.post).toHaveLength(0);
+ await togglePreview();
+ expect(mockAxios.history.post).toHaveLength(1);
+
+ instance.setValue('New Value');
+ await waitForPromises();
+ expect(mockAxios.history.post).toHaveLength(2);
+ });
+
+ it('stores disposable listener for model changes', async () => {
+ expect(instance.preview.modelChangeListener).toBeUndefined();
+ await togglePreview();
+ expect(instance.preview.modelChangeListener).toBeDefined();
+ });
+ });
+
+ describe('already visible preview', () => {
+ beforeEach(async () => {
+ await togglePreview();
+ mockAxios.resetHistory();
+ });
+
+ it('does not re-fetch the preview', () => {
+ instance.togglePreview();
+ expect(mockAxios.history.post).toHaveLength(0);
+ });
+
+ it('disposes the model change event listener', () => {
+ const disposeSpy = jest.fn();
+ instance.preview.modelChangeListener = {
+ dispose: disposeSpy,
+ };
+ instance.togglePreview();
+ expect(disposeSpy).toHaveBeenCalled();
+ });
+ });
+ });
});
describe('getSelectedText', () => {
diff --git a/spec/frontend/editor/utils_spec.js b/spec/frontend/editor/utils_spec.js
new file mode 100644
index 00000000000..97d3e9e081d
--- /dev/null
+++ b/spec/frontend/editor/utils_spec.js
@@ -0,0 +1,85 @@
+import { editor as monacoEditor } from 'monaco-editor';
+import * as utils from '~/editor/utils';
+import { DEFAULT_THEME } from '~/ide/lib/themes';
+
+describe('Source Editor utils', () => {
+ let el;
+
+ const stubUserColorScheme = (value) => {
+ if (window.gon == null) {
+ window.gon = {};
+ }
+ window.gon.user_color_scheme = value;
+ };
+
+ describe('clearDomElement', () => {
+ beforeEach(() => {
+ setFixtures('<div id="foo"><div id="bar">Foo</div></div>');
+ el = document.getElementById('foo');
+ });
+
+ it('removes all child nodes from an element', () => {
+ expect(el.children.length).toBe(1);
+ utils.clearDomElement(el);
+ expect(el.children.length).toBe(0);
+ });
+ });
+
+ describe('setupEditorTheme', () => {
+ beforeEach(() => {
+ jest.spyOn(monacoEditor, 'defineTheme').mockImplementation();
+ jest.spyOn(monacoEditor, 'setTheme').mockImplementation();
+ });
+
+ it.each`
+ themeName | expectedThemeName
+ ${'solarized-light'} | ${'solarized-light'}
+ ${DEFAULT_THEME} | ${DEFAULT_THEME}
+ ${'non-existent'} | ${DEFAULT_THEME}
+ `(
+ 'sets the $expectedThemeName theme when $themeName is set in the user preference',
+ ({ themeName, expectedThemeName }) => {
+ stubUserColorScheme(themeName);
+ utils.setupEditorTheme();
+
+ expect(monacoEditor.setTheme).toHaveBeenCalledWith(expectedThemeName);
+ },
+ );
+ });
+
+ describe('getBlobLanguage', () => {
+ it.each`
+ path | expectedLanguage
+ ${'foo.js'} | ${'javascript'}
+ ${'foo.js.rb'} | ${'ruby'}
+ ${'foo.bar'} | ${'plaintext'}
+ ${undefined} | ${'plaintext'}
+ `(
+ 'sets the $expectedThemeName theme when $themeName is set in the user preference',
+ ({ path, expectedLanguage }) => {
+ const language = utils.getBlobLanguage(path);
+
+ expect(language).toEqual(expectedLanguage);
+ },
+ );
+ });
+
+ describe('setupCodeSnipet', () => {
+ beforeEach(() => {
+ jest.spyOn(monacoEditor, 'colorizeElement').mockImplementation();
+ jest.spyOn(monacoEditor, 'setTheme').mockImplementation();
+ setFixtures('<pre id="foo"></pre>');
+ el = document.getElementById('foo');
+ });
+
+ it('colorizes the element and applies the preference theme', () => {
+ expect(monacoEditor.colorizeElement).not.toHaveBeenCalled();
+ expect(monacoEditor.setTheme).not.toHaveBeenCalled();
+
+ utils.setupCodeSnippet(el);
+
+ expect(monacoEditor.colorizeElement).toHaveBeenCalledWith(el);
+ expect(monacoEditor.setTheme).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
index 29aa416149c..cf47a1cd7bb 100644
--- a/spec/frontend/environment.js
+++ b/spec/frontend/environment.js
@@ -88,13 +88,32 @@ class CustomEnvironment extends JSDOMEnvironment {
}),
});
- this.global.PerformanceObserver = class {
+ /**
+ * JSDom doesn't have an own observer implementation, so this a Noop Observer.
+ * If you are testing functionality, related to observers, have a look at __helpers__/mock_dom_observer.js
+ *
+ * JSDom actually implements a _proper_ MutationObserver, so no need to mock it!
+ */
+ class NoopObserver {
/* eslint-disable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */
constructor(callback) {}
disconnect() {}
observe(element, initObject) {}
+ unobserve(element) {}
+ takeRecords() {
+ return [];
+ }
/* eslint-enable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */
- };
+ }
+
+ ['IntersectionObserver', 'PerformanceObserver', 'ResizeObserver'].forEach((observer) => {
+ if (this.global[observer]) {
+ throw new Error(
+ `We overwrite an existing Observer in jsdom (${observer}), are you sure you want to do that?`,
+ );
+ }
+ this.global[observer] = NoopObserver;
+ });
}
async teardown() {
diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js
index 8fb53579f96..d62aaec4f69 100644
--- a/spec/frontend/environments/confirm_rollback_modal_spec.js
+++ b/spec/frontend/environments/confirm_rollback_modal_spec.js
@@ -1,70 +1,104 @@
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
import eventHub from '~/environments/event_hub';
describe('Confirm Rollback Modal Component', () => {
let environment;
+ let component;
- beforeEach(() => {
- environment = {
- name: 'test',
- last_deployment: {
- commit: {
- short_id: 'abc0123',
- },
+ const envWithLastDeployment = {
+ name: 'test',
+ last_deployment: {
+ commit: {
+ short_id: 'abc0123',
},
- modalId: 'test',
- };
- });
+ },
+ modalId: 'test',
+ };
- it('should show "Rollback" when isLastDeployment is false', () => {
- const component = shallowMount(ConfirmRollbackModal, {
- propsData: {
- environment: {
- ...environment,
- isLastDeployment: false,
- },
- },
- });
- const modal = component.find(GlModal);
+ const envWithoutLastDeployment = {
+ name: 'test',
+ modalId: 'test',
+ commitShortSha: 'abc0123',
+ commitUrl: 'test/-/commit/abc0123',
+ };
- expect(modal.attributes('title')).toContain('Rollback');
- expect(modal.attributes('title')).toContain('test');
- expect(modal.attributes('ok-title')).toBe('Rollback');
- expect(modal.text()).toContain('commit abc0123');
- expect(modal.text()).toContain('Are you sure you want to continue?');
- });
+ const retryPath = 'test/-/jobs/123/retry';
- it('should show "Re-deploy" when isLastDeployment is true', () => {
- const component = shallowMount(ConfirmRollbackModal, {
+ const createComponent = (props = {}) => {
+ component = shallowMount(ConfirmRollbackModal, {
propsData: {
- environment: {
- ...environment,
- isLastDeployment: true,
- },
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
},
});
- const modal = component.find(GlModal);
+ };
- expect(modal.attributes('title')).toContain('Re-deploy');
- expect(modal.attributes('title')).toContain('test');
- expect(modal.attributes('ok-title')).toBe('Re-deploy');
- expect(modal.text()).toContain('commit abc0123');
- expect(modal.text()).toContain('Are you sure you want to continue?');
- });
+ describe.each`
+ hasMultipleCommits | environmentData | retryUrl | primaryPropsAttrs
+ ${true} | ${envWithLastDeployment} | ${null} | ${[{ variant: 'danger' }]}
+ ${false} | ${envWithoutLastDeployment} | ${retryPath} | ${[{ variant: 'danger' }, { 'data-method': 'post' }, { href: retryPath }]}
+ `(
+ 'when hasMultipleCommits=$hasMultipleCommits',
+ ({ hasMultipleCommits, environmentData, retryUrl, primaryPropsAttrs }) => {
+ beforeEach(() => {
+ environment = environmentData;
+ });
- it('should emit the "rollback" event when "ok" is clicked', () => {
- environment = { ...environment, isLastDeployment: true };
- const component = shallowMount(ConfirmRollbackModal, {
- propsData: {
- environment,
- },
- });
- const eventHubSpy = jest.spyOn(eventHub, '$emit');
- const modal = component.find(GlModal);
- modal.vm.$emit('ok');
+ it('should show "Rollback" when isLastDeployment is false', () => {
+ createComponent({
+ environment: {
+ ...environment,
+ isLastDeployment: false,
+ },
+ hasMultipleCommits,
+ retryUrl,
+ });
+ const modal = component.find(GlModal);
+
+ expect(modal.attributes('title')).toContain('Rollback');
+ expect(modal.attributes('title')).toContain('test');
+ expect(modal.props('actionPrimary').text).toBe('Rollback');
+ expect(modal.props('actionPrimary').attributes).toEqual(primaryPropsAttrs);
+ expect(modal.text()).toContain('commit abc0123');
+ expect(modal.text()).toContain('Are you sure you want to continue?');
+ });
+
+ it('should show "Re-deploy" when isLastDeployment is true', () => {
+ createComponent({
+ environment: {
+ ...environment,
+ isLastDeployment: true,
+ },
+ hasMultipleCommits,
+ });
+
+ const modal = component.find(GlModal);
+
+ expect(modal.attributes('title')).toContain('Re-deploy');
+ expect(modal.attributes('title')).toContain('test');
+ expect(modal.props('actionPrimary').text).toBe('Re-deploy');
+ expect(modal.text()).toContain('commit abc0123');
+ expect(modal.text()).toContain('Are you sure you want to continue?');
+ });
+
+ it('should emit the "rollback" event when "ok" is clicked', () => {
+ const env = { ...environmentData, isLastDeployment: true };
+
+ createComponent({
+ environment: env,
+ hasMultipleCommits,
+ });
+
+ const eventHubSpy = jest.spyOn(eventHub, '$emit');
+ const modal = component.find(GlModal);
+ modal.vm.$emit('ok');
- expect(eventHubSpy).toHaveBeenCalledWith('rollbackEnvironment', environment);
- });
+ expect(eventHubSpy).toHaveBeenCalledWith('rollbackEnvironment', env);
+ });
+ },
+ );
});
diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js
new file mode 100644
index 00000000000..3e7f5dd5ff4
--- /dev/null
+++ b/spec/frontend/environments/edit_environment_spec.js
@@ -0,0 +1,104 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import EditEnvironment from '~/environments/components/edit_environment.vue';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility');
+jest.mock('~/flash');
+
+const DEFAULT_OPTS = {
+ provide: {
+ projectEnvironmentsPath: '/projects/environments',
+ updateEnvironmentPath: '/proejcts/environments/1',
+ },
+ propsData: { environment: { name: 'foo', externalUrl: 'https://foo.example.com' } },
+};
+
+describe('~/environments/components/edit.vue', () => {
+ let wrapper;
+ let mock;
+ let name;
+ let url;
+ let form;
+
+ const createWrapper = (opts = {}) =>
+ mountExtended(EditEnvironment, {
+ ...DEFAULT_OPTS,
+ ...opts,
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ wrapper = createWrapper();
+ name = wrapper.findByLabelText('Name');
+ url = wrapper.findByLabelText('External URL');
+ form = wrapper.findByRole('form', { name: 'Edit environment' });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ const showsLoading = () => wrapper.find(GlLoadingIcon).exists();
+
+ const submitForm = async (expected, response) => {
+ mock
+ .onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, {
+ name: expected.name,
+ external_url: expected.url,
+ })
+ .reply(...response);
+ await name.setValue(expected.name);
+ await url.setValue(expected.url);
+
+ await form.trigger('submit');
+ await waitForPromises();
+ };
+
+ it('sets the title to Edit environment', () => {
+ const header = wrapper.findByRole('heading', { name: 'Edit environment' });
+ expect(header.exists()).toBe(true);
+ });
+
+ it.each`
+ input | value
+ ${() => name} | ${'test'}
+ ${() => url} | ${'https://example.org'}
+ `('it changes the value of the input to $value', async ({ input, value }) => {
+ await input().setValue(value);
+
+ expect(input().element.value).toBe(value);
+ });
+
+ it('shows loader after form is submitted', async () => {
+ const expected = { name: 'test', url: 'https://google.ca' };
+
+ expect(showsLoading()).toBe(false);
+
+ await submitForm(expected, [200, { path: '/test' }]);
+
+ expect(showsLoading()).toBe(true);
+ });
+
+ it('submits the updated environment on submit', async () => {
+ const expected = { name: 'test', url: 'https://google.ca' };
+
+ await submitForm(expected, [200, { path: '/test' }]);
+
+ expect(visitUrl).toHaveBeenCalledWith('/test');
+ });
+
+ it('shows errors on error', async () => {
+ const expected = { name: 'test', url: 'https://google.ca' };
+
+ await submitForm(expected, [400, { message: ['name taken'] }]);
+
+ expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' });
+ expect(showsLoading()).toBe(false);
+ });
+});
diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js
new file mode 100644
index 00000000000..ed8fda71dab
--- /dev/null
+++ b/spec/frontend/environments/environment_form_spec.js
@@ -0,0 +1,105 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import EnvironmentForm from '~/environments/components/environment_form.vue';
+
+jest.mock('~/lib/utils/csrf');
+
+const DEFAULT_PROPS = {
+ environment: { name: '', externalUrl: '' },
+ title: 'environment',
+ cancelPath: '/cancel',
+};
+
+describe('~/environments/components/form.vue', () => {
+ let wrapper;
+
+ const createWrapper = (propsData = {}) =>
+ mountExtended(EnvironmentForm, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...propsData,
+ },
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+
+ it('links to documentation regarding environments', () => {
+ const link = wrapper.findByRole('link', { name: 'More information' });
+ expect(link.attributes('href')).toBe('/help/ci/environments/index.md');
+ });
+
+ it('links the cancel button to the cancel path', () => {
+ const cancel = wrapper.findByRole('link', { name: 'Cancel' });
+
+ expect(cancel.attributes('href')).toBe(DEFAULT_PROPS.cancelPath);
+ });
+
+ describe('name input', () => {
+ let name;
+
+ beforeEach(() => {
+ name = wrapper.findByLabelText('Name');
+ });
+
+ it('should emit changes to the name', async () => {
+ await name.setValue('test');
+ await name.trigger('blur');
+
+ expect(wrapper.emitted('change')).toEqual([[{ name: 'test', externalUrl: '' }]]);
+ });
+
+ it('should validate that the name is required', async () => {
+ await name.setValue('');
+ await name.trigger('blur');
+
+ expect(wrapper.findByText('This field is required').exists()).toBe(true);
+ expect(name.attributes('aria-invalid')).toBe('true');
+ });
+ });
+
+ describe('url input', () => {
+ let url;
+
+ beforeEach(() => {
+ url = wrapper.findByLabelText('External URL');
+ });
+
+ it('should emit changes to the url', async () => {
+ await url.setValue('https://example.com');
+ await url.trigger('blur');
+
+ expect(wrapper.emitted('change')).toEqual([
+ [{ name: '', externalUrl: 'https://example.com' }],
+ ]);
+ });
+
+ it('should validate that the url is required', async () => {
+ await url.setValue('example.com');
+ await url.trigger('blur');
+
+ expect(wrapper.findByText('The URL should start with http:// or https://').exists()).toBe(
+ true,
+ );
+ expect(url.attributes('aria-invalid')).toBe('true');
+ });
+ });
+
+ it('submits when the form does', async () => {
+ await wrapper.findByRole('form', { title: 'environment' }).trigger('submit');
+
+ expect(wrapper.emitted('submit')).toEqual([[]]);
+ });
+ });
+
+ it('shows a loading icon while loading', () => {
+ wrapper = createWrapper({ loading: true });
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
index 62806c9e44c..a568a7d5396 100644
--- a/spec/frontend/environments/environment_item_spec.js
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -1,14 +1,21 @@
import { mount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import { format } from 'timeago.js';
+import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
+import ActionsComponent from '~/environments/components/environment_actions.vue';
import DeleteComponent from '~/environments/components/environment_delete.vue';
+import ExternalUrlComponent from '~/environments/components/environment_external_url.vue';
import EnvironmentItem from '~/environments/components/environment_item.vue';
import PinComponent from '~/environments/components/environment_pin.vue';
+import RollbackComponent from '~/environments/components/environment_rollback.vue';
+import StopComponent from '~/environments/components/environment_stop.vue';
+import TerminalButtonComponent from '~/environments/components/environment_terminal_button.vue';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
import { environment, folder, tableData } from './mock_data';
describe('Environment item', () => {
let wrapper;
+ let tracking;
const factory = (options = {}) => {
// This destroys any wrappers created before a nested call to factory reassigns it
@@ -28,6 +35,12 @@ describe('Environment item', () => {
tableData,
},
});
+
+ tracking = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
});
const findAutoStop = () => wrapper.find('.js-auto-stop');
@@ -62,7 +75,7 @@ describe('Environment item', () => {
});
it('should not render the delete button', () => {
- expect(wrapper.find(DeleteComponent).exists()).toBe(false);
+ expect(wrapper.findComponent(DeleteComponent).exists()).toBe(false);
});
describe('With user information', () => {
@@ -176,12 +189,14 @@ describe('Environment item', () => {
});
it('should not render the auto-stop button', () => {
- expect(wrapper.find(PinComponent).exists()).toBe(false);
+ expect(wrapper.findComponent(PinComponent).exists()).toBe(false);
});
});
describe('With auto-stop date', () => {
describe('in the future', () => {
+ let pin;
+
const futureDate = new Date(Date.now() + 100000);
beforeEach(() => {
factory({
@@ -195,6 +210,9 @@ describe('Environment item', () => {
shouldShowAutoStopDate: true,
},
});
+ tracking = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ pin = wrapper.findComponent(PinComponent);
});
it('renders the date', () => {
@@ -202,7 +220,15 @@ describe('Environment item', () => {
});
it('should render the auto-stop button', () => {
- expect(wrapper.find(PinComponent).exists()).toBe(true);
+ expect(pin.exists()).toBe(true);
+ });
+
+ it('should tracks clicks', () => {
+ pin.trigger('click');
+
+ expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'environment_pin',
+ });
});
});
@@ -227,33 +253,104 @@ describe('Environment item', () => {
});
it('should not render the suto-stop button', () => {
- expect(wrapper.find(PinComponent).exists()).toBe(false);
+ expect(wrapper.findComponent(PinComponent).exists()).toBe(false);
});
});
});
});
describe('With manual actions', () => {
+ let actions;
+
+ beforeEach(() => {
+ actions = wrapper.findComponent(ActionsComponent);
+ });
+
it('should render actions component', () => {
- expect(wrapper.find('.js-manual-actions-container')).toBeDefined();
+ expect(actions.exists()).toBe(true);
+ });
+
+ it('should track clicks', () => {
+ actions.trigger('click');
+ expect(tracking).toHaveBeenCalledWith('_category_', 'click_dropdown', {
+ label: 'environment_actions',
+ });
});
});
describe('With external URL', () => {
+ let externalUrl;
+
+ beforeEach(() => {
+ externalUrl = wrapper.findComponent(ExternalUrlComponent);
+ });
+
it('should render external url component', () => {
- expect(wrapper.find('.js-external-url-container')).toBeDefined();
+ expect(externalUrl.exists()).toBe(true);
+ });
+
+ it('should track clicks', () => {
+ externalUrl.trigger('click');
+ expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'environment_url',
+ });
});
});
describe('With stop action', () => {
+ let stop;
+
+ beforeEach(() => {
+ stop = wrapper.findComponent(StopComponent);
+ });
+
it('should render stop action component', () => {
- expect(wrapper.find('.js-stop-component-container')).toBeDefined();
+ expect(stop.exists()).toBe(true);
+ });
+
+ it('should track clicks', () => {
+ stop.trigger('click');
+ expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'environment_stop',
+ });
});
});
describe('With retry action', () => {
+ let rollback;
+
+ beforeEach(() => {
+ rollback = wrapper.findComponent(RollbackComponent);
+ });
+
it('should render rollback component', () => {
- expect(wrapper.find('.js-rollback-component-container')).toBeDefined();
+ expect(rollback.exists()).toBe(true);
+ });
+
+ it('should track clicks', () => {
+ rollback.trigger('click');
+ expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'environment_rollback',
+ });
+ });
+ });
+
+ describe('With terminal path', () => {
+ let terminal;
+
+ beforeEach(() => {
+ terminal = wrapper.findComponent(TerminalButtonComponent);
+ });
+
+ it('should render terminal action component', () => {
+ expect(terminal.exists()).toBe(true);
+ });
+
+ it('should track clicks', () => {
+ triggerEvent(terminal.element);
+ expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'environment_terminal',
+ });
});
});
});
@@ -312,7 +409,17 @@ describe('Environment item', () => {
});
it('should render the delete button', () => {
- expect(wrapper.find(DeleteComponent).exists()).toBe(true);
+ expect(wrapper.findComponent(DeleteComponent).exists()).toBe(true);
+ });
+
+ it('should trigger a tracking event', async () => {
+ tracking = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ await wrapper.findComponent(DeleteComponent).trigger('click');
+
+ expect(tracking).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'environment_delete',
+ });
});
});
});
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index 1abdeff614c..dc176001943 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -1,4 +1,4 @@
-import { GlTabs } from '@gitlab/ui';
+import { GlTabs, GlAlert } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -7,7 +7,9 @@ import DeployBoard from '~/environments/components/deploy_board.vue';
import EmptyState from '~/environments/components/empty_state.vue';
import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue';
import EnvironmentsApp from '~/environments/components/environments_app.vue';
+import { ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME } from '~/environments/constants';
import axios from '~/lib/utils/axios_utils';
+import { setCookie, getCookie, removeCookie } from '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import { environment, folder } from './mock_data';
@@ -48,6 +50,7 @@ describe('Environment', () => {
const findNewEnvironmentButton = () => wrapper.findByTestId('new-environment');
const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a');
const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a');
+ const findSurveyAlert = () => wrapper.find(GlAlert);
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -280,4 +283,49 @@ describe('Environment', () => {
expect(wrapper.findComponent(GlTabs).attributes('value')).toBe('1');
});
});
+
+ describe('survey alert', () => {
+ beforeEach(async () => {
+ mockRequest(200, { environments: [] });
+ await createWrapper(true);
+ });
+
+ afterEach(() => {
+ removeCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME);
+ });
+
+ describe('when the user has not dismissed the alert', () => {
+ it('shows the alert', () => {
+ expect(findSurveyAlert().exists()).toBe(true);
+ });
+
+ describe('when the user dismisses the alert', () => {
+ beforeEach(() => {
+ findSurveyAlert().vm.$emit('dismiss');
+ });
+
+ it('hides the alert', () => {
+ expect(findSurveyAlert().exists()).toBe(false);
+ });
+
+ it('persists the dismisal using a cookie', () => {
+ const cookieValue = getCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME);
+
+ expect(cookieValue).toBe('true');
+ });
+ });
+ });
+
+ describe('when the user has previously dismissed the alert', () => {
+ beforeEach(async () => {
+ setCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME, 'true');
+
+ await createWrapper(true);
+ });
+
+ it('does not show the alert', () => {
+ expect(findSurveyAlert().exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js
new file mode 100644
index 00000000000..6334060c736
--- /dev/null
+++ b/spec/frontend/environments/environments_detail_header_spec.js
@@ -0,0 +1,238 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue';
+import EnvironmentsDetailHeader from '~/environments/components/environments_detail_header.vue';
+import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { createEnvironment } from './mock_data';
+
+describe('Environments detail header component', () => {
+ const cancelAutoStopPath = '/my-environment/cancel/path';
+ const terminalPath = '/my-environment/terminal/path';
+ const metricsPath = '/my-environment/metrics/path';
+ const updatePath = '/my-environment/edit/path';
+
+ let wrapper;
+
+ const findHeader = () => wrapper.findByRole('heading');
+ const findAutoStopsAt = () => wrapper.findByTestId('auto-stops-at');
+ const findCancelAutoStopAtButton = () => wrapper.findByTestId('cancel-auto-stop-button');
+ const findCancelAutoStopAtForm = () => wrapper.findByTestId('cancel-auto-stop-form');
+ const findTerminalButton = () => wrapper.findByTestId('terminal-button');
+ const findExternalUrlButton = () => wrapper.findByTestId('external-url-button');
+ const findMetricsButton = () => wrapper.findByTestId('metrics-button');
+ const findEditButton = () => wrapper.findByTestId('edit-button');
+ const findStopButton = () => wrapper.findByTestId('stop-button');
+ const findDestroyButton = () => wrapper.findByTestId('destroy-button');
+ const findStopEnvironmentModal = () => wrapper.findComponent(StopEnvironmentModal);
+ const findDeleteEnvironmentModal = () => wrapper.findComponent(DeleteEnvironmentModal);
+
+ const buttons = [
+ ['Cancel Auto Stop At', findCancelAutoStopAtButton],
+ ['Terminal', findTerminalButton],
+ ['External Url', findExternalUrlButton],
+ ['Metrics', findMetricsButton],
+ ['Edit', findEditButton],
+ ['Stop', findStopButton],
+ ['Destroy', findDestroyButton],
+ ];
+
+ const createWrapper = ({ props }) => {
+ wrapper = shallowMountExtended(EnvironmentsDetailHeader, {
+ stubs: {
+ GlSprintf,
+ TimeAgo,
+ },
+ propsData: {
+ canReadEnvironment: false,
+ canAdminEnvironment: false,
+ canUpdateEnvironment: false,
+ canStopEnvironment: false,
+ canDestroyEnvironment: false,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default state with minimal access', () => {
+ beforeEach(() => {
+ createWrapper({ props: { environment: createEnvironment() } });
+ });
+
+ it('displays the environment name', () => {
+ expect(findHeader().text()).toBe('My environment');
+ });
+
+ it('does not display an auto stops at text', () => {
+ expect(findAutoStopsAt().exists()).toBe(false);
+ });
+
+ it.each(buttons)('does not display button: %s', (_, findSelector) => {
+ expect(findSelector().exists()).toBe(false);
+ });
+
+ it('does not display stop environment modal', () => {
+ expect(findStopEnvironmentModal().exists()).toBe(false);
+ });
+
+ it('does not display delete environment modal', () => {
+ expect(findDeleteEnvironmentModal().exists()).toBe(false);
+ });
+ });
+
+ describe('when auto stops at is enabled and environment is available', () => {
+ beforeEach(() => {
+ const now = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(now.getDate() + 1);
+ createWrapper({
+ props: {
+ environment: createEnvironment({ autoStopAt: tomorrow.toISOString() }),
+ cancelAutoStopPath,
+ },
+ });
+ });
+
+ it('displays a text that describes when the environment is going to be stopped', () => {
+ expect(findAutoStopsAt().text()).toBe('Auto stops in 1 day');
+ });
+
+ it('displays a cancel auto stops at button with a form to make a post request', () => {
+ const button = findCancelAutoStopAtButton();
+ const form = findCancelAutoStopAtForm();
+ expect(form.attributes('action')).toBe(cancelAutoStopPath);
+ expect(form.attributes('method')).toBe('POST');
+ expect(button.props('icon')).toBe('thumbtack');
+ expect(button.attributes('type')).toBe('submit');
+ });
+
+ it('includes a csrf token', () => {
+ const input = findCancelAutoStopAtForm().find('input');
+ expect(input.attributes('name')).toBe('authenticity_token');
+ });
+ });
+
+ describe('when auto stops at is enabled and environment is unavailable (already stopped)', () => {
+ beforeEach(() => {
+ const now = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(now.getDate() + 1);
+ createWrapper({
+ props: {
+ environment: createEnvironment({
+ autoStopAt: tomorrow.toISOString(),
+ isAvailable: false,
+ }),
+ cancelAutoStopPath,
+ },
+ });
+ });
+
+ it('does not display a text that describes when the environment is going to be stopped', () => {
+ expect(findAutoStopsAt().exists()).toBe(false);
+ });
+
+ it('displays a cancel auto stops at button with correct path', () => {
+ expect(findCancelAutoStopAtButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when has a terminal', () => {
+ beforeEach(() => {
+ createWrapper({
+ props: {
+ environment: createEnvironment({ hasTerminals: true }),
+ canAdminEnvironment: true,
+ terminalPath,
+ },
+ });
+ });
+
+ it('displays the terminal button with correct path', () => {
+ expect(findTerminalButton().attributes('href')).toBe(terminalPath);
+ });
+ });
+
+ describe('when has an external url enabled', () => {
+ const externalUrl = 'https://example.com/my-environment/external/url';
+
+ beforeEach(() => {
+ createWrapper({
+ props: {
+ environment: createEnvironment({ hasTerminals: true, externalUrl }),
+ canReadEnvironment: true,
+ },
+ });
+ });
+
+ it('displays the external url button with correct path', () => {
+ expect(findExternalUrlButton().attributes('href')).toBe(externalUrl);
+ });
+ });
+
+ describe('when metrics are enabled', () => {
+ beforeEach(() => {
+ createWrapper({
+ props: {
+ environment: createEnvironment(),
+ canReadEnvironment: true,
+ metricsPath,
+ },
+ });
+ });
+
+ it('displays the metrics button with correct path', () => {
+ expect(findMetricsButton().attributes('href')).toBe(metricsPath);
+ });
+ });
+
+ describe('when has all admin rights', () => {
+ beforeEach(() => {
+ createWrapper({
+ props: {
+ environment: createEnvironment(),
+ canReadEnvironment: true,
+ canAdminEnvironment: true,
+ canStopEnvironment: true,
+ canUpdateEnvironment: true,
+ updatePath,
+ },
+ });
+ });
+
+ it('displays the edit button with correct path', () => {
+ expect(findEditButton().attributes('href')).toBe(updatePath);
+ });
+
+ it('displays the stop button with correct icon', () => {
+ expect(findStopButton().attributes('icon')).toBe('stop');
+ });
+
+ it('displays stop environment modal', () => {
+ expect(findStopEnvironmentModal().exists()).toBe(true);
+ });
+ });
+
+ describe('when the environment is unavailable and user has destroy permissions', () => {
+ beforeEach(() => {
+ createWrapper({
+ props: {
+ environment: createEnvironment({ isAvailable: false }),
+ canDestroyEnvironment: true,
+ },
+ });
+ });
+
+ it('displays a delete button', () => {
+ expect(findDestroyButton().exists()).toBe(true);
+ });
+
+ it('displays delete environment modal', () => {
+ expect(findDeleteEnvironmentModal().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js
index 9ba71b78c2f..a6d67c26304 100644
--- a/spec/frontend/environments/mock_data.js
+++ b/spec/frontend/environments/mock_data.js
@@ -71,6 +71,8 @@ const environment = {
state: 'stopped',
external_url: 'http://external.com',
environment_type: null,
+ can_stop: true,
+ terminal_path: '/terminal',
last_deployment: {
id: 66,
iid: 6,
@@ -301,4 +303,22 @@ const tableData = {
},
};
-export { environment, environmentsList, folder, serverData, tableData, deployBoardMockData };
+const createEnvironment = (data = {}) => ({
+ id: 1,
+ name: 'My environment',
+ externalUrl: 'my external url',
+ isAvailable: true,
+ hasTerminals: false,
+ autoStopAt: null,
+ ...data,
+});
+
+export {
+ environment,
+ environmentsList,
+ folder,
+ serverData,
+ tableData,
+ deployBoardMockData,
+ createEnvironment,
+};
diff --git a/spec/frontend/environments/new_environment_spec.js b/spec/frontend/environments/new_environment_spec.js
new file mode 100644
index 00000000000..f6d970e02d8
--- /dev/null
+++ b/spec/frontend/environments/new_environment_spec.js
@@ -0,0 +1,100 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import NewEnvironment from '~/environments/components/new_environment.vue';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility');
+jest.mock('~/flash');
+
+const DEFAULT_OPTS = {
+ provide: { projectEnvironmentsPath: '/projects/environments' },
+};
+
+describe('~/environments/components/new.vue', () => {
+ let wrapper;
+ let mock;
+ let name;
+ let url;
+ let form;
+
+ const createWrapper = (opts = {}) =>
+ mountExtended(NewEnvironment, {
+ ...DEFAULT_OPTS,
+ ...opts,
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ wrapper = createWrapper();
+ name = wrapper.findByLabelText('Name');
+ url = wrapper.findByLabelText('External URL');
+ form = wrapper.findByRole('form', { name: 'New environment' });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ const showsLoading = () => wrapper.find(GlLoadingIcon).exists();
+
+ const submitForm = async (expected, response) => {
+ mock
+ .onPost(DEFAULT_OPTS.provide.projectEnvironmentsPath, {
+ name: expected.name,
+ external_url: expected.url,
+ })
+ .reply(...response);
+ await name.setValue(expected.name);
+ await url.setValue(expected.url);
+
+ await form.trigger('submit');
+ await waitForPromises();
+ };
+
+ it('sets the title to New environment', () => {
+ const header = wrapper.findByRole('heading', { name: 'New environment' });
+ expect(header.exists()).toBe(true);
+ });
+
+ it.each`
+ input | value
+ ${() => name} | ${'test'}
+ ${() => url} | ${'https://example.org'}
+ `('it changes the value of the input to $value', async ({ input, value }) => {
+ await input().setValue(value);
+
+ expect(input().element.value).toBe(value);
+ });
+
+ it('shows loader after form is submitted', async () => {
+ const expected = { name: 'test', url: 'https://google.ca' };
+
+ expect(showsLoading()).toBe(false);
+
+ await submitForm(expected, [200, { path: '/test' }]);
+
+ expect(showsLoading()).toBe(true);
+ });
+
+ it('submits the new environment on submit', async () => {
+ const expected = { name: 'test', url: 'https://google.ca' };
+
+ await submitForm(expected, [200, { path: '/test' }]);
+
+ expect(visitUrl).toHaveBeenCalledWith('/test');
+ });
+
+ it('shows errors on error', async () => {
+ const expected = { name: 'test', url: 'https://google.ca' };
+
+ await submitForm(expected, [400, { message: ['name taken'] }]);
+
+ expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' });
+ expect(showsLoading()).toBe(false);
+ });
+});
diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
index 02216370b79..07aa456e69e 100644
--- a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
@@ -66,15 +66,14 @@ describe('feature_flags/components/strategies/flexible_rollout.vue', () => {
});
it('emits a change when the stickiness value changes', async () => {
- stickinessSelect.setValue('USERID');
- await wrapper.vm.$nextTick();
+ await stickinessSelect.setValue('userId');
expect(wrapper.emitted('change')).toEqual([
[
{
parameters: {
rollout: flexibleRolloutStrategy.parameters.rollout,
groupId: PERCENT_ROLLOUT_GROUP_ID,
- stickiness: 'USERID',
+ stickiness: 'userId',
},
},
],
diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js
index b5f09ac1957..4c40c2acf01 100644
--- a/spec/frontend/feature_flags/mock_data.js
+++ b/spec/frontend/feature_flags/mock_data.js
@@ -76,7 +76,7 @@ export const percentRolloutStrategy = {
export const flexibleRolloutStrategy = {
name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT,
- parameters: { rollout: '50', groupId: 'default', stickiness: 'DEFAULT' },
+ parameters: { rollout: '50', groupId: 'default', stickiness: 'default' },
scopes: [],
};
diff --git a/spec/frontend/fixtures/analytics.rb b/spec/frontend/fixtures/analytics.rb
new file mode 100644
index 00000000000..6d106dce166
--- /dev/null
+++ b/spec/frontend/fixtures/analytics.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
+ include_context 'Analytics fixtures shared context'
+
+ let_it_be(:value_stream_id) { 'default' }
+
+ before(:all) do
+ clean_frontend_fixtures('projects/analytics/value_stream_analytics/')
+ end
+
+ before do
+ update_metrics
+ create_deployment
+ end
+
+ describe Projects::Analytics::CycleAnalytics::StagesController, type: :controller do
+ render_views
+
+ let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } }
+
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+ end
+
+ it 'projects/analytics/value_stream_analytics/stages' do
+ get(:index, params: params, format: :json)
+
+ expect(response).to be_successful
+ end
+ end
+
+ describe Projects::CycleAnalytics::EventsController, type: :controller do
+ render_views
+ let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } }
+
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+ end
+
+ Gitlab::Analytics::CycleAnalytics::DefaultStages.all.each do |stage|
+ it "projects/analytics/value_stream_analytics/events/#{stage[:name]}" do
+ get(stage[:name], params: params, format: :json)
+
+ expect(response).to be_successful
+ end
+ end
+ end
+
+ describe Projects::Analytics::CycleAnalytics::SummaryController, type: :controller do
+ render_views
+ let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } }
+
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+ end
+
+ it "projects/analytics/value_stream_analytics/summary" do
+ get(:show, params: params, format: :json)
+
+ expect(response).to be_successful
+ end
+ end
+end
diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb
index 94db262e4fd..cb9a116f293 100644
--- a/spec/frontend/fixtures/api_markdown.rb
+++ b/spec/frontend/fixtures/api_markdown.rb
@@ -7,12 +7,17 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do
include WikiHelpers
include JavaScriptFixturesHelpers
- let_it_be(:user) { create(:user) }
+ let_it_be(:user) { create(:user, username: 'gitlab') }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, :repository, group: group) }
- let_it_be(:project_wiki) { create(:project_wiki, user: user) }
+ let_it_be(:label) { create(:label, project: project, title: 'bug') }
+ let_it_be(:milestone) { create(:milestone, project: project, title: '1.1') }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+
+ let_it_be(:project_wiki) { create(:project_wiki, project: project, user: user) }
let(:project_wiki_page) { create(:wiki_page, wiki: project_wiki) }
diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml
index 8d8c9a1d902..b581aac6aee 100644
--- a/spec/frontend/fixtures/api_markdown.yml
+++ b/spec/frontend/fixtures/api_markdown.yml
@@ -8,6 +8,14 @@
markdown: '_emphasized text_'
- name: inline_code
markdown: '`code`'
+- name: inline_diff
+ markdown: |-
+ * {-deleted-}
+ * {+added+}
+- name: subscript
+ markdown: H<sub>2</sub>O
+- name: superscript
+ markdown: 2<sup>8</sup> = 256
- name: strike
markdown: '~~del~~'
- name: horizontal_rule
@@ -68,6 +76,22 @@
1. list item 1
2. list item 2
3. list item 3
+- name: task_list
+ markdown: |-
+ * [x] hello
+ * [x] world
+ * [ ] example
+ * [ ] of nested
+ * [x] task list
+ * [ ] items
+- name: ordered_task_list
+ markdown: |-
+ 1. [x] hello
+ 2. [x] world
+ 3. [ ] example
+ 1. [ ] of nested
+ 1. [x] task list
+ 2. [ ] items
- name: image
markdown: '![alt text](https://gitlab.com/logo.png)'
- name: hard_break
@@ -86,4 +110,9 @@
|--------|------------|----------|
| cell | cell | cell |
| cell | cell | cell |
-
+- name: emoji
+ markdown: ':sparkles: :heart: :100:'
+- name: reference
+ context: project_wiki
+ markdown: |-
+ Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1
diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb
index 003f7b768dd..be2ead756cf 100644
--- a/spec/frontend/fixtures/startup_css.rb
+++ b/spec/frontend/fixtures/startup_css.rb
@@ -10,8 +10,6 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
render_views
before(:all) do
- stub_feature_flags(combined_menu: true)
- stub_feature_flags(sidebar_refactor: true)
clean_frontend_fixtures('startup_css/')
end
@@ -23,17 +21,6 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
sign_in(user)
end
- it "startup_css/project-#{type}-legacy-menu.html" do
- stub_feature_flags(combined_menu: false)
-
- get :show, params: {
- namespace_id: project.namespace.to_param,
- id: project
- }
-
- expect(response).to be_successful
- end
-
it "startup_css/project-#{type}.html" do
get :show, params: {
namespace_id: project.namespace.to_param,
@@ -43,17 +30,6 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
expect(response).to be_successful
end
- it "startup_css/project-#{type}-legacy-sidebar.html" do
- stub_feature_flags(sidebar_refactor: false)
-
- get :show, params: {
- namespace_id: project.namespace.to_param,
- id: project
- }
-
- expect(response).to be_successful
- end
-
it "startup_css/project-#{type}-signed-out.html" do
sign_out(user)
diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js
index 56bfb02ea4a..1732f24eeff 100644
--- a/spec/frontend/graphql_shared/utils_spec.js
+++ b/spec/frontend/graphql_shared/utils_spec.js
@@ -1,4 +1,5 @@
import {
+ isGid,
getIdFromGraphQLId,
convertToGraphQLId,
convertToGraphQLIds,
@@ -10,6 +11,16 @@ const mockType = 'Group';
const mockId = 12;
const mockGid = `gid://gitlab/Group/12`;
+describe('isGid', () => {
+ it('returns true if passed id is gid', () => {
+ expect(isGid(mockGid)).toBe(true);
+ });
+
+ it('returns false if passed id is not gid', () => {
+ expect(isGid(mockId)).toBe(false);
+ });
+});
+
describe('getIdFromGraphQLId', () => {
[
{
@@ -67,6 +78,10 @@ describe('convertToGraphQLId', () => {
`('throws TypeError with "$message" if a param is missing', ({ type, id, message }) => {
expect(() => convertToGraphQLId(type, id)).toThrow(new TypeError(message));
});
+
+ it('returns id as is if it follows the gid format', () => {
+ expect(convertToGraphQLId(mockType, mockGid)).toStrictEqual(mockGid);
+ });
});
describe('convertToGraphQLIds', () => {
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
index 2369685f506..60d47895a95 100644
--- a/spec/frontend/groups/components/group_item_spec.js
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import Vue from 'vue';
+import waitForPromises from 'helpers/wait_for_promises';
import GroupFolder from '~/groups/components/group_folder.vue';
import GroupItem from '~/groups/components/group_item.vue';
import ItemActions from '~/groups/components/item_actions.vue';
@@ -22,8 +22,7 @@ describe('GroupItemComponent', () => {
beforeEach(() => {
wrapper = createComponent();
-
- return Vue.nextTick();
+ return waitForPromises();
});
afterEach(() => {
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 4bf3334ae6b..3f722c24dbb 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -166,6 +166,11 @@ describe('RepoEditor', () => {
expect(tabs).toHaveLength(1);
expect(tabs.at(0).text()).toBe('Edit');
});
+
+ it('does not get markdown extension by default', async () => {
+ await createComponent();
+ expect(vm.editor.projectPath).toBeUndefined();
+ });
});
describe('when file is markdown', () => {
@@ -213,6 +218,11 @@ describe('RepoEditor', () => {
});
expect(findTabs()).toHaveLength(0);
});
+
+ it('uses the markdown extension and sets it up correctly', async () => {
+ await createComponent({ activeFile });
+ expect(vm.editor.projectPath).toBe(vm.currentProjectId);
+ });
});
describe('when file is binary and not raw', () => {
diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js
index 99ef6d9a7fb..bbd8463e685 100644
--- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js
@@ -3,21 +3,22 @@ import {
GlEmptyState,
GlLoadingIcon,
GlSearchBoxByClick,
- GlSprintf,
GlDropdown,
GlDropdownItem,
+ GlTable,
} from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import stubChildren from 'helpers/stub_children';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { STATUSES } from '~/import_entities/constants';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
-import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue';
+import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
-import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql';
-import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql';
+import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures';
@@ -41,10 +42,15 @@ describe('import table', () => {
];
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
- const findImportAllButton = () => wrapper.find('h1').find(GlButton);
+ const findImportSelectedButton = () =>
+ wrapper.findAllComponents(GlButton).wrappers.find((w) => w.text() === 'Import selected');
const findPaginationDropdown = () => wrapper.findComponent(GlDropdown);
const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text();
+ // TODO: remove this ugly approach when
+ // issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
+ const findTable = () => wrapper.vm.getTableRef();
+
const createComponent = ({ bulkImportSourceGroups }) => {
apolloProvider = createMockApollo([], {
Query: {
@@ -58,14 +64,17 @@ describe('import table', () => {
},
});
- wrapper = shallowMount(ImportTable, {
+ wrapper = mount(ImportTable, {
propsData: {
groupPathRegex: /.*/,
sourceUrl: SOURCE_URL,
+ groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.',
},
stubs: {
- GlSprintf,
+ ...stubChildren(ImportTable),
+ GlSprintf: false,
GlDropdown: GlDropdownStub,
+ GlTable: false,
},
localVue,
apolloProvider,
@@ -115,7 +124,7 @@ describe('import table', () => {
});
await waitForPromises();
- expect(wrapper.findAll(ImportTableRow)).toHaveLength(FAKE_GROUPS.length);
+ expect(wrapper.findAll('tbody tr')).toHaveLength(FAKE_GROUPS.length);
});
it('does not render status string when result list is empty', async () => {
@@ -139,19 +148,32 @@ describe('import table', () => {
});
it.each`
- event | payload | mutation | variables
- ${'update-target-namespace'} | ${'new-namespace'} | ${setTargetNamespaceMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace' }}
- ${'update-new-name'} | ${'new-name'} | ${setNewNameMutation} | ${{ sourceGroupId: FAKE_GROUP.id, newName: 'new-name' }}
- ${'import-group'} | ${undefined} | ${importGroupsMutation} | ${{ sourceGroupIds: [FAKE_GROUP.id] }}
+ event | payload | mutation | variables
+ ${'update-target-namespace'} | ${'new-namespace'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace', newName: 'group1' }}
+ ${'update-new-name'} | ${'new-name'} | ${setImportTargetMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'root', newName: 'new-name' }}
`('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
- wrapper.find(ImportTableRow).vm.$emit(event, payload);
+ wrapper.find(ImportTargetCell).vm.$emit(event, payload);
await waitForPromises();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation,
variables,
});
});
+
+ it('invokes importGroups mutation when row button is clicked', async () => {
+ jest.spyOn(apolloProvider.defaultClient, 'mutate');
+ const triggerImportButton = wrapper
+ .findAllComponents(GlButton)
+ .wrappers.find((w) => w.text() === 'Import');
+
+ triggerImportButton.vm.$emit('click');
+ await waitForPromises();
+ expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
+ mutation: importGroupsMutation,
+ variables: { sourceGroupIds: [FAKE_GROUP.id] },
+ });
+ });
});
describe('pagination', () => {
@@ -279,16 +301,20 @@ describe('import table', () => {
});
});
- describe('import all button', () => {
- it('does not exists when no groups available', () => {
+ describe('bulk operations', () => {
+ it('import selected button is disabled when no groups selected', async () => {
createComponent({
- bulkImportSourceGroups: () => new Promise(() => {}),
+ bulkImportSourceGroups: () => ({
+ nodes: FAKE_GROUPS,
+ pageInfo: FAKE_PAGE_INFO,
+ }),
});
+ await waitForPromises();
- expect(findImportAllButton().exists()).toBe(false);
+ expect(findImportSelectedButton().props().disabled).toBe(true);
});
- it('exists when groups are available for import', async () => {
+ it('import selected button is enabled when groups were selected for import', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
@@ -296,16 +322,14 @@ describe('import table', () => {
}),
});
await waitForPromises();
+ wrapper.find(GlTable).vm.$emit('row-selected', [FAKE_GROUPS[0]]);
+ await nextTick();
- expect(findImportAllButton().exists()).toBe(true);
+ expect(findImportSelectedButton().props().disabled).toBe(false);
});
- it('counts only not-imported groups', async () => {
- const NEW_GROUPS = [
- generateFakeEntry({ id: 1, status: STATUSES.NONE }),
- generateFakeEntry({ id: 2, status: STATUSES.NONE }),
- generateFakeEntry({ id: 3, status: STATUSES.FINISHED }),
- ];
+ it('does not allow selecting already started groups', async () => {
+ const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.FINISHED })];
createComponent({
bulkImportSourceGroups: () => ({
@@ -315,17 +339,41 @@ describe('import table', () => {
});
await waitForPromises();
- expect(findImportAllButton().text()).toMatchInterpolatedText('Import 2 groups');
+ findTable().selectRow(0);
+ await nextTick();
+
+ expect(findImportSelectedButton().props().disabled).toBe(true);
});
- it('disables button when any group has validation errors', async () => {
+ it('does not allow selecting groups with validation errors', async () => {
const NEW_GROUPS = [
- generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({
id: 2,
status: STATUSES.NONE,
- validation_errors: [{ field: 'new_name', message: 'test validation error' }],
+ validation_errors: [{ field: 'new_name', message: 'FAKE_VALIDATION_ERROR' }],
}),
+ ];
+
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: NEW_GROUPS,
+ pageInfo: FAKE_PAGE_INFO,
+ }),
+ });
+ await waitForPromises();
+
+ // TODO: remove this ugly approach when
+ // issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531
+ findTable().selectRow(0);
+ await nextTick();
+
+ expect(findImportSelectedButton().props().disabled).toBe(true);
+ });
+
+ it('invokes importGroups mutation when import selected button is clicked', async () => {
+ const NEW_GROUPS = [
+ generateFakeEntry({ id: 1, status: STATUSES.NONE }),
+ generateFakeEntry({ id: 2, status: STATUSES.NONE }),
generateFakeEntry({ id: 3, status: STATUSES.FINISHED }),
];
@@ -335,9 +383,19 @@ describe('import table', () => {
pageInfo: FAKE_PAGE_INFO,
}),
});
+ jest.spyOn(apolloProvider.defaultClient, 'mutate');
await waitForPromises();
- expect(findImportAllButton().props().disabled).toBe(true);
+ findTable().selectRow(0);
+ findTable().selectRow(1);
+ await nextTick();
+
+ findImportSelectedButton().vm.$emit('click');
+
+ expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
+ mutation: importGroupsMutation,
+ variables: { sourceGroupIds: [NEW_GROUPS[0].id, NEW_GROUPS[1].id] },
+ });
});
});
});
diff --git a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js
index 654a8fd00d3..8231297e594 100644
--- a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js
@@ -2,19 +2,13 @@ import { GlButton, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue';
import { STATUSES } from '~/import_entities/constants';
-import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue';
-import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql';
-import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql';
-import groupAndProjectQuery from '~/import_entities/import_groups/graphql/queries/groupAndProject.query.graphql';
+import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import { availableNamespacesFixture } from '../graphql/fixtures';
Vue.use(VueApollo);
-const { i18n: I18N } = ImportTableRow;
-
const getFakeGroup = (status) => ({
web_url: 'https://fake.host/',
full_path: 'fake_group_1',
@@ -28,48 +22,23 @@ const getFakeGroup = (status) => ({
progress: { status },
});
-const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group';
-const EXISTING_GROUP_PATH = 'existing-path';
-const EXISTING_PROJECT_PATH = 'existing-project-path';
-
-describe('import table row', () => {
+describe('import target cell', () => {
let wrapper;
- let apolloProvider;
let group;
const findByText = (cmp, text) => {
return wrapper.findAll(cmp).wrappers.find((node) => node.text().indexOf(text) === 0);
};
- const findImportButton = () => findByText(GlButton, 'Import');
const findNameInput = () => wrapper.find(GlFormInput);
const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown);
const createComponent = (props) => {
- apolloProvider = createMockApollo([
- [
- groupAndProjectQuery,
- ({ fullPath }) => {
- const existingGroup =
- fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_GROUP_PATH}`
- ? { id: 1 }
- : null;
-
- const existingProject =
- fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_PROJECT_PATH}`
- ? { id: 1 }
- : null;
-
- return Promise.resolve({ data: { existingGroup, existingProject } });
- },
- ],
- ]);
-
- wrapper = shallowMount(ImportTableRow, {
- apolloProvider,
+ wrapper = shallowMount(ImportTargetCell, {
stubs: { ImportGroupDropdown },
propsData: {
availableNamespaces: availableNamespacesFixture,
groupPathRegex: /.*/,
+ groupUrlErrorMessage: 'Please choose a group URL with no special characters or spaces.',
...props,
},
});
@@ -86,14 +55,10 @@ describe('import table row', () => {
createComponent({ group });
});
- it.each`
- selector | sourceEvent | payload | event
- ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'}
- ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'}
- `('invokes $event', ({ selector, sourceEvent, payload, event }) => {
- selector().vm.$emit(sourceEvent, payload);
- expect(wrapper.emitted(event)).toBeDefined();
- expect(wrapper.emitted(event)[0][0]).toBe(payload);
+ it('invokes $event', () => {
+ findNameInput().vm.$emit('input', 'demo');
+ expect(wrapper.emitted('update-new-name')).toBeDefined();
+ expect(wrapper.emitted('update-new-name')[0][0]).toBe('demo');
});
it('emits update-target-namespace when dropdown option is clicked', () => {
@@ -113,10 +78,6 @@ describe('import table row', () => {
createComponent({ group });
});
- it('renders Import button', () => {
- expect(findByText(GlButton, 'Import').exists()).toBe(true);
- });
-
it('renders namespace dropdown as not disabled', () => {
expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined);
});
@@ -198,7 +159,9 @@ describe('import table row', () => {
groupPathRegex: /^[a-zA-Z]+$/,
});
- expect(wrapper.text()).toContain('Please choose a group URL with no special characters.');
+ expect(wrapper.text()).toContain(
+ 'Please choose a group URL with no special characters or spaces.',
+ );
});
it('reports invalid group name if relevant validation error exists', async () => {
@@ -221,101 +184,5 @@ describe('import table row', () => {
expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE);
});
-
- it('sets validation error when targetting existing group', async () => {
- const testGroup = getFakeGroup(STATUSES.NONE);
-
- createComponent({
- group: {
- ...testGroup,
- import_target: {
- target_namespace: EXISTING_GROUP_TARGET_NAMESPACE,
- new_name: EXISTING_GROUP_PATH,
- },
- },
- });
-
- jest.spyOn(wrapper.vm.$apollo, 'mutate');
-
- jest.runOnlyPendingTimers();
- await nextTick();
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: addValidationErrorMutation,
- variables: {
- field: 'new_name',
- message: I18N.NAME_ALREADY_EXISTS,
- sourceGroupId: testGroup.id,
- },
- });
- });
-
- it('sets validation error when targetting existing project', async () => {
- const testGroup = getFakeGroup(STATUSES.NONE);
-
- createComponent({
- group: {
- ...testGroup,
- import_target: {
- target_namespace: EXISTING_GROUP_TARGET_NAMESPACE,
- new_name: EXISTING_PROJECT_PATH,
- },
- },
- });
-
- jest.spyOn(wrapper.vm.$apollo, 'mutate');
-
- jest.runOnlyPendingTimers();
- await nextTick();
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: addValidationErrorMutation,
- variables: {
- field: 'new_name',
- message: I18N.NAME_ALREADY_EXISTS,
- sourceGroupId: testGroup.id,
- },
- });
- });
-
- it('clears validation error when target is updated', async () => {
- const testGroup = getFakeGroup(STATUSES.NONE);
-
- createComponent({
- group: {
- ...testGroup,
- import_target: {
- target_namespace: EXISTING_GROUP_TARGET_NAMESPACE,
- new_name: EXISTING_PROJECT_PATH,
- },
- },
- });
-
- jest.runOnlyPendingTimers();
- await nextTick();
-
- jest.spyOn(wrapper.vm.$apollo, 'mutate');
-
- await wrapper.setProps({
- group: {
- ...testGroup,
- import_target: {
- target_namespace: 'valid_namespace',
- new_name: 'valid_path',
- },
- },
- });
-
- jest.runOnlyPendingTimers();
- await nextTick();
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: removeValidationErrorMutation,
- variables: {
- field: 'new_name',
- sourceGroupId: testGroup.id,
- },
- });
- });
});
});
diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
index ef83c9ebbc4..ec50dfd037f 100644
--- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
+++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
@@ -12,12 +12,12 @@ import addValidationErrorMutation from '~/import_entities/import_groups/graphql/
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql';
import setImportProgressMutation from '~/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql';
-import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql';
-import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql';
+import setImportTargetMutation from '~/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql';
import updateImportStatusMutation from '~/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql';
import bulkImportSourceGroupQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql';
import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql';
+import groupAndProjectQuery from '~/import_entities/import_groups/graphql/queries/group_and_project.query.graphql';
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
import axios from '~/lib/utils/axios_utils';
@@ -38,18 +38,29 @@ const FAKE_ENDPOINTS = {
jobs: '/fake_jobs',
};
+const FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER = jest.fn().mockResolvedValue({
+ data: {
+ existingGroup: null,
+ existingProject: null,
+ },
+});
+
describe('Bulk import resolvers', () => {
let axiosMockAdapter;
let client;
const createClient = (extraResolverArgs) => {
- return createMockClient({
+ const mockedClient = createMockClient({
cache: new InMemoryCache({
fragmentMatcher: { match: () => true },
addTypename: false,
}),
resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }),
});
+
+ mockedClient.setRequestHandler(groupAndProjectQuery, FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER);
+
+ return mockedClient;
};
beforeEach(() => {
@@ -196,6 +207,12 @@ describe('Bulk import resolvers', () => {
const [statusPoller] = StatusPoller.mock.instances;
expect(statusPoller.startPolling).toHaveBeenCalled();
});
+
+ it('requests validation status when request completes', async () => {
+ expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).not.toHaveBeenCalled();
+ jest.runOnlyPendingTimers();
+ expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalled();
+ });
});
it.each`
@@ -256,40 +273,49 @@ describe('Bulk import resolvers', () => {
});
});
- it('setTargetNamespaces updates group target namespace', async () => {
- const NEW_TARGET_NAMESPACE = 'target';
- const {
- data: {
- setTargetNamespace: {
- id: idInResponse,
- import_target: { target_namespace: namespaceInResponse },
+ describe('setImportTarget', () => {
+ it('updates group target namespace and name', async () => {
+ const NEW_TARGET_NAMESPACE = 'target';
+ const NEW_NAME = 'new';
+
+ const {
+ data: {
+ setImportTarget: {
+ id: idInResponse,
+ import_target: { target_namespace: namespaceInResponse, new_name: newNameInResponse },
+ },
},
- },
- } = await client.mutate({
- mutation: setTargetNamespaceMutation,
- variables: { sourceGroupId: GROUP_ID, targetNamespace: NEW_TARGET_NAMESPACE },
+ } = await client.mutate({
+ mutation: setImportTargetMutation,
+ variables: {
+ sourceGroupId: GROUP_ID,
+ targetNamespace: NEW_TARGET_NAMESPACE,
+ newName: NEW_NAME,
+ },
+ });
+
+ expect(idInResponse).toBe(GROUP_ID);
+ expect(namespaceInResponse).toBe(NEW_TARGET_NAMESPACE);
+ expect(newNameInResponse).toBe(NEW_NAME);
});
- expect(idInResponse).toBe(GROUP_ID);
- expect(namespaceInResponse).toBe(NEW_TARGET_NAMESPACE);
- });
+ it('invokes validation', async () => {
+ const NEW_TARGET_NAMESPACE = 'target';
+ const NEW_NAME = 'new';
- it('setNewName updates group target name', async () => {
- const NEW_NAME = 'new';
- const {
- data: {
- setNewName: {
- id: idInResponse,
- import_target: { new_name: nameInResponse },
+ await client.mutate({
+ mutation: setImportTargetMutation,
+ variables: {
+ sourceGroupId: GROUP_ID,
+ targetNamespace: NEW_TARGET_NAMESPACE,
+ newName: NEW_NAME,
},
- },
- } = await client.mutate({
- mutation: setNewNameMutation,
- variables: { sourceGroupId: GROUP_ID, newName: NEW_NAME },
- });
+ });
- expect(idInResponse).toBe(GROUP_ID);
- expect(nameInResponse).toBe(NEW_NAME);
+ expect(FAKE_GROUP_AND_PROJECTS_QUERY_HANDLER).toHaveBeenCalledWith({
+ fullPath: `${NEW_TARGET_NAMESPACE}/${NEW_NAME}`,
+ });
+ });
});
describe('importGroup', () => {
diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js
index 8784b3c2b00..da8a2f41c1b 100644
--- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js
+++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js
@@ -182,6 +182,19 @@ describe('DynamicField', () => {
expect(findGlFormGroup().find('small').html()).toContain(helpHTML);
});
+
+ it('strips unsafe HTML from the help text', () => {
+ const helpHTML =
+ '[<code>1</code> <iframe>2</iframe> <a href="javascript:alert(document.cookie)">3</a> <a href="foo" target="_blank">4</a>]';
+
+ createComponent({
+ help: helpHTML,
+ });
+
+ expect(findGlFormGroup().find('small').html()).toContain(
+ '[<code>1</code> <a>3</a> <a target="_blank" href="foo">4</a>]',
+ );
+ });
});
describe('label text', () => {
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index cbce26762b1..ff602327592 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -278,6 +278,7 @@ describe('IntegrationForm', () => {
<svg class="gl-icon">
<use></use>
</svg>
+ <a data-confirm="Are you sure?" data-method="delete" href="/settings/slack"></a>
</div>
`);
@@ -291,9 +292,14 @@ describe('IntegrationForm', () => {
});
const helpHtml = wrapper.findByTestId(mockTestId);
+ const helpLink = helpHtml.find('a');
expect(helpHtml.isVisible()).toBe(true);
expect(helpHtml.find('svg').isVisible()).toBe(true);
+ expect(helpLink.attributes()).toMatchObject({
+ 'data-confirm': 'Are you sure?',
+ 'data-method': 'delete',
+ });
});
});
});
diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js
new file mode 100644
index 00000000000..dbed236d7df
--- /dev/null
+++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js
@@ -0,0 +1,146 @@
+import { GlTable, GlLink, GlPagination } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import { DEFAULT_PER_PAGE } from '~/api';
+import createFlash from '~/flash';
+import IntegrationOverrides from '~/integrations/overrides/components/integration_overrides.vue';
+import axios from '~/lib/utils/axios_utils';
+import httpStatus from '~/lib/utils/http_status';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+
+jest.mock('~/flash');
+
+const mockOverrides = Array(DEFAULT_PER_PAGE * 3)
+ .fill(1)
+ .map((_, index) => ({
+ name: `test-proj-${index}`,
+ avatar_url: `avatar-${index}`,
+ full_path: `test-proj-${index}`,
+ full_name: `test-proj-${index}`,
+ }));
+
+describe('IntegrationOverrides', () => {
+ let wrapper;
+ let mockAxios;
+
+ const defaultProps = {
+ overridesPath: 'mock/overrides',
+ };
+
+ const createComponent = ({ mountFn = shallowMount } = {}) => {
+ wrapper = mountFn(IntegrationOverrides, {
+ propsData: defaultProps,
+ });
+ };
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+ mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, mockOverrides, {
+ 'X-TOTAL': mockOverrides.length,
+ 'X-PAGE': 1,
+ });
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ wrapper.destroy();
+ });
+
+ const findGlTable = () => wrapper.findComponent(GlTable);
+ const findPagination = () => wrapper.findComponent(GlPagination);
+ const findRowsAsModel = () =>
+ findGlTable()
+ .findAllComponents(GlLink)
+ .wrappers.map((link) => {
+ const avatar = link.findComponent(ProjectAvatar);
+
+ return {
+ href: link.attributes('href'),
+ avatarUrl: avatar.props('projectAvatarUrl'),
+ avatarName: avatar.props('projectName'),
+ text: link.text(),
+ };
+ });
+
+ describe('while loading', () => {
+ it('sets GlTable `busy` attribute to `true`', () => {
+ createComponent();
+
+ const table = findGlTable();
+ expect(table.exists()).toBe(true);
+ expect(table.attributes('busy')).toBe('true');
+ });
+ });
+
+ describe('when initial request is successful', () => {
+ it('sets GlTable `busy` attribute to `false`', async () => {
+ createComponent();
+ await waitForPromises();
+
+ const table = findGlTable();
+ expect(table.exists()).toBe(true);
+ expect(table.attributes('busy')).toBeFalsy();
+ });
+
+ describe('table template', () => {
+ beforeEach(async () => {
+ createComponent({ mountFn: mount });
+ await waitForPromises();
+ });
+
+ it('renders overrides as rows in table', () => {
+ expect(findRowsAsModel()).toEqual(
+ mockOverrides.map((x) => ({
+ href: x.full_path,
+ avatarUrl: x.avatar_url,
+ avatarName: x.name,
+ text: expect.stringContaining(x.full_name),
+ })),
+ );
+ });
+ });
+ });
+
+ describe('when request fails', () => {
+ beforeEach(async () => {
+ mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.INTERNAL_SERVER_ERROR);
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('calls createFlash', () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: IntegrationOverrides.i18n.defaultErrorMessage,
+ captureError: true,
+ error: expect.any(Error),
+ });
+ });
+ });
+
+ describe('pagination', () => {
+ it('triggers fetch when `input` event is emitted', async () => {
+ createComponent();
+ jest.spyOn(axios, 'get');
+ await waitForPromises();
+
+ await findPagination().vm.$emit('input', 2);
+ expect(axios.get).toHaveBeenCalledWith(defaultProps.overridesPath, {
+ params: { page: 2, per_page: DEFAULT_PER_PAGE },
+ });
+ });
+
+ it('does not render with <=1 page', async () => {
+ mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], {
+ 'X-TOTAL': 1,
+ 'X-PAGE': 1,
+ });
+
+ createComponent();
+ await waitForPromises();
+
+ expect(findPagination().exists()).toBe(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
index b828b5d8a04..95b1c55b82d 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -6,6 +6,7 @@ import {
GlSprintf,
GlLink,
GlModal,
+ GlFormCheckboxGroup,
} from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { stubComponent } from 'helpers/stub_component';
@@ -15,7 +16,8 @@ import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
-import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
+import { INVITE_MEMBERS_IN_COMMENT, MEMBER_AREAS_OF_FOCUS } from '~/invite_members/constants';
+import eventHub from '~/invite_members/event_hub';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
@@ -32,7 +34,12 @@ const inviteeType = 'members';
const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
const defaultAccessLevel = 10;
const inviteSource = 'unknown';
+const noSelectionAreasOfFocus = ['no_selection'];
const helpLink = 'https://example.com';
+const areasOfFocusOptions = [
+ { text: 'area1', value: 'area1' },
+ { text: 'area2', value: 'area2' },
+];
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' };
@@ -58,7 +65,9 @@ const createComponent = (data = {}, props = {}) => {
isProject,
inviteeType,
accessLevels,
+ areasOfFocusOptions,
defaultAccessLevel,
+ noSelectionAreasOfFocus,
helpLink,
...props,
},
@@ -74,7 +83,7 @@ const createComponent = (data = {}, props = {}) => {
GlDropdownItem: true,
GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
- props: ['state', 'invalidFeedback'],
+ props: ['state', 'invalidFeedback', 'description'],
}),
},
});
@@ -116,9 +125,12 @@ describe('InviteMembersModal', () => {
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
const clickInviteButton = () => findInviteButton().vm.$emit('click');
+ const clickCancelButton = () => findCancelButton().vm.$emit('click');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback');
+ const membersFormGroupDescription = () => findMembersFormGroup().props('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
+ const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
describe('rendering the modal', () => {
beforeEach(() => {
@@ -137,6 +149,10 @@ describe('InviteMembersModal', () => {
expect(findInviteButton().text()).toBe('Invite');
});
+ it('renders the Invite button modal without isLoading', () => {
+ expect(findInviteButton().props('loading')).toBe(false);
+ });
+
describe('rendering the access levels dropdown', () => {
it('sets the default dropdown text to the default access level name', () => {
expect(findDropdown().attributes('text')).toBe('Guest');
@@ -160,13 +176,29 @@ describe('InviteMembersModal', () => {
});
});
- describe('displaying the correct introText', () => {
+ describe('rendering the areas_of_focus', () => {
+ it('renders the areas_of_focus checkboxes', () => {
+ createComponent();
+
+ expect(findAreaofFocusCheckBoxGroup().props('options')).toBe(areasOfFocusOptions);
+ expect(findAreaofFocusCheckBoxGroup().exists()).toBe(true);
+ });
+
+ it('does not render the areas_of_focus checkboxes', () => {
+ createComponent({}, { areasOfFocusOptions: [] });
+
+ expect(findAreaofFocusCheckBoxGroup().exists()).toBe(false);
+ });
+ });
+
+ describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
describe('when inviting members', () => {
it('includes the correct invitee, type, and formatted name', () => {
createInviteMembersToProjectWrapper();
expect(findIntroText()).toBe("You're inviting members to the test name project.");
+ expect(membersFormGroupDescription()).toBe('Select members or type email addresses');
});
});
@@ -175,6 +207,7 @@ describe('InviteMembersModal', () => {
createInviteGroupToProjectWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name project.");
+ expect(membersFormGroupDescription()).toBe('');
});
});
});
@@ -185,6 +218,7 @@ describe('InviteMembersModal', () => {
createInviteMembersToGroupWrapper();
expect(findIntroText()).toBe("You're inviting members to the test name group.");
+ expect(membersFormGroupDescription()).toBe('Select members or type email addresses');
});
});
@@ -193,6 +227,7 @@ describe('InviteMembersModal', () => {
createInviteGroupToGroupWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name group.");
+ expect(membersFormGroupDescription()).toBe('');
});
});
});
@@ -210,6 +245,20 @@ describe('InviteMembersModal', () => {
"email 'email@example.com' does not match the allowed domains: example1.org";
const expectedSyntaxError = 'email contains an invalid email address';
+ it('calls the API with the expected focus data when an areas_of_focus checkbox is clicked', () => {
+ const spy = jest.spyOn(Api, 'addGroupMembersByUserId');
+ const expectedFocus = [areasOfFocusOptions[0].value];
+ createComponent({ newUsersToInvite: [user1] });
+
+ findAreaofFocusCheckBoxGroup().vm.$emit('input', expectedFocus);
+ clickInviteButton();
+
+ expect(spy).toHaveBeenCalledWith(
+ user1.id.toString(),
+ expect.objectContaining({ areas_of_focus: expectedFocus }),
+ );
+ });
+
describe('when inviting an existing user to group by user ID', () => {
const postData = {
user_id: '1,2',
@@ -217,6 +266,7 @@ describe('InviteMembersModal', () => {
expires_at: undefined,
invite_source: inviteSource,
format: 'json',
+ areas_of_focus: noSelectionAreasOfFocus,
};
describe('when member is added successfully', () => {
@@ -226,20 +276,34 @@ describe('InviteMembersModal', () => {
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
+ });
+
+ it('includes the non-default selected areas of focus', () => {
+ const focus = ['abc'];
+ const updatedPostData = { ...postData, areas_of_focus: focus };
+ wrapper.setData({ selectedAreasOfFocus: focus });
clickInviteButton();
+
+ expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, updatedPostData);
});
- it('calls Api addGroupMembersByUserId with the correct params', async () => {
- await waitForPromises;
+ describe('when triggered from regular mounting', () => {
+ beforeEach(() => {
+ clickInviteButton();
+ });
- expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData);
- });
+ it('sets isLoading on the Invite button when it is clicked', () => {
+ expect(findInviteButton().props('loading')).toBe(true);
+ });
- it('displays the successful toastMessage', async () => {
- await waitForPromises;
+ it('calls Api addGroupMembersByUserId with the correct params', () => {
+ expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData);
+ });
- expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ it('displays the successful toastMessage', () => {
+ expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ });
});
});
@@ -260,6 +324,51 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
expect(findMembersFormGroup().props('state')).toBe(false);
expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findInviteButton().props('loading')).toBe(false);
+ });
+
+ describe('clearing the invalid state and message', () => {
+ beforeEach(async () => {
+ mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
+
+ clickInviteButton();
+
+ await waitForPromises();
+ });
+
+ it('clears the error when the list of members to invite is cleared', async () => {
+ expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
+ expect(findMembersFormGroup().props('state')).toBe(false);
+ expect(findMembersSelect().props('validationState')).toBe(false);
+
+ findMembersSelect().vm.$emit('clear');
+
+ await wrapper.vm.$nextTick();
+
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersFormGroup().props('state')).not.toBe(false);
+ expect(findMembersSelect().props('validationState')).not.toBe(false);
+ });
+
+ it('clears the error when the cancel button is clicked', async () => {
+ clickCancelButton();
+
+ await wrapper.vm.$nextTick();
+
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersFormGroup().props('state')).not.toBe(false);
+ expect(findMembersSelect().props('validationState')).not.toBe(false);
+ });
+
+ it('clears the error when the modal is hidden', async () => {
+ wrapper.findComponent(GlModal).vm.$emit('hide');
+
+ await wrapper.vm.$nextTick();
+
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersFormGroup().props('state')).not.toBe(false);
+ expect(findMembersSelect().props('validationState')).not.toBe(false);
+ });
});
it('clears the invalid state and message once the list of members to invite is cleared', async () => {
@@ -272,6 +381,7 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
expect(findMembersFormGroup().props('state')).toBe(false);
expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findInviteButton().props('loading')).toBe(false);
findMembersSelect().vm.$emit('clear');
@@ -280,6 +390,7 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersFormGroup().props('state')).not.toBe(false);
expect(findMembersSelect().props('validationState')).not.toBe(false);
+ expect(findInviteButton().props('loading')).toBe(false);
});
it('displays the generic error for http server error', async () => {
@@ -336,6 +447,7 @@ describe('InviteMembersModal', () => {
expires_at: undefined,
email: 'email@example.com',
invite_source: inviteSource,
+ areas_of_focus: noSelectionAreasOfFocus,
format: 'json',
};
@@ -346,16 +458,30 @@ describe('InviteMembersModal', () => {
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
+ });
+
+ it('includes the non-default selected areas of focus', () => {
+ const focus = ['abc'];
+ const updatedPostData = { ...postData, areas_of_focus: focus };
+ wrapper.setData({ selectedAreasOfFocus: focus });
clickInviteButton();
- });
- it('calls Api inviteGroupMembersByEmail with the correct params', () => {
- expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData);
+ expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, updatedPostData);
});
- it('displays the successful toastMessage', () => {
- expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ describe('when triggered from regular mounting', () => {
+ beforeEach(() => {
+ clickInviteButton();
+ });
+
+ it('calls Api inviteGroupMembersByEmail with the correct params', () => {
+ expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData);
+ });
+
+ it('displays the successful toastMessage', () => {
+ expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ });
});
});
@@ -375,6 +501,7 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findInviteButton().props('loading')).toBe(false);
});
it('displays the restricted email error when restricted email is invited', async () => {
@@ -386,6 +513,7 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findInviteButton().props('loading')).toBe(false);
});
it('displays the successful toast message when email has already been invited', async () => {
@@ -446,6 +574,7 @@ describe('InviteMembersModal', () => {
access_level: defaultAccessLevel,
expires_at: undefined,
invite_source: inviteSource,
+ areas_of_focus: noSelectionAreasOfFocus,
format: 'json',
};
@@ -482,7 +611,7 @@ describe('InviteMembersModal', () => {
});
it('calls Apis with the invite source passed through to openModal', () => {
- wrapper.vm.openModal({ inviteeType: 'members', source: '_invite_source_' });
+ eventHub.$emit('openModal', { inviteeType: 'members', source: '_invite_source_' });
clickInviteButton();
@@ -548,9 +677,9 @@ describe('InviteMembersModal', () => {
describe('when sharing the group fails', () => {
beforeEach(() => {
- createComponent({ groupToBeSharedWith: sharedGroup });
+ createInviteGroupToGroupWrapper();
- wrapper.setData({ inviteeType: 'group' });
+ wrapper.setData({ groupToBeSharedWith: sharedGroup });
wrapper.vm.$toast = { show: jest.fn() };
jest
@@ -560,10 +689,9 @@ describe('InviteMembersModal', () => {
clickInviteButton();
});
- it('displays the generic error message', async () => {
- await waitForPromises();
-
+ it('displays the generic error message', () => {
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
+ expect(membersFormGroupDescription()).toBe('');
});
});
});
@@ -577,7 +705,7 @@ describe('InviteMembersModal', () => {
});
it('tracks the invite', () => {
- wrapper.vm.openModal({ inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT });
+ eventHub.$emit('openModal', { inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT });
clickInviteButton();
@@ -586,19 +714,37 @@ describe('InviteMembersModal', () => {
});
it('does not track invite for unknown source', () => {
- wrapper.vm.openModal({ inviteeType: 'members', source: 'unknown' });
+ eventHub.$emit('openModal', { inviteeType: 'members', source: 'unknown' });
clickInviteButton();
- expect(ExperimentTracking).not.toHaveBeenCalled();
+ expect(ExperimentTracking).not.toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT);
});
it('does not track invite undefined source', () => {
- wrapper.vm.openModal({ inviteeType: 'members' });
+ eventHub.$emit('openModal', { inviteeType: 'members' });
+
+ clickInviteButton();
+
+ expect(ExperimentTracking).not.toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT);
+ });
+
+ it('tracks the view for areas_of_focus', () => {
+ eventHub.$emit('openModal', { inviteeType: 'members' });
+
+ expect(ExperimentTracking).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.name);
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.view);
+ });
+
+ it('tracks the invite for areas_of_focus', () => {
+ eventHub.$emit('openModal', { inviteeType: 'members' });
clickInviteButton();
- expect(ExperimentTracking).not.toHaveBeenCalled();
+ expect(ExperimentTracking).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.name);
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
+ MEMBER_AREAS_OF_FOCUS.submit,
+ );
});
});
});
diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js
index 12db7e42464..196a716d08c 100644
--- a/spec/frontend/invite_members/components/members_token_select_spec.js
+++ b/spec/frontend/invite_members/components/members_token_select_spec.js
@@ -12,11 +12,12 @@ const user1 = { id: 1, name: 'John Smith', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Jane Doe', username: 'two_2', avatar_url: '' };
const allUsers = [user1, user2];
-const createComponent = () => {
+const createComponent = (props) => {
return shallowMount(MembersTokenSelect, {
propsData: {
ariaLabelledby: label,
placeholder,
+ ...props,
},
stubs: {
GlTokenSelector: stubComponent(GlTokenSelector),
@@ -27,11 +28,6 @@ const createComponent = () => {
describe('MembersTokenSelect', () => {
let wrapper;
- beforeEach(() => {
- jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers });
- wrapper = createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
wrapper = null;
@@ -41,6 +37,8 @@ describe('MembersTokenSelect', () => {
describe('rendering the token-selector component', () => {
it('renders with the correct props', () => {
+ wrapper = createComponent();
+
const expectedProps = {
ariaLabelledby: label,
placeholder,
@@ -51,6 +49,11 @@ describe('MembersTokenSelect', () => {
});
describe('users', () => {
+ beforeEach(() => {
+ jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers });
+ wrapper = createComponent();
+ });
+
describe('when input is focused for the first time (modal auto-focus)', () => {
it('does not call the API', async () => {
findTokenSelector().vm.$emit('focus');
@@ -90,10 +93,10 @@ describe('MembersTokenSelect', () => {
await waitForPromises();
- expect(UserApi.getUsers).toHaveBeenCalledWith(
- searchParam,
- wrapper.vm.$options.queryOptions,
- );
+ expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, {
+ active: true,
+ exclude_internal: true,
+ });
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
@@ -134,6 +137,8 @@ describe('MembersTokenSelect', () => {
describe('when text input is blurred', () => {
it('clears text input', async () => {
+ wrapper = createComponent();
+
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('blur');
@@ -143,4 +148,33 @@ describe('MembersTokenSelect', () => {
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
+
+ describe('when component is mounted for a group using a saml provider', () => {
+ const searchParam = 'name';
+ const samlProviderId = 123;
+ let resolveApiRequest;
+
+ beforeEach(() => {
+ jest.spyOn(UserApi, 'getUsers').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveApiRequest = resolve;
+ }),
+ );
+
+ wrapper = createComponent({ filterId: samlProviderId, usersFilter: 'saml_provider_id' });
+
+ findTokenSelector().vm.$emit('text-input', searchParam);
+ });
+
+ it('calls the API with the saml provider ID param', () => {
+ resolveApiRequest({ data: allUsers });
+
+ expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, {
+ active: true,
+ exclude_internal: true,
+ saml_provider_id: samlProviderId,
+ });
+ });
+ });
});
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
index 4c06f2dca1b..babe3a66578 100644
--- a/spec/frontend/issue_show/components/app_spec.js
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -2,7 +2,6 @@ import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
-import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
@@ -30,8 +29,6 @@ jest.mock('~/issue_show/event_hub');
const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
describe('Issuable output', () => {
- useMockIntersectionObserver();
-
let mock;
let realtimeRequestCount = 0;
let wrapper;
diff --git a/spec/frontend/issue_show/components/fields/type_spec.js b/spec/frontend/issue_show/components/fields/type_spec.js
index 0c8af60d50d..fac745716d7 100644
--- a/spec/frontend/issue_show/components/fields/type_spec.js
+++ b/spec/frontend/issue_show/components/fields/type_spec.js
@@ -1,4 +1,4 @@
-import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -35,6 +35,9 @@ describe('Issue type field component', () => {
const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup);
const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown);
const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findTypeFromDropDownItemAt = (at) => findTypeFromDropDownItems().at(at);
+ const findTypeFromDropDownItemIconAt = (at) =>
+ findTypeFromDropDownItems().at(at).findComponent(GlIcon);
const createComponent = ({ data } = {}) => {
fakeApollo = createMockApollo([], mockResolvers);
@@ -60,6 +63,15 @@ describe('Issue type field component', () => {
wrapper.destroy();
});
+ it.each`
+ at | text | icon
+ ${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon}
+ ${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon}
+ `(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => {
+ expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon);
+ expect(findTypeFromDropDownItemAt(at).text()).toBe(text);
+ });
+
it('renders a form group with the correct label', () => {
expect(findTypeFromGroup().attributes('label')).toBe(i18n.label);
});
diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js
index d043693b863..76989413edb 100644
--- a/spec/frontend/issue_show/issue_spec.js
+++ b/spec/frontend/issue_show/issue_spec.js
@@ -1,5 +1,4 @@
import MockAdapter from 'axios-mock-adapter';
-import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import waitForPromises from 'helpers/wait_for_promises';
import { initIssuableApp } from '~/issue_show/issue';
import * as parseData from '~/issue_show/utils/parse_data';
@@ -10,8 +9,6 @@ import { appProps } from './mock_data/mock_data';
const mock = new MockAdapter(axios);
mock.onGet().reply(200);
-useMockIntersectionObserver();
-
jest.mock('~/lib/utils/poll');
const setupHTML = (initialData) => {
diff --git a/spec/frontend/issues_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js
index 86112dad444..5ef2a2e0525 100644
--- a/spec/frontend/issues_list/components/issuables_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js
@@ -6,6 +6,7 @@ import {
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
@@ -27,11 +28,6 @@ const TEST_ENDPOINT = '/issues';
const TEST_CREATE_ISSUES_PATH = '/createIssue';
const TEST_SVG_PATH = '/emptySvg';
-const setUrl = (query) => {
- window.location.href = `${TEST_LOCATION}${query}`;
- window.location.search = query;
-};
-
const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL)
.fill(0)
.map((_, i) => ({
@@ -40,7 +36,6 @@ const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL)
}));
describe('Issuables list component', () => {
- let oldLocation;
let mockAxios;
let wrapper;
let apiSpy;
@@ -75,19 +70,13 @@ describe('Issuables list component', () => {
beforeEach(() => {
mockAxios = new MockAdapter(axios);
- oldLocation = window.location;
- Object.defineProperty(window, 'location', {
- writable: true,
- value: { href: '', search: '' },
- });
- window.location.href = TEST_LOCATION;
+ setWindowLocation(TEST_LOCATION);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockAxios.restore();
- window.location = oldLocation;
});
describe('with failed issues response', () => {
@@ -314,7 +303,7 @@ describe('Issuables list component', () => {
'?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&not[label_name][]=Afterpod&not[milestone_title][]=13';
beforeEach(() => {
- setUrl(query);
+ setWindowLocation(query);
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory({ sortKey: 'milestone_due_desc' });
@@ -358,7 +347,7 @@ describe('Issuables list component', () => {
'?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&page=3';
beforeEach(() => {
- setUrl(query);
+ setWindowLocation(query);
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory({ sortKey: 'milestone_due_desc' });
@@ -387,7 +376,7 @@ describe('Issuables list component', () => {
describe('with hash in window.location', () => {
beforeEach(() => {
- window.location.href = `${TEST_LOCATION}#stuff`;
+ setWindowLocation(`${TEST_LOCATION}#stuff`);
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory();
return waitForPromises();
@@ -422,7 +411,7 @@ describe('Issuables list component', () => {
describe('with query in window location', () => {
beforeEach(() => {
- window.location.search = '?weight=Any';
+ setWindowLocation('?weight=Any');
factory();
@@ -436,7 +425,7 @@ describe('Issuables list component', () => {
describe('with closed state', () => {
beforeEach(() => {
- window.location.search = '?state=closed';
+ setWindowLocation('?state=closed');
factory();
@@ -450,7 +439,7 @@ describe('Issuables list component', () => {
describe('with all state', () => {
beforeEach(() => {
- window.location.search = '?state=all';
+ setWindowLocation('?state=all');
factory();
@@ -565,7 +554,7 @@ describe('Issuables list component', () => {
});
it('sets value according to query', () => {
- setUrl(query);
+ setWindowLocation(query);
factory({ type: 'jira' });
@@ -583,7 +572,7 @@ describe('Issuables list component', () => {
it('sets value according to query', () => {
const query = '?search=free+text';
- setUrl(query);
+ setWindowLocation(query);
factory({ type: 'jira' });
diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
index 634687e77ab..d195c159cbb 100644
--- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js
+++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
@@ -24,6 +24,7 @@ describe('IssuesListApp component', () => {
const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]');
const mountComponent = ({
+ closedAt = null,
dueDate = issue.dueDate,
milestoneDueDate = issue.milestone.dueDate,
milestoneStartDate = issue.milestone.startDate,
@@ -37,6 +38,7 @@ describe('IssuesListApp component', () => {
dueDate: milestoneDueDate,
startDate: milestoneStartDate,
},
+ closedAt,
dueDate,
},
},
@@ -87,10 +89,23 @@ describe('IssuesListApp component', () => {
});
describe('when in the past', () => {
- it('renders in red', () => {
- wrapper = mountComponent({ dueDate: new Date('2020-10-10') });
+ describe('when issue is open', () => {
+ it('renders in red', () => {
+ wrapper = mountComponent({ dueDate: new Date('2020-10-10') });
- expect(findDueDate().classes()).toContain('gl-text-red-500');
+ expect(findDueDate().classes()).toContain('gl-text-red-500');
+ });
+ });
+
+ describe('when issue is closed', () => {
+ it('does not render in red', () => {
+ wrapper = mountComponent({
+ dueDate: new Date('2020-10-10'),
+ closedAt: '2020-09-05T13:06:25Z',
+ });
+
+ expect(findDueDate().classes()).not.toContain('gl-text-red-500');
+ });
});
});
});
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
index 846236e1fb5..0cb1092135f 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -7,6 +7,7 @@ import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import {
@@ -17,7 +18,7 @@ import {
getIssuesCountQueryResponse,
} from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
@@ -35,6 +36,7 @@ import {
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_TYPE,
TOKEN_TYPE_WEIGHT,
urlSortParams,
} from '~/issues_list/constants';
@@ -42,7 +44,7 @@ import eventHub from '~/issues_list/eventhub';
import { getSortOptions } from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
+import { joinPaths } from '~/lib/utils/url_utility';
jest.mock('~/flash');
jest.mock('~/lib/utils/scroll_utils', () => ({
@@ -115,11 +117,11 @@ describe('IssuesListApp component', () => {
};
beforeEach(() => {
+ setWindowLocation(TEST_HOST);
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
- global.jsdom.reconfigure({ url: TEST_HOST });
axiosMock.reset();
wrapper.destroy();
});
@@ -186,7 +188,7 @@ describe('IssuesListApp component', () => {
const search = '?search=refactor&sort=created_date&state=opened';
beforeEach(() => {
- global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` });
+ setWindowLocation(search);
wrapper = mountComponent({
provide: { ...defaultProvide, isSignedIn: true },
@@ -258,7 +260,7 @@ describe('IssuesListApp component', () => {
describe('initial url params', () => {
describe('due_date', () => {
it('is set from the url params', () => {
- global.jsdom.reconfigure({ url: `${TEST_HOST}?${PARAM_DUE_DATE}=${DUE_DATE_OVERDUE}` });
+ setWindowLocation(`?${PARAM_DUE_DATE}=${DUE_DATE_OVERDUE}`);
wrapper = mountComponent();
@@ -268,7 +270,7 @@ describe('IssuesListApp component', () => {
describe('search', () => {
it('is set from the url params', () => {
- global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
+ setWindowLocation(locationSearch);
wrapper = mountComponent();
@@ -278,9 +280,7 @@ describe('IssuesListApp component', () => {
describe('sort', () => {
it.each(Object.keys(urlSortParams))('is set as %s from the url params', (sortKey) => {
- global.jsdom.reconfigure({
- url: setUrlParams({ sort: urlSortParams[sortKey] }, TEST_HOST),
- });
+ setWindowLocation(`?sort=${urlSortParams[sortKey]}`);
wrapper = mountComponent();
@@ -297,7 +297,7 @@ describe('IssuesListApp component', () => {
it('is set from the url params', () => {
const initialState = IssuableStates.All;
- global.jsdom.reconfigure({ url: setUrlParams({ state: initialState }, TEST_HOST) });
+ setWindowLocation(`?state=${initialState}`);
wrapper = mountComponent();
@@ -307,7 +307,7 @@ describe('IssuesListApp component', () => {
describe('filter tokens', () => {
it('is set from the url params', () => {
- global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
+ setWindowLocation(locationSearch);
wrapper = mountComponent();
@@ -347,7 +347,7 @@ describe('IssuesListApp component', () => {
describe('when there are issues', () => {
describe('when search returns no results', () => {
beforeEach(() => {
- global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` });
+ setWindowLocation(`?search=no+results`);
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
});
@@ -377,9 +377,7 @@ describe('IssuesListApp component', () => {
describe('when "Closed" tab has no issues', () => {
beforeEach(() => {
- global.jsdom.reconfigure({
- url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST),
- });
+ setWindowLocation(`?state=${IssuableStates.Closed}`);
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
});
@@ -560,6 +558,7 @@ describe('IssuesListApp component', () => {
{ type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors },
{ type: TOKEN_TYPE_MILESTONE },
{ type: TOKEN_TYPE_LABEL },
+ { type: TOKEN_TYPE_TYPE },
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_CONFIDENTIAL },
{ type: TOKEN_TYPE_ITERATION },
@@ -625,25 +624,25 @@ describe('IssuesListApp component', () => {
const issueOne = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/1',
- iid: 101,
+ iid: '101',
title: 'Issue one',
};
const issueTwo = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/2',
- iid: 102,
+ iid: '102',
title: 'Issue two',
};
const issueThree = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/3',
- iid: 103,
+ iid: '103',
title: 'Issue three',
};
const issueFour = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/4',
- iid: 104,
+ iid: '104',
title: 'Issue four',
};
const response = {
@@ -662,9 +661,36 @@ describe('IssuesListApp component', () => {
jest.runOnlyPendingTimers();
});
+ describe('when successful', () => {
+ describe.each`
+ description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
+ ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
+ ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
+ ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
+ ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
+ `(
+ 'when moving issue $description',
+ ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
+ it('makes API call to reorder the issue', async () => {
+ findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
+
+ await waitForPromises();
+
+ expect(axiosMock.history.put[0]).toMatchObject({
+ url: joinPaths(defaultProvide.issuesPath, issueToMove.iid, 'reorder'),
+ data: JSON.stringify({
+ move_before_id: getIdFromGraphQLId(moveBeforeId),
+ move_after_id: getIdFromGraphQLId(moveAfterId),
+ }),
+ });
+ });
+ },
+ );
+ });
+
describe('when unsuccessful', () => {
it('displays an error message', async () => {
- axiosMock.onPut(`${defaultProvide.issuesPath}/${issueOne.iid}/reorder`).reply(500);
+ axiosMock.onPut(joinPaths(defaultProvide.issuesPath, issueOne.iid, 'reorder')).reply(500);
findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
diff --git a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js
index 0c96b95a61f..633799816d8 100644
--- a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js
+++ b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js
@@ -43,10 +43,12 @@ describe('JiraIssuesImportStatus', () => {
wrapper = null;
});
- describe('when Jira import is not in progress', () => {
- it('does not show an alert', () => {
+ describe('when Jira import is neither in progress nor finished', () => {
+ beforeEach(() => {
wrapper = mountComponent();
+ });
+ it('does not show an alert', () => {
expect(wrapper.find(GlAlert).exists()).toBe(false);
});
});
diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js
index fd59241fd1d..d3f3f2f9f23 100644
--- a/spec/frontend/issues_list/mock_data.js
+++ b/spec/frontend/issues_list/mock_data.js
@@ -23,6 +23,7 @@ export const getIssuesQueryResponse = {
downvotes: 2,
dueDate: '2021-05-29',
humanTimeEstimate: null,
+ mergeRequestsCount: false,
moved: false,
title: 'Issue title',
updatedAt: '2021-05-22T04:08:01Z',
@@ -106,8 +107,11 @@ export const locationSearch = [
export const locationSearchWithSpecialValues = [
'assignee_id=123',
'assignee_username=bart',
+ 'type[]=issue',
+ 'type[]=incident',
'my_reaction_emoji=None',
'iteration_id=Current',
+ 'milestone_title=Upcoming',
'epic_id=None',
'weight=None',
].join('&');
@@ -140,8 +144,11 @@ export const filteredTokens = [
export const filteredTokensWithSpecialValues = [
{ type: 'assignee_username', value: { data: '123', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } },
+ { type: 'type', value: { data: 'issue', operator: OPERATOR_IS } },
+ { type: 'type', value: { data: 'incident', operator: OPERATOR_IS } },
{ type: 'my_reaction_emoji', value: { data: 'None', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } },
+ { type: 'milestone', value: { data: 'Upcoming', operator: OPERATOR_IS } },
{ type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } },
{ type: 'weight', value: { data: 'None', operator: OPERATOR_IS } },
];
@@ -170,8 +177,10 @@ export const apiParams = {
export const apiParamsWithSpecialValues = {
assigneeId: '123',
assigneeUsernames: 'bart',
+ types: ['ISSUE', 'INCIDENT'],
myReactionEmoji: 'None',
iterationWildcardId: 'CURRENT',
+ milestoneWildcardId: 'UPCOMING',
epicId: 'None',
weight: 'None',
};
@@ -198,8 +207,10 @@ export const urlParams = {
export const urlParamsWithSpecialValues = {
assignee_id: '123',
'assignee_username[]': 'bart',
+ 'type[]': ['issue', 'incident'],
my_reaction_emoji: 'None',
iteration_id: 'Current',
+ milestone_title: 'Upcoming',
epic_id: 'None',
weight: 'None',
};
diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues_list/utils_spec.js
index b7863068570..458776d9ec5 100644
--- a/spec/frontend/issues_list/utils_spec.js
+++ b/spec/frontend/issues_list/utils_spec.js
@@ -8,17 +8,36 @@ import {
urlParams,
urlParamsWithSpecialValues,
} from 'jest/issues_list/mock_data';
-import { DUE_DATE_VALUES, urlSortParams } from '~/issues_list/constants';
+import {
+ defaultPageSizeParams,
+ DUE_DATE_VALUES,
+ largePageSizeParams,
+ RELATIVE_POSITION_ASC,
+ urlSortParams,
+} from '~/issues_list/constants';
import {
convertToApiParams,
convertToSearchQuery,
convertToUrlParams,
getDueDateValue,
getFilterTokens,
+ getInitialPageParams,
getSortKey,
getSortOptions,
} from '~/issues_list/utils';
+describe('getInitialPageParams', () => {
+ it.each(Object.keys(urlSortParams))(
+ 'returns the correct page params for sort key %s',
+ (sortKey) => {
+ const expectedPageParams =
+ sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams;
+
+ expect(getInitialPageParams(sortKey)).toBe(expectedPageParams);
+ },
+ );
+});
+
describe('getSortKey', () => {
it.each(Object.keys(urlSortParams))('returns %s given the correct inputs', (sortKey) => {
const sort = urlSortParams[sortKey];
diff --git a/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js b/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js
new file mode 100644
index 00000000000..7326b84ad54
--- /dev/null
+++ b/spec/frontend/jira_connect/branches/components/new_branch_form_spec.js
@@ -0,0 +1,236 @@
+import { GlAlert, GlForm, GlFormInput, GlButton } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import NewBranchForm from '~/jira_connect/branches/components/new_branch_form.vue';
+import ProjectDropdown from '~/jira_connect/branches/components/project_dropdown.vue';
+import SourceBranchDropdown from '~/jira_connect/branches/components/source_branch_dropdown.vue';
+import {
+ CREATE_BRANCH_ERROR_GENERIC,
+ CREATE_BRANCH_ERROR_WITH_CONTEXT,
+} from '~/jira_connect/branches/constants';
+import createBranchMutation from '~/jira_connect/branches/graphql/mutations/create_branch.mutation.graphql';
+
+const mockProject = {
+ id: 'test',
+ fullPath: 'test-path',
+ repository: {
+ branchNames: ['main', 'f-test', 'release'],
+ rootRef: 'main',
+ },
+};
+const mockCreateBranchMutationResponse = {
+ data: {
+ createBranch: {
+ clientMutationId: 1,
+ errors: [],
+ },
+ },
+};
+const mockCreateBranchMutationResponseWithErrors = {
+ data: {
+ createBranch: {
+ clientMutationId: 1,
+ errors: ['everything is broken, sorry.'],
+ },
+ },
+};
+const mockCreateBranchMutationSuccess = jest
+ .fn()
+ .mockResolvedValue(mockCreateBranchMutationResponse);
+const mockCreateBranchMutationWithErrors = jest
+ .fn()
+ .mockResolvedValue(mockCreateBranchMutationResponseWithErrors);
+const mockCreateBranchMutationFailed = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+const mockMutationLoading = jest.fn().mockReturnValue(new Promise(() => {}));
+
+const localVue = createLocalVue();
+
+describe('NewBranchForm', () => {
+ let wrapper;
+
+ const findSourceBranchDropdown = () => wrapper.findComponent(SourceBranchDropdown);
+ const findProjectDropdown = () => wrapper.findComponent(ProjectDropdown);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ const completeForm = async () => {
+ await findInput().vm.$emit('input', 'cool-branch-name');
+ await findProjectDropdown().vm.$emit('change', mockProject);
+ await findSourceBranchDropdown().vm.$emit('change', 'source-branch');
+ };
+
+ function createMockApolloProvider({
+ mockCreateBranchMutation = mockCreateBranchMutationSuccess,
+ } = {}) {
+ localVue.use(VueApollo);
+
+ const mockApollo = createMockApollo([[createBranchMutation, mockCreateBranchMutation]]);
+
+ return mockApollo;
+ }
+
+ function createComponent({ mockApollo, provide } = {}) {
+ wrapper = shallowMount(NewBranchForm, {
+ localVue,
+ apolloProvider: mockApollo || createMockApolloProvider(),
+ provide: {
+ initialBranchName: '',
+ ...provide,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when selecting items from dropdowns', () => {
+ describe('when a project is selected', () => {
+ it('sets the `selectedProject` prop for ProjectDropdown and SourceBranchDropdown', async () => {
+ createComponent();
+
+ const projectDropdown = findProjectDropdown();
+ await projectDropdown.vm.$emit('change', mockProject);
+
+ expect(projectDropdown.props('selectedProject')).toEqual(mockProject);
+ expect(findSourceBranchDropdown().props('selectedProject')).toEqual(mockProject);
+ });
+ });
+
+ describe('when a source branch is selected', () => {
+ it('sets the `selectedBranchName` prop for SourceBranchDropdown', async () => {
+ createComponent();
+
+ const mockBranchName = 'main';
+ const sourceBranchDropdown = findSourceBranchDropdown();
+ await sourceBranchDropdown.vm.$emit('change', mockBranchName);
+
+ expect(sourceBranchDropdown.props('selectedBranchName')).toBe(mockBranchName);
+ });
+ });
+ });
+
+ describe('when submitting form', () => {
+ describe('when form submission is loading', () => {
+ it('sets submit button `loading` prop to `true`', async () => {
+ createComponent({
+ mockApollo: createMockApolloProvider({
+ mockCreateBranchMutation: mockMutationLoading,
+ }),
+ });
+
+ await completeForm();
+
+ await findForm().vm.$emit('submit', new Event('submit'));
+ await waitForPromises();
+
+ expect(findButton().props('loading')).toBe(true);
+ });
+ });
+
+ describe('when form submission is successful', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await completeForm();
+
+ await findForm().vm.$emit('submit', new Event('submit'));
+ await waitForPromises();
+ });
+
+ it('emits `success` event', () => {
+ expect(wrapper.emitted('success')).toBeTruthy();
+ });
+
+ it('called `createBranch` mutation correctly', () => {
+ expect(mockCreateBranchMutationSuccess).toHaveBeenCalledWith({
+ name: 'cool-branch-name',
+ projectPath: mockProject.fullPath,
+ ref: 'source-branch',
+ });
+ });
+
+ it('sets submit button `loading` prop to `false`', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when form submission fails', () => {
+ describe.each`
+ scenario | mutation | alertTitle | alertText
+ ${'with errors-as-data'} | ${mockCreateBranchMutationWithErrors} | ${CREATE_BRANCH_ERROR_WITH_CONTEXT} | ${mockCreateBranchMutationResponseWithErrors.data.createBranch.errors[0]}
+ ${'top-level error'} | ${mockCreateBranchMutationFailed} | ${''} | ${CREATE_BRANCH_ERROR_GENERIC}
+ `('', ({ mutation, alertTitle, alertText }) => {
+ beforeEach(async () => {
+ createComponent({
+ mockApollo: createMockApolloProvider({
+ mockCreateBranchMutation: mutation,
+ }),
+ });
+
+ await completeForm();
+
+ await findForm().vm.$emit('submit', new Event('submit'));
+ await waitForPromises();
+ });
+
+ it('displays an alert', () => {
+ const alert = findAlert();
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toBe(alertText);
+ expect(alert.props()).toMatchObject({ title: alertTitle, variant: 'danger' });
+ });
+
+ it('sets submit button `loading` prop to `false`', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('when `initialBranchName` is specified', () => {
+ it('sets value of branch name input to `initialBranchName` by default', () => {
+ const mockInitialBranchName = 'ap1-test-branch-name';
+
+ createComponent({ provide: { initialBranchName: mockInitialBranchName } });
+ expect(findInput().attributes('value')).toBe(mockInitialBranchName);
+ });
+ });
+
+ describe('error handling', () => {
+ describe.each`
+ component | componentName
+ ${SourceBranchDropdown} | ${'SourceBranchDropdown'}
+ ${ProjectDropdown} | ${'ProjectDropdown'}
+ `('when $componentName emits error', ({ component }) => {
+ const mockErrorMessage = 'oh noes!';
+
+ beforeEach(async () => {
+ createComponent();
+ await wrapper.findComponent(component).vm.$emit('error', { message: mockErrorMessage });
+ });
+
+ it('displays an alert', () => {
+ const alert = findAlert();
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toBe(mockErrorMessage);
+ expect(alert.props('variant')).toBe('danger');
+ });
+
+ describe('when alert is dismissed', () => {
+ it('hides alert', async () => {
+ const alert = findAlert();
+ expect(alert.exists()).toBe(true);
+
+ await alert.vm.$emit('dismiss');
+
+ expect(alert.exists()).toBe(false);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/branches/pages/index_spec.js b/spec/frontend/jira_connect/branches/pages/index_spec.js
new file mode 100644
index 00000000000..92976dd28da
--- /dev/null
+++ b/spec/frontend/jira_connect/branches/pages/index_spec.js
@@ -0,0 +1,65 @@
+import { GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import NewBranchForm from '~/jira_connect/branches/components/new_branch_form.vue';
+import {
+ I18N_PAGE_TITLE_WITH_BRANCH_NAME,
+ I18N_PAGE_TITLE_DEFAULT,
+} from '~/jira_connect/branches/constants';
+import JiraConnectNewBranchPage from '~/jira_connect/branches/pages/index.vue';
+import { sprintf } from '~/locale';
+
+describe('NewBranchForm', () => {
+ let wrapper;
+
+ const findPageTitle = () => wrapper.find('h1');
+ const findNewBranchForm = () => wrapper.findComponent(NewBranchForm);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+ function createComponent({ provide } = {}) {
+ wrapper = shallowMount(JiraConnectNewBranchPage, {
+ provide: {
+ initialBranchName: '',
+ successStateSvgPath: '',
+ ...provide,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('page title', () => {
+ it.each`
+ initialBranchName | pageTitle
+ ${undefined} | ${I18N_PAGE_TITLE_DEFAULT}
+ ${'ap1-test-button'} | ${sprintf(I18N_PAGE_TITLE_WITH_BRANCH_NAME, { jiraIssue: 'ap1-test-button' })}
+ `(
+ 'sets page title to "$pageTitle" when initial branch name is "$initialBranchName"',
+ ({ initialBranchName, pageTitle }) => {
+ createComponent({ provide: { initialBranchName } });
+
+ expect(findPageTitle().text()).toBe(pageTitle);
+ },
+ );
+ });
+
+ it('renders NewBranchForm by default', () => {
+ createComponent();
+
+ expect(findNewBranchForm().exists()).toBe(true);
+ expect(findEmptyState().exists()).toBe(false);
+ });
+
+ describe('when `sucesss` event emitted from NewBranchForm', () => {
+ it('renders the success state', async () => {
+ createComponent();
+
+ const newBranchForm = findNewBranchForm();
+ await newBranchForm.vm.$emit('success');
+
+ expect(findNewBranchForm().exists()).toBe(false);
+ expect(findEmptyState().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/api_spec.js b/spec/frontend/jira_connect/subscriptions/api_spec.js
index 88922999715..57b11bdbc27 100644
--- a/spec/frontend/jira_connect/api_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/api_spec.js
@@ -1,10 +1,10 @@
import MockAdapter from 'axios-mock-adapter';
-import { addSubscription, removeSubscription, fetchGroups } from '~/jira_connect/api';
-import { getJwt } from '~/jira_connect/utils';
+import { addSubscription, removeSubscription, fetchGroups } from '~/jira_connect/subscriptions/api';
+import { getJwt } from '~/jira_connect/subscriptions/utils';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
-jest.mock('~/jira_connect/utils', () => ({
+jest.mock('~/jira_connect/subscriptions/utils', () => ({
getJwt: jest.fn().mockResolvedValue('jwt'),
}));
diff --git a/spec/frontend/jira_connect/components/__snapshots__/group_item_name_spec.js.snap b/spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap
index 21c903f064d..21c903f064d 100644
--- a/spec/frontend/jira_connect/components/__snapshots__/group_item_name_spec.js.snap
+++ b/spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap
diff --git a/spec/frontend/jira_connect/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
index e0d61d8209b..8915a7697a5 100644
--- a/spec/frontend/jira_connect/components/app_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
@@ -1,12 +1,12 @@
import { GlAlert, GlButton, GlModal, GlLink } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
-import JiraConnectApp from '~/jira_connect/components/app.vue';
-import createStore from '~/jira_connect/store';
-import { SET_ALERT } from '~/jira_connect/store/mutation_types';
+import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue';
+import createStore from '~/jira_connect/subscriptions/store';
+import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { __ } from '~/locale';
-jest.mock('~/jira_connect/utils', () => ({
+jest.mock('~/jira_connect/subscriptions/utils', () => ({
retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }),
getLocation: jest.fn(),
}));
diff --git a/spec/frontend/jira_connect/components/group_item_name_spec.js b/spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js
index ea0067f8ed1..b5fe08486b1 100644
--- a/spec/frontend/jira_connect/components/group_item_name_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/group_item_name_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import GroupItemName from '~/jira_connect/components/group_item_name.vue';
+import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue';
import { mockGroup1 } from '../mock_data';
describe('GroupItemName', () => {
diff --git a/spec/frontend/jira_connect/components/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js
index bcc27cc2898..b69435df83a 100644
--- a/spec/frontend/jira_connect/components/groups_list_item_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/groups_list_item_spec.js
@@ -2,13 +2,13 @@ import { GlButton } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
-import * as JiraConnectApi from '~/jira_connect/api';
-import GroupItemName from '~/jira_connect/components/group_item_name.vue';
-import GroupsListItem from '~/jira_connect/components/groups_list_item.vue';
-import { persistAlert, reloadPage } from '~/jira_connect/utils';
+import * as JiraConnectApi from '~/jira_connect/subscriptions/api';
+import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue';
+import GroupsListItem from '~/jira_connect/subscriptions/components/groups_list_item.vue';
+import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils';
import { mockGroup1 } from '../mock_data';
-jest.mock('~/jira_connect/utils');
+jest.mock('~/jira_connect/subscriptions/utils');
describe('GroupsListItem', () => {
let wrapper;
diff --git a/spec/frontend/jira_connect/components/groups_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js
index d583fb68771..d3a9a3bfd41 100644
--- a/spec/frontend/jira_connect/components/groups_list_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/groups_list_spec.js
@@ -2,10 +2,10 @@ import { GlAlert, GlLoadingIcon, GlSearchBoxByType, GlPagination } from '@gitlab
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { fetchGroups } from '~/jira_connect/api';
-import GroupsList from '~/jira_connect/components/groups_list.vue';
-import GroupsListItem from '~/jira_connect/components/groups_list_item.vue';
-import { DEFAULT_GROUPS_PER_PAGE } from '~/jira_connect/constants';
+import { fetchGroups } from '~/jira_connect/subscriptions/api';
+import GroupsList from '~/jira_connect/subscriptions/components/groups_list.vue';
+import GroupsListItem from '~/jira_connect/subscriptions/components/groups_list_item.vue';
+import { DEFAULT_GROUPS_PER_PAGE } from '~/jira_connect/subscriptions/constants';
import { mockGroup1, mockGroup2 } from '../mock_data';
const createMockGroup = (groupId) => {
@@ -19,7 +19,7 @@ const createMockGroups = (count) => {
return [...new Array(count)].map((_, idx) => createMockGroup(idx));
};
-jest.mock('~/jira_connect/api', () => {
+jest.mock('~/jira_connect/subscriptions/api', () => {
return {
fetchGroups: jest.fn(),
};
diff --git a/spec/frontend/jira_connect/components/subscriptions_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js
index ff86969367d..32b43765843 100644
--- a/spec/frontend/jira_connect/components/subscriptions_list_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/subscriptions_list_spec.js
@@ -2,14 +2,14 @@ import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
-import * as JiraConnectApi from '~/jira_connect/api';
-import SubscriptionsList from '~/jira_connect/components/subscriptions_list.vue';
-import createStore from '~/jira_connect/store';
-import { SET_ALERT } from '~/jira_connect/store/mutation_types';
-import { reloadPage } from '~/jira_connect/utils';
+import * as JiraConnectApi from '~/jira_connect/subscriptions/api';
+import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
+import createStore from '~/jira_connect/subscriptions/store';
+import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
+import { reloadPage } from '~/jira_connect/subscriptions/utils';
import { mockSubscription } from '../mock_data';
-jest.mock('~/jira_connect/utils');
+jest.mock('~/jira_connect/subscriptions/utils');
describe('SubscriptionsList', () => {
let wrapper;
diff --git a/spec/frontend/jira_connect/index_spec.js b/spec/frontend/jira_connect/subscriptions/index_spec.js
index 0161cfa0273..786f3b4a7d3 100644
--- a/spec/frontend/jira_connect/index_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/index_spec.js
@@ -1,6 +1,6 @@
-import { initJiraConnect } from '~/jira_connect';
+import { initJiraConnect } from '~/jira_connect/subscriptions';
-jest.mock('~/jira_connect/utils', () => ({
+jest.mock('~/jira_connect/subscriptions/utils', () => ({
getLocation: jest.fn().mockResolvedValue('test/location'),
}));
diff --git a/spec/frontend/jira_connect/mock_data.js b/spec/frontend/jira_connect/subscriptions/mock_data.js
index 5247a3dc522..5247a3dc522 100644
--- a/spec/frontend/jira_connect/mock_data.js
+++ b/spec/frontend/jira_connect/subscriptions/mock_data.js
diff --git a/spec/frontend/jira_connect/store/mutations_spec.js b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js
index 584b17b36f7..84a33dbf0b5 100644
--- a/spec/frontend/jira_connect/store/mutations_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/store/mutations_spec.js
@@ -1,5 +1,5 @@
-import mutations from '~/jira_connect/store/mutations';
-import state from '~/jira_connect/store/state';
+import mutations from '~/jira_connect/subscriptions/store/mutations';
+import state from '~/jira_connect/subscriptions/store/state';
describe('JiraConnect store mutations', () => {
let localState;
diff --git a/spec/frontend/jira_connect/utils_spec.js b/spec/frontend/jira_connect/subscriptions/utils_spec.js
index 7eae870478d..2dd95de1b8c 100644
--- a/spec/frontend/jira_connect/utils_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/utils_spec.js
@@ -1,6 +1,6 @@
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
-import { ALERT_LOCALSTORAGE_KEY } from '~/jira_connect/constants';
+import { ALERT_LOCALSTORAGE_KEY } from '~/jira_connect/subscriptions/constants';
import {
persistAlert,
retrieveAlert,
@@ -8,7 +8,7 @@ import {
getLocation,
reloadPage,
sizeToParent,
-} from '~/jira_connect/utils';
+} from '~/jira_connect/subscriptions/utils';
describe('JiraConnect utils', () => {
describe('alert utils', () => {
diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js
index 76c35703106..3ff0bd73581 100644
--- a/spec/frontend/jobs/components/log/mock_data.js
+++ b/spec/frontend/jobs/components/log/mock_data.js
@@ -123,6 +123,15 @@ export const multipleCollapsibleSectionsMockData = [
},
];
+export const backwardsCompatibilityTrace = [
+ {
+ offset: 2365,
+ content: [],
+ section: 'download-artifacts',
+ section_duration: '00:01',
+ },
+];
+
export const originalTrace = [
{
offset: 1,
diff --git a/spec/frontend/jobs/components/stages_dropdown_spec.js b/spec/frontend/jobs/components/stages_dropdown_spec.js
index b75d1707a8d..b0e95a2d5b6 100644
--- a/spec/frontend/jobs/components/stages_dropdown_spec.js
+++ b/spec/frontend/jobs/components/stages_dropdown_spec.js
@@ -20,6 +20,7 @@ describe('Stages Dropdown', () => {
const findPipelineInfoText = () => wrapper.findByTestId('pipeline-info').text();
const findPipelinePath = () => wrapper.findByTestId('pipeline-path').attributes('href');
const findMRLinkPath = () => wrapper.findByTestId('mr-link').attributes('href');
+ const findCopySourceBranchBtn = () => wrapper.findByTestId('copy-source-ref-link');
const findSourceBranchLinkPath = () =>
wrapper.findByTestId('source-branch-link').attributes('href');
const findTargetBranchLinkPath = () =>
@@ -70,6 +71,10 @@ describe('Stages Dropdown', () => {
expect(actual).toBe(expected);
});
+
+ it(`renders the source ref copy button`, () => {
+ expect(findCopySourceBranchBtn().exists()).toBe(true);
+ });
});
describe('with an "attached" merge request pipeline', () => {
@@ -103,6 +108,10 @@ describe('Stages Dropdown', () => {
mockPipelineWithAttachedMR.merge_request.target_branch_path,
);
});
+
+ it(`renders the source ref copy button`, () => {
+ expect(findCopySourceBranchBtn().exists()).toBe(true);
+ });
});
describe('with a detached merge request pipeline', () => {
@@ -130,5 +139,9 @@ describe('Stages Dropdown', () => {
mockPipelineDetached.merge_request.source_branch_path,
);
});
+
+ it(`renders the source ref copy button`, () => {
+ expect(findCopySourceBranchBtn().exists()).toBe(true);
+ });
});
});
diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js
index 35ac2945ab5..0c5fa150002 100644
--- a/spec/frontend/jobs/store/utils_spec.js
+++ b/spec/frontend/jobs/store/utils_spec.js
@@ -19,6 +19,7 @@ import {
collapsibleTrace,
collapsibleTraceIncremental,
multipleCollapsibleSectionsMockData,
+ backwardsCompatibilityTrace,
} from '../components/log/mock_data';
describe('Jobs Store Utils', () => {
@@ -297,6 +298,21 @@ describe('Jobs Store Utils', () => {
expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection));
});
});
+
+ describe('backwards compatibility', () => {
+ beforeEach(() => {
+ result = logLinesParser(backwardsCompatibilityTrace);
+ });
+
+ it('should return an object with a parsedLines prop', () => {
+ expect(result).toEqual(
+ expect.objectContaining({
+ parsedLines: expect.any(Array),
+ }),
+ );
+ expect(result.parsedLines).toHaveLength(1);
+ });
+ });
});
describe('findOffsetAndRemove', () => {
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 66d0faa95e7..c8ac7ffc9d9 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -1,3 +1,4 @@
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as urlUtils from '~/lib/utils/url_utility';
@@ -16,24 +17,11 @@ const shas = {
],
};
-const setWindowLocation = (value) => {
- Object.defineProperty(window, 'location', {
- writable: true,
- value,
- });
-};
+beforeEach(() => {
+ setWindowLocation(TEST_HOST);
+});
describe('URL utility', () => {
- let originalLocation;
-
- beforeAll(() => {
- originalLocation = window.location;
- });
-
- afterAll(() => {
- window.location = originalLocation;
- });
-
describe('webIDEUrl', () => {
afterEach(() => {
gon.relative_url_root = '';
@@ -68,14 +56,7 @@ describe('URL utility', () => {
describe('getParameterValues', () => {
beforeEach(() => {
- setWindowLocation({
- href: 'https://gitlab.com?test=passing&multiple=1&multiple=2',
- // make our fake location act like real window.location.toString
- // URL() (used in getParameterValues) does this if passed an object
- toString() {
- return this.href;
- },
- });
+ setWindowLocation('https://gitlab.com?test=passing&multiple=1&multiple=2');
});
it('returns empty array for no params', () => {
@@ -330,9 +311,7 @@ describe('URL utility', () => {
describe('doesHashExistInUrl', () => {
beforeEach(() => {
- setWindowLocation({
- hash: 'https://gitlab.com/gitlab-org/gitlab-test/issues/1#note_1',
- });
+ setWindowLocation('#note_1');
});
it('should return true when the given string exists in the URL hash', () => {
@@ -442,10 +421,7 @@ describe('URL utility', () => {
describe('getBaseURL', () => {
beforeEach(() => {
- setWindowLocation({
- protocol: 'https:',
- host: 'gitlab.com',
- });
+ setWindowLocation('https://gitlab.com');
});
it('returns correct base URL', () => {
@@ -637,10 +613,7 @@ describe('URL utility', () => {
${'http:'} | ${'ws:'}
${'https:'} | ${'wss:'}
`('returns "$expectation" with "$protocol" protocol', ({ protocol, expectation }) => {
- setWindowLocation({
- protocol,
- host: 'example.com',
- });
+ setWindowLocation(`${protocol}//example.com`);
expect(urlUtils.getWebSocketProtocol()).toEqual(expectation);
});
@@ -648,10 +621,7 @@ describe('URL utility', () => {
describe('getWebSocketUrl', () => {
it('joins location host to path', () => {
- setWindowLocation({
- protocol: 'http:',
- host: 'example.com',
- });
+ setWindowLocation('http://example.com');
const path = '/lorem/ipsum?a=bc';
@@ -700,21 +670,23 @@ describe('URL utility', () => {
describe('queryToObject', () => {
it.each`
- case | query | options | result
- ${'converts query'} | ${'?one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }}
- ${'converts query without ?'} | ${'one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }}
- ${'removes undefined values'} | ${'?one=1&two=2&three'} | ${undefined} | ${{ one: '1', two: '2' }}
- ${'overwrites values with same key and does not change key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${undefined} | ${{ 'one[]': '2', two: '3' }}
- ${'gathers values with the same array-key, strips `[]` from key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['1', '2'], two: '3' }}
- ${'overwrites values with the same array-key name'} | ${'?one=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['2'], two: '3' }}
- ${'overwrites values with the same key name'} | ${'?one[]=1&one=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: '2', two: '3' }}
- ${'ignores plus symbols'} | ${'?search=a+b'} | ${{ legacySpacesDecode: true }} | ${{ search: 'a+b' }}
- ${'ignores plus symbols in keys'} | ${'?search+term=a'} | ${{ legacySpacesDecode: true }} | ${{ 'search+term': 'a' }}
- ${'ignores plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true, legacySpacesDecode: true }} | ${{ search: ['a+b'] }}
- ${'replaces plus symbols with spaces'} | ${'?search=a+b'} | ${undefined} | ${{ search: 'a b' }}
- ${'replaces plus symbols in keys with spaces'} | ${'?search+term=a'} | ${undefined} | ${{ 'search term': 'a' }}
- ${'replaces plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true }} | ${{ search: ['a b'] }}
- ${'replaces plus symbols when gathering arrays for values with same key'} | ${'?search[]=a+b&search[]=c+d'} | ${{ gatherArrays: true }} | ${{ search: ['a b', 'c d'] }}
+ case | query | options | result
+ ${'converts query'} | ${'?one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }}
+ ${'converts query without ?'} | ${'one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }}
+ ${'removes undefined values'} | ${'?one=1&two=2&three'} | ${undefined} | ${{ one: '1', two: '2' }}
+ ${'overwrites values with same key and does not change key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${undefined} | ${{ 'one[]': '2', two: '3' }}
+ ${'gathers values with the same array-key, strips `[]` from key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['1', '2'], two: '3' }}
+ ${'overwrites values with the same array-key name'} | ${'?one=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['2'], two: '3' }}
+ ${'overwrites values with the same key name'} | ${'?one[]=1&one=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: '2', two: '3' }}
+ ${'ignores plus symbols'} | ${'?search=a+b'} | ${{ legacySpacesDecode: true }} | ${{ search: 'a+b' }}
+ ${'ignores plus symbols in keys'} | ${'?search+term=a'} | ${{ legacySpacesDecode: true }} | ${{ 'search+term': 'a' }}
+ ${'ignores plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true, legacySpacesDecode: true }} | ${{ search: ['a+b'] }}
+ ${'replaces plus symbols with spaces'} | ${'?search=a+b'} | ${undefined} | ${{ search: 'a b' }}
+ ${'replaces plus symbols in keys with spaces'} | ${'?search+term=a'} | ${undefined} | ${{ 'search term': 'a' }}
+ ${'preserves square brackets in array params'} | ${'?search[]=a&search[]=b'} | ${{ gatherArrays: true }} | ${{ search: ['a', 'b'] }}
+ ${'decodes encoded square brackets in array params'} | ${'?search%5B%5D=a&search%5B%5D=b'} | ${{ gatherArrays: true }} | ${{ search: ['a', 'b'] }}
+ ${'replaces plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true }} | ${{ search: ['a b'] }}
+ ${'replaces plus symbols when gathering arrays for values with same key'} | ${'?search[]=a+b&search[]=c+d'} | ${{ gatherArrays: true }} | ${{ search: ['a b', 'c d'] }}
`('$case', ({ query, options, result }) => {
expect(urlUtils.queryToObject(query, options)).toEqual(result);
});
@@ -724,32 +696,32 @@ describe('URL utility', () => {
const { getParameterByName } = urlUtils;
it('should return valid parameter', () => {
- setWindowLocation({ search: '?scope=all&p=2' });
+ setWindowLocation('?scope=all&p=2');
expect(getParameterByName('p')).toEqual('2');
expect(getParameterByName('scope')).toBe('all');
});
it('should return invalid parameter', () => {
- setWindowLocation({ search: '?scope=all&p=2' });
+ setWindowLocation('?scope=all&p=2');
expect(getParameterByName('fakeParameter')).toBe(null);
});
it('should return a parameter with spaces', () => {
- setWindowLocation({ search: '?search=my terms' });
+ setWindowLocation('?search=my terms');
expect(getParameterByName('search')).toBe('my terms');
});
it('should return a parameter with encoded spaces', () => {
- setWindowLocation({ search: '?search=my%20terms' });
+ setWindowLocation('?search=my%20terms');
expect(getParameterByName('search')).toBe('my terms');
});
it('should return a parameter with plus signs as spaces', () => {
- setWindowLocation({ search: '?search=my+terms' });
+ setWindowLocation('?search=my+terms');
expect(getParameterByName('search')).toBe('my terms');
});
@@ -842,18 +814,20 @@ describe('URL utility', () => {
});
describe('urlIsDifferent', () => {
+ const current = 'http://current.test/';
+
beforeEach(() => {
- setWindowLocation('current');
+ setWindowLocation(current);
});
it('should compare against the window location if no compare value is provided', () => {
expect(urlUtils.urlIsDifferent('different')).toBeTruthy();
- expect(urlUtils.urlIsDifferent('current')).toBeFalsy();
+ expect(urlUtils.urlIsDifferent(current)).toBeFalsy();
});
it('should use the provided compare value', () => {
- expect(urlUtils.urlIsDifferent('different', 'current')).toBeTruthy();
- expect(urlUtils.urlIsDifferent('current', 'current')).toBeFalsy();
+ expect(urlUtils.urlIsDifferent('different', current)).toBeTruthy();
+ expect(urlUtils.urlIsDifferent(current, current)).toBeFalsy();
});
});
@@ -944,9 +918,8 @@ describe('URL utility', () => {
it.each([[httpProtocol], [httpsProtocol]])(
'when no url passed, returns correct protocol for %i from window location',
(protocol) => {
- setWindowLocation({
- protocol,
- });
+ setWindowLocation(`${protocol}//test.host`);
+
expect(urlUtils.getHTTPProtocol()).toBe(protocol.slice(0, -1));
},
);
@@ -979,10 +952,8 @@ describe('URL utility', () => {
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);
+ setWindowLocation('https://user:pass@origin.test:1234/foo/bar?foo=1#bar');
+ expect(urlUtils.getURLOrigin()).toBe('https://origin.test:1234');
});
it.each`
@@ -1032,10 +1003,6 @@ describe('URL utility', () => {
// eslint-disable-next-line no-script-url
const javascriptUrl = 'javascript:alert(1)';
- beforeEach(() => {
- setWindowLocation({ origin: TEST_HOST });
- });
-
it.each`
url | expected
${TEST_HOST} | ${true}
diff --git a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js
index e7a99a96da6..79252456f67 100644
--- a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js
+++ b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js
@@ -37,7 +37,7 @@ describe('InviteActionButtons', () => {
});
it('sets props correctly', () => {
- expect(findRemoveMemberButton().props()).toEqual({
+ expect(findRemoveMemberButton().props()).toMatchObject({
memberId: member.id,
memberType: null,
message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.fullName}"`,
diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js
index 4ff12f7fa97..d8453d453e7 100644
--- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js
+++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js
@@ -1,6 +1,8 @@
+import { GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { modalData } from 'jest/members/mock_data';
import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue';
import { MEMBER_TYPES } from '~/members/constants';
@@ -10,6 +12,10 @@ localVue.use(Vuex);
describe('RemoveMemberButton', () => {
let wrapper;
+ const actions = {
+ showRemoveMemberModal: jest.fn(),
+ };
+
const createStore = (state = {}) => {
return new Vuex.Store({
modules: {
@@ -19,6 +25,7 @@ describe('RemoveMemberButton', () => {
memberPath: '/groups/foo-bar/-/group_members/:id',
...state,
},
+ actions,
},
},
});
@@ -47,20 +54,16 @@ describe('RemoveMemberButton', () => {
});
};
+ beforeEach(() => {
+ createComponent();
+ });
+
afterEach(() => {
wrapper.destroy();
});
it('sets attributes on button', () => {
- createComponent();
-
expect(wrapper.attributes()).toMatchObject({
- 'data-member-path': '/groups/foo-bar/-/group_members/1',
- 'data-member-type': 'GroupMember',
- 'data-message': 'Are you sure you want to remove John Smith?',
- 'data-is-access-request': 'true',
- 'data-is-invite': 'true',
- 'data-oncall-schedules': '{"name":"user","schedules":[]}',
'aria-label': 'Remove member',
title: 'Remove member',
icon: 'remove',
@@ -68,14 +71,12 @@ describe('RemoveMemberButton', () => {
});
it('displays `title` prop as a tooltip', () => {
- createComponent();
-
expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined();
});
- it('has CSS class used by `remove_member_modal.vue`', () => {
- createComponent();
+ it('calls Vuex action to show `remove member` modal when clicked', () => {
+ wrapper.findComponent(GlButton).vm.$emit('click');
- expect(wrapper.classes()).toContain('js-remove-member-button');
+ expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), modalData);
});
});
diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
index a3b91cb20bb..3f47fa024bc 100644
--- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
+++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js
@@ -1,11 +1,23 @@
import { GlFilteredSearchToken } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { redirectTo } from '~/lib/utils/url_utility';
import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue';
import { MEMBER_TYPES } from '~/members/constants';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+jest.mock('~/lib/utils/url_utility', () => {
+ const urlUtility = jest.requireActual('~/lib/utils/url_utility');
+
+ return {
+ __esModule: true,
+ ...urlUtility,
+ redirectTo: jest.fn(),
+ };
+});
+
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -113,12 +125,11 @@ describe('MembersFilteredSearchBar', () => {
describe('when filters are set via query params', () => {
beforeEach(() => {
- delete window.location;
- window.location = new URL('https://localhost');
+ setWindowLocation('https://localhost');
});
it('parses and passes tokens to `FilteredSearchBar` component as `initialFilterValue` prop', () => {
- window.location.search = '?two_factor=enabled&token_not_available=foobar';
+ setWindowLocation('?two_factor=enabled&token_not_available=foobar');
createComponent();
@@ -134,7 +145,7 @@ describe('MembersFilteredSearchBar', () => {
});
it('parses and passes search param to `FilteredSearchBar` component as `initialFilterValue` prop', () => {
- window.location.search = '?search=foobar';
+ setWindowLocation('?search=foobar');
createComponent();
@@ -149,7 +160,7 @@ describe('MembersFilteredSearchBar', () => {
});
it('parses and passes search param with multiple words to `FilteredSearchBar` component as `initialFilterValue` prop', () => {
- window.location.search = '?search=foo+bar+baz';
+ setWindowLocation('?search=foo+bar+baz');
createComponent();
@@ -166,8 +177,7 @@ describe('MembersFilteredSearchBar', () => {
describe('when filter bar is submitted', () => {
beforeEach(() => {
- delete window.location;
- window.location = new URL('https://localhost');
+ setWindowLocation('https://localhost');
});
it('adds correct filter query params', () => {
@@ -177,7 +187,7 @@ describe('MembersFilteredSearchBar', () => {
{ type: 'two_factor', value: { data: 'enabled', operator: '=' } },
]);
- expect(window.location.href).toBe('https://localhost/?two_factor=enabled');
+ expect(redirectTo).toHaveBeenCalledWith('https://localhost/?two_factor=enabled');
});
it('adds search query param', () => {
@@ -188,7 +198,9 @@ describe('MembersFilteredSearchBar', () => {
{ type: 'filtered-search-term', value: { data: 'foobar' } },
]);
- expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foobar');
+ expect(redirectTo).toHaveBeenCalledWith(
+ 'https://localhost/?two_factor=enabled&search=foobar',
+ );
});
it('adds search query param with multiple words', () => {
@@ -199,11 +211,13 @@ describe('MembersFilteredSearchBar', () => {
{ type: 'filtered-search-term', value: { data: 'foo bar baz' } },
]);
- expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foo+bar+baz');
+ expect(redirectTo).toHaveBeenCalledWith(
+ 'https://localhost/?two_factor=enabled&search=foo+bar+baz',
+ );
});
it('adds sort query param', () => {
- window.location.search = '?sort=name_asc';
+ setWindowLocation('?sort=name_asc');
createComponent();
@@ -212,13 +226,13 @@ describe('MembersFilteredSearchBar', () => {
{ type: 'filtered-search-term', value: { data: 'foobar' } },
]);
- expect(window.location.href).toBe(
+ expect(redirectTo).toHaveBeenCalledWith(
'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc',
);
});
it('adds active tab query param', () => {
- window.location.search = '?tab=invited';
+ setWindowLocation('?tab=invited');
createComponent();
@@ -226,7 +240,7 @@ describe('MembersFilteredSearchBar', () => {
{ type: 'filtered-search-term', value: { data: 'foobar' } },
]);
- expect(window.location.href).toBe('https://localhost/?search=foobar&tab=invited');
+ expect(redirectTo).toHaveBeenCalledWith('https://localhost/?search=foobar&tab=invited');
});
});
});
diff --git a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js
index 4b335755980..d0684acd487 100644
--- a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js
+++ b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js
@@ -1,6 +1,7 @@
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
import * as urlUtilities from '~/lib/utils/url_utility';
import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue';
import { MEMBER_TYPES } from '~/members/constants';
@@ -52,17 +53,16 @@ describe('SortDropdown', () => {
.findAll(GlSortingItem)
.wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text);
- describe('dropdown options', () => {
- beforeEach(() => {
- delete window.location;
- window.location = new URL(URL_HOST);
- });
+ beforeEach(() => {
+ setWindowLocation(URL_HOST);
+ });
+ describe('dropdown options', () => {
it('adds dropdown items for all the sortable fields', () => {
const URL_FILTER_PARAMS = '?two_factor=enabled&search=foobar';
const EXPECTED_BASE_URL = `${URL_HOST}${URL_FILTER_PARAMS}&sort=`;
- window.location.search = URL_FILTER_PARAMS;
+ setWindowLocation(URL_FILTER_PARAMS);
const expectedDropdownItems = [
{
@@ -94,7 +94,7 @@ describe('SortDropdown', () => {
});
it('checks selected sort option', () => {
- window.location.search = '?sort=access_level_asc';
+ setWindowLocation('?sort=access_level_asc');
createComponent();
@@ -103,11 +103,6 @@ describe('SortDropdown', () => {
});
describe('dropdown toggle', () => {
- beforeEach(() => {
- delete window.location;
- window.location = new URL(URL_HOST);
- });
-
it('defaults to sorting by "Account" in ascending order', () => {
createComponent();
@@ -116,7 +111,7 @@ describe('SortDropdown', () => {
});
it('sets text as selected sort option', () => {
- window.location.search = '?sort=access_level_asc';
+ setWindowLocation('?sort=access_level_asc');
createComponent();
@@ -126,15 +121,12 @@ describe('SortDropdown', () => {
describe('sort direction toggle', () => {
beforeEach(() => {
- delete window.location;
- window.location = new URL(URL_HOST);
-
- jest.spyOn(urlUtilities, 'visitUrl');
+ jest.spyOn(urlUtilities, 'visitUrl').mockImplementation();
});
describe('when current sort direction is ascending', () => {
beforeEach(() => {
- window.location.search = '?sort=access_level_asc';
+ setWindowLocation('?sort=access_level_asc');
createComponent();
});
@@ -152,7 +144,7 @@ describe('SortDropdown', () => {
describe('when current sort direction is descending', () => {
beforeEach(() => {
- window.location.search = '?sort=access_level_desc';
+ setWindowLocation('?sort=access_level_desc');
createComponent();
});
diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js
index 33d8eebf7eb..1d882e5ef09 100644
--- a/spec/frontend/members/components/members_tabs_spec.js
+++ b/spec/frontend/members/components/members_tabs_spec.js
@@ -1,6 +1,7 @@
-import { GlTabs } from '@gitlab/ui';
+import { GlTabs, GlButton } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import MembersApp from '~/members/components/app.vue';
import MembersTabs from '~/members/components/members_tabs.vue';
@@ -16,7 +17,7 @@ describe('MembersTabs', () => {
let wrapper;
- const createComponent = ({ totalItems = 10, options = {} } = {}) => {
+ const createComponent = ({ totalItems = 10, provide = {} } = {}) => {
const store = new Vuex.Store({
modules: {
[MEMBER_TYPES.user]: {
@@ -78,8 +79,10 @@ describe('MembersTabs', () => {
stubs: ['members-app'],
provide: {
canManageMembers: true,
+ canExportMembers: true,
+ exportCsvPath: '',
+ ...provide,
},
- ...options,
});
return nextTick();
@@ -88,10 +91,10 @@ describe('MembersTabs', () => {
const findTabs = () => wrapper.findAllByRole('tab').wrappers;
const findTabByText = (text) => findTabs().find((tab) => tab.text().includes(text));
const findActiveTab = () => wrapper.findByRole('tab', { selected: true });
+ const findExportButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
- delete window.location;
- window.location = new URL('https://localhost');
+ setWindowLocation('https://localhost');
});
afterEach(() => {
@@ -151,7 +154,7 @@ describe('MembersTabs', () => {
describe('when url param matches `filteredSearchBar.searchParam`', () => {
beforeEach(() => {
- window.location.search = '?search_groups=foo+bar';
+ setWindowLocation('?search_groups=foo+bar');
});
it('shows tab that corresponds to search param', async () => {
@@ -164,7 +167,7 @@ describe('MembersTabs', () => {
describe('when `canManageMembers` is `false`', () => {
it('shows all tabs except `Invited` and `Access requests`', async () => {
- await createComponent({ options: { provide: { canManageMembers: false } } });
+ await createComponent({ provide: { canManageMembers: false } });
expect(findTabByText('Members')).not.toBeUndefined();
expect(findTabByText('Groups')).not.toBeUndefined();
@@ -172,4 +175,20 @@ describe('MembersTabs', () => {
expect(findTabByText('Access requests')).toBeUndefined();
});
});
+
+ describe('when `canExportMembers` is true', () => {
+ it('shows the CSV export button with export path', async () => {
+ await createComponent({ provide: { canExportMembers: true, exportCsvPath: 'foo' } });
+
+ expect(findExportButton().attributes('href')).toBe('foo');
+ });
+ });
+
+ describe('when `canExportMembers` is false', () => {
+ it('does not show the CSV export button', async () => {
+ await createComponent({ provide: { canExportMembers: false } });
+
+ expect(findExportButton().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/members/components/modals/remove_member_modal_spec.js
index ce9de28d53c..1dc41582c12 100644
--- a/spec/frontend/vue_shared/components/remove_member_modal_spec.js
+++ b/spec/frontend/members/components/modals/remove_member_modal_spec.js
@@ -1,37 +1,61 @@
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue';
+import { MEMBER_TYPES } from '~/members/constants';
import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
-import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
-const mockSchedules = JSON.stringify({
- schedules: [
- {
- id: 1,
- name: 'Schedule 1',
- },
- ],
- name: 'User1',
-});
+Vue.use(Vuex);
describe('RemoveMemberModal', () => {
const memberPath = '/gitlab-org/gitlab-test/-/project_members/90';
+ const mockSchedules = {
+ name: 'User1',
+ schedules: [{ id: 1, name: 'Schedule 1' }],
+ };
let wrapper;
+ const actions = {
+ hideRemoveMemberModal: jest.fn(),
+ };
+
+ const createStore = (removeMemberModalData) =>
+ new Vuex.Store({
+ modules: {
+ [MEMBER_TYPES.user]: {
+ namespaced: true,
+ state: {
+ removeMemberModalData,
+ },
+ actions,
+ },
+ },
+ });
+
+ const createComponent = (state) => {
+ wrapper = shallowMount(RemoveMemberModal, {
+ store: createStore(state),
+ provide: {
+ namespace: MEMBER_TYPES.user,
+ },
+ });
+ };
+
const findForm = () => wrapper.find({ ref: 'form' });
const findGlModal = () => wrapper.findComponent(GlModal);
const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList);
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
describe.each`
- state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules
- ${'removing a group member'} | ${'GroupMember'} | ${false} | ${'false'} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${`{}`}
- ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${'false'} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
- ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${'false'} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${`{}`}
- ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${'true'} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
+ state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules
+ ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}}
+ ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
+ ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}}
+ ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
`(
'when $state',
({
@@ -45,24 +69,17 @@ describe('RemoveMemberModal', () => {
onCallSchedules,
}) => {
beforeEach(() => {
- wrapper = shallowMount(RemoveMemberModal, {
- data() {
- return {
- modalData: {
- isAccessRequest,
- isInvite,
- message,
- memberPath,
- memberType,
- onCallSchedules,
- },
- };
- },
+ createComponent({
+ isAccessRequest,
+ isInvite,
+ message,
+ memberPath,
+ memberType,
+ onCallSchedules,
});
});
- const parsedSchedules = JSON.parse(onCallSchedules);
- const isPartOfOncallSchedules = Boolean(isAccessRequest && parsedSchedules.schedules?.length);
+ const isPartOfOncallSchedules = Boolean(isAccessRequest && onCallSchedules.schedules?.length);
it(`has the title ${actionText}`, () => {
expect(findGlModal().attributes('title')).toBe(actionText);
@@ -73,7 +90,7 @@ describe('RemoveMemberModal', () => {
});
it('displays a message to the user', () => {
- expect(wrapper.find('[data-testid=modal-message]').text()).toBe(message);
+ expect(wrapper.find('p').text()).toBe(message);
});
it(`shows ${
@@ -105,6 +122,12 @@ describe('RemoveMemberModal', () => {
spy.mockRestore();
});
+
+ it('calls Vuex action to hide the modal when `GlModal` emits `hide` event', () => {
+ findGlModal().vm.$emit('hide');
+
+ expect(actions.hideRemoveMemberModal).toHaveBeenCalled();
+ });
},
);
});
diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js
index 3a17d78bd17..6885da53b26 100644
--- a/spec/frontend/members/components/table/members_table_spec.js
+++ b/spec/frontend/members/components/table/members_table_spec.js
@@ -6,6 +6,7 @@ import {
} from '@testing-library/dom';
import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CreatedAt from '~/members/components/table/created_at.vue';
import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue';
@@ -72,6 +73,7 @@ describe('MembersTable', () => {
'member-action-buttons',
'role-dropdown',
'remove-group-link-modal',
+ 'remove-member-modal',
'expiration-datepicker',
],
});
@@ -242,12 +244,8 @@ describe('MembersTable', () => {
});
describe('when required pagination data is provided', () => {
- beforeEach(() => {
- delete window.location;
- });
-
it('renders `gl-pagination` component with correct props', () => {
- window.location = new URL(url);
+ setWindowLocation(url);
createComponent();
@@ -267,7 +265,7 @@ describe('MembersTable', () => {
});
it('uses `pagination.paramName` to generate the pagination links', () => {
- window.location = new URL(url);
+ setWindowLocation(url);
createComponent({
pagination: {
@@ -282,7 +280,7 @@ describe('MembersTable', () => {
});
it('removes any url params defined as `null` in the `params` attribute', () => {
- window.location = new URL(`${url}&search_groups=foo`);
+ setWindowLocation(`${url}&search_groups=foo`);
createComponent({
pagination: {
diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js
index 4275db5fa9f..eb9f905fea2 100644
--- a/spec/frontend/members/mock_data.js
+++ b/spec/frontend/members/mock_data.js
@@ -57,6 +57,15 @@ export const group = {
validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
};
+export const modalData = {
+ isAccessRequest: true,
+ isInvite: true,
+ memberPath: '/groups/foo-bar/-/group_members/1',
+ memberType: 'GroupMember',
+ message: 'Are you sure you want to remove John Smith?',
+ oncallSchedules: { name: 'user', schedules: [] },
+};
+
const { user, ...memberNoUser } = member;
export const invite = {
...memberNoUser,
diff --git a/spec/frontend/members/store/actions_spec.js b/spec/frontend/members/store/actions_spec.js
index d913c5c56df..d37e6871387 100644
--- a/spec/frontend/members/store/actions_spec.js
+++ b/spec/frontend/members/store/actions_spec.js
@@ -3,12 +3,14 @@ import MockAdapter from 'axios-mock-adapter';
import { noop } from 'lodash';
import { useFakeDate } from 'helpers/fake_date';
import testAction from 'helpers/vuex_action_helper';
-import { members, group } from 'jest/members/mock_data';
+import { members, group, modalData } from 'jest/members/mock_data';
import httpStatusCodes from '~/lib/utils/http_status';
import {
updateMemberRole,
showRemoveGroupLinkModal,
hideRemoveGroupLinkModal,
+ showRemoveMemberModal,
+ hideRemoveMemberModal,
updateMemberExpiration,
} from '~/members/store/actions';
import * as types from '~/members/store/mutation_types';
@@ -153,4 +155,32 @@ describe('Vuex members actions', () => {
});
});
});
+
+ describe('Remove member modal', () => {
+ const state = {
+ removeMemberModalVisible: false,
+ removeMemberModalData: {},
+ };
+
+ describe('showRemoveMemberModal', () => {
+ it(`commits ${types.SHOW_REMOVE_MEMBER_MODAL} mutation`, () => {
+ testAction(showRemoveMemberModal, modalData, state, [
+ {
+ type: types.SHOW_REMOVE_MEMBER_MODAL,
+ payload: modalData,
+ },
+ ]);
+ });
+ });
+
+ describe('hideRemoveMemberModal', () => {
+ it(`commits ${types.HIDE_REMOVE_MEMBER_MODAL} mutation`, () => {
+ testAction(hideRemoveMemberModal, undefined, state, [
+ {
+ type: types.HIDE_REMOVE_MEMBER_MODAL,
+ },
+ ]);
+ });
+ });
+ });
});
diff --git a/spec/frontend/members/store/mutations_spec.js b/spec/frontend/members/store/mutations_spec.js
index 78bbad394a0..8160cc373d8 100644
--- a/spec/frontend/members/store/mutations_spec.js
+++ b/spec/frontend/members/store/mutations_spec.js
@@ -1,4 +1,4 @@
-import { members, group } from 'jest/members/mock_data';
+import { members, group, modalData } from 'jest/members/mock_data';
import * as types from '~/members/store/mutation_types';
import mutations from '~/members/store/mutations';
@@ -152,4 +152,32 @@ describe('Vuex members mutations', () => {
expect(state.removeGroupLinkModalVisible).toBe(false);
});
});
+
+ describe(types.SHOW_REMOVE_MEMBER_MODAL, () => {
+ it('sets `removeMemberModalVisible` and `removeMemberModalData`', () => {
+ const state = {
+ removeMemberModalVisible: false,
+ removeMemberModalData: {},
+ };
+
+ mutations[types.SHOW_REMOVE_MEMBER_MODAL](state, modalData);
+
+ expect(state).toEqual({
+ removeMemberModalVisible: true,
+ removeMemberModalData: modalData,
+ });
+ });
+ });
+
+ describe(types.HIDE_REMOVE_MEMBER_MODAL, () => {
+ it('sets `removeMemberModalVisible` to `false`', () => {
+ const state = {
+ removeMemberModalVisible: true,
+ };
+
+ mutations[types.HIDE_REMOVE_MEMBER_MODAL](state);
+
+ expect(state.removeMemberModalVisible).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js
index 9740e1c2edb..a157cfa1c1d 100644
--- a/spec/frontend/members/utils_spec.js
+++ b/spec/frontend/members/utils_spec.js
@@ -1,3 +1,4 @@
+import setWindowLocation from 'helpers/set_window_location_helper';
import { DEFAULT_SORT, MEMBER_TYPES } from '~/members/constants';
import {
generateBadges,
@@ -150,21 +151,18 @@ describe('Members Utils', () => {
describe('parseSortParam', () => {
beforeEach(() => {
- delete window.location;
- window.location = new URL(URL_HOST);
+ setWindowLocation(URL_HOST);
});
describe('when `sort` param is not present', () => {
it('returns default sort options', () => {
- window.location.search = '';
-
expect(parseSortParam(['account'])).toEqual(DEFAULT_SORT);
});
});
describe('when field passed in `sortableFields` argument does not have `sort` key defined', () => {
it('returns default sort options', () => {
- window.location.search = '?sort=source_asc';
+ setWindowLocation('?sort=source_asc');
expect(parseSortParam(['source'])).toEqual(DEFAULT_SORT);
});
@@ -182,7 +180,7 @@ describe('Members Utils', () => {
${'oldest_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: true }}
`('when `sort` query string param is `$sortParam`', ({ sortParam, expected }) => {
it(`returns ${JSON.stringify(expected)}`, async () => {
- window.location.search = `?sort=${sortParam}`;
+ setWindowLocation(`?sort=${sortParam}`);
expect(parseSortParam(['account', 'granted', 'expires', 'maxRole', 'lastSignIn'])).toEqual(
expected,
@@ -193,8 +191,7 @@ describe('Members Utils', () => {
describe('buildSortHref', () => {
beforeEach(() => {
- delete window.location;
- window.location = new URL(URL_HOST);
+ setWindowLocation(URL_HOST);
});
describe('when field passed in `sortBy` argument does not have `sort` key defined', () => {
@@ -225,7 +222,7 @@ describe('Members Utils', () => {
describe('when filter params are set', () => {
it('merges the `sort` param with the filter params', () => {
- window.location.search = '?two_factor=enabled&with_inherited_permissions=exclude';
+ setWindowLocation('?two_factor=enabled&with_inherited_permissions=exclude');
expect(
buildSortHref({
@@ -240,7 +237,7 @@ describe('Members Utils', () => {
describe('when search param is set', () => {
it('merges the `sort` param with the search param', () => {
- window.location.search = '?search=foobar';
+ setWindowLocation('?search=foobar');
expect(
buildSortHref({
diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
index dbb9fd5f603..f2116c1f478 100644
--- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
@@ -301,9 +301,6 @@ describe('Actions menu', () => {
});
it('redirects to the newly created dashboard', () => {
- delete window.location;
- window.location = new URL('https://localhost');
-
const newDashboard = dashboardGitResponse[1];
const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml';
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 7ca1b97d849..f899580b3df 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import VueDraggable from 'vuedraggable';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createFlash from '~/flash';
@@ -226,32 +227,25 @@ describe('Dashboard', () => {
});
describe('when the URL contains a reference to a panel', () => {
- let location;
+ const location = window.location.href;
- const setSearch = (search) => {
- window.location = { ...location, search };
+ const setSearch = (searchParams) => {
+ setWindowLocation(`?${objectToQuery(searchParams)}`);
};
- beforeEach(() => {
- location = window.location;
- delete window.location;
- });
-
afterEach(() => {
- window.location = location;
+ setWindowLocation(location);
});
it('when the URL points to a panel it expands', () => {
const panelGroup = metricsDashboardViewModel.panelGroups[0];
const panel = panelGroup.panels[0];
- setSearch(
- objectToQuery({
- group: panelGroup.group,
- title: panel.title,
- y_label: panel.y_label,
- }),
- );
+ setSearch({
+ group: panelGroup.group,
+ title: panel.title,
+ y_label: panel.y_label,
+ });
createMountedWrapper({ hasMetrics: true });
setupStoreWithData(store);
@@ -268,7 +262,7 @@ describe('Dashboard', () => {
});
it('when the URL does not link to any panel, no panel is expanded', () => {
- setSearch('');
+ setSearch();
createMountedWrapper({ hasMetrics: true });
setupStoreWithData(store);
@@ -285,13 +279,11 @@ describe('Dashboard', () => {
const panelGroup = metricsDashboardViewModel.panelGroups[0];
const panel = panelGroup.panels[0];
- setSearch(
- objectToQuery({
- group: panelGroup.group,
- title: 'incorrect',
- y_label: panel.y_label,
- }),
- );
+ setSearch({
+ group: panelGroup.group,
+ title: 'incorrect',
+ y_label: panel.y_label,
+ });
createMountedWrapper({ hasMetrics: true });
setupStoreWithData(store);
diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js
index 25ae4dcd702..31975052077 100644
--- a/spec/frontend/monitoring/utils_spec.js
+++ b/spec/frontend/monitoring/utils_spec.js
@@ -448,7 +448,7 @@ describe('monitoring/utils', () => {
input | urlParams
${[]} | ${''}
${[{ name: 'env', value: 'prod' }]} | ${'?var-env=prod'}
- ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${'?var-env=prod&var-env1=prod'}
+ ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${'?var-env1=prod'}
`(
'setCustomVariablesFromUrl updates history with query "$urlParams" with input $input',
({ input, urlParams }) => {
diff --git a/spec/frontend/nav/components/responsive_app_spec.js b/spec/frontend/nav/components/responsive_app_spec.js
index e1b443745e3..4af8c6020bc 100644
--- a/spec/frontend/nav/components/responsive_app_spec.js
+++ b/spec/frontend/nav/components/responsive_app_spec.js
@@ -3,16 +3,10 @@ import ResponsiveApp from '~/nav/components/responsive_app.vue';
import ResponsiveHeader from '~/nav/components/responsive_header.vue';
import ResponsiveHome from '~/nav/components/responsive_home.vue';
import TopNavContainerView from '~/nav/components/top_nav_container_view.vue';
-import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '~/nav/event_hub';
import { resetMenuItemsActive } from '~/nav/utils/reset_menu_items_active';
import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
import { TEST_NAV_DATA } from '../mock_data';
-const HTML_HEADER_CONTENT = '<div class="header-content"></div>';
-const HTML_MENU_EXPANDED = '<div class="menu-expanded"></div>';
-const HTML_HEADER_WITH_MENU_EXPANDED =
- '<div></div><div class="header-content menu-expanded"></div>';
-
describe('~/nav/components/responsive_app.vue', () => {
let wrapper;
@@ -26,13 +20,10 @@ describe('~/nav/components/responsive_app.vue', () => {
},
});
};
- const triggerResponsiveToggle = () => eventHub.$emit(EVENT_RESPONSIVE_TOGGLE);
-
const findHome = () => wrapper.findComponent(ResponsiveHome);
const findMobileOverlay = () => wrapper.find('[data-testid="mobile-overlay"]');
const findSubviewHeader = () => wrapper.findComponent(ResponsiveHeader);
const findSubviewContainer = () => wrapper.findComponent(TopNavContainerView);
- const hasBodyResponsiveOpen = () => document.body.classList.contains('top-nav-responsive-open');
const hasMobileOverlayVisible = () => findMobileOverlay().classes('mobile-nav-open');
beforeEach(() => {
@@ -58,23 +49,6 @@ describe('~/nav/components/responsive_app.vue', () => {
});
it.each`
- bodyHtml | expectation
- ${''} | ${false}
- ${HTML_HEADER_CONTENT} | ${false}
- ${HTML_MENU_EXPANDED} | ${false}
- ${HTML_HEADER_WITH_MENU_EXPANDED} | ${true}
- `(
- 'with responsive toggle event and html set to $bodyHtml, responsive open = $expectation',
- ({ bodyHtml, expectation }) => {
- document.body.innerHTML = bodyHtml;
-
- triggerResponsiveToggle();
-
- expect(hasBodyResponsiveOpen()).toBe(expectation);
- },
- );
-
- it.each`
events | expectation
${[]} | ${false}
${['bv::dropdown::show']} | ${true}
@@ -96,17 +70,6 @@ describe('~/nav/components/responsive_app.vue', () => {
);
});
- describe('with menu expanded in body', () => {
- beforeEach(() => {
- document.body.innerHTML = HTML_HEADER_WITH_MENU_EXPANDED;
- createComponent();
- });
-
- it('sets the body responsive open', () => {
- expect(hasBodyResponsiveOpen()).toBe(true);
- });
- });
-
const projectsContainerProps = {
containerClass: 'gl-px-3',
frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.namespace,
@@ -159,17 +122,4 @@ describe('~/nav/components/responsive_app.vue', () => {
});
});
});
-
- describe('when destroyed', () => {
- beforeEach(() => {
- createComponent();
- wrapper.destroy();
- });
-
- it('responsive toggle event does nothing', () => {
- triggerResponsiveToggle();
-
- expect(hasBodyResponsiveOpen()).toBe(false);
- });
- });
});
diff --git a/spec/frontend/notes/components/comment_field_layout_spec.js b/spec/frontend/notes/components/comment_field_layout_spec.js
index 4d9b4ea8c6f..90c989540b9 100644
--- a/spec/frontend/notes/components/comment_field_layout_spec.js
+++ b/spec/frontend/notes/components/comment_field_layout_spec.js
@@ -134,4 +134,18 @@ describe('Comment Field Layout Component', () => {
]);
});
});
+
+ describe('issue has email participants, but note is confidential', () => {
+ it('does not show EmailParticipantsWarning', () => {
+ createWrapper({
+ noteableData: {
+ ...noteableDataMock,
+ issue_email_participants: [{ email: 'someone@gitlab.com' }],
+ },
+ noteIsConfidential: true,
+ });
+
+ expect(findEmailParticipantsWarning().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js
index f217dfd2e48..467a8bec21b 100644
--- a/spec/frontend/notes/components/noteable_note_spec.js
+++ b/spec/frontend/notes/components/noteable_note_spec.js
@@ -258,7 +258,11 @@ describe('issue_note', () => {
},
});
- noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {});
+ noteBodyComponent.vm.$emit('handleFormUpdate', {
+ noteText: noteBody,
+ parentElement: null,
+ callback: () => {},
+ });
await waitForPromises();
expect(alertSpy).not.toHaveBeenCalled();
@@ -287,14 +291,18 @@ describe('issue_note', () => {
const noteBody = wrapper.findComponent(NoteBody);
noteBody.vm.resetAutoSave = () => {};
- noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {});
+ noteBody.vm.$emit('handleFormUpdate', {
+ noteText: updatedText,
+ parentElement: null,
+ callback: () => {},
+ });
await wrapper.vm.$nextTick();
let noteBodyProps = noteBody.props();
expect(noteBodyProps.note.note_html).toBe(`<p>${updatedText}</p>\n`);
- noteBody.vm.$emit('cancelForm');
+ noteBody.vm.$emit('cancelForm', {});
await wrapper.vm.$nextTick();
noteBodyProps = noteBody.props();
@@ -305,7 +313,12 @@ describe('issue_note', () => {
describe('formUpdateHandler', () => {
const updateNote = jest.fn();
- const params = ['', null, jest.fn(), ''];
+ const params = {
+ noteText: '',
+ parentElement: null,
+ callback: jest.fn(),
+ resolveDiscussion: false,
+ };
const updateActions = () => {
store.hotUpdate({
@@ -325,14 +338,14 @@ describe('issue_note', () => {
it('responds to handleFormUpdate', () => {
createWrapper();
updateActions();
- wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
+ wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
expect(wrapper.emitted('handleUpdateNote')).toBeTruthy();
});
it('does not stringify empty position', () => {
createWrapper();
updateActions();
- wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
+ wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined();
});
@@ -341,7 +354,7 @@ describe('issue_note', () => {
const expectation = JSON.stringify(position);
createWrapper({ note: { ...note, position } });
updateActions();
- wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
+ wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation);
});
});
diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js
index 3132ec61942..377e7e05f09 100644
--- a/spec/frontend/packages/details/components/app_spec.js
+++ b/spec/frontend/packages/details/components/app_spec.js
@@ -2,6 +2,7 @@ import { GlEmptyState } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import stubChildren from 'helpers/stub_children';
import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue';
@@ -30,6 +31,8 @@ import {
const localVue = createLocalVue();
localVue.use(Vuex);
+useMockLocationHelper();
+
describe('PackagesApp', () => {
let wrapper;
let store;
@@ -37,7 +40,6 @@ describe('PackagesApp', () => {
const deletePackage = jest.fn();
const deletePackageFile = jest.fn();
const defaultProjectName = 'bar';
- const { location } = window;
function createComponent({
packageEntity = mavenPackage,
@@ -100,14 +102,8 @@ describe('PackagesApp', () => {
const findInstallationCommands = () => wrapper.find(InstallationCommands);
const findPackageFiles = () => wrapper.find(PackageFiles);
- beforeEach(() => {
- delete window.location;
- window.location = { replace: jest.fn() };
- });
-
afterEach(() => {
wrapper.destroy();
- window.location = location;
});
it('renders the app and displays the package title', async () => {
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 4de2dd0789e..b94192c531c 100644
--- a/spec/frontend/packages/list/components/packages_list_app_spec.js
+++ b/spec/frontend/packages/list/components/packages_list_app_spec.js
@@ -1,6 +1,7 @@
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
import createFlash from '~/flash';
import * as commonUtils from '~/lib/utils/common_utils';
import PackageListApp from '~/packages/list/components/packages_list_app.vue';
@@ -233,21 +234,17 @@ describe('packages_list_app', () => {
});
describe('delete alert handling', () => {
- const { location } = window.location;
+ const originalLocation = window.location.href;
const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`;
beforeEach(() => {
createStore();
jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {});
- delete window.location;
- window.location = {
- href: `foo_bar_baz${search}`,
- search,
- };
+ setWindowLocation(search);
});
afterEach(() => {
- window.location = location;
+ setWindowLocation(originalLocation);
});
it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
@@ -262,11 +259,11 @@ describe('packages_list_app', () => {
it('calls historyReplaceState with a clean url', () => {
mountComponent();
- expect(commonUtils.historyReplaceState).toHaveBeenCalledWith('foo_bar_baz');
+ expect(commonUtils.historyReplaceState).toHaveBeenCalledWith(originalLocation);
});
it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
- window.location.search = '';
+ setWindowLocation('?');
mountComponent();
expect(createFlash).not.toHaveBeenCalled();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap
new file mode 100644
index 00000000000..e9f80d5f512
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ConanInstallation renders all the messages 1`] = `
+<div>
+ <installation-title-stub
+ options="[object Object]"
+ packagetype="conan"
+ />
+
+ <code-instruction-stub
+ copytext="Copy Conan Command"
+ instruction="conan install @gitlab-org/package-15 --remote=gitlab"
+ label="Conan Command"
+ trackingaction="copy_conan_command"
+ trackinglabel="code_instruction"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <code-instruction-stub
+ copytext="Copy Conan Setup Command"
+ instruction="conan remote add gitlab conanPath"
+ label="Add Conan Remote"
+ trackingaction="copy_conan_setup_command"
+ trackinglabel="code_instruction"
+ />
+
+ <gl-sprintf-stub
+ message="For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/dependency_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/dependency_row_spec.js.snap
new file mode 100644
index 00000000000..f83df7b11f4
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/dependency_row_spec.js.snap
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DependencyRow renders full dependency 1`] = `
+<div
+ class="gl-responsive-table-row"
+>
+ <div
+ class="table-section section-50"
+ >
+ <strong
+ class="gl-text-body"
+ >
+ Ninject.Extensions.Factory
+ </strong>
+
+ <span
+ data-testid="target-framework"
+ >
+
+ (.NETCoreApp3.1)
+
+ </span>
+ </div>
+
+ <div
+ class="table-section section-50 gl-display-flex gl-md-justify-content-end"
+ data-testid="version-pattern"
+ >
+ <span
+ class="gl-text-body"
+ >
+ 3.3.2
+ </span>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap
new file mode 100644
index 00000000000..881d441e116
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap
@@ -0,0 +1,30 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FileSha renders 1`] = `
+<div
+ class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-word-break-all gl-py-2 gl-border-b-solid gl-border-gray-100 gl-border-b-1"
+>
+ <!---->
+
+ <span>
+ <div
+ class="gl-px-4"
+ >
+
+ bar:
+ foo
+
+ <gl-button-stub
+ aria-label="Copy this value"
+ buttontextclasses=""
+ category="tertiary"
+ data-clipboard-text="foo"
+ icon="copy-to-clipboard"
+ size="small"
+ title="Copy SHA"
+ variant="default"
+ />
+ </div>
+ </span>
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap
new file mode 100644
index 00000000000..4865b8205ab
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap
@@ -0,0 +1,135 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MavenInstallation groovy renders all the messages 1`] = `
+<div>
+ <installation-title-stub
+ options="[object Object],[object Object],[object Object]"
+ packagetype="maven"
+ />
+
+ <code-instruction-stub
+ class="gl-mb-5"
+ copytext="Copy Gradle Groovy DSL install command"
+ instruction="implementation 'appGroup:appName:appVersion'"
+ label="Gradle Groovy DSL install command"
+ trackingaction="copy_gradle_install_command"
+ trackinglabel="code_instruction"
+ />
+
+ <code-instruction-stub
+ copytext="Copy add Gradle Groovy DSL repository command"
+ instruction="maven {
+ url 'mavenPath'
+}"
+ label="Add Gradle Groovy DSL repository command"
+ multiline="true"
+ trackingaction="copy_gradle_add_to_source_command"
+ trackinglabel="code_instruction"
+ />
+</div>
+`;
+
+exports[`MavenInstallation kotlin renders all the messages 1`] = `
+<div>
+ <installation-title-stub
+ options="[object Object],[object Object],[object Object]"
+ packagetype="maven"
+ />
+
+ <code-instruction-stub
+ class="gl-mb-5"
+ copytext="Copy Gradle Kotlin DSL install command"
+ instruction="implementation(\\"appGroup:appName:appVersion\\")"
+ label="Gradle Kotlin DSL install command"
+ trackingaction="copy_kotlin_install_command"
+ trackinglabel="code_instruction"
+ />
+
+ <code-instruction-stub
+ copytext="Copy add Gradle Kotlin DSL repository command"
+ instruction="maven(\\"mavenPath\\")"
+ label="Add Gradle Kotlin DSL repository command"
+ multiline="true"
+ trackingaction="copy_kotlin_add_to_source_command"
+ trackinglabel="code_instruction"
+ />
+</div>
+`;
+
+exports[`MavenInstallation maven renders all the messages 1`] = `
+<div>
+ <installation-title-stub
+ options="[object Object],[object Object],[object Object]"
+ packagetype="maven"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block."
+ />
+ </p>
+
+ <code-instruction-stub
+ copytext="Copy Maven XML"
+ instruction="<dependency>
+ <groupId>appGroup</groupId>
+ <artifactId>appName</artifactId>
+ <version>appVersion</version>
+</dependency>"
+ label=""
+ multiline="true"
+ trackingaction="copy_maven_xml"
+ trackinglabel="code_instruction"
+ />
+
+ <code-instruction-stub
+ copytext="Copy Maven command"
+ instruction="mvn dependency:get -Dartifact=appGroup:appName:appVersion"
+ label="Maven Command"
+ trackingaction="copy_maven_command"
+ trackinglabel="code_instruction"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <p>
+ <gl-sprintf-stub
+ message="If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file."
+ />
+ </p>
+
+ <code-instruction-stub
+ copytext="Copy Maven registry XML"
+ instruction="<repositories>
+ <repository>
+ <id>gitlab-maven</id>
+ <url>mavenPath</url>
+ </repository>
+</repositories>
+
+<distributionManagement>
+ <repository>
+ <id>gitlab-maven</id>
+ <url>mavenPath</url>
+ </repository>
+
+ <snapshotRepository>
+ <id>gitlab-maven</id>
+ <url>mavenPath</url>
+ </snapshotRepository>
+</distributionManagement>"
+ label=""
+ multiline="true"
+ trackingaction="copy_maven_setup_xml"
+ trackinglabel="code_instruction"
+ />
+
+ <gl-sprintf-stub
+ message="For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap
new file mode 100644
index 00000000000..6a7f14dc33f
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`NpmInstallation renders all the messages 1`] = `
+<div>
+ <installation-title-stub
+ options="[object Object],[object Object]"
+ packagetype="npm"
+ />
+
+ <code-instruction-stub
+ copytext="Copy npm command"
+ instruction="npm i @gitlab-org/package-15"
+ label=""
+ trackingaction="copy_npm_install_command"
+ trackinglabel="code_instruction"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <code-instruction-stub
+ copytext="Copy npm setup command"
+ instruction="echo @gitlab-org:registry=npmPath/ >> .npmrc"
+ label=""
+ trackingaction="copy_npm_setup_command"
+ trackinglabel="code_instruction"
+ />
+
+ <gl-sprintf-stub
+ message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap
new file mode 100644
index 00000000000..29ddd7b77ed
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`NugetInstallation renders all the messages 1`] = `
+<div>
+ <installation-title-stub
+ options="[object Object]"
+ packagetype="nuget"
+ />
+
+ <code-instruction-stub
+ copytext="Copy NuGet Command"
+ instruction="nuget install @gitlab-org/package-15 -Source \\"GitLab\\""
+ label="NuGet Command"
+ trackingaction="copy_nuget_install_command"
+ trackinglabel="code_instruction"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <code-instruction-stub
+ copytext="Copy NuGet Setup Command"
+ instruction="nuget source Add -Name \\"GitLab\\" -Source \\"nugetPath\\" -UserName <your_username> -Password <your_token>"
+ label="Add NuGet Source"
+ trackingaction="copy_nuget_setup_command"
+ trackinglabel="code_instruction"
+ />
+
+ <gl-sprintf-stub
+ message="For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
new file mode 100644
index 00000000000..45d261625b4
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
@@ -0,0 +1,197 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PackageTitle renders with tags 1`] = `
+<div
+ class="gl-display-flex gl-flex-direction-column"
+ data-qa-selector="package_title"
+>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-py-3"
+ >
+ <div
+ class="gl-flex-direction-column gl-flex-grow-1"
+ >
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+
+ <div
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ data-testid="title"
+ >
+ @gitlab-org/package-15
+ </h1>
+
+ <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"
+ />
+
+ <span
+ data-testid="sub-header"
+ >
+ v
+ 1.0.0
+ published
+ <time-ago-tooltip-stub
+ class="gl-ml-2"
+ cssclass=""
+ time="2020-08-17T14:23:32Z"
+ tooltipplacement="top"
+ />
+ </span>
+ </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="npm"
+ texttooltip=""
+ />
+ </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="800.00 KiB"
+ texttooltip=""
+ />
+ </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]"
+ />
+ </div>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+
+ <p />
+</div>
+`;
+
+exports[`PackageTitle renders without tags 1`] = `
+<div
+ class="gl-display-flex gl-flex-direction-column"
+ data-qa-selector="package_title"
+>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-py-3"
+ >
+ <div
+ class="gl-flex-direction-column gl-flex-grow-1"
+ >
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+
+ <div
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ data-testid="title"
+ >
+ @gitlab-org/package-15
+ </h1>
+
+ <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"
+ />
+
+ <span
+ data-testid="sub-header"
+ >
+ v
+ 1.0.0
+ published
+ <time-ago-tooltip-stub
+ class="gl-ml-2"
+ cssclass=""
+ time="2020-08-17T14:23:32Z"
+ tooltipplacement="top"
+ />
+ </span>
+ </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="npm"
+ texttooltip=""
+ />
+ </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="800.00 KiB"
+ texttooltip=""
+ />
+ </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]"
+ />
+ </div>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+
+ <p />
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap
new file mode 100644
index 00000000000..158bbbc3463
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap
@@ -0,0 +1,48 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PypiInstallation renders all the messages 1`] = `
+<div>
+ <installation-title-stub
+ options="[object Object]"
+ packagetype="pypi"
+ />
+
+ <code-instruction-stub
+ copytext="Copy Pip command"
+ data-testid="pip-command"
+ instruction="pip install @gitlab-org/package-15 --extra-index-url pypiPath"
+ label="Pip Command"
+ trackingaction="copy_pip_install_command"
+ trackinglabel="code_instruction"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <p>
+ <gl-sprintf-stub
+ message="If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file."
+ />
+ </p>
+
+ <code-instruction-stub
+ copytext="Copy .pypirc content"
+ data-testid="pypi-setup-content"
+ instruction="[gitlab]
+repository = pypiSetupPath
+username = __token__
+password = <your personal access token>"
+ label=""
+ multiline="true"
+ trackingaction="copy_pypi_setup_command"
+ trackinglabel="code_instruction"
+ />
+
+ <gl-sprintf-stub
+ message="For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap
new file mode 100644
index 00000000000..8f69f943112
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap
@@ -0,0 +1,101 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`VersionRow renders 1`] = `
+<div
+ class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1 gl-border-t-transparent gl-border-b-gray-100"
+>
+ <div
+ class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5"
+ >
+ <!---->
+
+ <div
+ class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1"
+ >
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"
+ >
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0"
+ >
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"
+ >
+ <gl-link-stub
+ class="gl-text-body gl-min-w-0"
+ href="243"
+ >
+ <span
+ class="gl-truncate"
+ title="@gitlab-org/package-15"
+ >
+ <span
+ class="gl-truncate-end"
+ >
+ @gitlab-org/package-15
+ </span>
+ </span>
+ </gl-link-stub>
+
+ <package-tags-stub
+ class="gl-ml-3"
+ hidelabel="true"
+ tagdisplaylimit="1"
+ tags="[object Object],[object Object],[object Object]"
+ />
+ </div>
+
+ <!---->
+ </div>
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1"
+ >
+
+ 1.0.1
+
+ </div>
+ </div>
+
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0"
+ >
+ <div
+ class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6"
+ >
+ <publish-method-stub
+ packageentity="[object Object]"
+ />
+ </div>
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-min-h-6"
+ >
+ Created
+ <time-ago-tooltip-stub
+ cssclass=""
+ time="2021-08-10T09:33:54Z"
+ tooltipplacement="top"
+ />
+ </div>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+
+ <div
+ class="gl-display-flex"
+ >
+ <div
+ class="gl-w-7"
+ />
+
+ <!---->
+
+ <div
+ class="gl-w-9"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js
new file mode 100644
index 00000000000..0504a42dfcf
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js
@@ -0,0 +1,130 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ conanMetadata,
+ mavenMetadata,
+ nugetMetadata,
+ packageData,
+} from 'jest/packages_and_registries/package_registry/mock_data';
+import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
+import {
+ PACKAGE_TYPE_NUGET,
+ PACKAGE_TYPE_CONAN,
+ PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_NPM,
+} from '~/packages_and_registries/package_registry/constants';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() };
+const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() };
+const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() };
+const npmPackage = { packageType: PACKAGE_TYPE_NPM, metadata: {} };
+
+describe('Package Additional Metadata', () => {
+ let wrapper;
+ const defaultProps = {
+ packageEntity: {
+ ...packageData(mavenPackage),
+ },
+ };
+
+ const mountComponent = (props) => {
+ wrapper = shallowMountExtended(component, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ DetailsRow,
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findTitle = () => wrapper.findByTestId('title');
+ const findMainArea = () => wrapper.findByTestId('main');
+ const findNugetSource = () => wrapper.findByTestId('nuget-source');
+ const findNugetLicense = () => wrapper.findByTestId('nuget-license');
+ const findConanRecipe = () => wrapper.findByTestId('conan-recipe');
+ const findMavenApp = () => wrapper.findByTestId('maven-app');
+ const findMavenGroup = () => wrapper.findByTestId('maven-group');
+ const findElementLink = (container) => container.findComponent(GlLink);
+
+ it('has the correct title', () => {
+ mountComponent();
+
+ const title = findTitle();
+
+ expect(title.exists()).toBe(true);
+ expect(title.text()).toBe('Additional Metadata');
+ });
+
+ it.each`
+ packageEntity | visible | packageType
+ ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN}
+ ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN}
+ ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET}
+ ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM}
+ `(
+ `It is $visible that the component is visible when the package is $packageType`,
+ ({ packageEntity, visible }) => {
+ mountComponent({ packageEntity });
+
+ expect(findTitle().exists()).toBe(visible);
+ expect(findMainArea().exists()).toBe(visible);
+ },
+ );
+
+ describe('nuget metadata', () => {
+ beforeEach(() => {
+ mountComponent({ packageEntity: nugetPackage });
+ });
+
+ it.each`
+ name | finderFunction | text | link | icon
+ ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'}
+ ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'license'}
+ `('$name element', ({ finderFunction, text, link, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]);
+ });
+ });
+
+ describe('conan metadata', () => {
+ beforeEach(() => {
+ mountComponent({ packageEntity: conanPackage });
+ });
+
+ it.each`
+ name | finderFunction | text | icon
+ ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/stable'} | ${'information-o'}
+ `('$name element', ({ finderFunction, text, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ });
+ });
+
+ describe('maven metadata', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it.each`
+ name | finderFunction | text | icon
+ ${'app'} | ${findMavenApp} | ${'App name: appName'} | ${'information-o'}
+ ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'information-o'}
+ `('$name element', ({ finderFunction, text, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js
index 97444ec108f..5119512564f 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js
@@ -1,35 +1,451 @@
-import { GlEmptyState } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlEmptyState, GlBadge, GlTabs, GlTab } from '@gitlab/ui';
+import { createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue';
+import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue';
+import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue';
+import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
+import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue';
+import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
+import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
+import {
+ FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
+ DELETE_PACKAGE_ERROR_MESSAGE,
+ PACKAGE_TYPE_COMPOSER,
+ DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ PACKAGE_TYPE_NUGET,
+} from '~/packages_and_registries/package_registry/constants';
+
+import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
+import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
+import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
+import {
+ packageDetailsQuery,
+ packageData,
+ packageVersions,
+ dependencyLinks,
+ emptyPackageDetailsQuery,
+ packageDestroyMutation,
+ packageDestroyMutationError,
+ packageFiles,
+ packageDestroyFileMutation,
+ packageDestroyFileMutationError,
+} from '../../mock_data';
+
+jest.mock('~/flash');
+useMockLocationHelper();
+
+const localVue = createLocalVue();
describe('PackagesApp', () => {
let wrapper;
+ let apolloProvider;
+
+ const provide = {
+ packageId: '111',
+ titleComponent: 'PackageTitle',
+ projectName: 'projectName',
+ canDelete: 'canDelete',
+ svgPath: 'svgPath',
+ npmPath: 'npmPath',
+ npmHelpPath: 'npmHelpPath',
+ projectListUrl: 'projectListUrl',
+ groupListUrl: 'groupListUrl',
+ };
- function createComponent() {
- wrapper = shallowMount(PackagesApp, {
- provide: {
- titleComponent: 'titleComponent',
- projectName: 'projectName',
- canDelete: 'canDelete',
- svgPath: 'svgPath',
- npmPath: 'npmPath',
- npmHelpPath: 'npmHelpPath',
- projectListUrl: 'projectListUrl',
- groupListUrl: 'groupListUrl',
+ function createComponent({
+ resolver = jest.fn().mockResolvedValue(packageDetailsQuery()),
+ mutationResolver = jest.fn().mockResolvedValue(packageDestroyMutation()),
+ fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()),
+ } = {}) {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [
+ [getPackageDetails, resolver],
+ [destroyPackageMutation, mutationResolver],
+ [destroyPackageFileMutation, fileDeleteMutationResolver],
+ ];
+ apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = shallowMountExtended(PackagesApp, {
+ localVue,
+ apolloProvider,
+ provide,
+ stubs: {
+ PackageTitle,
+ GlModal: {
+ template: '<div></div>',
+ methods: {
+ show: jest.fn(),
+ },
+ },
+ GlTabs,
+ GlTab,
},
});
}
- const emptyState = () => wrapper.findComponent(GlEmptyState);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findPackageTitle = () => wrapper.findComponent(PackageTitle);
+ const findPackageHistory = () => wrapper.findComponent(PackageHistory);
+ const findAdditionalMetadata = () => wrapper.findComponent(AdditionalMetadata);
+ const findInstallationCommands = () => wrapper.findComponent(InstallationCommands);
+ const findDeleteModal = () => wrapper.findByTestId('delete-modal');
+ const findDeleteButton = () => wrapper.findByTestId('delete-package');
+ const findPackageFiles = () => wrapper.findComponent(PackageFiles);
+ const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal');
+ const findVersionRows = () => wrapper.findAllComponents(VersionRow);
+ const noVersionsMessage = () => wrapper.findByTestId('no-versions-message');
+ const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge);
+ const findNoDependenciesMessage = () => wrapper.findByTestId('no-dependencies-message');
+ const findDependencyRows = () => wrapper.findAllComponents(DependencyRow);
afterEach(() => {
wrapper.destroy();
});
- it('renders an empty state component', () => {
+ it('renders an empty state component', async () => {
+ createComponent({ resolver: jest.fn().mockResolvedValue(emptyPackageDetailsQuery) });
+
+ await waitForPromises();
+
+ expect(findEmptyState().exists()).toBe(true);
+ });
+
+ it('renders the app and displays the package title', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findPackageTitle().exists()).toBe(true);
+ expect(findPackageTitle().props()).toMatchObject({
+ packageEntity: expect.objectContaining(packageData()),
+ });
+ });
+
+ it('emits an error message if the load fails', async () => {
+ createComponent({ resolver: jest.fn().mockRejectedValue() });
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
+ }),
+ );
+ });
+
+ it('renders history and has the right props', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findPackageHistory().exists()).toBe(true);
+ expect(findPackageHistory().props()).toMatchObject({
+ packageEntity: expect.objectContaining(packageData()),
+ projectName: provide.projectName,
+ });
+ });
+
+ it('renders additional metadata and has the right props', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findAdditionalMetadata().exists()).toBe(true);
+ expect(findAdditionalMetadata().props()).toMatchObject({
+ packageEntity: expect.objectContaining(packageData()),
+ });
+ });
+
+ it('renders installation commands and has the right props', async () => {
createComponent();
- expect(emptyState().exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findInstallationCommands().exists()).toBe(true);
+ expect(findInstallationCommands().props()).toMatchObject({
+ packageEntity: expect.objectContaining(packageData()),
+ });
+ });
+
+ describe('delete package', () => {
+ const originalReferrer = document.referrer;
+ const setReferrer = (value = provide.projectName) => {
+ Object.defineProperty(document, 'referrer', {
+ value,
+ configurable: true,
+ });
+ };
+
+ const performDeletePackage = async () => {
+ await findDeleteButton().trigger('click');
+
+ findDeleteModal().vm.$emit('primary');
+
+ await waitForPromises();
+ };
+
+ afterEach(() => {
+ Object.defineProperty(document, 'referrer', {
+ value: originalReferrer,
+ configurable: true,
+ });
+ });
+
+ it('shows the delete confirmation modal when delete is clicked', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ await findDeleteButton().trigger('click');
+
+ expect(findDeleteModal().exists()).toBe(true);
+ });
+
+ describe('successful request', () => {
+ it('when referrer contains project name calls window.replace with project url', async () => {
+ setReferrer();
+
+ createComponent();
+
+ await waitForPromises();
+
+ await performDeletePackage();
+
+ expect(window.location.replace).toHaveBeenCalledWith(
+ 'projectListUrl?showSuccessDeleteAlert=true',
+ );
+ });
+
+ it('when referrer does not contain project name calls window.replace with group url', async () => {
+ setReferrer('baz');
+
+ createComponent();
+
+ await waitForPromises();
+
+ await performDeletePackage();
+
+ expect(window.location.replace).toHaveBeenCalledWith(
+ 'groupListUrl?showSuccessDeleteAlert=true',
+ );
+ });
+ });
+
+ describe('request failure', () => {
+ it('on global failure it displays an alert', async () => {
+ createComponent({ mutationResolver: jest.fn().mockRejectedValue() });
+
+ await waitForPromises();
+
+ await performDeletePackage();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_ERROR_MESSAGE,
+ }),
+ );
+ });
+
+ it('on payload with error it displays an alert', async () => {
+ createComponent({
+ mutationResolver: jest.fn().mockResolvedValue(packageDestroyMutationError()),
+ });
+
+ await waitForPromises();
+
+ await performDeletePackage();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_ERROR_MESSAGE,
+ }),
+ );
+ });
+ });
+ });
+
+ describe('package files', () => {
+ it('renders the package files component and has the right props', async () => {
+ const expectedFile = { ...packageFiles()[0] };
+ // eslint-disable-next-line no-underscore-dangle
+ delete expectedFile.__typename;
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findPackageFiles().exists()).toBe(true);
+
+ expect(findPackageFiles().props('packageFiles')[0]).toMatchObject(expectedFile);
+ });
+
+ it('does not render the package files table when the package is composer', async () => {
+ createComponent({
+ resolver: jest
+ .fn()
+ .mockResolvedValue(packageDetailsQuery({ packageType: PACKAGE_TYPE_COMPOSER })),
+ });
+
+ await waitForPromises();
+
+ expect(findPackageFiles().exists()).toBe(false);
+ });
+
+ describe('deleting a file', () => {
+ const [fileToDelete] = packageFiles();
+
+ const doDeleteFile = () => {
+ findPackageFiles().vm.$emit('delete-file', fileToDelete);
+
+ findDeleteFileModal().vm.$emit('primary');
+
+ return waitForPromises();
+ };
+
+ it('opens a confirmation modal', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ findPackageFiles().vm.$emit('delete-file', fileToDelete);
+
+ await nextTick();
+
+ expect(findDeleteFileModal().exists()).toBe(true);
+ });
+
+ it('confirming on the modal deletes the file and shows a success message', async () => {
+ const resolver = jest.fn().mockResolvedValue(packageDetailsQuery());
+ createComponent({ resolver });
+
+ await waitForPromises();
+
+ await doDeleteFile();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ }),
+ );
+ // we are re-fetching the package details, so we expect the resolver to have been called twice
+ expect(resolver).toHaveBeenCalledTimes(2);
+ });
+
+ describe('errors', () => {
+ it('shows an error when the mutation request fails', async () => {
+ createComponent({ fileDeleteMutationResolver: jest.fn().mockRejectedValue() });
+ await waitForPromises();
+
+ await doDeleteFile();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ }),
+ );
+ });
+
+ it('shows an error when the mutation request returns an error payload', async () => {
+ createComponent({
+ fileDeleteMutationResolver: jest
+ .fn()
+ .mockResolvedValue(packageDestroyFileMutationError()),
+ });
+ await waitForPromises();
+
+ await doDeleteFile();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ }),
+ );
+ });
+ });
+ });
+ });
+
+ describe('versions', () => {
+ it('displays the correct version count when the package has versions', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findVersionRows()).toHaveLength(packageVersions().length);
+ });
+
+ it('binds the correct props', async () => {
+ const [versionPackage] = packageVersions();
+ // eslint-disable-next-line no-underscore-dangle
+ delete versionPackage.__typename;
+ delete versionPackage.tags;
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findVersionRows().at(0).props()).toMatchObject({
+ packageEntity: expect.objectContaining(versionPackage),
+ });
+ });
+
+ it('displays the no versions message when there are none', async () => {
+ createComponent({
+ resolver: jest.fn().mockResolvedValue(packageDetailsQuery({ versions: { nodes: [] } })),
+ });
+
+ await waitForPromises();
+
+ expect(noVersionsMessage().exists()).toBe(true);
+ });
+ });
+ describe('dependency links', () => {
+ it('does not show the dependency links for a non nuget package', async () => {
+ createComponent();
+
+ expect(findDependenciesCountBadge().exists()).toBe(false);
+ });
+
+ it('shows the dependencies tab with 0 count when a nuget package with no dependencies', async () => {
+ createComponent({
+ resolver: jest.fn().mockResolvedValue(
+ packageDetailsQuery({
+ packageType: PACKAGE_TYPE_NUGET,
+ dependencyLinks: { nodes: [] },
+ }),
+ ),
+ });
+
+ await waitForPromises();
+
+ expect(findDependenciesCountBadge().exists()).toBe(true);
+ expect(findDependenciesCountBadge().text()).toBe('0');
+ expect(findNoDependenciesMessage().exists()).toBe(true);
+ });
+
+ it('renders the correct number of dependency rows for a nuget package', async () => {
+ createComponent({
+ resolver: jest.fn().mockResolvedValue(
+ packageDetailsQuery({
+ packageType: PACKAGE_TYPE_NUGET,
+ }),
+ ),
+ });
+ await waitForPromises();
+
+ expect(findDependenciesCountBadge().exists()).toBe(true);
+ expect(findDependenciesCountBadge().text()).toBe(dependencyLinks().length.toString());
+ expect(findDependencyRows()).toHaveLength(dependencyLinks().length);
+ });
});
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js
new file mode 100644
index 00000000000..aedf20e873a
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js
@@ -0,0 +1,118 @@
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { packageData } from 'jest/packages_and_registries/package_registry/mock_data';
+import ComposerInstallation from '~/packages_and_registries/package_registry/components/details/composer_installation.vue';
+import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
+import {
+ TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND,
+ TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND,
+ PACKAGE_TYPE_COMPOSER,
+} from '~/packages_and_registries/package_registry/constants';
+
+const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_COMPOSER };
+
+describe('ComposerInstallation', () => {
+ let wrapper;
+
+ const findRootNode = () => wrapper.findByTestId('root-node');
+ const findRegistryInclude = () => wrapper.findByTestId('registry-include');
+ const findPackageInclude = () => wrapper.findByTestId('package-include');
+ const findHelpText = () => wrapper.findByTestId('help-text');
+ const findHelpLink = () => wrapper.findComponent(GlLink);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
+
+ function createComponent(groupListUrl = 'groupListUrl') {
+ wrapper = shallowMountExtended(ComposerInstallation, {
+ provide: {
+ composerHelpPath: 'composerHelpPath',
+ composerConfigRepositoryName: 'composerConfigRepositoryName',
+ composerPath: 'composerPath',
+ groupListUrl,
+ },
+ propsData: { packageEntity },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ createComponent();
+
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'composer',
+ options: [{ value: 'composer', label: 'Show Composer commands' }],
+ });
+ });
+ });
+
+ describe('registry include command', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('uses code_instructions', () => {
+ const registryIncludeCommand = findRegistryInclude();
+ expect(registryIncludeCommand.exists()).toBe(true);
+ expect(registryIncludeCommand.props()).toMatchObject({
+ instruction: `composer config repositories.composerConfigRepositoryName '{"type": "composer", "url": "composerPath"}'`,
+ copyText: 'Copy registry include',
+ trackingAction: TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND,
+ });
+ });
+
+ it('has the correct title', () => {
+ expect(findRegistryInclude().props('label')).toBe('Add composer registry');
+ });
+ });
+
+ describe('package include command', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('uses code_instructions', () => {
+ const registryIncludeCommand = findPackageInclude();
+ expect(registryIncludeCommand.exists()).toBe(true);
+ expect(registryIncludeCommand.props()).toMatchObject({
+ instruction: 'composer req @gitlab-org/package-15:1.0.0',
+ copyText: 'Copy require package include',
+ trackingAction: TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND,
+ });
+ });
+
+ it('has the correct title', () => {
+ expect(findPackageInclude().props('label')).toBe('Install package version');
+ });
+
+ it('has the correct help text', () => {
+ expect(findHelpText().text()).toBe(
+ 'For more information on Composer packages in GitLab, see the documentation.',
+ );
+ expect(findHelpLink().attributes()).toMatchObject({
+ href: 'composerHelpPath',
+ target: '_blank',
+ });
+ });
+ });
+
+ describe('root node', () => {
+ it('is normally rendered', () => {
+ createComponent();
+
+ expect(findRootNode().exists()).toBe(true);
+ });
+
+ it('is not rendered when the group does not exist', () => {
+ createComponent('');
+
+ expect(findRootNode().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js
new file mode 100644
index 00000000000..6b642cc21b7
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js
@@ -0,0 +1,65 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { packageData } from 'jest/packages_and_registries/package_registry/mock_data';
+import ConanInstallation from '~/packages_and_registries/package_registry/components/details/conan_installation.vue';
+import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
+import { PACKAGE_TYPE_CONAN } from '~/packages_and_registries/package_registry/constants';
+import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
+
+const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_CONAN };
+
+describe('ConanInstallation', () => {
+ let wrapper;
+
+ const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
+
+ function createComponent() {
+ wrapper = shallowMountExtended(ConanInstallation, {
+ provide: {
+ conanHelpPath: 'conanHelpPath',
+ conanPath: 'conanPath',
+ },
+ propsData: {
+ packageEntity,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'conan',
+ options: [{ value: 'conan', label: 'Show Conan commands' }],
+ });
+ });
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct command', () => {
+ expect(findCodeInstructions().at(0).props('instruction')).toBe(
+ 'conan install @gitlab-org/package-15 --remote=gitlab',
+ );
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct command', () => {
+ expect(findCodeInstructions().at(1).props('instruction')).toBe(
+ 'conan remote add gitlab conanPath',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js
new file mode 100644
index 00000000000..9aed5b90c73
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/dependency_row_spec.js
@@ -0,0 +1,69 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue';
+import { dependencyLinks } from '../../mock_data';
+
+describe('DependencyRow', () => {
+ let wrapper;
+
+ const [fullDependencyLink] = dependencyLinks();
+ const { dependency, metadata } = fullDependencyLink;
+
+ function createComponent(dependencyLink = fullDependencyLink) {
+ wrapper = shallowMountExtended(DependencyRow, {
+ propsData: {
+ dependencyLink,
+ },
+ });
+ }
+
+ const dependencyVersion = () => wrapper.findByTestId('version-pattern');
+ const dependencyFramework = () => wrapper.findByTestId('target-framework');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('renders', () => {
+ it('full dependency', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('version', () => {
+ it('does not render any version information when not supplied', () => {
+ createComponent({
+ ...fullDependencyLink,
+ dependency: { ...dependency, versionPattern: undefined },
+ });
+
+ expect(dependencyVersion().exists()).toBe(false);
+ });
+
+ it('does render version info when it exists', () => {
+ createComponent();
+
+ expect(dependencyVersion().exists()).toBe(true);
+ expect(dependencyVersion().text()).toBe(dependency.versionPattern);
+ });
+ });
+
+ describe('target framework', () => {
+ it('does not render any framework information when not supplied', () => {
+ createComponent({
+ ...fullDependencyLink,
+ metadata: { ...metadata, targetFramework: undefined },
+ });
+
+ expect(dependencyFramework().exists()).toBe(false);
+ });
+
+ it('does render framework info when it exists', () => {
+ createComponent();
+
+ expect(dependencyFramework().exists()).toBe(true);
+ expect(dependencyFramework().text()).toBe(`(${metadata.targetFramework})`);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js
new file mode 100644
index 00000000000..ebfbbe5b864
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+
+import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+describe('FileSha', () => {
+ let wrapper;
+
+ const defaultProps = { sha: 'foo', title: 'bar' };
+
+ function createComponent() {
+ wrapper = shallowMount(FileSha, {
+ propsData: {
+ ...defaultProps,
+ },
+ stubs: {
+ ClipboardButton,
+ DetailsRow,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js
new file mode 100644
index 00000000000..5fe795f768e
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/installation_title_spec.js
@@ -0,0 +1,58 @@
+import { shallowMount } from '@vue/test-utils';
+
+import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
+import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue';
+
+describe('InstallationTitle', () => {
+ let wrapper;
+
+ const defaultProps = { packageType: 'foo', options: [{ value: 'foo', label: 'bar' }] };
+
+ const findPersistedDropdownSelection = () => wrapper.findComponent(PersistedDropdownSelection);
+ const findTitle = () => wrapper.find('h3');
+
+ function createComponent({ props = {} } = {}) {
+ wrapper = shallowMount(InstallationTitle, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('has a title', () => {
+ createComponent();
+
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toBe('Installation');
+ });
+
+ describe('persisted dropdown selection', () => {
+ it('exists', () => {
+ createComponent();
+
+ expect(findPersistedDropdownSelection().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ createComponent();
+
+ expect(findPersistedDropdownSelection().props()).toMatchObject({
+ storageKey: 'package_foo_installation_instructions',
+ options: defaultProps.options,
+ });
+ });
+
+ it('on change event emits a change event', () => {
+ createComponent();
+
+ findPersistedDropdownSelection().vm.$emit('change', 'baz');
+
+ expect(wrapper.emitted('change')).toEqual([['baz']]);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js
new file mode 100644
index 00000000000..b24946c8638
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js
@@ -0,0 +1,64 @@
+import { shallowMount } from '@vue/test-utils';
+import { packageData } from 'jest/packages_and_registries/package_registry/mock_data';
+import ComposerInstallation from '~/packages_and_registries/package_registry/components/details/composer_installation.vue';
+import ConanInstallation from '~/packages_and_registries/package_registry/components/details/conan_installation.vue';
+import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue';
+
+import MavenInstallation from '~/packages_and_registries/package_registry/components/details/maven_installation.vue';
+import NpmInstallation from '~/packages_and_registries/package_registry/components/details/npm_installation.vue';
+import NugetInstallation from '~/packages_and_registries/package_registry/components/details/nuget_installation.vue';
+import PypiInstallation from '~/packages_and_registries/package_registry/components/details/pypi_installation.vue';
+import {
+ PACKAGE_TYPE_CONAN,
+ PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_NPM,
+ PACKAGE_TYPE_NUGET,
+ PACKAGE_TYPE_PYPI,
+ PACKAGE_TYPE_COMPOSER,
+} from '~/packages_and_registries/package_registry/constants';
+
+const conanPackage = { ...packageData(), packageType: PACKAGE_TYPE_CONAN };
+const mavenPackage = { ...packageData(), packageType: PACKAGE_TYPE_MAVEN };
+const npmPackage = { ...packageData(), packageType: PACKAGE_TYPE_NPM };
+const nugetPackage = { ...packageData(), packageType: PACKAGE_TYPE_NUGET };
+const pypiPackage = { ...packageData(), packageType: PACKAGE_TYPE_PYPI };
+const composerPackage = { ...packageData(), packageType: PACKAGE_TYPE_COMPOSER };
+
+describe('InstallationCommands', () => {
+ let wrapper;
+
+ function createComponent(propsData) {
+ wrapper = shallowMount(InstallationCommands, {
+ propsData,
+ });
+ }
+
+ const npmInstallation = () => wrapper.find(NpmInstallation);
+ const mavenInstallation = () => wrapper.find(MavenInstallation);
+ const conanInstallation = () => wrapper.find(ConanInstallation);
+ const nugetInstallation = () => wrapper.find(NugetInstallation);
+ const pypiInstallation = () => wrapper.find(PypiInstallation);
+ const composerInstallation = () => wrapper.find(ComposerInstallation);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('installation instructions', () => {
+ describe.each`
+ packageEntity | selector
+ ${conanPackage} | ${conanInstallation}
+ ${mavenPackage} | ${mavenInstallation}
+ ${npmPackage} | ${npmInstallation}
+ ${nugetPackage} | ${nugetInstallation}
+ ${pypiPackage} | ${pypiInstallation}
+ ${composerPackage} | ${composerInstallation}
+ `('renders', ({ packageEntity, selector }) => {
+ it(`${packageEntity.packageType} instructions exist`, () => {
+ createComponent({ packageEntity });
+
+ expect(selector()).toExist();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js
new file mode 100644
index 00000000000..eed7e903833
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js
@@ -0,0 +1,213 @@
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import {
+ packageData,
+ mavenMetadata,
+} from 'jest/packages_and_registries/package_registry/mock_data';
+import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
+import MavenInstallation from '~/packages_and_registries/package_registry/components/details/maven_installation.vue';
+import {
+ TRACKING_ACTION_COPY_MAVEN_XML,
+ TRACKING_ACTION_COPY_MAVEN_COMMAND,
+ TRACKING_ACTION_COPY_MAVEN_SETUP,
+ TRACKING_ACTION_COPY_GRADLE_INSTALL_COMMAND,
+ TRACKING_ACTION_COPY_GRADLE_ADD_TO_SOURCE_COMMAND,
+ TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND,
+ TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND,
+ PACKAGE_TYPE_MAVEN,
+} from '~/packages_and_registries/package_registry/constants';
+import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
+
+describe('MavenInstallation', () => {
+ let wrapper;
+
+ const packageEntity = {
+ ...packageData(),
+ packageType: PACKAGE_TYPE_MAVEN,
+ metadata: mavenMetadata(),
+ };
+
+ const mavenHelpPath = 'mavenHelpPath';
+ const mavenPath = 'mavenPath';
+
+ const xmlCodeBlock = `<dependency>
+ <groupId>appGroup</groupId>
+ <artifactId>appName</artifactId>
+ <version>appVersion</version>
+</dependency>`;
+ const mavenCommandStr = 'mvn dependency:get -Dartifact=appGroup:appName:appVersion';
+ const mavenSetupXml = `<repositories>
+ <repository>
+ <id>gitlab-maven</id>
+ <url>${mavenPath}</url>
+ </repository>
+</repositories>
+
+<distributionManagement>
+ <repository>
+ <id>gitlab-maven</id>
+ <url>${mavenPath}</url>
+ </repository>
+
+ <snapshotRepository>
+ <id>gitlab-maven</id>
+ <url>${mavenPath}</url>
+ </snapshotRepository>
+</distributionManagement>`;
+ const gradleGroovyInstallCommandText = `implementation 'appGroup:appName:appVersion'`;
+ const gradleGroovyAddSourceCommandText = `maven {
+ url '${mavenPath}'
+}`;
+ const gradleKotlinInstallCommandText = `implementation("appGroup:appName:appVersion")`;
+ const gradleKotlinAddSourceCommandText = `maven("${mavenPath}")`;
+
+ const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
+
+ function createComponent({ data = {} } = {}) {
+ wrapper = shallowMountExtended(MavenInstallation, {
+ provide: {
+ mavenHelpPath,
+ mavenPath,
+ },
+ propsData: {
+ packageEntity,
+ },
+ data() {
+ return data;
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ createComponent();
+
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'maven',
+ options: [
+ { value: 'maven', label: 'Maven XML' },
+ { value: 'groovy', label: 'Gradle Groovy DSL' },
+ { value: 'kotlin', label: 'Gradle Kotlin DSL' },
+ ],
+ });
+ });
+
+ it('on change event updates the instructions to show', async () => {
+ createComponent();
+
+ expect(findCodeInstructions().at(0).props('instruction')).toBe(xmlCodeBlock);
+ findInstallationTitle().vm.$emit('change', 'groovy');
+
+ await nextTick();
+
+ expect(findCodeInstructions().at(0).props('instruction')).toBe(
+ gradleGroovyInstallCommandText,
+ );
+ });
+ });
+
+ describe('maven', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct xml block', () => {
+ expect(findCodeInstructions().at(0).props()).toMatchObject({
+ instruction: xmlCodeBlock,
+ multiline: true,
+ trackingAction: TRACKING_ACTION_COPY_MAVEN_XML,
+ });
+ });
+
+ it('renders the correct maven command', () => {
+ expect(findCodeInstructions().at(1).props()).toMatchObject({
+ instruction: mavenCommandStr,
+ multiline: false,
+ trackingAction: TRACKING_ACTION_COPY_MAVEN_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct xml block', () => {
+ expect(findCodeInstructions().at(2).props()).toMatchObject({
+ instruction: mavenSetupXml,
+ multiline: true,
+ trackingAction: TRACKING_ACTION_COPY_MAVEN_SETUP,
+ });
+ });
+ });
+ });
+
+ describe('groovy', () => {
+ beforeEach(() => {
+ createComponent({ data: { instructionType: 'groovy' } });
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the gradle install command', () => {
+ expect(findCodeInstructions().at(0).props()).toMatchObject({
+ instruction: gradleGroovyInstallCommandText,
+ multiline: false,
+ trackingAction: TRACKING_ACTION_COPY_GRADLE_INSTALL_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct gradle command', () => {
+ expect(findCodeInstructions().at(1).props()).toMatchObject({
+ instruction: gradleGroovyAddSourceCommandText,
+ multiline: true,
+ trackingAction: TRACKING_ACTION_COPY_GRADLE_ADD_TO_SOURCE_COMMAND,
+ });
+ });
+ });
+ });
+
+ describe('kotlin', () => {
+ beforeEach(() => {
+ createComponent({ data: { instructionType: 'kotlin' } });
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the gradle install command', () => {
+ expect(findCodeInstructions().at(0).props()).toMatchObject({
+ instruction: gradleKotlinInstallCommandText,
+ multiline: false,
+ trackingAction: TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct gradle command', () => {
+ expect(findCodeInstructions().at(1).props()).toMatchObject({
+ instruction: gradleKotlinAddSourceCommandText,
+ multiline: true,
+ trackingAction: TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js
new file mode 100644
index 00000000000..083c6858ad0
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js
@@ -0,0 +1,122 @@
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import { packageData } from 'jest/packages_and_registries/package_registry/mock_data';
+import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
+import NpmInstallation from '~/packages_and_registries/package_registry/components/details/npm_installation.vue';
+import {
+ TRACKING_ACTION_COPY_NPM_INSTALL_COMMAND,
+ TRACKING_ACTION_COPY_NPM_SETUP_COMMAND,
+ TRACKING_ACTION_COPY_YARN_INSTALL_COMMAND,
+ TRACKING_ACTION_COPY_YARN_SETUP_COMMAND,
+ PACKAGE_TYPE_NPM,
+ NPM_PACKAGE_MANAGER,
+ YARN_PACKAGE_MANAGER,
+} from '~/packages_and_registries/package_registry/constants';
+import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
+
+const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_NPM };
+
+describe('NpmInstallation', () => {
+ let wrapper;
+
+ const npmInstallationCommandLabel = 'npm i @gitlab-org/package-15';
+ const yarnInstallationCommandLabel = 'yarn add @gitlab-org/package-15';
+
+ const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
+
+ function createComponent({ data = {} } = {}) {
+ wrapper = shallowMountExtended(NpmInstallation, {
+ provide: {
+ npmHelpPath: 'npmHelpPath',
+ npmPath: 'npmPath',
+ },
+ propsData: {
+ packageEntity,
+ },
+ data() {
+ return data;
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: NPM_PACKAGE_MANAGER,
+ options: [
+ { value: NPM_PACKAGE_MANAGER, label: 'Show NPM commands' },
+ { value: YARN_PACKAGE_MANAGER, label: 'Show Yarn commands' },
+ ],
+ });
+ });
+
+ it('on change event updates the instructions to show', async () => {
+ createComponent();
+
+ expect(findCodeInstructions().at(0).props('instruction')).toBe(npmInstallationCommandLabel);
+ findInstallationTitle().vm.$emit('change', YARN_PACKAGE_MANAGER);
+
+ await nextTick();
+
+ expect(findCodeInstructions().at(0).props('instruction')).toBe(yarnInstallationCommandLabel);
+ });
+ });
+
+ describe('npm', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+ it('renders the correct installation command', () => {
+ expect(findCodeInstructions().at(0).props()).toMatchObject({
+ instruction: npmInstallationCommandLabel,
+ multiline: false,
+ trackingAction: TRACKING_ACTION_COPY_NPM_INSTALL_COMMAND,
+ });
+ });
+
+ it('renders the correct setup command', () => {
+ expect(findCodeInstructions().at(1).props()).toMatchObject({
+ instruction: 'echo @gitlab-org:registry=npmPath/ >> .npmrc',
+ multiline: false,
+ trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND,
+ });
+ });
+ });
+
+ describe('yarn', () => {
+ beforeEach(() => {
+ createComponent({ data: { instructionType: YARN_PACKAGE_MANAGER } });
+ });
+
+ it('renders the correct setup command', () => {
+ expect(findCodeInstructions().at(0).props()).toMatchObject({
+ instruction: yarnInstallationCommandLabel,
+ multiline: false,
+ trackingAction: TRACKING_ACTION_COPY_YARN_INSTALL_COMMAND,
+ });
+ });
+
+ it('renders the correct registry command', () => {
+ expect(findCodeInstructions().at(1).props()).toMatchObject({
+ instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc',
+ multiline: false,
+ trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js
new file mode 100644
index 00000000000..c48a3f07299
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js
@@ -0,0 +1,75 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { packageData } from 'jest/packages_and_registries/package_registry/mock_data';
+import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
+import NugetInstallation from '~/packages_and_registries/package_registry/components/details/nuget_installation.vue';
+import {
+ TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND,
+ TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND,
+ PACKAGE_TYPE_NUGET,
+} from '~/packages_and_registries/package_registry/constants';
+import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
+
+const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_NUGET };
+
+describe('NugetInstallation', () => {
+ let wrapper;
+
+ const nugetInstallationCommandStr = 'nuget install @gitlab-org/package-15 -Source "GitLab"';
+ const nugetSetupCommandStr =
+ 'nuget source Add -Name "GitLab" -Source "nugetPath" -UserName <your_username> -Password <your_token>';
+
+ const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
+
+ function createComponent() {
+ wrapper = shallowMountExtended(NugetInstallation, {
+ provide: {
+ nugetHelpPath: 'nugetHelpPath',
+ nugetPath: 'nugetPath',
+ },
+ propsData: {
+ packageEntity,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'nuget',
+ options: [{ value: 'nuget', label: 'Show Nuget commands' }],
+ });
+ });
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct command', () => {
+ expect(findCodeInstructions().at(0).props()).toMatchObject({
+ instruction: nugetInstallationCommandStr,
+ trackingAction: TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct command', () => {
+ expect(findCodeInstructions().at(1).props()).toMatchObject({
+ instruction: nugetSetupCommandStr,
+ trackingAction: TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
new file mode 100644
index 00000000000..042b2026199
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
@@ -0,0 +1,272 @@
+import { GlDropdown, GlButton } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import stubChildren from 'helpers/stub_children';
+import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { packageFiles as packageFilesMock } from 'jest/packages_and_registries/package_registry/mock_data';
+import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+describe('Package Files', () => {
+ let wrapper;
+
+ const findAllRows = () => wrapper.findAllByTestId('file-row');
+ const findFirstRow = () => extendedWrapper(findAllRows().at(0));
+ const findSecondRow = () => extendedWrapper(findAllRows().at(1));
+ const findFirstRowDownloadLink = () => findFirstRow().findByTestId('download-link');
+ const findFirstRowCommitLink = () => findFirstRow().findByTestId('commit-link');
+ const findSecondRowCommitLink = () => findSecondRow().findByTestId('commit-link');
+ const findFirstRowFileIcon = () => findFirstRow().findComponent(FileIcon);
+ const findFirstRowCreatedAt = () => findFirstRow().findComponent(TimeAgoTooltip);
+ const findFirstActionMenu = () => extendedWrapper(findFirstRow().findComponent(GlDropdown));
+ const findActionMenuDelete = () => findFirstActionMenu().findByTestId('delete-file');
+ const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton);
+ const findFirstRowShaComponent = (id) => wrapper.findByTestId(id);
+
+ const files = packageFilesMock();
+ const [file] = files;
+
+ const createComponent = ({ packageFiles = [file], canDelete = true } = {}) => {
+ wrapper = mountExtended(PackageFiles, {
+ provide: { canDelete },
+ propsData: {
+ packageFiles,
+ },
+ stubs: {
+ ...stubChildren(PackageFiles),
+ GlTable: false,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('rows', () => {
+ it('renders a single file for an npm package', () => {
+ createComponent();
+
+ expect(findAllRows()).toHaveLength(1);
+ });
+
+ it('renders multiple files for a package that contains more than one file', () => {
+ createComponent({ packageFiles: files });
+
+ expect(findAllRows()).toHaveLength(2);
+ });
+ });
+
+ describe('link', () => {
+ it('exists', () => {
+ createComponent();
+
+ expect(findFirstRowDownloadLink().exists()).toBe(true);
+ });
+
+ it('has the correct attrs bound', () => {
+ createComponent();
+
+ expect(findFirstRowDownloadLink().attributes('href')).toBe(file.downloadPath);
+ });
+
+ it('emits "download-file" event on click', () => {
+ createComponent();
+
+ findFirstRowDownloadLink().vm.$emit('click');
+
+ expect(wrapper.emitted('download-file')).toEqual([[]]);
+ });
+ });
+
+ describe('file-icon', () => {
+ it('exists', () => {
+ createComponent();
+
+ expect(findFirstRowFileIcon().exists()).toBe(true);
+ });
+
+ it('has the correct props bound', () => {
+ createComponent();
+
+ expect(findFirstRowFileIcon().props('fileName')).toBe(file.fileName);
+ });
+ });
+
+ describe('time-ago tooltip', () => {
+ it('exists', () => {
+ createComponent();
+
+ expect(findFirstRowCreatedAt().exists()).toBe(true);
+ });
+
+ it('has the correct props bound', () => {
+ createComponent();
+
+ expect(findFirstRowCreatedAt().props('time')).toBe(file.createdAt);
+ });
+ });
+
+ describe('commit', () => {
+ const withPipeline = {
+ ...file,
+ pipelines: [
+ {
+ sha: 'sha',
+ id: 1,
+ commitPath: 'commitPath',
+ },
+ ],
+ };
+
+ describe('when package file has a pipeline associated', () => {
+ it('exists', () => {
+ createComponent({ packageFiles: [withPipeline] });
+
+ expect(findFirstRowCommitLink().exists()).toBe(true);
+ });
+
+ it('the link points to the commit path', () => {
+ createComponent({ packageFiles: [withPipeline] });
+
+ expect(findFirstRowCommitLink().attributes('href')).toBe(
+ withPipeline.pipelines[0].commitPath,
+ );
+ });
+
+ it('the text is the pipeline sha', () => {
+ createComponent({ packageFiles: [withPipeline] });
+
+ expect(findFirstRowCommitLink().text()).toBe(withPipeline.pipelines[0].sha);
+ });
+ });
+
+ describe('when package file has no pipeline associated', () => {
+ it('does not exist', () => {
+ createComponent();
+
+ expect(findFirstRowCommitLink().exists()).toBe(false);
+ });
+ });
+
+ describe('when only one file lacks an associated pipeline', () => {
+ it('renders the commit when it exists and not otherwise', () => {
+ createComponent({ packageFiles: [withPipeline, file] });
+
+ expect(findFirstRowCommitLink().exists()).toBe(true);
+ expect(findSecondRowCommitLink().exists()).toBe(false);
+ });
+ });
+
+ describe('action menu', () => {
+ describe('when the user can delete', () => {
+ it('exists', () => {
+ createComponent();
+
+ expect(findFirstActionMenu().exists()).toBe(true);
+ });
+
+ describe('menu items', () => {
+ describe('delete file', () => {
+ it('exists', () => {
+ createComponent();
+
+ expect(findActionMenuDelete().exists()).toBe(true);
+ });
+
+ it('emits a delete event when clicked', () => {
+ createComponent();
+
+ findActionMenuDelete().vm.$emit('click');
+
+ const [[{ id }]] = wrapper.emitted('delete-file');
+ expect(id).toBe(file.id);
+ });
+ });
+ });
+ });
+
+ describe('when the user can not delete', () => {
+ const canDelete = false;
+
+ it('does not exist', () => {
+ createComponent({ canDelete });
+
+ expect(findFirstActionMenu().exists()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('additional details', () => {
+ describe('details toggle button', () => {
+ it('exists', () => {
+ createComponent();
+
+ expect(findFirstToggleDetailsButton().exists()).toBe(true);
+ });
+
+ it('is hidden when no details is present', () => {
+ const { ...noShaFile } = file;
+ noShaFile.fileSha256 = null;
+ noShaFile.fileMd5 = null;
+ noShaFile.fileSha1 = null;
+ createComponent({ packageFiles: [noShaFile] });
+
+ expect(findFirstToggleDetailsButton().exists()).toBe(false);
+ });
+
+ it('toggles the details row', async () => {
+ createComponent();
+
+ expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down');
+
+ findFirstToggleDetailsButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findFirstRowShaComponent('sha-256').exists()).toBe(true);
+ expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-up');
+
+ findFirstToggleDetailsButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findFirstRowShaComponent('sha-256').exists()).toBe(false);
+ expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down');
+ });
+ });
+
+ describe('file shas', () => {
+ const showShaFiles = () => {
+ findFirstToggleDetailsButton().vm.$emit('click');
+ return nextTick();
+ };
+
+ it.each`
+ selector | title | sha
+ ${'sha-256'} | ${'SHA-256'} | ${'fileSha256'}
+ ${'md5'} | ${'MD5'} | ${'fileMd5'}
+ ${'sha-1'} | ${'SHA-1'} | ${'be93151dc23ac34a82752444556fe79b32c7a1ad'}
+ `('has a $title row', async ({ selector, title, sha }) => {
+ createComponent();
+
+ await showShaFiles();
+
+ expect(findFirstRowShaComponent(selector).props()).toMatchObject({
+ title,
+ sha,
+ });
+ });
+
+ it('does not display a row when the data is missing', async () => {
+ const { ...missingMd5 } = file;
+ missingMd5.fileMd5 = null;
+
+ createComponent({ packageFiles: [missingMd5] });
+
+ await showShaFiles();
+
+ expect(findFirstRowShaComponent('md5').exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js
new file mode 100644
index 00000000000..b69008f04f0
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js
@@ -0,0 +1,122 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { stubComponent } from 'helpers/stub_component';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ packageData,
+ packagePipelines,
+} from 'jest/packages_and_registries/package_registry/mock_data';
+import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants';
+import component from '~/packages_and_registries/package_registry/components/details/package_history.vue';
+import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+describe('Package History', () => {
+ let wrapper;
+ const defaultProps = {
+ projectName: 'baz project',
+ packageEntity: { ...packageData() },
+ };
+
+ const [onePipeline] = packagePipelines();
+
+ const createPipelines = (amount) =>
+ [...Array(amount)].map((x, index) => packagePipelines({ id: index + 1 })[0]);
+
+ const mountComponent = (props) => {
+ wrapper = shallowMountExtended(component, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ HistoryItem: stubComponent(HistoryItem, {
+ template: '<div data-testid="history-element"><slot></slot></div>',
+ }),
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findHistoryElement = (testId) => wrapper.findByTestId(testId);
+ const findElementLink = (container) => container.findComponent(GlLink);
+ const findElementTimeAgo = (container) => container.findComponent(TimeAgoTooltip);
+ const findTitle = () => wrapper.findByTestId('title');
+ const findTimeline = () => wrapper.findByTestId('timeline');
+
+ it('has the correct title', () => {
+ mountComponent();
+
+ const title = findTitle();
+
+ expect(title.exists()).toBe(true);
+ expect(title.text()).toBe('History');
+ });
+
+ it('has a timeline container', () => {
+ mountComponent();
+
+ const title = findTimeline();
+
+ expect(title.exists()).toBe(true);
+ expect(title.classes()).toEqual(
+ expect.arrayContaining(['timeline', 'main-notes-list', 'notes']),
+ );
+ });
+
+ describe.each`
+ name | amount | icon | text | timeAgoTooltip | link
+ ${'created-on'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'clock'} | ${'@gitlab-org/package-15 version 1.0.0 was first created'} | ${packageData().createdAt} | ${null}
+ ${'first-pipeline-commit'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'commit'} | ${'Created by commit #b83d6e39 on branch master'} | ${null} | ${onePipeline.commitPath}
+ ${'first-pipeline-pipeline'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pipeline'} | ${'Built by pipeline #1 triggered by Administrator'} | ${onePipeline.createdAt} | ${onePipeline.path}
+ ${'published'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'package'} | ${'Published to the baz project Package Registry'} | ${packageData().createdAt} | ${null}
+ ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'history'} | ${'Package has 1 archived update'} | ${null} | ${null}
+ ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 3} | ${'history'} | ${'Package has 2 archived updates'} | ${null} | ${null}
+ ${'pipeline-entry'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pencil'} | ${'Package updated by commit #b83d6e39 on branch master, built by pipeline #3, and published to the registry'} | ${packageData().createdAt} | ${onePipeline.commitPath}
+ `(
+ 'with $amount pipelines history element $name',
+ ({ name, icon, text, timeAgoTooltip, link, amount }) => {
+ let element;
+
+ beforeEach(() => {
+ const packageEntity = { ...packageData(), pipelines: { nodes: createPipelines(amount) } };
+ mountComponent({
+ packageEntity,
+ });
+ element = findHistoryElement(name);
+ });
+
+ it('exists', () => {
+ expect(element.exists()).toBe(true);
+ });
+
+ it('has the correct icon', () => {
+ expect(element.props('icon')).toBe(icon);
+ });
+
+ it('has the correct text', () => {
+ expect(element.text()).toBe(text);
+ });
+
+ it('time-ago tooltip', () => {
+ const timeAgo = findElementTimeAgo(element);
+ const exist = Boolean(timeAgoTooltip);
+
+ expect(timeAgo.exists()).toBe(exist);
+ if (exist) {
+ expect(timeAgo.props('time')).toBe(timeAgoTooltip);
+ }
+ });
+
+ it('link', () => {
+ const linkElement = findElementLink(element);
+ const exist = Boolean(link);
+
+ expect(linkElement.exists()).toBe(exist);
+ if (exist) {
+ expect(linkElement.attributes('href')).toBe(link);
+ }
+ });
+ },
+ );
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
new file mode 100644
index 00000000000..327f6d81905
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
@@ -0,0 +1,202 @@
+import { GlIcon, GlSprintf } from '@gitlab/ui';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import PackageTags from '~/packages/shared/components/package_tags.vue';
+import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
+import {
+ PACKAGE_TYPE_CONAN,
+ PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_NPM,
+ PACKAGE_TYPE_NUGET,
+} from '~/packages_and_registries/package_registry/constants';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import { packageData, packageFiles, packageTags, packagePipelines } from '../../mock_data';
+
+const packageWithTags = {
+ ...packageData(),
+ tags: { nodes: packageTags() },
+ packageFiles: { nodes: packageFiles() },
+};
+
+describe('PackageTitle', () => {
+ let wrapper;
+
+ function createComponent(packageEntity = packageWithTags) {
+ wrapper = shallowMountExtended(PackageTitle, {
+ propsData: { packageEntity },
+ stubs: {
+ TitleArea,
+ GlSprintf,
+ },
+ });
+ return wrapper.vm.$nextTick();
+ }
+
+ const findTitleArea = () => wrapper.findComponent(TitleArea);
+ const findPackageType = () => wrapper.findByTestId('package-type');
+ const findPackageSize = () => wrapper.findByTestId('package-size');
+ const findPipelineProject = () => wrapper.findByTestId('pipeline-project');
+ const findPackageRef = () => wrapper.findByTestId('package-ref');
+ const findPackageTags = () => wrapper.findComponent(PackageTags);
+ const findPackageBadges = () => wrapper.findAllByTestId('tag-badge');
+ const findSubHeaderIcon = () => wrapper.findComponent(GlIcon);
+ const findSubHeaderText = () => wrapper.findByTestId('sub-header');
+ const findSubHeaderTimeAgo = () => wrapper.findComponent(TimeAgoTooltip);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('renders', () => {
+ it('without tags', async () => {
+ await createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('with tags', async () => {
+ await createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('with tags on mobile', async () => {
+ jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false);
+ await createComponent();
+
+ await wrapper.vm.$nextTick();
+
+ expect(findPackageBadges()).toHaveLength(packageTags().length);
+ });
+ });
+
+ describe('package title', () => {
+ it('is correctly bound', async () => {
+ await createComponent();
+
+ expect(findTitleArea().props('title')).toBe(packageData().name);
+ });
+ });
+
+ describe('package icon', () => {
+ const iconUrl = 'a-fake-src';
+
+ it('shows an icon when present and package type is NUGET', async () => {
+ await createComponent({
+ ...packageData(),
+ packageType: PACKAGE_TYPE_NUGET,
+ metadata: { iconUrl },
+ });
+
+ expect(findTitleArea().props('avatar')).toBe(iconUrl);
+ });
+
+ it('hides the icon when not present', async () => {
+ await createComponent();
+
+ expect(findTitleArea().props('avatar')).toBe(null);
+ });
+ });
+
+ describe('sub-header', () => {
+ it('has the eye icon', async () => {
+ await createComponent();
+
+ expect(findSubHeaderIcon().props('name')).toBe('eye');
+ });
+
+ it('has a text showing version', async () => {
+ await createComponent();
+
+ expect(findSubHeaderText().text()).toMatchInterpolatedText('v 1.0.0 published');
+ });
+
+ it('has a time ago tooltip component', async () => {
+ await createComponent();
+ expect(findSubHeaderTimeAgo().props('time')).toBe(packageWithTags.createdAt);
+ });
+ });
+
+ describe.each`
+ packageType | text
+ ${PACKAGE_TYPE_CONAN} | ${'Conan'}
+ ${PACKAGE_TYPE_MAVEN} | ${'Maven'}
+ ${PACKAGE_TYPE_NPM} | ${'npm'}
+ ${PACKAGE_TYPE_NUGET} | ${'NuGet'}
+ `(`package type`, ({ packageType, text }) => {
+ beforeEach(() => createComponent({ ...packageData, packageType }));
+
+ it(`${packageType} should render ${text}`, () => {
+ expect(findPackageType().props()).toEqual(expect.objectContaining({ text, icon: 'package' }));
+ });
+ });
+
+ describe('calculates the package size', () => {
+ it('correctly calculates when there is only 1 file', async () => {
+ await createComponent({ ...packageData(), packageFiles: { nodes: [packageFiles()[0]] } });
+
+ expect(findPackageSize().props()).toMatchObject({ text: '400.00 KiB', icon: 'disk' });
+ });
+
+ it('correctly calculates when there are multiple files', async () => {
+ await createComponent();
+
+ expect(findPackageSize().props('text')).toBe('800.00 KiB');
+ });
+ });
+
+ describe('package tags', () => {
+ it('displays the package-tags component when the package has tags', async () => {
+ await createComponent();
+
+ expect(findPackageTags().exists()).toBe(true);
+ });
+
+ it('does not display the package-tags component when there are no tags', async () => {
+ await createComponent({ ...packageData(), tags: { nodes: [] } });
+
+ expect(findPackageTags().exists()).toBe(false);
+ });
+ });
+
+ describe('package ref', () => {
+ it('does not display the ref if missing', async () => {
+ await createComponent();
+
+ expect(findPackageRef().exists()).toBe(false);
+ });
+
+ it('correctly shows the package ref if there is one', async () => {
+ await createComponent({
+ ...packageData(),
+ pipelines: { nodes: packagePipelines({ ref: 'test' }) },
+ });
+ expect(findPackageRef().props()).toMatchObject({
+ text: 'test',
+ icon: 'branch',
+ });
+ });
+ });
+
+ describe('pipeline project', () => {
+ it('does not display the project if missing', async () => {
+ await createComponent();
+
+ expect(findPipelineProject().exists()).toBe(false);
+ });
+
+ it('correctly shows the pipeline project if there is one', async () => {
+ await createComponent({
+ ...packageData(),
+ pipelines: { nodes: packagePipelines() },
+ });
+ expect(findPipelineProject().props()).toMatchObject({
+ text: packagePipelines()[0].project.name,
+ icon: 'review-list',
+ link: packagePipelines()[0].project.webUrl,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js
new file mode 100644
index 00000000000..410c1b65348
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js
@@ -0,0 +1,80 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { packageData } from 'jest/packages_and_registries/package_registry/mock_data';
+import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
+import PypiInstallation from '~/packages_and_registries/package_registry/components/details/pypi_installation.vue';
+import {
+ PACKAGE_TYPE_PYPI,
+ TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND,
+ TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND,
+} from '~/packages_and_registries/package_registry/constants';
+
+const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_PYPI };
+
+describe('PypiInstallation', () => {
+ let wrapper;
+
+ const pipCommandStr = 'pip install @gitlab-org/package-15 --extra-index-url pypiPath';
+ const pypiSetupStr = `[gitlab]
+repository = pypiSetupPath
+username = __token__
+password = <your personal access token>`;
+
+ const pipCommand = () => wrapper.findByTestId('pip-command');
+ const setupInstruction = () => wrapper.findByTestId('pypi-setup-content');
+
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
+
+ function createComponent() {
+ wrapper = shallowMountExtended(PypiInstallation, {
+ provide: {
+ pypiHelpPath: 'pypiHelpPath',
+ pypiPath: 'pypiPath',
+ pypiSetupPath: 'pypiSetupPath',
+ },
+ propsData: {
+ packageEntity,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'pypi',
+ options: [{ value: 'pypi', label: 'Show PyPi commands' }],
+ });
+ });
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct pip command', () => {
+ expect(pipCommand().props()).toMatchObject({
+ instruction: pipCommandStr,
+ trackingAction: TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct setup block', () => {
+ expect(setupInstruction().props()).toMatchObject({
+ instruction: pypiSetupStr,
+ multiline: true,
+ trackingAction: TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
new file mode 100644
index 00000000000..f7613949fe4
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
@@ -0,0 +1,89 @@
+import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import PackageTags from '~/packages/shared/components/package_tags.vue';
+import PublishMethod from '~/packages/shared/components/publish_method.vue';
+import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import { packageVersions } from '../../mock_data';
+
+const packageVersion = packageVersions()[0];
+
+describe('VersionRow', () => {
+ let wrapper;
+
+ const findListItem = () => wrapper.findComponent(ListItem);
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findPackageTags = () => wrapper.findComponent(PackageTags);
+ const findPublishMethod = () => wrapper.findComponent(PublishMethod);
+ const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
+
+ function createComponent(packageEntity = packageVersion) {
+ wrapper = shallowMountExtended(VersionRow, {
+ propsData: {
+ packageEntity,
+ },
+ stubs: {
+ ListItem,
+ GlSprintf,
+ GlTruncate,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('has a link to the version detail', () => {
+ createComponent();
+
+ expect(findLink().attributes('href')).toBe(`${getIdFromGraphQLId(packageVersion.id)}`);
+ expect(findLink().text()).toBe(packageVersion.name);
+ });
+
+ it('has the version of the package', () => {
+ createComponent();
+
+ expect(wrapper.text()).toContain(packageVersion.version);
+ });
+
+ it('has a package tags component', () => {
+ createComponent();
+
+ expect(findPackageTags().props('tags')).toBe(packageVersion.tags.nodes);
+ });
+
+ it('has a publish method component', () => {
+ createComponent();
+
+ expect(findPublishMethod().props('packageEntity')).toBe(packageVersion);
+ });
+ it('has a time-ago tooltip', () => {
+ createComponent();
+
+ expect(findTimeAgoTooltip().props('time')).toBe(packageVersion.createdAt);
+ });
+
+ describe('disabled status', () => {
+ it('disables the list item', () => {
+ createComponent({ ...packageVersion, status: 'something' });
+
+ expect(findListItem().props('disabled')).toBe(true);
+ });
+
+ it('disables the link', () => {
+ createComponent({ ...packageVersion, status: 'something' });
+
+ expect(findLink().attributes('disabled')).toBe('true');
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js
new file mode 100644
index 00000000000..98ff29ef728
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js
@@ -0,0 +1,251 @@
+export const packageTags = () => [
+ { id: 'gid://gitlab/Packages::Tag/87', name: 'bananas_9', __typename: 'PackageTag' },
+ { id: 'gid://gitlab/Packages::Tag/86', name: 'bananas_8', __typename: 'PackageTag' },
+ { id: 'gid://gitlab/Packages::Tag/85', name: 'bananas_7', __typename: 'PackageTag' },
+];
+
+export const packagePipelines = (extend) => [
+ {
+ commitPath: '/namespace14/project14/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0',
+ createdAt: '2020-08-17T14:23:32Z',
+ id: 'gid://gitlab/Ci::Pipeline/36',
+ path: '/namespace14/project14/-/pipelines/36',
+ name: 'project14',
+ ref: 'master',
+ sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
+ project: {
+ name: 'project14',
+ webUrl: 'http://gdk.test:3000/namespace14/project14',
+ __typename: 'Project',
+ },
+ user: {
+ name: 'Administrator',
+ },
+ ...extend,
+ __typename: 'Pipeline',
+ },
+];
+
+export const packageFiles = () => [
+ {
+ id: 'gid://gitlab/Packages::PackageFile/118',
+ fileMd5: 'fileMd5',
+ fileName: 'foo-1.0.1.tgz',
+ fileSha1: 'be93151dc23ac34a82752444556fe79b32c7a1ad',
+ fileSha256: 'fileSha256',
+ size: '409600',
+ createdAt: '2020-08-17T14:23:32Z',
+ downloadPath: 'downloadPath',
+ __typename: 'PackageFile',
+ },
+ {
+ id: 'gid://gitlab/Packages::PackageFile/119',
+ fileMd5: null,
+ fileName: 'foo-1.0.2.tgz',
+ fileSha1: 'be93151dc23ac34a82752444556fe79b32c7a1ss',
+ fileSha256: null,
+ size: '409600',
+ createdAt: '2020-08-17T14:23:32Z',
+ downloadPath: 'downloadPath',
+ __typename: 'PackageFile',
+ },
+];
+
+export const dependencyLinks = () => [
+ {
+ dependencyType: 'DEPENDENCIES',
+ id: 'gid://gitlab/Packages::DependencyLink/77',
+ __typename: 'PackageDependencyLink',
+ dependency: {
+ id: 'gid://gitlab/Packages::Dependency/3',
+ name: 'Ninject.Extensions.Factory',
+ versionPattern: '3.3.2',
+ __typename: 'PackageDependency',
+ },
+ metadata: {
+ id: 'gid://gitlab/Packages::Nuget::DependencyLinkMetadatum/77',
+ targetFramework: '.NETCoreApp3.1',
+ __typename: 'NugetDependencyLinkMetadata',
+ },
+ },
+ {
+ dependencyType: 'DEPENDENCIES',
+ id: 'gid://gitlab/Packages::DependencyLink/78',
+ __typename: 'PackageDependencyLink',
+ dependency: {
+ id: 'gid://gitlab/Packages::Dependency/4',
+ name: 'Ninject.Extensions.Factory',
+ versionPattern: '3.3.2',
+ __typename: 'PackageDependency',
+ },
+ metadata: {
+ id: 'gid://gitlab/Packages::Nuget::DependencyLinkMetadatum/78',
+ targetFramework: '.NETCoreApp3.1',
+ __typename: 'NugetDependencyLinkMetadata',
+ },
+ },
+];
+
+export const packageVersions = () => [
+ {
+ createdAt: '2021-08-10T09:33:54Z',
+ id: 'gid://gitlab/Packages::Package/243',
+ name: '@gitlab-org/package-15',
+ status: 'DEFAULT',
+ tags: { nodes: packageTags() },
+ version: '1.0.1',
+ __typename: 'Package',
+ },
+ {
+ createdAt: '2021-08-10T09:33:54Z',
+ id: 'gid://gitlab/Packages::Package/244',
+ name: '@gitlab-org/package-15',
+ status: 'DEFAULT',
+ tags: { nodes: packageTags() },
+ version: '1.0.2',
+ __typename: 'Package',
+ },
+];
+
+export const packageData = (extend) => ({
+ id: 'gid://gitlab/Packages::Package/111',
+ name: '@gitlab-org/package-15',
+ packageType: 'NPM',
+ version: '1.0.0',
+ createdAt: '2020-08-17T14:23:32Z',
+ updatedAt: '2020-08-17T14:23:32Z',
+ status: 'DEFAULT',
+ ...extend,
+});
+
+export const conanMetadata = () => ({
+ packageChannel: 'stable',
+ packageUsername: 'gitlab-org+gitlab-test',
+ recipe: 'package-8/1.0.0@gitlab-org+gitlab-test/stable',
+ recipePath: 'package-8/1.0.0/gitlab-org+gitlab-test/stable',
+});
+
+export const composerMetadata = () => ({
+ targetSha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
+ composerJson: {
+ license: 'MIT',
+ version: '1.0.0',
+ },
+});
+
+export const pypyMetadata = () => ({
+ requiredPython: '1.0.0',
+});
+
+export const mavenMetadata = () => ({
+ appName: 'appName',
+ appGroup: 'appGroup',
+ appVersion: 'appVersion',
+ path: 'path',
+});
+
+export const nugetMetadata = () => ({
+ iconUrl: 'iconUrl',
+ licenseUrl: 'licenseUrl',
+ projectUrl: 'projectUrl',
+});
+
+export const packageDetailsQuery = (extendPackage) => ({
+ data: {
+ package: {
+ ...packageData(),
+ metadata: {
+ ...conanMetadata(),
+ ...composerMetadata(),
+ ...pypyMetadata(),
+ ...mavenMetadata(),
+ ...nugetMetadata(),
+ },
+ project: {
+ path: 'projectPath',
+ },
+ tags: {
+ nodes: packageTags(),
+ __typename: 'PackageTagConnection',
+ },
+ pipelines: {
+ nodes: packagePipelines(),
+ __typename: 'PipelineConnection',
+ },
+ packageFiles: {
+ nodes: packageFiles(),
+ __typename: 'PackageFileConnection',
+ },
+ versions: {
+ nodes: packageVersions(),
+ __typename: 'PackageConnection',
+ },
+ dependencyLinks: {
+ nodes: dependencyLinks(),
+ },
+ __typename: 'PackageDetailsType',
+ ...extendPackage,
+ },
+ },
+});
+
+export const emptyPackageDetailsQuery = () => ({
+ data: {
+ package: {
+ __typename: 'PackageDetailsType',
+ },
+ },
+});
+
+export const packageDestroyMutation = () => ({
+ data: {
+ destroyPackage: {
+ errors: [],
+ },
+ },
+});
+
+export const packageDestroyMutationError = () => ({
+ data: {
+ destroyPackage: null,
+ },
+ errors: [
+ {
+ message:
+ "The resource that you are attempting to access does not exist or you don't have permission to perform this action",
+ locations: [
+ {
+ line: 2,
+ column: 3,
+ },
+ ],
+ path: ['destroyPackage'],
+ },
+ ],
+});
+
+export const packageDestroyFileMutation = () => ({
+ data: {
+ destroyPackageFile: {
+ errors: [],
+ },
+ },
+});
+export const packageDestroyFileMutationError = () => ({
+ data: {
+ destroyPackageFile: null,
+ },
+ errors: [
+ {
+ message:
+ "The resource that you are attempting to access does not exist or you don't have permission to perform this action",
+ locations: [
+ {
+ line: 2,
+ column: 3,
+ },
+ ],
+ path: ['destroyPackageFile'],
+ },
+ ],
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/utils_spec.js b/spec/frontend/packages_and_registries/package_registry/utils_spec.js
new file mode 100644
index 00000000000..019f94aaec2
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/utils_spec.js
@@ -0,0 +1,23 @@
+import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils';
+
+describe('Packages shared utils', () => {
+ describe('getPackageTypeLabel', () => {
+ describe.each`
+ packageType | expectedResult
+ ${'CONAN'} | ${'Conan'}
+ ${'MAVEN'} | ${'Maven'}
+ ${'NPM'} | ${'npm'}
+ ${'NUGET'} | ${'NuGet'}
+ ${'PYPI'} | ${'PyPI'}
+ ${'RUBYGEMS'} | ${'RubyGems'}
+ ${'COMPOSER'} | ${'Composer'}
+ ${'DEBIAN'} | ${'Debian'}
+ ${'HELM'} | ${'Helm'}
+ ${'FOO'} | ${null}
+ `(`package type`, ({ packageType, expectedResult }) => {
+ it(`${packageType} should show as ${expectedResult}`, () => {
+ expect(getPackageTypeLabel(packageType)).toBe(expectedResult);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
index 858c7b76ac8..4140b985682 100644
--- a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
+++ b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js
@@ -5,53 +5,53 @@ import initSetHelperText, {
describe('UsageStatistics', () => {
const FIXTURE = 'application_settings/usage.html';
- let usagePingCheckBox;
- let usagePingFeaturesCheckBox;
- let usagePingFeaturesLabel;
- let usagePingFeaturesHelperText;
+ let servicePingCheckBox;
+ let servicePingFeaturesCheckBox;
+ let servicePingFeaturesLabel;
+ let servicePingFeaturesHelperText;
beforeEach(() => {
loadFixtures(FIXTURE);
initSetHelperText();
- usagePingCheckBox = document.getElementById('application_setting_usage_ping_enabled');
- usagePingFeaturesCheckBox = document.getElementById(
+ servicePingCheckBox = document.getElementById('application_setting_usage_ping_enabled');
+ servicePingFeaturesCheckBox = document.getElementById(
'application_setting_usage_ping_features_enabled',
);
- usagePingFeaturesLabel = document.getElementById('service_ping_features_label');
- usagePingFeaturesHelperText = document.getElementById('service_ping_features_helper_text');
+ servicePingFeaturesLabel = document.getElementById('service_ping_features_label');
+ servicePingFeaturesHelperText = document.getElementById('service_ping_features_helper_text');
});
- const expectEnabledUsagePingFeaturesCheckBox = () => {
- expect(usagePingFeaturesCheckBox.classList.contains('gl-cursor-not-allowed')).toBe(false);
- expect(usagePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_ENABLED);
+ const expectEnabledservicePingFeaturesCheckBox = () => {
+ expect(servicePingFeaturesCheckBox.classList.contains('gl-cursor-not-allowed')).toBe(false);
+ expect(servicePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_ENABLED);
};
- const expectDisabledUsagePingFeaturesCheckBox = () => {
- expect(usagePingFeaturesLabel.classList.contains('gl-cursor-not-allowed')).toBe(true);
- expect(usagePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_DISABLED);
+ const expectDisabledservicePingFeaturesCheckBox = () => {
+ expect(servicePingFeaturesLabel.classList.contains('gl-cursor-not-allowed')).toBe(true);
+ expect(servicePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_DISABLED);
};
describe('Registration Features checkbox', () => {
- it('is disabled when Usage Ping checkbox is unchecked', () => {
- expect(usagePingCheckBox.checked).toBe(false);
- expectDisabledUsagePingFeaturesCheckBox();
+ it('is disabled when Service Ping checkbox is unchecked', () => {
+ expect(servicePingCheckBox.checked).toBe(false);
+ expectDisabledservicePingFeaturesCheckBox();
});
- it('is enabled when Usage Ping checkbox is checked', () => {
- usagePingCheckBox.click();
- expect(usagePingCheckBox.checked).toBe(true);
- expectEnabledUsagePingFeaturesCheckBox();
+ it('is enabled when Servie Ping checkbox is checked', () => {
+ servicePingCheckBox.click();
+ expect(servicePingCheckBox.checked).toBe(true);
+ expectEnabledservicePingFeaturesCheckBox();
});
- it('is switched to disabled when Usage Ping checkbox is unchecked ', () => {
- usagePingCheckBox.click();
- usagePingFeaturesCheckBox.click();
- expectEnabledUsagePingFeaturesCheckBox();
+ it('is switched to disabled when Service Ping checkbox is unchecked ', () => {
+ servicePingCheckBox.click();
+ servicePingFeaturesCheckBox.click();
+ expectEnabledservicePingFeaturesCheckBox();
- usagePingCheckBox.click();
- expect(usagePingCheckBox.checked).toBe(false);
- expect(usagePingFeaturesCheckBox.checked).toBe(false);
- expectDisabledUsagePingFeaturesCheckBox();
+ servicePingCheckBox.click();
+ expect(servicePingCheckBox.checked).toBe(false);
+ expect(servicePingFeaturesCheckBox.checked).toBe(false);
+ expectDisabledservicePingFeaturesCheckBox();
});
});
});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
index 4c253f0610b..1e562419f32 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
@@ -1,4 +1,4 @@
-import { GlToggle } from '@gitlab/ui';
+import { GlSprintf, GlToggle } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue';
import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue';
@@ -22,12 +22,11 @@ const defaultProps = {
operationsAccessLevel: 20,
pagesAccessLevel: 10,
analyticsAccessLevel: 20,
- containerRegistryEnabled: true,
+ containerRegistryAccessLevel: 20,
lfsEnabled: true,
emailsDisabled: false,
packagesEnabled: true,
showDefaultAwardEmojis: true,
- allowEditingCommitMessages: false,
},
isGitlabCom: true,
canDisableEmails: true,
@@ -53,7 +52,7 @@ describe('Settings Panel', () => {
let wrapper;
const mountComponent = (
- { currentSettings = {}, glFeatures = {}, ...customProps } = {},
+ { currentSettings = {}, ...customProps } = {},
mountFn = shallowMount,
) => {
const propsData = {
@@ -64,9 +63,6 @@ describe('Settings Panel', () => {
return mountFn(settingsPanel, {
propsData,
- provide: {
- glFeatures,
- },
});
};
@@ -89,8 +85,10 @@ describe('Settings Panel', () => {
const findBuildsAccessLevelInput = () =>
wrapper.find('[name="project[project_feature_attributes][builds_access_level]"]');
const findContainerRegistrySettings = () => wrapper.find({ ref: 'container-registry-settings' });
- const findContainerRegistryEnabledInput = () =>
- wrapper.find('[name="project[container_registry_enabled]"]');
+ const findContainerRegistryPublicNoteGlSprintfComponent = () =>
+ findContainerRegistrySettings().findComponent(GlSprintf);
+ const findContainerRegistryAccessLevelInput = () =>
+ wrapper.find('[name="project[project_feature_attributes][container_registry_access_level]"]');
const findPackageSettings = () => wrapper.find({ ref: 'package-settings' });
const findPackagesEnabledInput = () => wrapper.find('[name="project[packages_enabled]"]');
const findPagesSettings = () => wrapper.find({ ref: 'pages-settings' });
@@ -100,8 +98,6 @@ describe('Settings Panel', () => {
const findShowDefaultAwardEmojis = () =>
wrapper.find('input[name="project[project_setting_attributes][show_default_award_emojis]"]');
const findMetricsVisibilitySettings = () => wrapper.find({ ref: 'metrics-visibility-settings' });
- const findAllowEditingCommitMessages = () =>
- wrapper.find({ ref: 'allow-editing-commit-messages' }).exists();
const findOperationsSettings = () => wrapper.find({ ref: 'operations-settings' });
afterEach(() => {
@@ -281,42 +277,38 @@ describe('Settings Panel', () => {
it('should show the container registry public note if the visibility level is public and the registry is available', () => {
wrapper = mountComponent({
- currentSettings: { visibilityLevel: visibilityOptions.PUBLIC },
- registryAvailable: true,
- });
-
- expect(findContainerRegistrySettings().text()).toContain(
- 'Note: the container registry is always visible when a project is public',
- );
- });
-
- it('should hide the container registry public note if the visibility level is private and the registry is available', () => {
- wrapper = mountComponent({
- currentSettings: { visibilityLevel: visibilityOptions.PRIVATE },
+ currentSettings: {
+ visibilityLevel: visibilityOptions.PUBLIC,
+ containerRegistryAccessLevel: featureAccessLevel.EVERYONE,
+ },
registryAvailable: true,
});
- expect(findContainerRegistrySettings().text()).not.toContain(
- 'Note: the container registry is always visible when a project is public',
+ expect(findContainerRegistryPublicNoteGlSprintfComponent().exists()).toBe(true);
+ expect(findContainerRegistryPublicNoteGlSprintfComponent().attributes('message')).toContain(
+ `Note: The container registry is always visible when a project is public and the container registry is set to '%{access_level_description}'`,
);
});
- it('should enable the container registry input when the repository is enabled', () => {
+ it('should hide the container registry public note if the visibility level is public but the registry is private', () => {
wrapper = mountComponent({
- currentSettings: { repositoryAccessLevel: featureAccessLevel.EVERYONE },
+ currentSettings: {
+ visibilityLevel: visibilityOptions.PUBLIC,
+ containerRegistryAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
+ },
registryAvailable: true,
});
- expect(findContainerRegistryEnabledInput().props('disabled')).toBe(false);
+ expect(findContainerRegistryPublicNoteGlSprintfComponent().exists()).toBe(false);
});
- it('should disable the container registry input when the repository is disabled', () => {
+ it('should hide the container registry public note if the visibility level is private and the registry is available', () => {
wrapper = mountComponent({
- currentSettings: { repositoryAccessLevel: featureAccessLevel.NOT_ENABLED },
+ currentSettings: { visibilityLevel: visibilityOptions.PRIVATE },
registryAvailable: true,
});
- expect(findContainerRegistryEnabledInput().props('disabled')).toBe(true);
+ expect(findContainerRegistryPublicNoteGlSprintfComponent().exists()).toBe(false);
});
it('has label for the toggle', () => {
@@ -325,7 +317,7 @@ describe('Settings Panel', () => {
registryAvailable: true,
});
- expect(findContainerRegistrySettings().findComponent(GlToggle).props('label')).toBe(
+ expect(findContainerRegistryAccessLevelInput().props('label')).toBe(
settingsPanel.i18n.containerRegistryLabel,
);
});
@@ -582,18 +574,6 @@ describe('Settings Panel', () => {
);
});
- describe('Settings panel with feature flags', () => {
- describe('Allow edit of commit message', () => {
- it('should show the allow editing of commit messages checkbox', () => {
- wrapper = mountComponent({
- glFeatures: { allowEditingCommitMessages: true },
- });
-
- expect(findAllowEditingCommitMessages()).toBe(true);
- });
- });
- });
-
describe('Analytics', () => {
it('should show the analytics toggle', () => {
wrapper = mountComponent();
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
index f36d6262b5f..082a8977710 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
@@ -15,6 +15,8 @@ import {
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+jest.mock('~/emoji');
+
describe('WikiForm', () => {
let wrapper;
let mock;
@@ -350,11 +352,6 @@ describe('WikiForm', () => {
await waitForPromises();
});
- it('editor is shown in a perpetual loading state', () => {
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
- expect(wrapper.findComponent(ContentEditor).exists()).toBe(false);
- });
-
it('disables the submit button', () => {
expect(findSubmitButton().props('disabled')).toBe(true);
});
diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js
index 1e51ddf909a..1db255106ed 100644
--- a/spec/frontend/persistent_user_callout_spec.js
+++ b/spec/frontend/persistent_user_callout_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -166,6 +167,8 @@ describe('PersistentUserCallout', () => {
let mockAxios;
let persistentUserCallout;
+ useMockLocationHelper();
+
beforeEach(() => {
const fixture = createFollowLinkFixture();
const container = fixture.querySelector('.container');
@@ -174,9 +177,6 @@ describe('PersistentUserCallout', () => {
persistentUserCallout = new PersistentUserCallout(container);
jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {});
-
- delete window.location;
- window.location = { assign: jest.fn() };
});
afterEach(() => {
diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
index b6d49d0d0f8..a95921359cc 100644
--- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
+++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
@@ -44,6 +44,7 @@ describe('Pipeline Status', () => {
const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]');
const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]');
const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]');
+ const findPipelineViewBtn = () => wrapper.find('[data-testid="pipeline-view-btn"]');
beforeEach(() => {
mockPipelineQuery = jest.fn();
@@ -96,11 +97,15 @@ describe('Pipeline Status', () => {
});
it('renders pipeline data', () => {
- const { id } = mockProjectPipeline.pipeline;
+ const {
+ id,
+ detailedStatus: { detailsPath },
+ } = mockProjectPipeline.pipeline;
expect(findCiIcon().exists()).toBe(true);
expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`);
expect(findPipelineCommit().text()).toBe(mockCommitSha);
+ expect(findPipelineViewBtn().attributes('href')).toBe(detailsPath);
});
});
@@ -121,6 +126,7 @@ describe('Pipeline Status', () => {
expect(findCiIcon().exists()).toBe(false);
expect(findPipelineId().exists()).toBe(false);
expect(findPipelineCommit().exists()).toBe(false);
+ expect(findPipelineViewBtn().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js
index 93ebbc648fe..9f910ed4f9c 100644
--- a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js
+++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js
@@ -1,5 +1,6 @@
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue';
import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants';
@@ -12,6 +13,10 @@ import {
LOAD_FAILURE_UNKNOWN,
} from '~/pipeline_editor/constants';
+beforeEach(() => {
+ setWindowLocation(TEST_HOST);
+});
+
describe('Pipeline Editor messages', () => {
let wrapper;
@@ -95,9 +100,7 @@ describe('Pipeline Editor messages', () => {
describe('code snippet alert', () => {
const setCodeSnippetUrlParam = (value) => {
- global.jsdom.reconfigure({
- url: `${TEST_HOST}/?code_snippet_copied_from=${value}`,
- });
+ setWindowLocation(`${TEST_HOST}/?code_snippet_copied_from=${value}`);
};
it('does not show by default', () => {
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
index b0d1a69ee56..0c5c08d7190 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
@@ -2,6 +2,7 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
@@ -348,15 +349,14 @@ describe('Pipeline editor app component', () => {
});
describe('when a template parameter is present in the URL', () => {
- const { location } = window;
+ const originalLocation = window.location.href;
beforeEach(() => {
- delete window.location;
- window.location = new URL('https://localhost?template=Android');
+ setWindowLocation('?template=Android');
});
afterEach(() => {
- window.location = location;
+ setWindowLocation(originalLocation);
});
it('renders the given template', async () => {
diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
index b0dbba37b94..e0ba6b2e8da 100644
--- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
@@ -20,6 +20,7 @@ describe('Pipelines filtered search', () => {
const findTagToken = () => getSearchToken('tag');
const findUserToken = () => getSearchToken('username');
const findStatusToken = () => getSearchToken('status');
+ const findSourceToken = () => getSearchToken('source');
const createComponent = (params = {}) => {
wrapper = mount(PipelinesFilteredSearch, {
@@ -32,6 +33,8 @@ describe('Pipelines filtered search', () => {
};
beforeEach(() => {
+ window.gon = { features: { pipelineSourceFilter: true } };
+
mock = new MockAdapter(axios);
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
@@ -70,6 +73,14 @@ describe('Pipelines filtered search', () => {
operators: OPERATOR_IS_ONLY,
});
+ expect(findSourceToken()).toMatchObject({
+ type: 'source',
+ icon: 'trigger-source',
+ title: 'Source',
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ });
+
expect(findStatusToken()).toMatchObject({
type: 'status',
icon: 'status',
diff --git a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js
deleted file mode 100644
index a955572a481..00000000000
--- a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js
+++ /dev/null
@@ -1,300 +0,0 @@
-import { GlLoadingIcon } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { setHTMLFixture } from 'helpers/fixtures';
-import GraphComponentLegacy from '~/pipelines/components/graph/graph_component_legacy.vue';
-import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue';
-import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue';
-import PipelinesMediator from '~/pipelines/pipeline_details_mediator';
-import PipelineStore from '~/pipelines/stores/pipeline_store';
-import linkedPipelineJSON from './linked_pipelines_mock_data';
-import graphJSON from './mock_data_legacy';
-
-describe('graph component', () => {
- let store;
- let mediator;
- let wrapper;
-
- const findExpandPipelineBtn = () => wrapper.find('[data-testid="expand-pipeline-button"]');
- const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expand-pipeline-button"]');
- const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy);
- const findStageColumnAt = (i) => findStageColumns().at(i);
-
- beforeEach(() => {
- mediator = new PipelinesMediator({ endpoint: '' });
- store = new PipelineStore();
- store.storePipeline(linkedPipelineJSON);
-
- setHTMLFixture('<div class="layout-page"></div>');
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('while is loading', () => {
- it('should render a loading icon', () => {
- wrapper = mount(GraphComponentLegacy, {
- propsData: {
- isLoading: true,
- pipeline: {},
- mediator,
- },
- });
-
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
- });
- });
-
- describe('with data', () => {
- beforeEach(() => {
- wrapper = mount(GraphComponentLegacy, {
- propsData: {
- isLoading: false,
- pipeline: graphJSON,
- mediator,
- },
- });
- });
-
- it('renders the graph', () => {
- expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true);
- 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', () => {
- beforeEach(() => {
- wrapper = mount(GraphComponentLegacy, {
- propsData: {
- isLoading: false,
- pipeline: store.state.pipeline,
- mediator,
- },
- });
- });
-
- describe('rendered output', () => {
- it('should include the pipelines graph', () => {
- expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true);
- });
-
- it('should not include the loading icon', () => {
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
- });
-
- it('should include the stage column', () => {
- expect(findStageColumnAt(0).exists()).toBe(true);
- });
-
- 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', () => {
- expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
- });
-
- 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', () => {
- expect(wrapper.find('.js-has-linked-pipelines').exists()).toBe(true);
- });
- });
-
- describe('computeds and methods', () => {
- describe('capitalizeStageName', () => {
- it('it capitalizes the stage name', () => {
- expect(wrapper.findAll('.stage-column .stage-name').at(1).text()).toBe('Prebuild');
- });
- });
-
- describe('stageConnectorClass', () => {
- it('it returns left-margin when there is a triggerer', () => {
- expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
- });
- });
- });
-
- describe('linked pipelines components', () => {
- beforeEach(() => {
- wrapper = mount(GraphComponentLegacy, {
- propsData: {
- isLoading: false,
- pipeline: store.state.pipeline,
- mediator,
- },
- });
- });
-
- it('should render an upstream pipelines column at first position', () => {
- expect(wrapper.find(LinkedPipelinesColumnLegacy).exists()).toBe(true);
- expect(wrapper.find('.stage-column .stage-name').text()).toBe('Upstream');
- });
-
- it('should render a downstream pipelines column at last position', () => {
- const stageColumnNames = wrapper.findAll('.stage-column .stage-name');
-
- expect(wrapper.find(LinkedPipelinesColumnLegacy).exists()).toBe(true);
- expect(stageColumnNames.at(stageColumnNames.length - 1).text()).toBe('Downstream');
- });
-
- describe('triggered by', () => {
- describe('on click', () => {
- it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', async () => {
- const btnWrapper = findExpandPipelineBtn();
-
- btnWrapper.trigger('click');
-
- await nextTick();
- expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([
- store.state.pipeline.triggered_by,
- ]);
- });
- });
-
- describe('with expanded pipeline', () => {
- it('should render expanded pipeline', async () => {
- // expand the pipeline
- store.state.pipeline.triggered_by[0].isExpanded = true;
-
- wrapper = mount(GraphComponentLegacy, {
- propsData: {
- isLoading: false,
- pipeline: store.state.pipeline,
- mediator,
- },
- });
-
- await nextTick();
- expect(wrapper.find('.js-upstream-pipeline-12').exists()).toBe(true);
- });
- });
- });
-
- describe('triggered', () => {
- describe('on click', () => {
- // We have to mock this property of HTMLElement since component relies on it
- let offsetParentDescriptor;
- beforeAll(() => {
- offsetParentDescriptor = Object.getOwnPropertyDescriptor(
- HTMLElement.prototype,
- 'offsetParent',
- );
- Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
- get() {
- return this.parentNode;
- },
- });
- });
- afterAll(() => {
- Object.defineProperty(HTMLElement.prototype, offsetParentDescriptor);
- });
-
- it('should emit `onClickDownstreamPipeline`', async () => {
- const btnWrappers = findAllExpandPipelineBtns();
- const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1);
-
- downstreamBtnWrapper.trigger('click');
-
- await nextTick();
- expect(wrapper.emitted().onClickDownstreamPipeline).toEqual([
- [store.state.pipeline.triggered[1]],
- ]);
- });
- });
-
- describe('with expanded pipeline', () => {
- it('should render expanded pipeline', async () => {
- // expand the pipeline
- store.state.pipeline.triggered[0].isExpanded = true;
-
- wrapper = mount(GraphComponentLegacy, {
- propsData: {
- isLoading: false,
- pipeline: store.state.pipeline,
- mediator,
- },
- });
-
- await nextTick();
- expect(wrapper.find('.js-downstream-pipeline-34993051')).not.toBeNull();
- });
- });
-
- describe('when column requests a refresh', () => {
- beforeEach(() => {
- findStageColumnAt(0).vm.$emit('refreshPipelineGraph');
- });
-
- it('refreshPipelineGraph is emitted', () => {
- expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1);
- });
- });
- });
- });
- });
-
- describe('when linked pipelines are not present', () => {
- beforeEach(() => {
- const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null });
- wrapper = mount(GraphComponentLegacy, {
- propsData: {
- isLoading: false,
- pipeline,
- mediator,
- },
- });
- });
-
- describe('rendered output', () => {
- it('should include the first column with a no margin', () => {
- const firstColumn = wrapper.find('.stage-column');
-
- expect(firstColumn.classes('no-margin')).toBe(true);
- });
-
- it('should not render a linked pipelines column', () => {
- expect(wrapper.find('.linked-pipelines-column').exists()).toBe(false);
- });
- });
-
- describe('stageConnectorClass', () => {
- it('it returns no-margin when no triggerer and there is one job', () => {
- expect(findStageColumnAt(0).classes('no-margin')).toBe(true);
- });
-
- it('it returns left-margin when no triggerer and not the first stage', () => {
- expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
- });
- });
- });
-
- describe('capitalizeStageName', () => {
- it('capitalizes and escapes stage name', () => {
- wrapper = mount(GraphComponentLegacy, {
- propsData: {
- isLoading: false,
- pipeline: graphJSON,
- mediator,
- },
- });
-
- expect(findStageColumnAt(1).props('title')).toEqual(
- 'Deploy &lt;img src=x onerror=alert(document.domain)&gt;',
- );
- });
- });
-});
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index 30914ba99a5..1fba3823161 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -4,8 +4,8 @@ import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import JobItem from '~/pipelines/components/graph/job_item.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
+import { calculatePipelineLayersInfo } from '~/pipelines/components/graph/utils';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
-import { listByLayers } from '~/pipelines/components/parsing_utils';
import {
generateResponse,
mockPipelineResponse,
@@ -150,7 +150,7 @@ describe('graph component', () => {
},
props: {
viewType: LAYER_VIEW,
- pipelineLayers: listByLayers(defaultProps.pipeline),
+ computedPipelineInfo: calculatePipelineLayersInfo(defaultProps.pipeline, 'layer', ''),
},
});
});
diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
index bb7e27b5ec2..2e8979f2b9d 100644
--- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
@@ -1,11 +1,19 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql';
+import axios from '~/lib/utils/axios_utils';
+import {
+ PIPELINES_DETAIL_LINK_DURATION,
+ PIPELINES_DETAIL_LINKS_TOTAL,
+ PIPELINES_DETAIL_LINKS_JOB_RATIO,
+} from '~/performance/constants';
+import * as perfUtils from '~/performance/utils';
import {
IID_FAILURE,
LAYER_VIEW,
@@ -16,8 +24,12 @@ import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue';
import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
+import * as Api from '~/pipelines/components/graph_shared/api';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
+import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql';
+import * as sentryUtils from '~/pipelines/utils';
+import { mockRunningPipelineHeaderData } from '../mock_data';
import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data';
const defaultProvide = {
@@ -72,8 +84,10 @@ describe('Pipeline graph wrapper', () => {
} = {}) => {
const callouts = mapCallouts(calloutsList);
const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse(callouts));
+ const getPipelineHeaderDataHandler = jest.fn().mockResolvedValue(mockRunningPipelineHeaderData);
const requestHandlers = [
+ [getPipelineHeaderData, getPipelineHeaderDataHandler],
[getPipelineDetails, getPipelineDetailsHandler],
[getUserCallouts, getUserCalloutsHandler],
];
@@ -111,6 +125,11 @@ describe('Pipeline graph wrapper', () => {
createComponentWithApollo();
expect(getGraph().exists()).toBe(false);
});
+
+ it('skips querying headerPipeline', () => {
+ createComponentWithApollo();
+ expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(true);
+ });
});
describe('when data has loaded', () => {
@@ -190,12 +209,15 @@ describe('Pipeline graph wrapper', () => {
describe('when refresh action is emitted', () => {
beforeEach(async () => {
createComponentWithApollo();
+ jest.spyOn(wrapper.vm.$apollo.queries.headerPipeline, 'refetch');
jest.spyOn(wrapper.vm.$apollo.queries.pipeline, 'refetch');
await wrapper.vm.$nextTick();
getGraph().vm.$emit('refreshPipelineGraph');
});
it('calls refetch', () => {
+ expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(false);
+ expect(wrapper.vm.$apollo.queries.headerPipeline.refetch).toHaveBeenCalled();
expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled();
});
});
@@ -245,28 +267,11 @@ describe('Pipeline graph wrapper', () => {
});
describe('view dropdown', () => {
- describe('when pipelineGraphLayersView feature flag is off', () => {
- beforeEach(async () => {
- createComponentWithApollo();
- jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
- });
-
- it('does not appear', () => {
- expect(getViewSelector().exists()).toBe(false);
- });
- });
-
- describe('when pipelineGraphLayersView feature flag is on', () => {
+ describe('default', () => {
let layersFn;
beforeEach(async () => {
layersFn = jest.spyOn(parsingUtils, 'listByLayers');
createComponentWithApollo({
- provide: {
- glFeatures: {
- pipelineGraphLayersView: true,
- },
- },
mountFn: mount,
});
@@ -304,14 +309,9 @@ describe('Pipeline graph wrapper', () => {
});
});
- describe('when pipelineGraphLayersView feature flag is on and layers view is selected', () => {
+ describe('when layers view is selected', () => {
beforeEach(async () => {
createComponentWithApollo({
- provide: {
- glFeatures: {
- pipelineGraphLayersView: true,
- },
- },
data: {
currentViewType: LAYER_VIEW,
},
@@ -334,14 +334,9 @@ describe('Pipeline graph wrapper', () => {
});
});
- describe('when pipelineGraphLayersView feature flag is on, layers view is selected, and links are active', () => {
+ describe('when layers view is selected, and links are active', () => {
beforeEach(async () => {
createComponentWithApollo({
- provide: {
- glFeatures: {
- pipelineGraphLayersView: true,
- },
- },
data: {
currentViewType: LAYER_VIEW,
showLinks: true,
@@ -362,11 +357,6 @@ describe('Pipeline graph wrapper', () => {
describe('when hover tip would otherwise show, but it has been previously dismissed', () => {
beforeEach(async () => {
createComponentWithApollo({
- provide: {
- glFeatures: {
- pipelineGraphLayersView: true,
- },
- },
data: {
currentViewType: LAYER_VIEW,
showLinks: true,
@@ -390,11 +380,6 @@ describe('Pipeline graph wrapper', () => {
localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW);
createComponentWithApollo({
- provide: {
- glFeatures: {
- pipelineGraphLayersView: true,
- },
- },
mountFn: mount,
});
@@ -422,11 +407,6 @@ describe('Pipeline graph wrapper', () => {
localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW);
createComponentWithApollo({
- provide: {
- glFeatures: {
- pipelineGraphLayersView: true,
- },
- },
mountFn: mount,
getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse),
});
@@ -450,11 +430,6 @@ describe('Pipeline graph wrapper', () => {
nonNeedsResponse.data.project.pipeline.usesNeeds = false;
createComponentWithApollo({
- provide: {
- glFeatures: {
- pipelineGraphLayersView: true,
- },
- },
mountFn: mount,
getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse),
});
@@ -468,4 +443,112 @@ describe('Pipeline graph wrapper', () => {
});
});
});
+
+ describe('performance metrics', () => {
+ const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json';
+ let markAndMeasure;
+ let reportToSentry;
+ let reportPerformance;
+ let mock;
+
+ beforeEach(() => {
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
+ markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure');
+ reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry');
+ reportPerformance = jest.spyOn(Api, 'reportPerformance');
+ });
+
+ describe('with no metrics path', () => {
+ beforeEach(async () => {
+ createComponentWithApollo();
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ });
+
+ it('is not called', () => {
+ expect(markAndMeasure).not.toHaveBeenCalled();
+ expect(reportToSentry).not.toHaveBeenCalled();
+ expect(reportPerformance).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with metrics path', () => {
+ const duration = 875;
+ const numLinks = 7;
+ const totalGroups = 8;
+ const metricsData = {
+ histograms: [
+ { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
+ { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks },
+ {
+ name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
+ value: numLinks / totalGroups,
+ },
+ ],
+ };
+
+ describe('when no duration is obtained', () => {
+ beforeEach(async () => {
+ jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
+ return [];
+ });
+
+ createComponentWithApollo({
+ provide: {
+ metricsPath,
+ glFeatures: {
+ pipelineGraphLayersView: true,
+ },
+ },
+ data: {
+ currentViewType: LAYER_VIEW,
+ },
+ });
+
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ });
+
+ it('attempts to collect metrics', () => {
+ expect(markAndMeasure).toHaveBeenCalled();
+ expect(reportPerformance).not.toHaveBeenCalled();
+ expect(reportToSentry).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with duration and no error', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onPost(metricsPath).reply(200, {});
+
+ jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
+ return [{ duration }];
+ });
+
+ createComponentWithApollo({
+ provide: {
+ metricsPath,
+ glFeatures: {
+ pipelineGraphLayersView: true,
+ },
+ },
+ data: {
+ currentViewType: LAYER_VIEW,
+ },
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('it calls reportPerformance with expected arguments', () => {
+ expect(markAndMeasure).toHaveBeenCalled();
+ expect(reportPerformance).toHaveBeenCalled();
+ expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData);
+ expect(reportToSentry).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js
deleted file mode 100644
index 200e3f48401..00000000000
--- a/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { UPSTREAM } from '~/pipelines/components/graph/constants';
-import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue';
-import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue';
-import mockData from './linked_pipelines_mock_data';
-
-describe('Linked Pipelines Column', () => {
- const propsData = {
- columnTitle: 'Upstream',
- linkedPipelines: mockData.triggered,
- graphPosition: 'right',
- projectId: 19,
- type: UPSTREAM,
- };
- let wrapper;
-
- beforeEach(() => {
- wrapper = shallowMount(LinkedPipelinesColumnLegacy, { propsData });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders the pipeline orientation', () => {
- const titleElement = wrapper.find('.linked-pipelines-column-title');
-
- expect(titleElement.text()).toBe(propsData.columnTitle);
- });
-
- it('renders the correct number of linked pipelines', () => {
- const linkedPipelineElements = wrapper.findAll(LinkedPipeline);
-
- expect(linkedPipelineElements.length).toBe(propsData.linkedPipelines.length);
- });
-
- it('renders cross project triangle when column is upstream', () => {
- expect(wrapper.find('.cross-project-triangle').exists()).toBe(true);
- });
-});
diff --git a/spec/frontend/pipelines/graph/mock_data_legacy.js b/spec/frontend/pipelines/graph/mock_data_legacy.js
deleted file mode 100644
index e1c8b027121..00000000000
--- a/spec/frontend/pipelines/graph/mock_data_legacy.js
+++ /dev/null
@@ -1,261 +0,0 @@
-export default {
- id: 123,
- user: {
- name: 'Root',
- username: 'root',
- id: 1,
- state: 'active',
- avatar_url: null,
- web_url: 'http://localhost:3000/root',
- },
- active: false,
- coverage: null,
- path: '/root/ci-mock/pipelines/123',
- details: {
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/pipelines/123',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- },
- duration: 9,
- finished_at: '2017-04-19T14:30:27.542Z',
- stages: [
- {
- name: 'test',
- title: 'test: passed',
- groups: [
- {
- name: 'test',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/builds/4153',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/root/ci-mock/builds/4153/retry',
- method: 'post',
- },
- },
- jobs: [
- {
- id: 4153,
- name: 'test',
- build_path: '/root/ci-mock/builds/4153',
- retry_path: '/root/ci-mock/builds/4153/retry',
- playable: false,
- created_at: '2017-04-13T09:25:18.959Z',
- updated_at: '2017-04-13T09:25:23.118Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/builds/4153',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/root/ci-mock/builds/4153/retry',
- method: 'post',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/pipelines/123#test',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- },
- path: '/root/ci-mock/pipelines/123#test',
- dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test',
- },
- {
- name: 'deploy <img src=x onerror=alert(document.domain)>',
- title: 'deploy: passed',
- groups: [
- {
- name: 'deploy to production',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/builds/4166',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/root/ci-mock/builds/4166/retry',
- method: 'post',
- },
- },
- jobs: [
- {
- id: 4166,
- name: 'deploy to production',
- build_path: '/root/ci-mock/builds/4166',
- retry_path: '/root/ci-mock/builds/4166/retry',
- playable: false,
- created_at: '2017-04-19T14:29:46.463Z',
- updated_at: '2017-04-19T14:30:27.498Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/builds/4166',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/root/ci-mock/builds/4166/retry',
- method: 'post',
- },
- },
- },
- ],
- },
- {
- name: 'deploy to staging',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/builds/4159',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/root/ci-mock/builds/4159/retry',
- method: 'post',
- },
- },
- jobs: [
- {
- id: 4159,
- name: 'deploy to staging',
- build_path: '/root/ci-mock/builds/4159',
- retry_path: '/root/ci-mock/builds/4159/retry',
- playable: false,
- created_at: '2017-04-18T16:32:08.420Z',
- updated_at: '2017-04-18T16:32:12.631Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/builds/4159',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/root/ci-mock/builds/4159/retry',
- method: 'post',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/root/ci-mock/pipelines/123#deploy',
- favicon:
- '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
- },
- path: '/root/ci-mock/pipelines/123#deploy',
- dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy',
- },
- ],
- artifacts: [],
- manual_actions: [
- {
- name: 'deploy to production',
- path: '/root/ci-mock/builds/4166/play',
- playable: false,
- },
- ],
- },
- flags: {
- latest: true,
- triggered: false,
- stuck: false,
- yaml_errors: false,
- retryable: false,
- cancelable: false,
- },
- ref: {
- name: 'main',
- path: '/root/ci-mock/tree/main',
- tag: false,
- branch: true,
- },
- commit: {
- id: '798e5f902592192afaba73f4668ae30e56eae492',
- short_id: '798e5f90',
- title: "Merge branch 'new-branch' into 'main'\r",
- created_at: '2017-04-13T10:25:17.000+01:00',
- parent_ids: [
- '54d483b1ed156fbbf618886ddf7ab023e24f8738',
- 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc',
- ],
- message:
- "Merge branch 'new-branch' into 'main'\r\n\r\nAdd new file\r\n\r\nSee merge request !1",
- author_name: 'Root',
- author_email: 'admin@example.com',
- authored_date: '2017-04-13T10:25:17.000+01:00',
- committer_name: 'Root',
- committer_email: 'admin@example.com',
- committed_date: '2017-04-13T10:25:17.000+01:00',
- author: {
- name: 'Root',
- username: 'root',
- id: 1,
- state: 'active',
- avatar_url: null,
- web_url: 'http://localhost:3000/root',
- },
- author_gravatar_url: null,
- commit_url:
- 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492',
- commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492',
- },
- created_at: '2017-04-13T09:25:18.881Z',
- updated_at: '2017-04-19T14:30:27.561Z',
-};
diff --git a/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js b/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js
deleted file mode 100644
index 2965325ea7c..00000000000
--- a/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue';
-
-describe('stage column component', () => {
- const mockJob = {
- id: 4250,
- name: 'test',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- details_path: '/root/ci-mock/builds/4250',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/root/ci-mock/builds/4250/retry',
- method: 'post',
- },
- },
- };
-
- let wrapper;
-
- beforeEach(() => {
- const mockGroups = [];
- for (let i = 0; i < 3; i += 1) {
- const mockedJob = { ...mockJob };
- mockedJob.id += i;
- mockGroups.push(mockedJob);
- }
-
- wrapper = shallowMount(StageColumnComponentLegacy, {
- propsData: {
- title: 'foo',
- groups: mockGroups,
- hasTriggeredBy: false,
- },
- });
- });
-
- it('should render provided title', () => {
- expect(wrapper.find('.stage-name').text().trim()).toBe('foo');
- });
-
- it('should render the provided groups', () => {
- expect(wrapper.findAll('.builds-container > ul > li').length).toBe(
- wrapper.props('groups').length,
- );
- });
-
- describe('jobId', () => {
- it('escapes job name', () => {
- wrapper = shallowMount(StageColumnComponentLegacy, {
- propsData: {
- groups: [
- {
- id: 4259,
- name: '<img src=x onerror=alert(document.domain)>',
- status: {
- icon: 'status_success',
- label: 'success',
- tooltip: '<img src=x onerror=alert(document.domain)>',
- },
- },
- ],
- title: 'test',
- hasTriggeredBy: false,
- },
- });
-
- expect(wrapper.find('.builds-container li').attributes('id')).toBe(
- 'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;',
- );
- });
- });
-
- describe('with action', () => {
- it('renders action button', () => {
- wrapper = shallowMount(StageColumnComponentLegacy, {
- propsData: {
- groups: [
- {
- id: 4259,
- name: '<img src=x onerror=alert(document.domain)>',
- status: {
- icon: 'status_success',
- label: 'success',
- tooltip: '<img src=x onerror=alert(document.domain)>',
- },
- },
- ],
- title: 'test',
- hasTriggeredBy: false,
- action: {
- icon: 'play',
- title: 'Play all',
- path: 'action',
- },
- },
- });
-
- expect(wrapper.find('.js-stage-action').exists()).toBe(true);
- });
- });
-
- describe('without action', () => {
- it('does not render action button', () => {
- wrapper = shallowMount(StageColumnComponentLegacy, {
- propsData: {
- groups: [
- {
- id: 4259,
- name: '<img src=x onerror=alert(document.domain)>',
- status: {
- icon: 'status_success',
- label: 'success',
- tooltip: '<img src=x onerror=alert(document.domain)>',
- },
- },
- ],
- title: 'test',
- hasTriggeredBy: false,
- },
- });
-
- expect(wrapper.find('.js-stage-action').exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js
index 8f39c8c2405..be422fac92c 100644
--- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js
+++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js
@@ -31,7 +31,7 @@ describe('Links Inner component', () => {
propsData: {
...defaultProps,
...props,
- parsedData: parseData(currentPipelineData.flatMap(({ groups }) => groups)),
+ linksData: parseData(currentPipelineData.flatMap(({ groups }) => groups)).links,
},
});
};
diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js
index 932a19f2f00..44ab60cbee7 100644
--- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js
+++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js
@@ -1,16 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
-import {
- PIPELINES_DETAIL_LINK_DURATION,
- PIPELINES_DETAIL_LINKS_TOTAL,
- PIPELINES_DETAIL_LINKS_JOB_RATIO,
-} from '~/performance/constants';
-import * as perfUtils from '~/performance/utils';
-import * as Api from '~/pipelines/components/graph_shared/api';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
-import * as sentryUtils from '~/pipelines/utils';
import { generateResponse, mockPipelineResponse } from '../graph/mock_data';
describe('links layer component', () => {
@@ -94,139 +84,4 @@ describe('links layer component', () => {
expect(findLinksInner().exists()).toBe(false);
});
});
-
- describe('performance metrics', () => {
- const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json';
- let markAndMeasure;
- let reportToSentry;
- let reportPerformance;
- let mock;
-
- beforeEach(() => {
- jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
- markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure');
- reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry');
- reportPerformance = jest.spyOn(Api, 'reportPerformance');
- });
-
- describe('with no metrics config object', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('is not called', () => {
- expect(markAndMeasure).not.toHaveBeenCalled();
- expect(reportToSentry).not.toHaveBeenCalled();
- expect(reportPerformance).not.toHaveBeenCalled();
- });
- });
-
- describe('with metrics config set to false', () => {
- beforeEach(() => {
- createComponent({
- props: {
- metricsConfig: {
- collectMetrics: false,
- metricsPath: '/path/to/metrics',
- },
- },
- });
- });
-
- it('is not called', () => {
- expect(markAndMeasure).not.toHaveBeenCalled();
- expect(reportToSentry).not.toHaveBeenCalled();
- expect(reportPerformance).not.toHaveBeenCalled();
- });
- });
-
- describe('with no metrics path', () => {
- beforeEach(() => {
- createComponent({
- props: {
- metricsConfig: {
- collectMetrics: true,
- metricsPath: '',
- },
- },
- });
- });
-
- it('is not called', () => {
- expect(markAndMeasure).not.toHaveBeenCalled();
- expect(reportToSentry).not.toHaveBeenCalled();
- expect(reportPerformance).not.toHaveBeenCalled();
- });
- });
-
- describe('with metrics path and collect set to true', () => {
- const duration = 875;
- const numLinks = 7;
- const totalGroups = 8;
- const metricsData = {
- histograms: [
- { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
- { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks },
- {
- name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
- value: numLinks / totalGroups,
- },
- ],
- };
-
- describe('when no duration is obtained', () => {
- beforeEach(() => {
- jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
- return [];
- });
-
- createComponent({
- props: {
- metricsConfig: {
- collectMetrics: true,
- path: metricsPath,
- },
- },
- });
- });
-
- it('attempts to collect metrics', () => {
- expect(markAndMeasure).toHaveBeenCalled();
- expect(reportPerformance).not.toHaveBeenCalled();
- expect(reportToSentry).not.toHaveBeenCalled();
- });
- });
-
- describe('with duration and no error', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onPost(metricsPath).reply(200, {});
-
- jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
- return [{ duration }];
- });
-
- createComponent({
- props: {
- metricsConfig: {
- collectMetrics: true,
- path: metricsPath,
- },
- },
- });
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('it calls reportPerformance with expected arguments', () => {
- expect(markAndMeasure).toHaveBeenCalled();
- expect(reportPerformance).toHaveBeenCalled();
- expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData);
- expect(reportToSentry).not.toHaveBeenCalled();
- });
- });
- });
- });
});
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index 31f0e72c279..e531e26a858 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -99,24 +99,6 @@ describe('Pipeline details header', () => {
);
});
- describe('polling', () => {
- it('is stopped when pipeline is finished', async () => {
- wrapper = createComponent({ ...mockRunningPipelineHeader });
-
- await wrapper.setData({
- pipeline: { ...mockCancelledPipelineHeader },
- });
-
- expect(wrapper.vm.$apollo.queries.pipeline.stopPolling).toHaveBeenCalled();
- });
-
- it('is not stopped when pipeline is not finished', () => {
- wrapper = createComponent();
-
- expect(wrapper.vm.$apollo.queries.pipeline.stopPolling).not.toHaveBeenCalled();
- });
- });
-
describe('actions', () => {
describe('Retry action', () => {
beforeEach(() => {
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
index 7e3c3727c9d..fdc78d48901 100644
--- a/spec/frontend/pipelines/mock_data.js
+++ b/spec/frontend/pipelines/mock_data.js
@@ -127,6 +127,28 @@ export const mockSuccessfulPipelineHeader = {
},
};
+export const mockRunningPipelineHeaderData = {
+ data: {
+ project: {
+ pipeline: {
+ ...mockRunningPipelineHeader,
+ iid: '28',
+ user: {
+ name: 'Foo',
+ username: 'foobar',
+ webPath: '/foo',
+ email: 'foo@bar.com',
+ avatarUrl: 'link',
+ status: null,
+ __typename: 'UserCore',
+ },
+ __typename: 'Pipeline',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
export const stageReply = {
name: 'deploy',
title: 'deploy: running',
diff --git a/spec/frontend/pipelines/parsing_utils_spec.js b/spec/frontend/pipelines/parsing_utils_spec.js
index 074009ae056..3a270c1c1b5 100644
--- a/spec/frontend/pipelines/parsing_utils_spec.js
+++ b/spec/frontend/pipelines/parsing_utils_spec.js
@@ -120,8 +120,8 @@ describe('DAG visualization parsing utilities', () => {
describe('generateColumnsFromLayersList', () => {
const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo');
- const layers = listByLayers(pipeline);
- const columns = generateColumnsFromLayersListBare(pipeline, layers);
+ const { pipelineLayers } = listByLayers(pipeline);
+ const columns = generateColumnsFromLayersListBare(pipeline, pipelineLayers);
it('returns stage-like objects with default name, id, and status', () => {
columns.forEach((col, idx) => {
@@ -136,7 +136,7 @@ describe('DAG visualization parsing utilities', () => {
it('creates groups that match the list created in listByLayers', () => {
columns.forEach((col, idx) => {
const groupNames = col.groups.map(({ name }) => name);
- expect(groupNames).toEqual(layers[idx]);
+ expect(groupNames).toEqual(pipelineLayers[idx]);
});
});
diff --git a/spec/frontend/pipelines/pipeline_details_mediator_spec.js b/spec/frontend/pipelines/pipeline_details_mediator_spec.js
deleted file mode 100644
index d6699a43b54..00000000000
--- a/spec/frontend/pipelines/pipeline_details_mediator_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import waitForPromises from 'helpers/wait_for_promises';
-import axios from '~/lib/utils/axios_utils';
-import PipelineMediator from '~/pipelines/pipeline_details_mediator';
-
-describe('PipelineMdediator', () => {
- let mediator;
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mediator = new PipelineMediator({ endpoint: 'foo.json' });
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should set defaults', () => {
- expect(mediator.options).toEqual({ endpoint: 'foo.json' });
- expect(mediator.state.isLoading).toEqual(false);
- expect(mediator.store).toBeDefined();
- expect(mediator.service).toBeDefined();
- });
-
- describe('request and store data', () => {
- it('should store received data', () => {
- mock.onGet('foo.json').reply(200, { id: '121123' });
- mediator.fetchPipeline();
-
- return waitForPromises().then(() => {
- expect(mediator.store.state.pipeline).toEqual({ id: '121123' });
- });
- });
- });
-});
diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js
index 88b3ef2032a..ce33b6011bf 100644
--- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js
+++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js
@@ -53,6 +53,7 @@ describe('Pipeline Multi Actions Dropdown', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId);
const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId);
+ const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message');
beforeEach(() => {
mockAxios = new MockAdapter(axios);
@@ -86,6 +87,7 @@ describe('Pipeline Multi Actions Dropdown', () => {
createComponent({ mockData: { artifacts } });
expect(findAllArtifactItems()).toHaveLength(artifacts.length);
+ expect(findEmptyMessage().exists()).toBe(false);
});
it('should render the correct artifact name and path', () => {
@@ -95,6 +97,12 @@ describe('Pipeline Multi Actions Dropdown', () => {
expect(findFirstArtifactItem().text()).toBe(`Download ${artifacts[0].name} artifact`);
});
+ it('should render empty message when no artifacts are found', () => {
+ createComponent({ mockData: { artifacts: [] } });
+
+ expect(findEmptyMessage().exists()).toBe(true);
+ });
+
describe('with a failing request', () => {
it('should render an error message', async () => {
const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
diff --git a/spec/frontend/pipelines/pipeline_store_spec.js b/spec/frontend/pipelines/pipeline_store_spec.js
deleted file mode 100644
index 1d5754d1f05..00000000000
--- a/spec/frontend/pipelines/pipeline_store_spec.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import PipelineStore from '~/pipelines/stores/pipeline_store';
-
-describe('Pipeline Store', () => {
- let store;
-
- beforeEach(() => {
- store = new PipelineStore();
- });
-
- it('should set defaults', () => {
- expect(store.state.pipeline).toEqual({});
- });
-
- describe('storePipeline', () => {
- it('should store empty object if none is provided', () => {
- store.storePipeline();
-
- expect(store.state.pipeline).toEqual({});
- });
-
- it('should store received object', () => {
- store.storePipeline({ foo: 'bar' });
-
- expect(store.state.pipeline).toEqual({ foo: 'bar' });
- });
- });
-});
diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js
index 367c7f2b2f6..912b5afe0e1 100644
--- a/spec/frontend/pipelines/pipeline_url_spec.js
+++ b/spec/frontend/pipelines/pipeline_url_spec.js
@@ -28,6 +28,7 @@ describe('Pipeline Url Component', () => {
flags: {},
},
pipelineScheduleUrl: 'foo',
+ pipelineKey: 'id',
};
const createComponent = (props) => {
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index 2166961cedd..76feaaad1ec 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -4,6 +4,8 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { chunk } from 'lodash';
import { nextTick } from 'vue';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
@@ -40,7 +42,6 @@ const mockPipelineWithStages = mockPipelinesResponse.pipelines.find(
describe('Pipelines', () => {
let wrapper;
let mock;
- let origWindowLocation;
const paths = {
emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
@@ -73,6 +74,7 @@ describe('Pipelines', () => {
const findTablePagination = () => wrapper.findComponent(TablePagination);
const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`);
+ const findPipelineKeyDropdown = () => wrapper.findByTestId('pipeline-key-dropdown');
const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button');
const findCiLintButton = () => wrapper.findByTestId('ci-lint-button');
const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button');
@@ -98,20 +100,13 @@ describe('Pipelines', () => {
);
};
- beforeAll(() => {
- origWindowLocation = window.location;
- delete window.location;
- window.location = {
- search: '',
- protocol: 'https:',
- };
- });
-
- afterAll(() => {
- window.location = origWindowLocation;
+ beforeEach(() => {
+ setWindowLocation(TEST_HOST);
});
beforeEach(() => {
+ window.gon = { features: { pipelineSourceFilter: true } };
+
mock = new MockAdapter(axios);
jest.spyOn(window.history, 'pushState');
@@ -536,6 +531,10 @@ describe('Pipelines', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
+ it('renders the pipeline key dropdown', () => {
+ expect(findPipelineKeyDropdown().exists()).toBe(true);
+ });
+
it('renders tab empty state finished scope', async () => {
mock.onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }).reply(200, {
pipelines: [],
@@ -631,6 +630,10 @@ describe('Pipelines', () => {
expect(findFilteredSearch().exists()).toBe(false);
});
+ it('does not render the pipeline key dropdown', () => {
+ expect(findPipelineKeyDropdown().exists()).toBe(false);
+ });
+
it('does not render tabs nor buttons', () => {
expect(findNavigationTabs().exists()).toBe(false);
expect(findTab('all').exists()).toBe(false);
diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js
index 68b0dfc018e..4472a5ae70d 100644
--- a/spec/frontend/pipelines/pipelines_table_spec.js
+++ b/spec/frontend/pipelines/pipelines_table_spec.js
@@ -8,6 +8,7 @@ import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_tr
import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue';
import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue';
+import { PipelineKeyOptions } from '~/pipelines/constants';
import eventHub from '~/pipelines/event_hub';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
@@ -24,6 +25,7 @@ describe('Pipelines Table', () => {
const defaultProps = {
pipelines: [],
viewType: 'root',
+ pipelineKeyOption: PipelineKeyOptions[0],
};
const createMockPipeline = () => {
@@ -80,7 +82,7 @@ describe('Pipelines Table', () => {
it('should render table head with correct columns', () => {
expect(findStatusTh().text()).toBe('Status');
- expect(findPipelineTh().text()).toBe('Pipeline');
+ expect(findPipelineTh().text()).toBe('Pipeline ID');
expect(findTriggererTh().text()).toBe('Triggerer');
expect(findCommitTh().text()).toBe('Commit');
expect(findStagesTh().text()).toBe('Stages');
diff --git a/spec/frontend/pipelines/stores/pipeline_store_spec.js b/spec/frontend/pipelines/stores/pipeline_store_spec.js
deleted file mode 100644
index 2daf7e4b324..00000000000
--- a/spec/frontend/pipelines/stores/pipeline_store_spec.js
+++ /dev/null
@@ -1,135 +0,0 @@
-import PipelineStore from '~/pipelines/stores/pipeline_store';
-import LinkedPipelines from '../linked_pipelines_mock.json';
-
-describe('EE Pipeline store', () => {
- let store;
- let data;
-
- beforeEach(() => {
- store = new PipelineStore();
- data = { ...LinkedPipelines };
-
- store.storePipeline(data);
- });
-
- describe('storePipeline', () => {
- describe('triggered_by', () => {
- it('sets triggered_by as an array', () => {
- expect(store.state.pipeline.triggered_by.length).toEqual(1);
- });
-
- it('adds isExpanding & isLoading keys set to false', () => {
- expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
- expect(store.state.pipeline.triggered_by[0].isLoading).toEqual(false);
- });
-
- it('parses nested triggered_by', () => {
- expect(store.state.pipeline.triggered_by[0].triggered_by.length).toEqual(1);
- expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
- expect(store.state.pipeline.triggered_by[0].triggered_by[0].isLoading).toEqual(false);
- });
- });
-
- describe('triggered', () => {
- it('adds isExpanding & isLoading keys set to false for each triggered pipeline', () => {
- store.state.pipeline.triggered.forEach((pipeline) => {
- expect(pipeline.isExpanded).toEqual(false);
- expect(pipeline.isLoading).toEqual(false);
- });
- });
-
- it('parses nested triggered pipelines', () => {
- store.state.pipeline.triggered[1].triggered.forEach((pipeline) => {
- expect(pipeline.isExpanded).toEqual(false);
- expect(pipeline.isLoading).toEqual(false);
- });
- });
- });
- });
-
- describe('resetTriggeredByPipeline', () => {
- it('closes the pipeline & nested ones', () => {
- store.state.pipeline.triggered_by[0].isExpanded = true;
- store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded = true;
-
- store.resetTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
-
- expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
- expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
- });
- });
-
- describe('openTriggeredByPipeline', () => {
- it('opens the given pipeline', () => {
- store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
-
- expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(true);
- });
- });
-
- describe('closeTriggeredByPipeline', () => {
- it('closes the given pipeline', () => {
- // open it first
- store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
-
- store.closeTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
-
- expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
- });
- });
-
- describe('resetTriggeredPipelines', () => {
- it('closes the pipeline & nested ones', () => {
- store.state.pipeline.triggered[0].isExpanded = true;
- store.state.pipeline.triggered[0].triggered[0].isExpanded = true;
-
- store.resetTriggeredPipelines(store.state.pipeline, store.state.pipeline.triggered[0]);
-
- expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
- expect(store.state.pipeline.triggered[0].triggered[0].isExpanded).toEqual(false);
- });
- });
-
- describe('openTriggeredPipeline', () => {
- it('opens the given pipeline', () => {
- store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
-
- expect(store.state.pipeline.triggered[0].isExpanded).toEqual(true);
- });
- });
-
- describe('closeTriggeredPipeline', () => {
- it('closes the given pipeline', () => {
- // open it first
- store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
-
- store.closeTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
-
- expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
- });
- });
-
- describe('toggleLoading', () => {
- it('toggles the isLoading property for the given pipeline', () => {
- store.toggleLoading(store.state.pipeline.triggered[0]);
-
- expect(store.state.pipeline.triggered[0].isLoading).toEqual(true);
- });
- });
-
- describe('addExpandedPipelineToRequestData', () => {
- it('pushes the given id to expandedPipelines array', () => {
- store.addExpandedPipelineToRequestData('213231');
-
- expect(store.state.expandedPipelines).toEqual(['213231']);
- });
- });
-
- describe('removeExpandedPipelineToRequestData', () => {
- it('pushes the given id to expandedPipelines array', () => {
- store.removeExpandedPipelineToRequestData('213231');
-
- expect(store.state.expandedPipelines).toEqual([]);
- });
- });
-});
diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js
new file mode 100644
index 00000000000..5d15f0a3c55
--- /dev/null
+++ b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js
@@ -0,0 +1,50 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
+import PipelineSourceToken from '~/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue';
+
+describe('Pipeline Source Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
+
+ const defaultProps = {
+ config: {
+ type: 'source',
+ icon: 'trigger-source',
+ title: 'Source',
+ unique: true,
+ },
+ value: {
+ data: '',
+ },
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineSourceToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ stubs: {
+ GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ }),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ describe('shows sources correctly', () => {
+ it('renders all pipeline sources available', () => {
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.sources.length);
+ });
+ });
+});
diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js
index 0c164d97564..25c509346d1 100644
--- a/spec/frontend/popovers/components/popovers_spec.js
+++ b/spec/frontend/popovers/components/popovers_spec.js
@@ -4,7 +4,7 @@ import { useMockMutationObserver } from 'helpers/mock_dom_observer';
import Popovers from '~/popovers/components/popovers.vue';
describe('popovers/components/popovers.vue', () => {
- const { trigger: triggerMutate, observersCount } = useMockMutationObserver();
+ const { trigger: triggerMutate } = useMockMutationObserver();
let wrapper;
const buildWrapper = (...targets) => {
@@ -120,10 +120,13 @@ describe('popovers/components/popovers.vue', () => {
it('disconnects mutation observer on beforeDestroy', async () => {
await buildWrapper(createPopoverTarget());
+ const { observer } = wrapper.vm;
+ jest.spyOn(observer, 'disconnect');
- expect(observersCount()).toBe(1);
+ expect(observer.disconnect).toHaveBeenCalledTimes(0);
wrapper.destroy();
- expect(observersCount()).toBe(0);
+
+ expect(observer.disconnect).toHaveBeenCalledTimes(1);
});
});
diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
index f1172a73d36..4d2dcf83d3b 100644
--- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js
+++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
@@ -1,6 +1,7 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import createFlash from '~/flash';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
@@ -19,6 +20,8 @@ import {
jest.mock('~/flash');
const expectedUrl = '/foo';
+useMockLocationHelper();
+
describe('ProfilePreferences component', () => {
let wrapper;
const defaultProvide = {
@@ -174,8 +177,6 @@ describe('ProfilePreferences component', () => {
});
describe('theme changes', () => {
- const { location } = window;
-
let themeInput;
let form;
@@ -197,18 +198,6 @@ describe('ProfilePreferences component', () => {
form.dispatchEvent(successEvent);
}
- beforeAll(() => {
- delete window.location;
- window.location = {
- ...location,
- reload: jest.fn(),
- };
- });
-
- afterAll(() => {
- window.location = location;
- });
-
beforeEach(() => {
setupBody();
themeInput = createThemeInput();
diff --git a/spec/frontend/projects/compare/components/app_legacy_spec.js b/spec/frontend/projects/compare/components/app_legacy_spec.js
deleted file mode 100644
index 6fdf4014575..00000000000
--- a/spec/frontend/projects/compare/components/app_legacy_spec.js
+++ /dev/null
@@ -1,159 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import CompareApp from '~/projects/compare/components/app_legacy.vue';
-import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue';
-
-jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
-
-const projectCompareIndexPath = 'some/path';
-const refsProjectPath = 'some/refs/path';
-const paramsFrom = 'main';
-const paramsTo = 'some-other-branch';
-
-describe('CompareApp component', () => {
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(CompareApp, {
- propsData: {
- projectCompareIndexPath,
- refsProjectPath,
- paramsFrom,
- paramsTo,
- projectMergeRequestPath: '',
- createMrPath: '',
- ...props,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- beforeEach(() => {
- createComponent();
- });
-
- const findSourceDropdown = () => wrapper.find('[data-testid="sourceRevisionDropdown"]');
- const findTargetDropdown = () => wrapper.find('[data-testid="targetRevisionDropdown"]');
-
- it('renders component with prop', () => {
- expect(wrapper.props()).toEqual(
- expect.objectContaining({
- projectCompareIndexPath,
- refsProjectPath,
- paramsFrom,
- paramsTo,
- }),
- );
- });
-
- it('contains the correct form attributes', () => {
- expect(wrapper.attributes('action')).toBe(projectCompareIndexPath);
- expect(wrapper.attributes('method')).toBe('POST');
- });
-
- it('has input with csrf token', () => {
- expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe(
- 'mock-csrf-token',
- );
- });
-
- it('has ellipsis', () => {
- expect(wrapper.find('[data-testid="ellipsis"]').exists()).toBe(true);
- });
-
- describe('Source and Target BranchDropdown components', () => {
- const findAllBranchDropdowns = () => wrapper.findAll(RevisionDropdown);
-
- it('renders the components with the correct props', () => {
- expect(findAllBranchDropdowns().length).toBe(2);
- expect(findSourceDropdown().props('revisionText')).toBe('Source');
- expect(findTargetDropdown().props('revisionText')).toBe('Target');
- });
-
- it('sets the revision when the "selectRevision" event is emitted', async () => {
- findSourceDropdown().vm.$emit('selectRevision', {
- direction: 'to',
- revision: 'some-source-revision',
- });
-
- findTargetDropdown().vm.$emit('selectRevision', {
- direction: 'from',
- revision: 'some-target-revision',
- });
-
- await wrapper.vm.$nextTick();
-
- expect(findTargetDropdown().props('paramsBranch')).toBe('some-target-revision');
- expect(findSourceDropdown().props('paramsBranch')).toBe('some-source-revision');
- });
- });
-
- describe('compare button', () => {
- const findCompareButton = () => wrapper.find(GlButton);
-
- it('renders button', () => {
- expect(findCompareButton().exists()).toBe(true);
- });
-
- it('submits form', () => {
- findCompareButton().vm.$emit('click');
- expect(wrapper.find('form').element.submit).toHaveBeenCalled();
- });
-
- it('has compare text', () => {
- expect(findCompareButton().text()).toBe('Compare');
- });
- });
-
- describe('swap revisions button', () => {
- const findSwapRevisionsButton = () => wrapper.find('[data-testid="swapRevisionsButton"]');
-
- it('renders the swap revisions button', () => {
- expect(findSwapRevisionsButton().exists()).toBe(true);
- });
-
- it('has the correct text', () => {
- expect(findSwapRevisionsButton().text()).toBe('Swap revisions');
- });
-
- it('swaps revisions when clicked', async () => {
- findSwapRevisionsButton().vm.$emit('click');
-
- await wrapper.vm.$nextTick();
-
- expect(findTargetDropdown().props('paramsBranch')).toBe(paramsTo);
- expect(findSourceDropdown().props('paramsBranch')).toBe(paramsFrom);
- });
- });
-
- describe('merge request buttons', () => {
- const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]');
- const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]');
-
- it('does not have merge request buttons', () => {
- createComponent();
- expect(findProjectMrButton().exists()).toBe(false);
- expect(findCreateMrButton().exists()).toBe(false);
- });
-
- it('has "View open merge request" button', () => {
- createComponent({
- projectMergeRequestPath: 'some/project/merge/request/path',
- });
- expect(findProjectMrButton().exists()).toBe(true);
- expect(findCreateMrButton().exists()).toBe(false);
- });
-
- it('has "Create merge request" button', () => {
- createComponent({
- createMrPath: 'some/create/create/mr/path',
- });
- expect(findProjectMrButton().exists()).toBe(false);
- expect(findCreateMrButton().exists()).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
index be34b207c4b..71c22998b08 100644
--- a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
+++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
@@ -5,19 +5,21 @@ import TerraformNotification from '~/projects/terraform_notification/components/
jest.mock('~/lib/utils/common_utils');
-const bannerDissmisedKey = 'terraform_notification_dismissed_for_project_1';
+const terraformImagePath = '/path/to/image';
+const bannerDismissedKey = 'terraform_notification_dismissed';
describe('TerraformNotificationBanner', () => {
let wrapper;
- const propsData = {
- projectId: 1,
+ const provideData = {
+ terraformImagePath,
+ bannerDismissedKey,
};
const findBanner = () => wrapper.findComponent(GlBanner);
beforeEach(() => {
wrapper = shallowMount(TerraformNotification, {
- propsData,
+ provide: provideData,
stubs: { GlBanner },
});
});
@@ -27,19 +29,6 @@ describe('TerraformNotificationBanner', () => {
parseBoolean.mockReturnValue(false);
});
- describe('when the dismiss cookie is set', () => {
- beforeEach(() => {
- parseBoolean.mockReturnValue(true);
- wrapper = shallowMount(TerraformNotification, {
- propsData,
- });
- });
-
- it('should not render the banner', () => {
- expect(findBanner().exists()).toBe(false);
- });
- });
-
describe('when the dismiss cookie is not set', () => {
it('should render the banner', () => {
expect(findBanner().exists()).toBe(true);
@@ -51,8 +40,8 @@ describe('TerraformNotificationBanner', () => {
await findBanner().vm.$emit('close');
});
- it('should set the cookie with the bannerDissmisedKey', () => {
- expect(setCookie).toHaveBeenCalledWith(bannerDissmisedKey, true);
+ it('should set the cookie with the bannerDismissedKey', () => {
+ expect(setCookie).toHaveBeenCalledWith(bannerDismissedKey, true);
});
it('should remove the banner', () => {
diff --git a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js
index 8fe659694ba..d2fe5af3a94 100644
--- a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js
@@ -1,5 +1,6 @@
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import component from '~/registry/explorer/components/details_page/delete_modal.vue';
import {
REMOVE_TAG_CONFIRMATION_TEXT,
@@ -12,8 +13,9 @@ import { GlModal } from '../../stubs';
describe('Delete Modal', () => {
let wrapper;
- const findModal = () => wrapper.find(GlModal);
+ const findModal = () => wrapper.findComponent(GlModal);
const findDescription = () => wrapper.find('[data-testid="description"]');
+ const findInputComponent = () => wrapper.findComponent(GlFormInput);
const mountComponent = (propsData) => {
wrapper = shallowMount(component, {
@@ -25,6 +27,13 @@ describe('Delete Modal', () => {
});
};
+ const expectPrimaryActionStatus = (disabled = true) =>
+ expect(findModal().props('actionPrimary')).toMatchObject(
+ expect.objectContaining({
+ attributes: [{ variant: 'danger' }, { disabled }],
+ }),
+ );
+
afterEach(() => {
wrapper.destroy();
wrapper = null;
@@ -65,11 +74,49 @@ describe('Delete Modal', () => {
it('has the correct description', () => {
mountComponent({ deleteImage: true });
- expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TEXT);
+ expect(wrapper.text()).toContain(
+ DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(),
+ );
+ });
+
+ describe('delete button', () => {
+ const itemsToBeDeleted = [{ project: { path: 'foo' } }];
+
+ it('is disabled by default', () => {
+ mountComponent({ deleteImage: true });
+
+ expectPrimaryActionStatus();
+ });
+
+ it('if the user types something different from the project path is disabled', async () => {
+ mountComponent({ deleteImage: true, itemsToBeDeleted });
+
+ findInputComponent().vm.$emit('input', 'bar');
+
+ await nextTick();
+
+ expectPrimaryActionStatus();
+ });
+
+ it('if the user types the project path it is enabled', async () => {
+ mountComponent({ deleteImage: true, itemsToBeDeleted });
+
+ findInputComponent().vm.$emit('input', 'foo');
+
+ await nextTick();
+
+ expectPrimaryActionStatus(false);
+ });
});
});
describe('when we are deleting tags', () => {
+ it('delete button is enabled', () => {
+ mountComponent();
+
+ expectPrimaryActionStatus(false);
+ });
+
describe('itemsToBeDeleted contains one element', () => {
beforeEach(() => {
mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] });
diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
index 632f506f4ae..acff5c21940 100644
--- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
@@ -1,10 +1,11 @@
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlDropdownItem, GlIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises';
+import { GlDropdown } from 'jest/registry/explorer/stubs';
import component from '~/registry/explorer/components/details_page/details_header.vue';
import {
UNSCHEDULED_STATUS,
@@ -48,8 +49,8 @@ describe('Details Header', () => {
const findTitle = () => findByTestId('title');
const findTagsCount = () => findByTestId('tags-count');
const findCleanup = () => findByTestId('cleanup');
- const findDeleteButton = () => wrapper.find(GlButton);
- const findInfoIcon = () => wrapper.find(GlIcon);
+ const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
+ const findInfoIcon = () => wrapper.findComponent(GlIcon);
const waitForMetadataItems = async () => {
// Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available
@@ -84,6 +85,8 @@ describe('Details Header', () => {
mocks,
stubs: {
TitleArea,
+ GlDropdown,
+ GlDropdownItem,
},
});
};
@@ -152,10 +155,11 @@ describe('Details Header', () => {
it('has the correct props', () => {
mountComponent();
- expect(findDeleteButton().props()).toMatchObject({
- variant: 'danger',
- disabled: false,
- });
+ expect(findDeleteButton().attributes()).toMatchObject(
+ expect.objectContaining({
+ variant: 'danger',
+ }),
+ );
});
it('emits the correct event', () => {
@@ -168,16 +172,16 @@ describe('Details Header', () => {
it.each`
canDelete | disabled | isDisabled
- ${true} | ${false} | ${false}
- ${true} | ${true} | ${true}
- ${false} | ${false} | ${true}
- ${false} | ${true} | ${true}
+ ${true} | ${false} | ${undefined}
+ ${true} | ${true} | ${'true'}
+ ${false} | ${false} | ${'true'}
+ ${false} | ${true} | ${'true'}
`(
'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled',
({ canDelete, disabled, isDisabled }) => {
mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } });
- expect(findDeleteButton().props('disabled')).toBe(isDisabled);
+ expect(findDeleteButton().attributes('disabled')).toBe(isDisabled);
},
);
});
diff --git a/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js
index c89bb874a7f..8f2c049a357 100644
--- a/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js
@@ -2,7 +2,7 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CleanupStatus from '~/registry/explorer/components/list_page/cleanup_status.vue';
import {
- ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ CLEANUP_TIMED_OUT_ERROR_MESSAGE,
CLEANUP_STATUS_SCHEDULED,
CLEANUP_STATUS_ONGOING,
CLEANUP_STATUS_UNFINISHED,
@@ -81,7 +81,7 @@ describe('cleanup_status', () => {
const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip');
- expect(tooltip.value.title).toBe(ASYNC_DELETE_IMAGE_ERROR_MESSAGE);
+ expect(tooltip.value.title).toBe(CLEANUP_TIMED_OUT_ERROR_MESSAGE);
});
});
});
diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js
index 27246cf2364..6a835a28807 100644
--- a/spec/frontend/registry/explorer/mock_data.js
+++ b/spec/frontend/registry/explorer/mock_data.js
@@ -119,6 +119,7 @@ export const containerRepositoryMock = {
expirationPolicyCleanupStatus: 'UNSCHEDULED',
project: {
visibility: 'public',
+ path: 'gitlab-test',
containerExpirationPolicy: {
enabled: false,
nextRunAt: '2020-11-27T08:59:27Z',
diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js
index 022f6e71fe6..21af9dcc60f 100644
--- a/spec/frontend/registry/explorer/pages/details_spec.js
+++ b/spec/frontend/registry/explorer/pages/details_spec.js
@@ -335,7 +335,7 @@ describe('Details Page', () => {
describe('Partial Cleanup Alert', () => {
const config = {
runCleanupPoliciesHelpPagePath: 'foo',
- cleanupPoliciesHelpPagePath: 'bar',
+ expirationPolicyHelpPagePath: 'bar',
userCalloutsPath: 'call_out_path',
userCalloutId: 'call_out_id',
showUnfinishedTagCleanupCallout: true,
@@ -367,7 +367,7 @@ describe('Details Page', () => {
expect(findPartialCleanupAlert().props()).toEqual({
runCleanupPoliciesHelpPagePath: config.runCleanupPoliciesHelpPagePath,
- cleanupPoliciesHelpPagePath: config.cleanupPoliciesHelpPagePath,
+ cleanupPoliciesHelpPagePath: config.expirationPolicyHelpPagePath,
});
});
diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js
index d6fba863ee0..4f65e73d3fa 100644
--- a/spec/frontend/registry/explorer/stubs.js
+++ b/spec/frontend/registry/explorer/stubs.js
@@ -2,6 +2,7 @@ import {
GlModal as RealGlModal,
GlEmptyState as RealGlEmptyState,
GlSkeletonLoader as RealGlSkeletonLoader,
+ GlDropdown as RealGlDropdown,
} from '@gitlab/ui';
import { RouterLinkStub } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
@@ -38,3 +39,7 @@ export const ListItem = {
};
},
};
+
+export const GlDropdown = stubComponent(RealGlDropdown, {
+ template: '<div><slot></slot></div>',
+});
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index 748b48dacaa..1db6fa21d6b 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter';
import { merge } from 'lodash';
import Vuex from 'vuex';
import { getJSONFixture } from 'helpers/fixtures';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as commonUtils from '~/lib/utils/common_utils';
import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue';
@@ -77,7 +78,7 @@ describe('Release edit/new component', () => {
};
beforeEach(() => {
- global.jsdom.reconfigure({ url: TEST_HOST });
+ setWindowLocation(TEST_HOST);
mock = new MockAdapter(axios);
gon.api_version = 'v4';
@@ -164,9 +165,7 @@ describe('Release edit/new component', () => {
`when the URL contains a "${BACK_URL_PARAM}=$backUrl" parameter`,
({ backUrl, expectedHref }) => {
beforeEach(async () => {
- global.jsdom.reconfigure({
- url: `${TEST_HOST}?${BACK_URL_PARAM}=${encodeURIComponent(backUrl)}`,
- });
+ setWindowLocation(`${TEST_HOST}?${BACK_URL_PARAM}=${encodeURIComponent(backUrl)}`);
await factory();
});
diff --git a/spec/frontend/releases/components/release_block_header_spec.js b/spec/frontend/releases/components/release_block_header_spec.js
index 0f6657090e6..47fd6377fcf 100644
--- a/spec/frontend/releases/components/release_block_header_spec.js
+++ b/spec/frontend/releases/components/release_block_header_spec.js
@@ -2,6 +2,7 @@ import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
import { getJSONFixture } from 'helpers/fixtures';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import ReleaseBlockHeader from '~/releases/components/release_block_header.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
@@ -60,12 +61,7 @@ describe('Release block header', () => {
const currentUrl = 'https://example.gitlab.com/path';
beforeEach(() => {
- Object.defineProperty(window, 'location', {
- writable: true,
- value: {
- href: currentUrl,
- },
- });
+ setWindowLocation(currentUrl);
factory();
});
diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
index b8299d44f13..84863eac3d3 100644
--- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
+++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
@@ -3,6 +3,7 @@ import Vuex from 'vuex';
import CodequalityIssueBody from '~/reports/codequality_report/components/codequality_issue_body.vue';
import GroupedCodequalityReportsApp from '~/reports/codequality_report/grouped_codequality_reports_app.vue';
import { getStoreConfig } from '~/reports/codequality_report/store';
+import { STATUS_NOT_FOUND } from '~/reports/constants';
import { parsedReportIssues } from './mock_data';
const localVue = createLocalVue();
@@ -14,8 +15,6 @@ describe('Grouped code quality reports app', () => {
const PATHS = {
codequalityHelpPath: 'codequality_help.html',
- basePath: 'base.json',
- headPath: 'head.json',
baseBlobPath: 'base/blob/path/',
headBlobPath: 'head/blob/path/',
};
@@ -127,21 +126,6 @@ describe('Grouped code quality reports app', () => {
});
});
- describe('when there is a head report but no base report', () => {
- beforeEach(() => {
- mockStore.state.basePath = null;
- mockStore.state.hasError = true;
- });
-
- it('renders error text', () => {
- expect(findWidget().text()).toContain('Failed to load codeclimate report');
- });
-
- it('renders a help icon with more information', () => {
- expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(true);
- });
- });
-
describe('on error', () => {
beforeEach(() => {
mockStore.state.hasError = true;
@@ -154,5 +138,15 @@ describe('Grouped code quality reports app', () => {
it('does not render a help icon', () => {
expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(false);
});
+
+ describe('when base report was not found', () => {
+ beforeEach(() => {
+ mockStore.state.status = STATUS_NOT_FOUND;
+ });
+
+ it('renders a help icon with more information', () => {
+ expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(true);
+ });
+ });
});
});
diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js
index 9dda024bffd..1821390786b 100644
--- a/spec/frontend/reports/codequality_report/store/actions_spec.js
+++ b/spec/frontend/reports/codequality_report/store/actions_spec.js
@@ -5,8 +5,14 @@ import axios from '~/lib/utils/axios_utils';
import createStore from '~/reports/codequality_report/store';
import * as actions from '~/reports/codequality_report/store/actions';
import * as types from '~/reports/codequality_report/store/mutation_types';
+import { STATUS_NOT_FOUND } from '~/reports/constants';
import { reportIssues, parsedReportIssues } from '../mock_data';
+const pollInterval = 123;
+const pollIntervalHeader = {
+ 'Poll-Interval': pollInterval,
+};
+
describe('Codequality Reports actions', () => {
let localState;
let localStore;
@@ -19,8 +25,6 @@ describe('Codequality Reports actions', () => {
describe('setPaths', () => {
it('should commit SET_PATHS mutation', (done) => {
const paths = {
- basePath: 'basePath',
- headPath: 'headPath',
baseBlobPath: 'baseBlobPath',
headBlobPath: 'headBlobPath',
reportsPath: 'reportsPath',
@@ -39,11 +43,11 @@ describe('Codequality Reports actions', () => {
});
describe('fetchReports', () => {
+ const endpoint = `${TEST_HOST}/codequality_reports.json`;
let mock;
beforeEach(() => {
- localState.reportsPath = `${TEST_HOST}/codequality_reports.json`;
- localState.basePath = '/base/path';
+ localState.reportsPath = endpoint;
mock = new MockAdapter(axios);
});
@@ -53,7 +57,7 @@ describe('Codequality Reports actions', () => {
describe('on success', () => {
it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', (done) => {
- mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, reportIssues);
+ mock.onGet(endpoint).reply(200, reportIssues);
testAction(
actions.fetchReports,
@@ -73,7 +77,7 @@ describe('Codequality Reports actions', () => {
describe('on error', () => {
it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => {
- mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(500);
+ mock.onGet(endpoint).reply(500);
testAction(
actions.fetchReports,
@@ -86,20 +90,78 @@ describe('Codequality Reports actions', () => {
});
});
- describe('with no base path', () => {
+ describe('when base report is not found', () => {
it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => {
- localState.basePath = null;
+ const data = { status: STATUS_NOT_FOUND };
+ mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, data);
testAction(
actions.fetchReports,
null,
localState,
[{ type: types.REQUEST_REPORTS }],
- [{ type: 'receiveReportsError' }],
+ [{ type: 'receiveReportsError', payload: data }],
done,
);
});
});
+
+ describe('while waiting for report results', () => {
+ it('continues polling until it receives data', (done) => {
+ mock
+ .onGet(endpoint)
+ .replyOnce(204, undefined, pollIntervalHeader)
+ .onGet(endpoint)
+ .reply(200, reportIssues);
+
+ Promise.all([
+ testAction(
+ actions.fetchReports,
+ null,
+ localState,
+ [{ type: types.REQUEST_REPORTS }],
+ [
+ {
+ payload: parsedReportIssues,
+ type: 'receiveReportsSuccess',
+ },
+ ],
+ done,
+ ),
+ axios
+ // wait for initial NO_CONTENT response to be fulfilled
+ .waitForAll()
+ .then(() => {
+ jest.advanceTimersByTime(pollInterval);
+ }),
+ ]).catch(done.fail);
+ });
+
+ it('continues polling until it receives an error', (done) => {
+ mock
+ .onGet(endpoint)
+ .replyOnce(204, undefined, pollIntervalHeader)
+ .onGet(endpoint)
+ .reply(500);
+
+ Promise.all([
+ testAction(
+ actions.fetchReports,
+ null,
+ localState,
+ [{ type: types.REQUEST_REPORTS }],
+ [{ type: 'receiveReportsError', payload: expect.any(Error) }],
+ done,
+ ),
+ axios
+ // wait for initial NO_CONTENT response to be fulfilled
+ .waitForAll()
+ .then(() => {
+ jest.advanceTimersByTime(pollInterval);
+ }),
+ ]).catch(done.fail);
+ });
+ });
});
describe('receiveReportsSuccess', () => {
diff --git a/spec/frontend/reports/codequality_report/store/getters_spec.js b/spec/frontend/reports/codequality_report/store/getters_spec.js
index de025f814ef..0378171084d 100644
--- a/spec/frontend/reports/codequality_report/store/getters_spec.js
+++ b/spec/frontend/reports/codequality_report/store/getters_spec.js
@@ -1,6 +1,6 @@
import createStore from '~/reports/codequality_report/store';
import * as getters from '~/reports/codequality_report/store/getters';
-import { LOADING, ERROR, SUCCESS } from '~/reports/constants';
+import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '~/reports/constants';
describe('Codequality reports store getters', () => {
let localState;
@@ -76,10 +76,9 @@ describe('Codequality reports store getters', () => {
});
describe('codequalityPopover', () => {
- describe('when head report is available but base report is not', () => {
+ describe('when base report is not available', () => {
it('returns a popover with a documentation link', () => {
- localState.headPath = 'head.json';
- localState.basePath = undefined;
+ localState.status = STATUS_NOT_FOUND;
localState.helpPath = 'codequality_help.html';
expect(getters.codequalityPopover(localState).title).toEqual(
diff --git a/spec/frontend/reports/codequality_report/store/mutations_spec.js b/spec/frontend/reports/codequality_report/store/mutations_spec.js
index 8bc6bb26c2a..6e14cd7438b 100644
--- a/spec/frontend/reports/codequality_report/store/mutations_spec.js
+++ b/spec/frontend/reports/codequality_report/store/mutations_spec.js
@@ -1,5 +1,6 @@
import createStore from '~/reports/codequality_report/store';
import mutations from '~/reports/codequality_report/store/mutations';
+import { STATUS_NOT_FOUND } from '~/reports/constants';
describe('Codequality Reports mutations', () => {
let localState;
@@ -12,24 +13,18 @@ describe('Codequality Reports mutations', () => {
describe('SET_PATHS', () => {
it('sets paths to given values', () => {
- const basePath = 'base.json';
- const headPath = 'head.json';
const baseBlobPath = 'base/blob/path/';
const headBlobPath = 'head/blob/path/';
const reportsPath = 'reports.json';
const helpPath = 'help.html';
mutations.SET_PATHS(localState, {
- basePath,
- headPath,
baseBlobPath,
headBlobPath,
reportsPath,
helpPath,
});
- expect(localState.basePath).toEqual(basePath);
- expect(localState.headPath).toEqual(headPath);
expect(localState.baseBlobPath).toEqual(baseBlobPath);
expect(localState.headBlobPath).toEqual(headBlobPath);
expect(localState.reportsPath).toEqual(reportsPath);
@@ -58,9 +53,10 @@ describe('Codequality Reports mutations', () => {
expect(localState.hasError).toEqual(false);
});
- it('clears statusReason', () => {
+ it('clears status and statusReason', () => {
mutations.RECEIVE_REPORTS_SUCCESS(localState, {});
+ expect(localState.status).toEqual('');
expect(localState.statusReason).toEqual('');
});
@@ -86,6 +82,13 @@ describe('Codequality Reports mutations', () => {
expect(localState.hasError).toEqual(true);
});
+ it('sets status based on error object', () => {
+ const error = { status: STATUS_NOT_FOUND };
+ mutations.RECEIVE_REPORTS_ERROR(localState, error);
+
+ expect(localState.status).toEqual(error.status);
+ });
+
it('sets statusReason to string from error response data', () => {
const data = { status_reason: 'This merge request does not have codequality reports' };
const error = { response: { data } };
diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js
index a449fd6f06c..f2a3354f204 100644
--- a/spec/frontend/repository/components/blob_button_group_spec.js
+++ b/spec/frontend/repository/components/blob_button_group_spec.js
@@ -12,6 +12,9 @@ const DEFAULT_PROPS = {
replacePath: 'some/replace/path',
deletePath: 'some/delete/path',
emptyRepo: false,
+ projectPath: 'some/project/path',
+ isLocked: false,
+ canLock: true,
};
const DEFAULT_INJECT = {
@@ -43,7 +46,7 @@ describe('BlobButtonGroup component', () => {
const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal);
const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
- const findReplaceButton = () => wrapper.findAll(GlButton).at(0);
+ const findReplaceButton = () => wrapper.find('[data-testid="replace"]');
it('renders component', () => {
createComponent();
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index a83d0a607f2..d462995328b 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -20,6 +20,8 @@ import blobInfoQuery from '~/repository/queries/blob_info.query.graphql';
jest.mock('~/repository/components/blob_viewers');
let wrapper;
+let mockResolver;
+
const simpleMockData = {
name: 'some_file.js',
size: 123,
@@ -37,9 +39,6 @@ const simpleMockData = {
externalStorageUrl: 'some_file.js',
replacePath: 'some_file.js/replace',
deletePath: 'some_file.js/delete',
- canLock: true,
- isLocked: false,
- lockLink: 'some_file.js/lock',
forkPath: 'some_file.js/fork',
simpleViewer: {
fileType: 'text',
@@ -62,6 +61,7 @@ const richMockData = {
const projectMockData = {
userPermissions: {
pushCode: true,
+ downloadCode: true,
},
repository: {
empty: false,
@@ -71,17 +71,28 @@ const projectMockData = {
const localVue = createLocalVue();
const mockAxios = new MockAdapter(axios);
-const createComponentWithApollo = (mockData = {}) => {
+const createComponentWithApollo = (mockData = {}, inject = {}) => {
localVue.use(VueApollo);
const defaultPushCode = projectMockData.userPermissions.pushCode;
+ const defaultDownloadCode = projectMockData.userPermissions.downloadCode;
const defaultEmptyRepo = projectMockData.repository.empty;
- const { blobs, emptyRepo = defaultEmptyRepo, canPushCode = defaultPushCode } = mockData;
-
- const mockResolver = jest.fn().mockResolvedValue({
+ const {
+ blobs,
+ emptyRepo = defaultEmptyRepo,
+ canPushCode = defaultPushCode,
+ canDownloadCode = defaultDownloadCode,
+ pathLocks = [],
+ } = mockData;
+
+ mockResolver = jest.fn().mockResolvedValue({
data: {
project: {
- userPermissions: { pushCode: canPushCode },
+ id: '1234',
+ userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode },
+ pathLocks: {
+ nodes: pathLocks,
+ },
repository: {
empty: emptyRepo,
blobs: {
@@ -101,6 +112,14 @@ const createComponentWithApollo = (mockData = {}) => {
path: 'some_file.js',
projectPath: 'some/path',
},
+ mixins: [
+ {
+ data: () => ({ ref: 'default-ref' }),
+ },
+ ],
+ provide: {
+ ...inject,
+ },
});
};
@@ -119,6 +138,7 @@ const createFactory = (mountFn) => (
queries: {
project: {
loading,
+ refetch: jest.fn(),
},
},
},
@@ -298,6 +318,7 @@ describe('Blob content viewer component', () => {
expect(findBlobEdit().props()).toMatchObject({
editPath: editBlobPath,
webIdePath: ideEditPath,
+ showEditButton: true,
});
});
@@ -315,10 +336,11 @@ describe('Blob content viewer component', () => {
expect(findBlobEdit().props()).toMatchObject({
editPath: editBlobPath,
webIdePath: ideEditPath,
+ showEditButton: true,
});
});
- it('does not render BlobHeaderEdit button when viewing a binary file', async () => {
+ it('renders BlobHeaderEdit button for binary files', async () => {
fullFactory({
mockData: { blobInfo: richMockData, isBinary: true },
stubs: {
@@ -329,13 +351,36 @@ describe('Blob content viewer component', () => {
await nextTick();
- expect(findBlobEdit().exists()).toBe(false);
+ expect(findBlobEdit().props()).toMatchObject({
+ editPath: editBlobPath,
+ webIdePath: ideEditPath,
+ showEditButton: false,
+ });
+ });
+
+ describe('blob header binary file', () => {
+ it.each([richMockData, { simpleViewer: { fileType: 'download' } }])(
+ 'passes the correct isBinary value when viewing a binary file',
+ async (blobInfo) => {
+ fullFactory({
+ mockData: {
+ blobInfo,
+ isBinary: true,
+ },
+ stubs: { BlobContent: true, BlobReplace: true },
+ });
+
+ await nextTick();
+
+ expect(findBlobHeader().props('isBinary')).toBe(true);
+ },
+ );
});
describe('BlobButtonGroup', () => {
const { name, path, replacePath, webPath } = simpleMockData;
const {
- userPermissions: { pushCode },
+ userPermissions: { pushCode, downloadCode },
repository: { empty },
} = projectMockData;
@@ -345,7 +390,7 @@ describe('Blob content viewer component', () => {
fullFactory({
mockData: {
blobInfo: simpleMockData,
- project: { userPermissions: { pushCode }, repository: { empty } },
+ project: { userPermissions: { pushCode, downloadCode }, repository: { empty } },
},
stubs: {
BlobContent: true,
@@ -361,10 +406,37 @@ describe('Blob content viewer component', () => {
replacePath,
deletePath: webPath,
canPushCode: pushCode,
+ canLock: true,
+ isLocked: false,
emptyRepo: empty,
});
});
+ it.each`
+ canPushCode | canDownloadCode | canLock
+ ${true} | ${true} | ${true}
+ ${false} | ${true} | ${false}
+ ${true} | ${false} | ${false}
+ `('passes the correct lock states', async ({ canPushCode, canDownloadCode, canLock }) => {
+ fullFactory({
+ mockData: {
+ blobInfo: simpleMockData,
+ project: {
+ userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode },
+ repository: { empty },
+ },
+ },
+ stubs: {
+ BlobContent: true,
+ BlobButtonGroup: true,
+ },
+ });
+
+ await nextTick();
+
+ expect(findBlobButtonGroup().props('canLock')).toBe(canLock);
+ });
+
it('does not render if not logged in', async () => {
window.gon.current_user_id = null;
@@ -382,4 +454,32 @@ describe('Blob content viewer component', () => {
});
});
});
+
+ describe('blob info query', () => {
+ it('is called with originalBranch value if the prop has a value', async () => {
+ const inject = { originalBranch: 'some-branch' };
+ createComponentWithApollo({ blobs: simpleMockData }, inject);
+
+ await waitForPromises();
+
+ expect(mockResolver).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ref: 'some-branch',
+ }),
+ );
+ });
+
+ it('is called with ref value if the originalBranch prop has no value', async () => {
+ const inject = { originalBranch: null };
+ createComponentWithApollo({ blobs: simpleMockData }, inject);
+
+ await waitForPromises();
+
+ expect(mockResolver).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ref: 'default-ref',
+ }),
+ );
+ });
+ });
});
diff --git a/spec/frontend/repository/components/blob_edit_spec.js b/spec/frontend/repository/components/blob_edit_spec.js
index e6e69cd8549..11739674bc9 100644
--- a/spec/frontend/repository/components/blob_edit_spec.js
+++ b/spec/frontend/repository/components/blob_edit_spec.js
@@ -6,6 +6,7 @@ import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
const DEFAULT_PROPS = {
editPath: 'some_file.js/edit',
webIdePath: 'some_file.js/ide/edit',
+ showEditButton: true,
};
describe('BlobEdit component', () => {
@@ -31,8 +32,8 @@ describe('BlobEdit component', () => {
});
const findButtons = () => wrapper.findAll(GlButton);
- const findEditButton = () => findButtons().at(0);
- const findWebIdeButton = () => findButtons().at(1);
+ const findEditButton = () => wrapper.find('[data-testid="edit"]');
+ const findWebIdeButton = () => wrapper.find('[data-testid="web-ide"]');
const findWebIdeLink = () => wrapper.find(WebIdeLink);
it('renders component', () => {
@@ -77,6 +78,23 @@ describe('BlobEdit component', () => {
editUrl,
webIdeUrl,
isBlob: true,
+ showEditButton: true,
+ });
+ });
+
+ describe('Without Edit button', () => {
+ const showEditButton = false;
+
+ it('renders WebIdeLink component without an edit button', () => {
+ createComponent(true, { showEditButton });
+
+ expect(findWebIdeLink().props()).toMatchObject({ showEditButton });
+ });
+
+ it('does not render an Edit button', () => {
+ createComponent(false, { showEditButton });
+
+ expect(findEditButton().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js
index 93bfd3d9d32..0733cffe4f4 100644
--- a/spec/frontend/repository/components/breadcrumbs_spec.js
+++ b/spec/frontend/repository/components/breadcrumbs_spec.js
@@ -3,10 +3,14 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+const defaultMockRoute = {
+ name: 'blobPath',
+};
+
describe('Repository breadcrumbs component', () => {
let wrapper;
- const factory = (currentPath, extraProps = {}) => {
+ const factory = (currentPath, extraProps = {}, mockRoute = {}) => {
const $apollo = {
queries: {
userPermissions: {
@@ -23,7 +27,13 @@ describe('Repository breadcrumbs component', () => {
stubs: {
RouterLink: RouterLinkStub,
},
- mocks: { $apollo },
+ mocks: {
+ $route: {
+ defaultMockRoute,
+ ...mockRoute,
+ },
+ $apollo,
+ },
});
};
@@ -69,6 +79,21 @@ describe('Repository breadcrumbs component', () => {
expect(wrapper.find(GlDropdown).exists()).toBe(false);
});
+ it.each`
+ routeName | isRendered
+ ${'blobPath'} | ${false}
+ ${'blobPathDecoded'} | ${false}
+ ${'treePath'} | ${true}
+ ${'treePathDecoded'} | ${true}
+ ${'projectRoot'} | ${true}
+ `(
+ 'does render add to tree dropdown $isRendered when route is $routeName',
+ ({ routeName, isRendered }) => {
+ factory('app/assets/javascripts.js', { canCollaborate: true }, { name: routeName });
+ expect(wrapper.find(GlDropdown).exists()).toBe(isRendered);
+ },
+ );
+
it('renders add to tree dropdown when permissions are true', async () => {
factory('/', { canCollaborate: true });
diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js
index a74e3e6d325..2c62868f391 100644
--- a/spec/frontend/repository/components/delete_blob_modal_spec.js
+++ b/spec/frontend/repository/components/delete_blob_modal_spec.js
@@ -1,5 +1,5 @@
-import { GlFormTextarea, GlModal, GlFormInput, GlToggle } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlFormTextarea, GlModal, GlFormInput, GlToggle, GlForm } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue';
@@ -19,17 +19,34 @@ const initialProps = {
describe('DeleteBlobModal', () => {
let wrapper;
- const createComponent = (props = {}) => {
- wrapper = shallowMount(DeleteBlobModal, {
+ const createComponentFactory = (mountFn) => (props = {}) => {
+ wrapper = mountFn(DeleteBlobModal, {
propsData: {
...initialProps,
...props,
},
+ attrs: {
+ static: true,
+ visible: true,
+ },
});
};
+ const createComponent = createComponentFactory(shallowMount);
+ const createFullComponent = createComponentFactory(mount);
+
const findModal = () => wrapper.findComponent(GlModal);
- const findForm = () => wrapper.findComponent({ ref: 'form' });
+ const findForm = () => findModal().findComponent(GlForm);
+ const findCommitTextarea = () => findForm().findComponent(GlFormTextarea);
+ const findTargetInput = () => findForm().findComponent(GlFormInput);
+ const findCommitHint = () => wrapper.find('[data-testid="hint"]');
+
+ const fillForm = async (inputValue = {}) => {
+ const { targetText, commitText } = inputValue;
+
+ await findTargetInput().vm.$emit('input', targetText);
+ await findCommitTextarea().vm.$emit('input', commitText);
+ };
afterEach(() => {
wrapper.destroy();
@@ -58,17 +75,6 @@ describe('DeleteBlobModal', () => {
expect(findForm().attributes('action')).toBe(initialProps.deletePath);
});
- it('submits the form', async () => {
- createComponent();
-
- const submitSpy = jest.spyOn(findForm().element, 'submit');
- findModal().vm.$emit('primary', { preventDefault: () => {} });
- await nextTick();
-
- expect(submitSpy).toHaveBeenCalled();
- submitSpy.mockRestore();
- });
-
it.each`
component | defaultValue | canPushCode | targetBranch | originalBranch | exist
${GlFormTextarea} | ${initialProps.commitMessage} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true}
@@ -127,4 +133,85 @@ describe('DeleteBlobModal', () => {
},
);
});
+
+ describe('hint', () => {
+ const targetText = 'some target branch';
+ const hintText = 'Try to keep the first line under 52 characters and the others under 72.';
+ const charsGenerator = (length) => 'lorem'.repeat(length);
+
+ beforeEach(async () => {
+ createFullComponent();
+ await nextTick();
+ });
+
+ it.each`
+ commitText | exist | desc
+ ${charsGenerator(53)} | ${true} | ${'first line length > 52'}
+ ${`lorem\n${charsGenerator(73)}`} | ${true} | ${'other line length > 72'}
+ ${charsGenerator(52)} | ${true} | ${'other line length = 52'}
+ ${`lorem\n${charsGenerator(72)}`} | ${true} | ${'other line length = 72'}
+ ${`lorem`} | ${false} | ${'first line length < 53'}
+ ${`lorem\nlorem`} | ${false} | ${'other line length < 53'}
+ `('displays hint $exist for $desc', async ({ commitText, exist }) => {
+ await fillForm({ targetText, commitText });
+
+ if (!exist) {
+ expect(findCommitHint().exists()).toBe(false);
+ return;
+ }
+
+ expect(findCommitHint().text()).toBe(hintText);
+ });
+ });
+
+ describe('form submission', () => {
+ let submitSpy;
+
+ beforeEach(async () => {
+ createFullComponent();
+ await nextTick();
+ submitSpy = jest.spyOn(findForm().element, 'submit');
+ });
+
+ afterEach(() => {
+ submitSpy.mockRestore();
+ });
+
+ describe('invalid form', () => {
+ beforeEach(async () => {
+ await fillForm({ targetText: '', commitText: '' });
+ });
+
+ it('disables submit button', async () => {
+ expect(findModal().props('actionPrimary').attributes[0]).toEqual(
+ expect.objectContaining({ disabled: true }),
+ );
+ });
+
+ it('does not submit form', async () => {
+ findModal().vm.$emit('primary', { preventDefault: () => {} });
+ expect(submitSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('valid form', () => {
+ beforeEach(async () => {
+ await fillForm({
+ targetText: 'some valid target branch',
+ commitText: 'some valid commit message',
+ });
+ });
+
+ it('enables submit button', async () => {
+ expect(findModal().props('actionPrimary').attributes[0]).toEqual(
+ expect.objectContaining({ disabled: false }),
+ );
+ });
+
+ it('submits form', async () => {
+ findModal().vm.$emit('primary', { preventDefault: () => {} });
+ expect(submitSpy).toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/spec/frontend/runner/runner_list/runner_list_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index 54b7d1f1bdb..c1596711be7 100644
--- a/spec/frontend/runner/runner_list/runner_list_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -1,11 +1,12 @@
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { TEST_HOST } from 'helpers/test_constants';
+import setWindowLocation from 'helpers/set_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
+import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
@@ -22,7 +23,6 @@ import {
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
-import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
import { captureException } from '~/runner/sentry_utils';
import { runnersData, runnersDataPaginated } from '../mock_data';
@@ -40,10 +40,9 @@ jest.mock('~/lib/utils/url_utility', () => ({
const localVue = createLocalVue();
localVue.use(VueApollo);
-describe('RunnerListApp', () => {
+describe('AdminRunnersApp', () => {
let wrapper;
let mockRunnersQuery;
- let originalLocation;
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
@@ -54,7 +53,7 @@ describe('RunnerListApp', () => {
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnersQuery, mockRunnersQuery]];
- wrapper = mountFn(RunnerListApp, {
+ wrapper = mountFn(AdminRunnersApp, {
localVue,
apolloProvider: createMockApollo(handlers),
propsData: {
@@ -65,22 +64,8 @@ describe('RunnerListApp', () => {
});
};
- const setQuery = (query) => {
- window.location.href = `${TEST_HOST}/admin/runners?${query}`;
- window.location.search = query;
- };
-
- beforeAll(() => {
- originalLocation = window.location;
- Object.defineProperty(window, 'location', { writable: true, value: { href: '', search: '' } });
- });
-
- afterAll(() => {
- window.location = originalLocation;
- });
-
beforeEach(async () => {
- setQuery('');
+ setWindowLocation('/admin/runners');
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
createComponentWithApollo();
@@ -116,7 +101,7 @@ describe('RunnerListApp', () => {
describe('when a filter is preselected', () => {
beforeEach(async () => {
- setQuery(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
+ setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
createComponentWithApollo();
await waitForPromises();
@@ -197,7 +182,7 @@ describe('RunnerListApp', () => {
it('error is reported to sentry', async () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error('Network error: Error!'),
- component: 'RunnerListApp',
+ component: 'AdminRunnersApp',
});
});
diff --git a/spec/frontend/runner/components/runner_registration_token_reset_spec.js b/spec/frontend/runner/components/runner_registration_token_reset_spec.js
index 6dc207e369c..8b360b88417 100644
--- a/spec/frontend/runner/components/runner_registration_token_reset_spec.js
+++ b/spec/frontend/runner/components/runner_registration_token_reset_spec.js
@@ -1,11 +1,12 @@
import { GlButton } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash, { FLASH_TYPES } from '~/flash';
import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
-import { INSTANCE_TYPE } from '~/runner/constants';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
@@ -23,11 +24,13 @@ describe('RunnerRegistrationTokenReset', () => {
const findButton = () => wrapper.findComponent(GlButton);
- const createComponent = () => {
+ const createComponent = ({ props, provide = {} } = {}) => {
wrapper = shallowMount(RunnerRegistrationTokenReset, {
localVue,
+ provide,
propsData: {
type: INSTANCE_TYPE,
+ ...props,
},
apolloProvider: createMockApollo([
[runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler],
@@ -59,31 +62,47 @@ describe('RunnerRegistrationTokenReset', () => {
});
describe('On click and confirmation', () => {
- beforeEach(async () => {
- window.confirm.mockReturnValueOnce(true);
- await findButton().vm.$emit('click');
- });
+ const mockGroupId = '11';
+ const mockProjectId = '22';
+
+ describe.each`
+ type | provide | expectedInput
+ ${INSTANCE_TYPE} | ${{}} | ${{ type: INSTANCE_TYPE }}
+ ${GROUP_TYPE} | ${{ groupId: mockGroupId }} | ${{ type: GROUP_TYPE, id: `gid://gitlab/Group/${mockGroupId}` }}
+ ${PROJECT_TYPE} | ${{ projectId: mockProjectId }} | ${{ type: PROJECT_TYPE, id: `gid://gitlab/Project/${mockProjectId}` }}
+ `('Resets token of type $type', ({ type, provide, expectedInput }) => {
+ beforeEach(async () => {
+ createComponent({
+ provide,
+ props: { type },
+ });
+
+ window.confirm.mockReturnValueOnce(true);
+ findButton().vm.$emit('click');
+ await waitForPromises();
+ });
- it('resets token', () => {
- expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1);
- expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({
- input: { type: INSTANCE_TYPE },
+ it('resets token', () => {
+ expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1);
+ expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({
+ input: expectedInput,
+ });
});
- });
- it('emits result', () => {
- expect(wrapper.emitted('tokenReset')).toHaveLength(1);
- expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]);
- });
+ it('emits result', () => {
+ expect(wrapper.emitted('tokenReset')).toHaveLength(1);
+ expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]);
+ });
- it('does not show a loading state', () => {
- expect(findButton().props('loading')).toBe(false);
- });
+ it('does not show a loading state', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
- it('shows confirmation', () => {
- expect(createFlash).toHaveBeenLastCalledWith({
- message: expect.stringContaining('registration token generated'),
- type: FLASH_TYPES.SUCCESS,
+ it('shows confirmation', () => {
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: expect.stringContaining('registration token generated'),
+ type: FLASH_TYPES.SUCCESS,
+ });
});
});
});
@@ -91,7 +110,8 @@ describe('RunnerRegistrationTokenReset', () => {
describe('On click without confirmation', () => {
beforeEach(async () => {
window.confirm.mockReturnValueOnce(false);
- await findButton().vm.$emit('click');
+ findButton().vm.$emit('click');
+ await waitForPromises();
});
it('does not reset token', () => {
@@ -118,7 +138,7 @@ describe('RunnerRegistrationTokenReset', () => {
runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
window.confirm.mockReturnValueOnce(true);
- await findButton().vm.$emit('click');
+ findButton().vm.$emit('click');
await waitForPromises();
expect(createFlash).toHaveBeenLastCalledWith({
@@ -144,7 +164,7 @@ describe('RunnerRegistrationTokenReset', () => {
});
window.confirm.mockReturnValueOnce(true);
- await findButton().vm.$emit('click');
+ findButton().vm.$emit('click');
await waitForPromises();
expect(createFlash).toHaveBeenLastCalledWith({
@@ -160,7 +180,8 @@ describe('RunnerRegistrationTokenReset', () => {
describe('Immediately after click', () => {
it('shows loading state', async () => {
window.confirm.mockReturnValue(true);
- await findButton().vm.$emit('click');
+ findButton().vm.$emit('click');
+ await nextTick();
expect(findButton().props('loading')).toBe(true);
});
diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js
index 5b136a77eeb..e54e499743b 100644
--- a/spec/frontend/runner/components/runner_type_alert_spec.js
+++ b/spec/frontend/runner/components/runner_type_alert_spec.js
@@ -23,10 +23,10 @@ describe('RunnerTypeAlert', () => {
});
describe.each`
- type | exampleText | anchor | variant
- ${INSTANCE_TYPE} | ${'Shared runners are available to every project'} | ${'#shared-runners'} | ${'success'}
- ${GROUP_TYPE} | ${'Use Group runners when you want all projects in a group'} | ${'#group-runners'} | ${'success'}
- ${PROJECT_TYPE} | ${'You can set up a specific runner to be used by multiple projects'} | ${'#specific-runners'} | ${'info'}
+ type | exampleText | anchor | variant
+ ${INSTANCE_TYPE} | ${'This runner is available to all groups and projects'} | ${'#shared-runners'} | ${'success'}
+ ${GROUP_TYPE} | ${'This runner is available to all projects and subgroups in a group'} | ${'#group-runners'} | ${'success'}
+ ${PROJECT_TYPE} | ${'This runner is associated with one or more projects'} | ${'#specific-runners'} | ${'info'}
`('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => {
beforeEach(() => {
createComponent({ props: { type } });
diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js
new file mode 100644
index 00000000000..6a0863e92b4
--- /dev/null
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
+import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
+import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue';
+
+const mockRegistrationToken = 'AABBCC';
+
+describe('GroupRunnersApp', () => {
+ let wrapper;
+
+ const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
+ const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
+
+ const createComponent = ({ mountFn = shallowMount } = {}) => {
+ wrapper = mountFn(GroupRunnersApp, {
+ propsData: {
+ registrationToken: mockRegistrationToken,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows the runner type help', () => {
+ expect(findRunnerTypeHelp().exists()).toBe(true);
+ });
+
+ it('shows the runner setup instructions', () => {
+ expect(findRunnerManualSetupHelp().exists()).toBe(true);
+ expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
+ });
+});
diff --git a/spec/frontend/runner/runner_list/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js
index e7969676549..3a0c3abe7bd 100644
--- a/spec/frontend/runner/runner_list/runner_search_utils_spec.js
+++ b/spec/frontend/runner/runner_search_utils_spec.js
@@ -3,7 +3,7 @@ import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
-} from '~/runner/runner_list/runner_search_utils';
+} from '~/runner/runner_search_utils';
describe('search_params.js', () => {
const examples = [
diff --git a/spec/frontend/search/index_spec.js b/spec/frontend/search/index_spec.js
index 1992a7f4437..c07cd74b456 100644
--- a/spec/frontend/search/index_spec.js
+++ b/spec/frontend/search/index_spec.js
@@ -1,4 +1,5 @@
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { initSearchApp } from '~/search';
import createStore from '~/search/store';
@@ -8,25 +9,6 @@ jest.mock('~/search/sidebar');
jest.mock('ee_else_ce/search/highlight_blob_search_result');
describe('initSearchApp', () => {
- let defaultLocation;
-
- const setUrl = (query) => {
- window.location.href = `https://localhost:3000/search${query}`;
- window.location.search = query;
- };
-
- beforeEach(() => {
- defaultLocation = window.location;
- Object.defineProperty(window, 'location', {
- writable: true,
- value: { href: '', search: '' },
- });
- });
-
- afterEach(() => {
- window.location = defaultLocation;
- });
-
describe.each`
search | decodedSearch
${'test'} | ${'test'}
@@ -38,7 +20,7 @@ describe('initSearchApp', () => {
${'test+%2520+this+%2520+stuff'} | ${'test %20 this %20 stuff'}
`('parameter decoding', ({ search, decodedSearch }) => {
beforeEach(() => {
- setUrl(`?search=${search}`);
+ setWindowLocation(`/search?search=${search}`);
initSearchApp();
});
diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js
index 24ce45e8a09..0542e96c77c 100644
--- a/spec/frontend/search/mock_data.js
+++ b/spec/frontend/search/mock_data.js
@@ -86,18 +86,21 @@ export const STALE_STORED_DATA = [
export const MOCK_FRESH_DATA_RES = { name: 'fresh' };
-export const PROMISE_ALL_EXPECTED_MUTATIONS = {
- initGroups: {
+export const PRELOAD_EXPECTED_MUTATIONS = [
+ {
type: types.LOAD_FREQUENT_ITEMS,
payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA },
},
- resGroups: {
+ {
type: types.LOAD_FREQUENT_ITEMS,
- payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: [MOCK_FRESH_DATA_RES, MOCK_FRESH_DATA_RES] },
+ payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA },
},
- initProjects: {
+];
+
+export const PROMISE_ALL_EXPECTED_MUTATIONS = {
+ resGroups: {
type: types.LOAD_FREQUENT_ITEMS,
- payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA },
+ payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: [MOCK_FRESH_DATA_RES, MOCK_FRESH_DATA_RES] },
},
resProjects: {
type: types.LOAD_FREQUENT_ITEMS,
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
index 3755f8ffae7..9f8c83f2873 100644
--- a/spec/frontend/search/store/actions_spec.js
+++ b/spec/frontend/search/store/actions_spec.js
@@ -17,6 +17,7 @@ import {
MOCK_GROUP,
FRESH_STORED_DATA,
MOCK_FRESH_DATA_RES,
+ PRELOAD_EXPECTED_MUTATIONS,
PROMISE_ALL_EXPECTED_MUTATIONS,
} from '../mock_data';
@@ -68,31 +69,31 @@ describe('Global Search Store Actions', () => {
});
describe.each`
- action | axiosMock | type | expectedMutations | flashCallCount | lsKey
- ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initGroups, PROMISE_ALL_EXPECTED_MUTATIONS.resGroups]} | ${0} | ${GROUPS_LOCAL_STORAGE_KEY}
- ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initGroups]} | ${1} | ${GROUPS_LOCAL_STORAGE_KEY}
- ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initProjects, PROMISE_ALL_EXPECTED_MUTATIONS.resProjects]} | ${0} | ${PROJECTS_LOCAL_STORAGE_KEY}
- ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initProjects]} | ${1} | ${PROJECTS_LOCAL_STORAGE_KEY}
- `(
- 'Promise.all calls',
- ({ action, axiosMock, type, expectedMutations, flashCallCount, lsKey }) => {
- describe(action.name, () => {
- describe(`on ${type}`, () => {
- beforeEach(() => {
- storeUtils.loadDataFromLS = jest.fn().mockReturnValue(FRESH_STORED_DATA);
- mock[axiosMock.method]().reply(axiosMock.code, MOCK_FRESH_DATA_RES);
- });
+ action | axiosMock | type | expectedMutations | flashCallCount
+ ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.resGroups]} | ${0}
+ ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[]} | ${1}
+ ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.resProjects]} | ${0}
+ ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[]} | ${1}
+ `('Promise.all calls', ({ action, axiosMock, type, expectedMutations, flashCallCount }) => {
+ describe(action.name, () => {
+ describe(`on ${type}`, () => {
+ beforeEach(() => {
+ state.frequentItems = {
+ [GROUPS_LOCAL_STORAGE_KEY]: FRESH_STORED_DATA,
+ [PROJECTS_LOCAL_STORAGE_KEY]: FRESH_STORED_DATA,
+ };
+
+ mock[axiosMock.method]().reply(axiosMock.code, MOCK_FRESH_DATA_RES);
+ });
- it(`should dispatch the correct mutations`, () => {
- return testAction({ action, state, expectedMutations }).then(() => {
- expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(lsKey);
- flashCallback(flashCallCount);
- });
+ it(`should dispatch the correct mutations`, () => {
+ return testAction({ action, state, expectedMutations }).then(() => {
+ flashCallback(flashCallCount);
});
});
});
- },
- );
+ });
+ });
describe('getGroupsData', () => {
const mockCommit = () => {};
@@ -182,14 +183,38 @@ describe('Global Search Store Actions', () => {
});
});
+ describe('preloadStoredFrequentItems', () => {
+ beforeEach(() => {
+ storeUtils.loadDataFromLS = jest.fn().mockReturnValue(FRESH_STORED_DATA);
+ });
+
+ it('calls preloadStoredFrequentItems for both groups and projects and commits LOAD_FREQUENT_ITEMS', async () => {
+ await testAction({
+ action: actions.preloadStoredFrequentItems,
+ state,
+ expectedMutations: PRELOAD_EXPECTED_MUTATIONS,
+ });
+
+ expect(storeUtils.loadDataFromLS).toHaveBeenCalledTimes(2);
+ expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(GROUPS_LOCAL_STORAGE_KEY);
+ expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(PROJECTS_LOCAL_STORAGE_KEY);
+ });
+ });
+
describe('setFrequentGroup', () => {
beforeEach(() => {
- storeUtils.setFrequentItemToLS = jest.fn();
+ storeUtils.setFrequentItemToLS = jest.fn().mockReturnValue(FRESH_STORED_DATA);
});
- it(`calls setFrequentItemToLS with ${GROUPS_LOCAL_STORAGE_KEY} and item data`, async () => {
+ it(`calls setFrequentItemToLS with ${GROUPS_LOCAL_STORAGE_KEY} and item data then commits LOAD_FREQUENT_ITEMS`, async () => {
await testAction({
action: actions.setFrequentGroup,
+ expectedMutations: [
+ {
+ type: types.LOAD_FREQUENT_ITEMS,
+ payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA },
+ },
+ ],
payload: MOCK_GROUP,
state,
});
@@ -204,12 +229,18 @@ describe('Global Search Store Actions', () => {
describe('setFrequentProject', () => {
beforeEach(() => {
- storeUtils.setFrequentItemToLS = jest.fn();
+ storeUtils.setFrequentItemToLS = jest.fn().mockReturnValue(FRESH_STORED_DATA);
});
it(`calls setFrequentItemToLS with ${PROJECTS_LOCAL_STORAGE_KEY} and item data`, async () => {
await testAction({
action: actions.setFrequentProject,
+ expectedMutations: [
+ {
+ type: types.LOAD_FREQUENT_ITEMS,
+ payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA },
+ },
+ ],
payload: MOCK_PROJECT,
state,
});
diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js
index 5055fa2cc3d..cd7f7dc3b5f 100644
--- a/spec/frontend/search/store/utils_spec.js
+++ b/spec/frontend/search/store/utils_spec.js
@@ -51,19 +51,25 @@ describe('Global Search Store Utils', () => {
describe('setFrequentItemToLS', () => {
const frequentItems = {};
+ let res;
describe('with existing data', () => {
describe(`when frequency is less than ${MAX_FREQUENCY}`, () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: PREV_TIME }];
- setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
+ res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
- it('adds 1 to the frequency, tracks lastUsed, and calls localStorage.setItem', () => {
+ it('adds 1 to the frequency, tracks lastUsed, calls localStorage.setItem and returns the array', () => {
+ const updatedFrequentItems = [
+ { ...MOCK_GROUPS[0], frequency: 2, lastUsed: CURRENT_TIME },
+ ];
+
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
- JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 2, lastUsed: CURRENT_TIME }]),
+ JSON.stringify(updatedFrequentItems),
);
+ expect(res).toEqual(updatedFrequentItems);
});
});
@@ -72,16 +78,19 @@ describe('Global Search Store Utils', () => {
frequentItems[MOCK_LS_KEY] = [
{ ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: PREV_TIME },
];
- setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
+ res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
- it(`does not further increase frequency past ${MAX_FREQUENCY}, tracks lastUsed, and calls localStorage.setItem`, () => {
+ it(`does not further increase frequency past ${MAX_FREQUENCY}, tracks lastUsed, calls localStorage.setItem, and returns the array`, () => {
+ const updatedFrequentItems = [
+ { ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: CURRENT_TIME },
+ ];
+
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
- JSON.stringify([
- { ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: CURRENT_TIME },
- ]),
+ JSON.stringify(updatedFrequentItems),
);
+ expect(res).toEqual(updatedFrequentItems);
});
});
});
@@ -89,14 +98,17 @@ describe('Global Search Store Utils', () => {
describe('with no existing data', () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [];
- setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
+ res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
- it('adds a new entry with frequency 1, tracks lastUsed, and calls localStorage.setItem', () => {
+ it('adds a new entry with frequency 1, tracks lastUsed, calls localStorage.setItem, and returns the array', () => {
+ const updatedFrequentItems = [{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }];
+
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
- JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]),
+ JSON.stringify(updatedFrequentItems),
);
+ expect(res).toEqual(updatedFrequentItems);
});
});
@@ -107,18 +119,21 @@ describe('Global Search Store Utils', () => {
{ id: 2, frequency: 1, lastUsed: PREV_TIME },
{ id: 3, frequency: 1, lastUsed: PREV_TIME },
];
- setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 3 });
+ res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 3 });
});
- it('sorts the array by most frequent and lastUsed', () => {
+ it('sorts the array by most frequent and lastUsed and returns the array', () => {
+ const updatedFrequentItems = [
+ { id: 3, frequency: 2, lastUsed: CURRENT_TIME },
+ { id: 1, frequency: 2, lastUsed: PREV_TIME },
+ { id: 2, frequency: 1, lastUsed: PREV_TIME },
+ ];
+
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
- JSON.stringify([
- { id: 3, frequency: 2, lastUsed: CURRENT_TIME },
- { id: 1, frequency: 2, lastUsed: PREV_TIME },
- { id: 2, frequency: 1, lastUsed: PREV_TIME },
- ]),
+ JSON.stringify(updatedFrequentItems),
);
+ expect(res).toEqual(updatedFrequentItems);
});
});
@@ -131,31 +146,35 @@ describe('Global Search Store Utils', () => {
{ id: 4, frequency: 2, lastUsed: PREV_TIME },
{ id: 5, frequency: 1, lastUsed: PREV_TIME },
];
- setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 6 });
+ res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 6 });
});
- it('removes the last item in the array', () => {
+ it('removes the last item in the array and returns the array', () => {
+ const updatedFrequentItems = [
+ { id: 1, frequency: 5, lastUsed: PREV_TIME },
+ { id: 2, frequency: 4, lastUsed: PREV_TIME },
+ { id: 3, frequency: 3, lastUsed: PREV_TIME },
+ { id: 4, frequency: 2, lastUsed: PREV_TIME },
+ { id: 6, frequency: 1, lastUsed: CURRENT_TIME },
+ ];
+
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
- JSON.stringify([
- { id: 1, frequency: 5, lastUsed: PREV_TIME },
- { id: 2, frequency: 4, lastUsed: PREV_TIME },
- { id: 3, frequency: 3, lastUsed: PREV_TIME },
- { id: 4, frequency: 2, lastUsed: PREV_TIME },
- { id: 6, frequency: 1, lastUsed: CURRENT_TIME },
- ]),
+ JSON.stringify(updatedFrequentItems),
);
+ expect(res).toEqual(updatedFrequentItems);
});
});
describe('with null data loaded in', () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = null;
- setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
+ res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
- it('wipes local storage', () => {
+ it('wipes local storage and returns empty array', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(MOCK_LS_KEY);
+ expect(res).toEqual([]);
});
});
@@ -163,14 +182,17 @@ describe('Global Search Store Utils', () => {
beforeEach(() => {
const MOCK_ADDITIONAL_DATA_GROUP = { ...MOCK_GROUPS[0], extraData: 'test' };
frequentItems[MOCK_LS_KEY] = [];
- setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_ADDITIONAL_DATA_GROUP);
+ res = setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_ADDITIONAL_DATA_GROUP);
});
- it('parses out extra data for LS', () => {
+ it('parses out extra data for LS and returns the array', () => {
+ const updatedFrequentItems = [{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }];
+
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
- JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]),
+ JSON.stringify(updatedFrequentItems),
);
+ expect(res).toEqual(updatedFrequentItems);
});
});
});
diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js
index fb953f2ed1b..7ce5efb3c52 100644
--- a/spec/frontend/search/topbar/components/app_spec.js
+++ b/spec/frontend/search/topbar/components/app_spec.js
@@ -1,13 +1,13 @@
import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import { MOCK_QUERY } from 'jest/search/mock_data';
import GlobalSearchTopbar from '~/search/topbar/components/app.vue';
import GroupFilter from '~/search/topbar/components/group_filter.vue';
import ProjectFilter from '~/search/topbar/components/project_filter.vue';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
describe('GlobalSearchTopbar', () => {
let wrapper;
@@ -15,6 +15,7 @@ describe('GlobalSearchTopbar', () => {
const actionSpies = {
applyQuery: jest.fn(),
setQuery: jest.fn(),
+ preloadStoredFrequentItems: jest.fn(),
};
const createComponent = (initialState) => {
@@ -27,14 +28,12 @@ describe('GlobalSearchTopbar', () => {
});
wrapper = shallowMount(GlobalSearchTopbar, {
- localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
const findTopbarForm = () => wrapper.find(GlForm);
@@ -110,4 +109,14 @@ describe('GlobalSearchTopbar', () => {
expect(actionSpies.applyQuery).toHaveBeenCalled();
});
});
+
+ describe('onCreate', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('calls preloadStoredFrequentItems', () => {
+ expect(actionSpies.preloadStoredFrequentItems).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js
index fbd7ad6bb57..bd173791fee 100644
--- a/spec/frontend/search/topbar/components/group_filter_spec.js
+++ b/spec/frontend/search/topbar/components/group_filter_spec.js
@@ -51,7 +51,6 @@ describe('GroupFilter', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
const findSearchableDropdown = () => wrapper.find(SearchableDropdown);
@@ -89,10 +88,11 @@ describe('GroupFilter', () => {
findSearchableDropdown().vm.$emit('change', ANY_OPTION);
});
- it('calls setUrlParams with group null, project id null, and then calls visitUrl', () => {
+ it('calls setUrlParams with group null, project id null, nav_source null, and then calls visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[GROUP_DATA.queryParam]: null,
[PROJECT_DATA.queryParam]: null,
+ nav_source: null,
});
expect(visitUrl).toHaveBeenCalled();
@@ -108,10 +108,11 @@ describe('GroupFilter', () => {
findSearchableDropdown().vm.$emit('change', MOCK_GROUP);
});
- it('calls setUrlParams with group id, project id null, and then calls visitUrl', () => {
+ it('calls setUrlParams with group id, project id null, nav_source null, and then calls visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[GROUP_DATA.queryParam]: MOCK_GROUP.id,
[PROJECT_DATA.queryParam]: null,
+ nav_source: null,
});
expect(visitUrl).toHaveBeenCalled();
@@ -156,4 +157,31 @@ describe('GroupFilter', () => {
});
});
});
+
+ describe.each`
+ navSource | initialData | callMethod
+ ${null} | ${null} | ${false}
+ ${null} | ${MOCK_GROUP} | ${false}
+ ${'navbar'} | ${null} | ${false}
+ ${'navbar'} | ${MOCK_GROUP} | ${true}
+ `('onCreate', ({ navSource, initialData, callMethod }) => {
+ describe(`when nav_source is ${navSource} and ${
+ initialData ? 'has' : 'does not have'
+ } an initial group`, () => {
+ beforeEach(() => {
+ createComponent({ query: { ...MOCK_QUERY, nav_source: navSource } }, { initialData });
+ });
+
+ it(`${callMethod ? 'does' : 'does not'} call setFrequentGroup`, () => {
+ if (callMethod) {
+ expect(actionSpies.setFrequentGroup).toHaveBeenCalledWith(
+ expect.any(Object),
+ initialData,
+ );
+ } else {
+ expect(actionSpies.setFrequentGroup).not.toHaveBeenCalled();
+ }
+ });
+ });
+ });
});
diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/topbar/components/project_filter_spec.js
index 63b0f882ca4..5afcd281d0c 100644
--- a/spec/frontend/search/topbar/components/project_filter_spec.js
+++ b/spec/frontend/search/topbar/components/project_filter_spec.js
@@ -51,7 +51,6 @@ describe('ProjectFilter', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
const findSearchableDropdown = () => wrapper.find(SearchableDropdown);
@@ -89,9 +88,10 @@ describe('ProjectFilter', () => {
findSearchableDropdown().vm.$emit('change', ANY_OPTION);
});
- it('calls setUrlParams with null, no group id, then calls visitUrl', () => {
+ it('calls setUrlParams with null, no group id, nav_source null, then calls visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[PROJECT_DATA.queryParam]: null,
+ nav_source: null,
});
expect(visitUrl).toHaveBeenCalled();
});
@@ -106,10 +106,11 @@ describe('ProjectFilter', () => {
findSearchableDropdown().vm.$emit('change', MOCK_PROJECT);
});
- it('calls setUrlParams with project id, group id, then calls visitUrl', () => {
+ it('calls setUrlParams with project id, group id, nav_source null, then calls visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[GROUP_DATA.queryParam]: MOCK_PROJECT.namespace.id,
[PROJECT_DATA.queryParam]: MOCK_PROJECT.id,
+ nav_source: null,
});
expect(visitUrl).toHaveBeenCalled();
});
@@ -157,4 +158,31 @@ describe('ProjectFilter', () => {
});
});
});
+
+ describe.each`
+ navSource | initialData | callMethod
+ ${null} | ${null} | ${false}
+ ${null} | ${MOCK_PROJECT} | ${false}
+ ${'navbar'} | ${null} | ${false}
+ ${'navbar'} | ${MOCK_PROJECT} | ${true}
+ `('onCreate', ({ navSource, initialData, callMethod }) => {
+ describe(`when nav_source is ${navSource} and ${
+ initialData ? 'has' : 'does not have'
+ } an initial project`, () => {
+ beforeEach(() => {
+ createComponent({ query: { ...MOCK_QUERY, nav_source: navSource } }, { initialData });
+ });
+
+ it(`${callMethod ? 'does' : 'does not'} call setFrequentProject`, () => {
+ if (callMethod) {
+ expect(actionSpies.setFrequentProject).toHaveBeenCalledWith(
+ expect.any(Object),
+ initialData,
+ );
+ } else {
+ expect(actionSpies.setFrequentProject).not.toHaveBeenCalled();
+ }
+ });
+ });
+ });
});
diff --git a/spec/frontend/security_configuration/app_spec.js b/spec/frontend/security_configuration/app_spec.js
deleted file mode 100644
index 11d481fb210..00000000000
--- a/spec/frontend/security_configuration/app_spec.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import App from '~/security_configuration/components/app.vue';
-import ConfigurationTable from '~/security_configuration/components/configuration_table.vue';
-
-describe('App Component', () => {
- let wrapper;
-
- const createComponent = () => {
- wrapper = shallowMount(App, {});
- };
- const findConfigurationTable = () => wrapper.findComponent(ConfigurationTable);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders correct primary & Secondary Heading', () => {
- createComponent();
- expect(wrapper.text()).toContain('Security Configuration');
- expect(wrapper.text()).toContain('Testing & Compliance');
- });
-
- it('renders ConfigurationTable Component', () => {
- createComponent();
- expect(findConfigurationTable().exists()).toBe(true);
- });
-});
diff --git a/spec/frontend/security_configuration/components/redesigned_app_spec.js b/spec/frontend/security_configuration/components/app_spec.js
index 119a25a77c1..f27f45f2b26 100644
--- a/spec/frontend/security_configuration/components/redesigned_app_spec.js
+++ b/spec/frontend/security_configuration/components/app_spec.js
@@ -1,8 +1,12 @@
import { GlTab } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
+import stubChildren from 'helpers/stub_children';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue';
import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue';
+import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue';
import {
SAST_NAME,
SAST_SHORT_NAME,
@@ -12,12 +16,10 @@ import {
LICENSE_COMPLIANCE_NAME,
LICENSE_COMPLIANCE_DESCRIPTION,
LICENSE_COMPLIANCE_HELP_PATH,
+ AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY,
} from '~/security_configuration/components/constants';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
-import RedesignedSecurityConfigurationApp, {
- i18n,
-} from '~/security_configuration/components/redesigned_app.vue';
import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue';
import {
REPORT_TYPE_LICENSE_COMPLIANCE,
@@ -28,8 +30,11 @@ const upgradePath = '/upgrade';
const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath';
const autoDevopsPath = '/autoDevopsPath';
const gitlabCiHistoryPath = 'test/historyPath';
+const projectPath = 'namespace/project';
-describe('redesigned App component', () => {
+useLocalStorageSpy();
+
+describe('App component', () => {
let wrapper;
let userCalloutDismissSpy;
@@ -37,14 +42,20 @@ describe('redesigned App component', () => {
userCalloutDismissSpy = jest.fn();
wrapper = extendedWrapper(
- mount(RedesignedSecurityConfigurationApp, {
+ mount(SecurityConfigurationApp, {
propsData,
provide: {
upgradePath,
autoDevopsHelpPagePath,
autoDevopsPath,
+ projectPath,
},
stubs: {
+ ...stubChildren(SecurityConfigurationApp),
+ GlLink: false,
+ GlSprintf: false,
+ LocalStorageSync: false,
+ SectionLayout: false,
UserCalloutDismisser: makeMockUserCalloutDismisser({
dismiss: userCalloutDismissSpy,
shouldShowCallout,
@@ -83,6 +94,7 @@ describe('redesigned App component', () => {
});
const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner);
const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert);
+ const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert);
const securityFeaturesMock = [
{
@@ -161,7 +173,7 @@ describe('redesigned App component', () => {
});
});
- describe('autoDevOpsAlert', () => {
+ describe('Auto DevOps hint alert', () => {
describe('given the right props', () => {
beforeEach(() => {
createComponent({
@@ -199,6 +211,76 @@ describe('redesigned App component', () => {
});
});
+ describe('Auto DevOps enabled alert', () => {
+ describe.each`
+ context | autoDevopsEnabled | localStorageValue | shouldRender
+ ${'enabled'} | ${true} | ${null} | ${true}
+ ${'enabled, alert dismissed on other project'} | ${true} | ${['foo/bar']} | ${true}
+ ${'enabled, alert dismissed on this project'} | ${true} | ${[projectPath]} | ${false}
+ ${'not enabled'} | ${false} | ${null} | ${false}
+ `('given Auto DevOps is $context', ({ autoDevopsEnabled, localStorageValue, shouldRender }) => {
+ beforeEach(() => {
+ if (localStorageValue !== null) {
+ window.localStorage.setItem(
+ AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY,
+ JSON.stringify(localStorageValue),
+ );
+ }
+
+ createComponent({
+ augmentedSecurityFeatures: securityFeaturesMock,
+ augmentedComplianceFeatures: complianceFeaturesMock,
+ autoDevopsEnabled,
+ });
+ });
+
+ it(shouldRender ? 'renders' : 'does not render', () => {
+ expect(findAutoDevopsEnabledAlert().exists()).toBe(shouldRender);
+ });
+ });
+
+ describe('dismissing', () => {
+ describe.each`
+ dismissedProjects | expectedWrittenValue
+ ${null} | ${[projectPath]}
+ ${[]} | ${[projectPath]}
+ ${['foo/bar']} | ${['foo/bar', projectPath]}
+ ${[projectPath]} | ${[projectPath]}
+ `(
+ 'given dismissed projects $dismissedProjects',
+ ({ dismissedProjects, expectedWrittenValue }) => {
+ beforeEach(() => {
+ if (dismissedProjects !== null) {
+ window.localStorage.setItem(
+ AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY,
+ JSON.stringify(dismissedProjects),
+ );
+ }
+
+ createComponent({
+ augmentedSecurityFeatures: securityFeaturesMock,
+ augmentedComplianceFeatures: complianceFeaturesMock,
+ autoDevopsEnabled: true,
+ });
+
+ findAutoDevopsEnabledAlert().vm.$emit('dismiss');
+ });
+
+ it('adds current project to localStorage value', () => {
+ expect(window.localStorage.setItem).toHaveBeenLastCalledWith(
+ AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY,
+ JSON.stringify(expectedWrittenValue),
+ );
+ });
+
+ it('hides the alert', () => {
+ expect(findAutoDevopsEnabledAlert().exists()).toBe(false);
+ });
+ },
+ );
+ });
+ });
+
describe('upgrade banner', () => {
const makeAvailable = (available) => (feature) => ({ ...feature, available });
diff --git a/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js b/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js
new file mode 100644
index 00000000000..778fea2896a
--- /dev/null
+++ b/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js
@@ -0,0 +1,46 @@
+import { GlAlert } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue';
+
+const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath';
+
+describe('AutoDevopsEnabledAlert component', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = mount(AutoDevopsEnabledAlert, {
+ provide: {
+ autoDevopsHelpPagePath,
+ },
+ });
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains correct body text', () => {
+ expect(wrapper.text()).toMatchInterpolatedText(AutoDevopsEnabledAlert.i18n.body);
+ });
+
+ it('renders the link correctly', () => {
+ const link = wrapper.find('a[href]');
+
+ expect(link.attributes('href')).toBe(autoDevopsHelpPagePath);
+ expect(link.text()).toBe('Auto DevOps');
+ });
+
+ it('bubbles up dismiss events from the GlAlert', () => {
+ expect(wrapper.emitted('dismiss')).toBe(undefined);
+
+ findAlert().vm.$emit('dismiss');
+
+ expect(wrapper.emitted('dismiss')).toEqual([[]]);
+ });
+});
diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js
index 3658dbb5ef2..fdb1d2f86e3 100644
--- a/spec/frontend/security_configuration/components/feature_card_spec.js
+++ b/spec/frontend/security_configuration/components/feature_card_spec.js
@@ -127,25 +127,35 @@ describe('FeatureCard component', () => {
describe('actions', () => {
describe.each`
- context | type | available | configured | configurationPath | canEnableByMergeRequest | action
- ${'unavailable'} | ${REPORT_TYPE_SAST} | ${false} | ${false} | ${null} | ${false} | ${null}
- ${'available'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${false} | ${'guide'}
- ${'configured'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${null} | ${false} | ${'guide'}
- ${'available, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${true} | ${'create-mr'}
- ${'available, can enable by MR, unknown type'} | ${'foo'} | ${true} | ${false} | ${null} | ${true} | ${'guide'}
- ${'configured, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${null} | ${true} | ${'guide'}
- ${'available with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'foo'} | ${false} | ${'enable'}
- ${'available with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'foo'} | ${true} | ${'enable'}
- ${'configured with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'foo'} | ${false} | ${'configure'}
- ${'configured with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'foo'} | ${true} | ${'configure'}
+ context | type | available | configured | configurationHelpPath | configurationPath | canEnableByMergeRequest | action
+ ${'unavailable'} | ${REPORT_TYPE_SAST} | ${false} | ${false} | ${'/help'} | ${null} | ${false} | ${null}
+ ${'available, no configurationHelpPath'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${null} | ${false} | ${null}
+ ${'available'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'/help'} | ${null} | ${false} | ${'guide'}
+ ${'configured'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'/help'} | ${null} | ${false} | ${'guide'}
+ ${'available, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'/help'} | ${null} | ${true} | ${'create-mr'}
+ ${'available, can enable by MR, unknown type'} | ${'foo'} | ${true} | ${false} | ${'/help'} | ${null} | ${true} | ${'guide'}
+ ${'configured, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'/help'} | ${null} | ${true} | ${'guide'}
+ ${'available with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'/help'} | ${'foo'} | ${false} | ${'enable'}
+ ${'available with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'/help'} | ${'foo'} | ${true} | ${'enable'}
+ ${'configured with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'/help'} | ${'foo'} | ${false} | ${'configure'}
+ ${'configured with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'/help'} | ${'foo'} | ${true} | ${'configure'}
`(
'given $context feature',
- ({ type, available, configured, configurationPath, canEnableByMergeRequest, action }) => {
+ ({
+ type,
+ available,
+ configured,
+ configurationHelpPath,
+ configurationPath,
+ canEnableByMergeRequest,
+ action,
+ }) => {
beforeEach(() => {
feature = makeFeature({
type,
available,
configured,
+ configurationHelpPath,
configurationPath,
canEnableByMergeRequest,
});
diff --git a/spec/frontend/security_configuration/components/upgrade_banner_spec.js b/spec/frontend/security_configuration/components/upgrade_banner_spec.js
index cf7945343af..a35fded72fb 100644
--- a/spec/frontend/security_configuration/components/upgrade_banner_spec.js
+++ b/spec/frontend/security_configuration/components/upgrade_banner_spec.js
@@ -43,11 +43,11 @@ describe('UpgradeBanner component', () => {
it('renders the list of benefits', () => {
const wrapperText = wrapper.text();
- expect(wrapperText).toContain('GitLab Ultimate checks your application');
+ expect(wrapperText).toContain('Immediately begin risk analysis and remediation');
expect(wrapperText).toContain('statistics in the merge request');
expect(wrapperText).toContain('statistics across projects');
expect(wrapperText).toContain('Runtime security metrics');
- expect(wrapperText).toContain('risk analysis and remediation');
+ expect(wrapperText).toContain('More scan types, including Container Scanning,');
});
it(`re-emits GlBanner's close event`, () => {
diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js
deleted file mode 100644
index fbd72265c4b..00000000000
--- a/spec/frontend/security_configuration/configuration_table_spec.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import ConfigurationTable from '~/security_configuration/components/configuration_table.vue';
-import { scanners, UPGRADE_CTA } from '~/security_configuration/components/constants';
-
-import {
- REPORT_TYPE_SAST,
- REPORT_TYPE_SECRET_DETECTION,
-} from '~/vue_shared/security_reports/constants';
-
-describe('Configuration Table Component', () => {
- let wrapper;
-
- const createComponent = () => {
- wrapper = extendedWrapper(
- mount(ConfigurationTable, {
- provide: {
- projectPath: 'testProjectPath',
- },
- }),
- );
- };
-
- const findHelpLinks = () => wrapper.findAll('[data-testid="help-link"]');
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- beforeEach(() => {
- createComponent();
- });
-
- describe.each(scanners.map((scanner, i) => [scanner, i]))('given scanner %s', (scanner, i) => {
- it('should match strings', () => {
- expect(wrapper.text()).toContain(scanner.name);
- expect(wrapper.text()).toContain(scanner.description);
- if (scanner.type === REPORT_TYPE_SAST) {
- expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via Merge Request');
- } else if (scanner.type === REPORT_TYPE_SECRET_DETECTION) {
- expect(wrapper.findByTestId(scanner.type).exists()).toBe(false);
- } else {
- expect(wrapper.findByTestId(scanner.type).text()).toMatchInterpolatedText(UPGRADE_CTA);
- }
- });
-
- it('should show expected help link', () => {
- const helpLink = findHelpLinks().at(i);
- expect(helpLink.attributes('href')).toBe(scanner.helpPath);
- });
- });
-});
diff --git a/spec/frontend/security_configuration/upgrade_spec.js b/spec/frontend/security_configuration/upgrade_spec.js
deleted file mode 100644
index 20bb38aa469..00000000000
--- a/spec/frontend/security_configuration/upgrade_spec.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { UPGRADE_CTA } from '~/security_configuration/components/constants';
-import Upgrade from '~/security_configuration/components/upgrade.vue';
-
-const TEST_URL = 'http://www.example.test';
-let wrapper;
-const createComponent = (componentData = {}) => {
- wrapper = mount(Upgrade, componentData);
-};
-
-afterEach(() => {
- wrapper.destroy();
-});
-
-describe('Upgrade component', () => {
- beforeEach(() => {
- createComponent({ provide: { upgradePath: TEST_URL } });
- });
-
- it('renders correct text in link', () => {
- expect(wrapper.text()).toMatchInterpolatedText(UPGRADE_CTA);
- });
-
- it('renders link with correct default attributes', () => {
- expect(wrapper.find('a').attributes()).toMatchObject({
- href: TEST_URL,
- target: '_blank',
- });
- });
-});
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 523f4e88985..1a874c3dcd6 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
@@ -30,8 +30,13 @@ exports[`self monitor component When the self monitor project has not been creat
class="js-section-sub-header"
>
- Enable or disable instance self monitoring
-
+ Activate or deactivate instance self monitoring.
+
+ <gl-link-stub
+ href="/help/administration/monitoring/gitlab_self_monitoring_project/index"
+ >
+ Learn more.
+ </gl-link-stub>
</p>
</div>
@@ -42,14 +47,14 @@ exports[`self monitor component When the self monitor project has not been creat
name="self-monitoring-form"
>
<p>
- Enabling this feature creates a project that can be used to monitor the health of your instance.
+ Activate self monitoring to create a project to use to monitor the health of your instance.
</p>
<gl-form-group-stub
labeldescription=""
>
<gl-toggle-stub
- label="Create Project"
+ label="Self monitoring"
labelposition="top"
/>
</gl-form-group-stub>
@@ -62,15 +67,15 @@ exports[`self monitor component When the self monitor project has not been creat
dismisslabel="Close"
modalclass=""
modalid="delete-self-monitor-modal"
- ok-title="Delete project"
+ ok-title="Delete self monitoring project"
ok-variant="danger"
size="md"
- title="Disable self monitoring?"
+ title="Deactivate self monitoring?"
titletag="h4"
>
<div>
- Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?
+ Deactivating self monitoring deletes the self monitoring project. Are you sure you want to deactivate self monitoring and delete the project?
</div>
</gl-modal-stub>
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 e6962e4c453..89ad5a00a14 100644
--- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js
+++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js
@@ -53,7 +53,7 @@ describe('self monitor component', () => {
wrapper = shallowMount(SelfMonitor, { store });
expect(wrapper.find('.js-section-sub-header').text()).toContain(
- 'Enable or disable instance self monitoring',
+ 'Activate or deactivate instance self monitoring.',
);
});
});
@@ -63,7 +63,7 @@ describe('self monitor component', () => {
wrapper = shallowMount(SelfMonitor, { store });
expect(wrapper.vm.selfMonitoringFormText).toContain(
- 'Enabling this feature creates a project that can be used to monitor the health of your instance.',
+ 'Activate self monitoring to create a project to use to monitor the health of your instance.',
);
});
diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js
index 29181e15680..6bcb2a713ea 100644
--- a/spec/frontend/self_monitor/store/actions_spec.js
+++ b/spec/frontend/self_monitor/store/actions_spec.js
@@ -134,7 +134,7 @@ describe('self monitor actions', () => {
payload: {
actionName: 'viewSelfMonitorProject',
actionText: 'View project',
- message: 'Self monitoring project has been successfully created.',
+ message: 'Self monitoring project successfully created.',
},
},
{ type: types.SET_SHOW_ALERT, payload: true },
@@ -245,7 +245,7 @@ describe('self monitor actions', () => {
payload: {
actionName: 'createProject',
actionText: 'Undo',
- message: 'Self monitoring project has been successfully deleted.',
+ message: 'Self monitoring project successfully deleted.',
},
},
{ type: types.SET_SHOW_ALERT, payload: true },
diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
index 5a3a152d201..69f6a6e6e04 100644
--- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
+++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
@@ -81,30 +81,33 @@ describe('AssigneeAvatarLink component', () => {
);
describe.each`
- tooltipHasName | availability | canMerge | expected
- ${true} | ${'Busy'} | ${false} | ${'Root (Busy) (cannot merge)'}
- ${true} | ${'Busy'} | ${true} | ${'Root (Busy)'}
- ${true} | ${''} | ${false} | ${'Root (cannot merge)'}
- ${true} | ${''} | ${true} | ${'Root'}
- ${false} | ${'Busy'} | ${false} | ${'Cannot merge'}
- ${false} | ${'Busy'} | ${true} | ${''}
- ${false} | ${''} | ${false} | ${'Cannot merge'}
- ${false} | ${''} | ${true} | ${''}
+ tooltipHasName | name | availability | canMerge | expected
+ ${true} | ${"Rabbit O'Hare"} | ${''} | ${true} | ${"Rabbit O'Hare"}
+ ${true} | ${"Rabbit O'Hare"} | ${'Busy'} | ${false} | ${"Rabbit O'Hare (Busy) (cannot merge)"}
+ ${true} | ${'Root'} | ${'Busy'} | ${false} | ${'Root (Busy) (cannot merge)'}
+ ${true} | ${'Root'} | ${'Busy'} | ${true} | ${'Root (Busy)'}
+ ${true} | ${'Root'} | ${''} | ${false} | ${'Root (cannot merge)'}
+ ${true} | ${'Root'} | ${''} | ${true} | ${'Root'}
+ ${false} | ${'Root'} | ${'Busy'} | ${false} | ${'Cannot merge'}
+ ${false} | ${'Root'} | ${'Busy'} | ${true} | ${''}
+ ${false} | ${'Root'} | ${''} | ${false} | ${'Cannot merge'}
+ ${false} | ${'Root'} | ${''} | ${true} | ${''}
`(
- "with tooltipHasName=$tooltipHasName and availability='$availability' and canMerge=$canMerge",
- ({ tooltipHasName, availability, canMerge, expected }) => {
+ "with name=$name tooltipHasName=$tooltipHasName and availability='$availability' and canMerge=$canMerge",
+ ({ name, tooltipHasName, availability, canMerge, expected }) => {
beforeEach(() => {
createComponent({
tooltipHasName,
user: {
...userDataMock(),
+ name,
can_merge: canMerge,
availability,
},
});
});
- it('sets tooltip to $expected', () => {
+ it(`sets tooltip to "${expected}"`, () => {
expect(findTooltipText()).toBe(expected);
});
},
diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js
index 747d370e1cf..6116bc68927 100644
--- a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js
+++ b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js
@@ -1,5 +1,6 @@
import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/components/severity/constants';
@@ -15,6 +16,7 @@ describe('SidebarSeverity', () => {
const projectPath = 'gitlab-org/gitlab-test';
const iid = '1';
const severity = 'CRITICAL';
+ let canUpdate = true;
function createComponent(props = {}) {
const propsData = {
@@ -25,8 +27,11 @@ describe('SidebarSeverity', () => {
...props,
};
mutate = jest.fn();
- wrapper = shallowMount(SidebarSeverity, {
+ wrapper = shallowMountExtended(SidebarSeverity, {
propsData,
+ provide: {
+ canUpdate,
+ },
mocks: {
$apollo: {
mutate,
@@ -45,22 +50,34 @@ describe('SidebarSeverity', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
- wrapper = null;
}
});
- const findSeverityToken = () => wrapper.findAll(SeverityToken);
- const findEditBtn = () => wrapper.find('[data-testid="editButton"]');
- const findDropdown = () => wrapper.find(GlDropdown);
- const findCriticalSeverityDropdownItem = () => wrapper.find(GlDropdownItem);
- const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const findTooltip = () => wrapper.find(GlTooltip);
+ const findSeverityToken = () => wrapper.findAllComponents(SeverityToken);
+ const findEditBtn = () => wrapper.findByTestId('editButton');
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findCriticalSeverityDropdownItem = () => wrapper.findComponent(GlDropdownItem);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
const findCollapsedSeverity = () => wrapper.find({ ref: 'severity' });
- it('renders severity widget', () => {
- expect(findEditBtn().exists()).toBe(true);
- expect(findSeverityToken().exists()).toBe(true);
- expect(findDropdown().exists()).toBe(true);
+ describe('Severity widget', () => {
+ it('renders severity dropdown and token', () => {
+ expect(findSeverityToken().exists()).toBe(true);
+ expect(findDropdown().exists()).toBe(true);
+ });
+
+ describe('edit button', () => {
+ it('is rendered when `canUpdate` provided as `true`', () => {
+ expect(findEditBtn().exists()).toBe(true);
+ });
+
+ it('is NOT rendered when `canUpdate` provided as `false`', () => {
+ canUpdate = false;
+ createComponent();
+ expect(findEditBtn().exists()).toBe(false);
+ });
+ });
});
describe('Update severity', () => {
@@ -100,7 +117,7 @@ describe('SidebarSeverity', () => {
);
findCriticalSeverityDropdownItem().vm.$emit('click');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
resolvePromise();
@@ -128,27 +145,29 @@ describe('SidebarSeverity', () => {
it('should expand the dropdown on collapsed icon click', async () => {
wrapper.vm.isDropdownShowing = false;
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
findCollapsedSeverity().trigger('click');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findDropdown().classes(SHOWN_CLASS)).toBe(true);
});
});
describe('expanded', () => {
it('toggles dropdown with edit button', async () => {
+ canUpdate = true;
+ createComponent();
wrapper.vm.isDropdownShowing = false;
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
findEditBtn().vm.$emit('click');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findDropdown().classes(SHOWN_CLASS)).toBe(true);
findEditBtn().vm.$emit('click');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true);
});
});
diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
index f5e5ab4a984..ca6e5ac5e7f 100644
--- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
+++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
@@ -12,11 +12,13 @@ import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
+import { timeFor } from '~/lib/utils/datetime_utility';
import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { IssuableAttributeType } from '~/sidebar/constants';
@@ -54,6 +56,7 @@ describe('SidebarDropdownWidget', () => {
const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors });
const findGlLink = () => wrapper.findComponent(GlLink);
+ const findDateTooltip = () => getBinding(findGlLink().element, 'gl-tooltip');
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownText = () => wrapper.findComponent(GlDropdownText);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
@@ -155,6 +158,9 @@ describe('SidebarDropdownWidget', () => {
},
},
},
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
stubs: {
SidebarEditableItem,
GlSearchBoxByType,
@@ -177,7 +183,7 @@ describe('SidebarDropdownWidget', () => {
beforeEach(() => {
createComponent({
data: {
- currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl' },
+ currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl', dueDate: '2021-09-09' },
},
stubs: {
GlDropdown,
@@ -223,6 +229,10 @@ describe('SidebarDropdownWidget', () => {
expect(findSelectedAttribute().text()).toBe('Some milestone title');
});
+ it('displays time for milestone due date in tooltip', () => {
+ expect(findDateTooltip().value).toBe(timeFor('2021-09-09'));
+ });
+
describe('when current attribute does not exist', () => {
it('renders "None" as the selected attribute title', () => {
createComponent();
@@ -451,7 +461,6 @@ describe('SidebarDropdownWidget', () => {
expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, {
fullPath: mockIssue.projectPath,
- sort: null,
state: 'active',
title: '',
});
@@ -478,7 +487,6 @@ describe('SidebarDropdownWidget', () => {
expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, {
fullPath: mockIssue.projectPath,
- sort: null,
state: 'active',
title: mockSearchTerm,
});
diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js
index 862bcbe861e..938750bd58b 100644
--- a/spec/frontend/sidebar/components/time_tracking/mock_data.js
+++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js
@@ -16,9 +16,10 @@ export const getIssueTimelogsQueryResponse = {
},
spentAt: '2020-05-01T00:00:00Z',
note: {
- body: 'I paired with @root on this last week.',
+ body: 'A note',
__typename: 'Note',
},
+ summary: 'A summary',
},
{
__typename: 'Timelog',
@@ -29,6 +30,7 @@ export const getIssueTimelogsQueryResponse = {
},
spentAt: '2021-05-07T13:19:01Z',
note: null,
+ summary: 'A summary',
},
{
__typename: 'Timelog',
@@ -39,9 +41,10 @@ export const getIssueTimelogsQueryResponse = {
},
spentAt: '2021-05-01T00:00:00Z',
note: {
- body: 'I did some work on this last week.',
+ body: 'A note',
__typename: 'Note',
},
+ summary: null,
},
],
__typename: 'TimelogConnection',
@@ -70,6 +73,7 @@ export const getMrTimelogsQueryResponse = {
body: 'Thirty minutes!',
__typename: 'Note',
},
+ summary: null,
},
{
__typename: 'Timelog',
@@ -80,6 +84,7 @@ export const getMrTimelogsQueryResponse = {
},
spentAt: '2021-05-07T14:44:39Z',
note: null,
+ summary: null,
},
{
__typename: 'Timelog',
@@ -93,6 +98,7 @@ export const getMrTimelogsQueryResponse = {
body: 'A note with some time',
__typename: 'Note',
},
+ summary: null,
},
],
__typename: 'TimelogConnection',
diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js
index 710fae8ddf7..66218626e6b 100644
--- a/spec/frontend/sidebar/components/time_tracking/report_spec.js
+++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js
@@ -74,6 +74,8 @@ describe('Issuable Time Tracking Report', () => {
expect(getAllByRole(wrapper.element, 'row', { name: /John Doe18/i })).toHaveLength(1);
expect(getAllByRole(wrapper.element, 'row', { name: /Administrator/i })).toHaveLength(2);
+ expect(getAllByRole(wrapper.element, 'row', { name: /A note/i })).toHaveLength(1);
+ expect(getAllByRole(wrapper.element, 'row', { name: /A summary/i })).toHaveLength(2);
});
});
diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js
index 9fab24d7518..1ebd3c622ca 100644
--- a/spec/frontend/sidebar/mock_data.js
+++ b/spec/frontend/sidebar/mock_data.js
@@ -415,7 +415,7 @@ const mockUser1 = {
status: null,
};
-const mockUser2 = {
+export const mockUser2 = {
id: 'gid://gitlab/User/4',
avatarUrl: '/avatar2',
name: 'rookie',
@@ -452,9 +452,40 @@ export const projectMembersResponse = {
null,
null,
// Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822
- mockUser1,
- mockUser1,
- mockUser2,
+ { user: mockUser1 },
+ { user: mockUser1 },
+ { user: mockUser2 },
+ {
+ user: {
+ id: 'gid://gitlab/User/2',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
+ name: 'Jacki Kub',
+ username: 'francina.skiles',
+ webUrl: '/franc',
+ status: {
+ availability: 'BUSY',
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const groupMembersResponse = {
+ data: {
+ workspace: {
+ __typename: 'roup',
+ users: {
+ nodes: [
+ // Remove nulls https://gitlab.com/gitlab-org/gitlab/-/issues/329750
+ null,
+ null,
+ // Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822
+ { user: mockUser1 },
+ { user: mockUser1 },
{
user: {
id: 'gid://gitlab/User/2',
@@ -531,6 +562,7 @@ export const mockMilestone1 = {
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1',
state: 'active',
expired: false,
+ dueDate: '2030-09-09',
};
export const mockMilestone2 = {
@@ -540,6 +572,7 @@ export const mockMilestone2 = {
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2',
state: 'active',
expired: false,
+ dueDate: '2030-09-09',
};
export const mockProjectMilestonesResponse = {
@@ -554,6 +587,19 @@ export const mockProjectMilestonesResponse = {
},
};
+export const mockGroupMilestonesResponse = {
+ data: {
+ workspace: {
+ id: 'gid://gitlab/Group/1',
+ attributes: {
+ nodes: [mockMilestone1, mockMilestone2],
+ },
+ __typename: 'MilestoneConnection',
+ },
+ __typename: 'Group',
+ },
+};
+
export const noCurrentMilestoneResponse = {
data: {
workspace: {
@@ -574,6 +620,7 @@ export const mockMilestoneMutationResponse = {
title: 'Awesome Milestone',
state: 'active',
expired: false,
+ dueDate: '2030-09-09',
__typename: 'Milestone',
},
__typename: 'Issue',
diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js
index e6162c6aad2..b7b638b5137 100644
--- a/spec/frontend/snippets/components/show_spec.js
+++ b/spec/frontend/snippets/components/show_spec.js
@@ -71,7 +71,9 @@ describe('Snippet view app', () => {
it('renders correct snippet-blob components', () => {
createComponent({
data: {
- blobs: [Blob, BinaryBlob],
+ snippet: {
+ blobs: [Blob, BinaryBlob],
+ },
},
});
const blobs = wrapper.findAll(SnippetBlob);
diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js
index 585614a6b79..fb95be3a77c 100644
--- a/spec/frontend/snippets/components/snippet_header_spec.js
+++ b/spec/frontend/snippets/components/snippet_header_spec.js
@@ -1,6 +1,7 @@
-import { GlButton, GlModal } from '@gitlab/ui';
+import { GlButton, GlModal, GlDropdown } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { Blob, BinaryBlob } from 'jest/blob/components/mock_data';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
@@ -16,6 +17,7 @@ describe('Snippet header component', () => {
let errorMsg;
let err;
const originalRelativeUrlRoot = gon.relative_url_root;
+ const reportAbusePath = '/-/snippets/42/mark_as_spam';
const GlEmoji = { template: '<img/>' };
@@ -24,6 +26,7 @@ describe('Snippet header component', () => {
permissions = {},
mutationRes = mutationTypes.RESOLVE,
snippetProps = {},
+ provide = {},
} = {}) {
const defaultProps = Object.assign(snippet, snippetProps);
if (permissions) {
@@ -42,6 +45,10 @@ describe('Snippet header component', () => {
wrapper = mount(SnippetHeader, {
mocks: { $apollo },
+ provide: {
+ reportAbusePath,
+ ...provide,
+ },
propsData: {
snippet: {
...defaultProps,
@@ -54,9 +61,27 @@ describe('Snippet header component', () => {
});
}
- const findAuthorEmoji = () => wrapper.find(GlEmoji);
+ const findAuthorEmoji = () => wrapper.findComponent(GlEmoji);
const findAuthoredMessage = () => wrapper.find('[data-testid="authored-message"]').text();
- const buttonCount = () => wrapper.findAll(GlButton).length;
+ const findButtons = () => wrapper.findAllComponents(GlButton);
+ const findButtonsAsModel = () =>
+ findButtons().wrappers.map((x) => ({
+ text: x.text(),
+ href: x.attributes('href'),
+ category: x.props('category'),
+ variant: x.props('variant'),
+ disabled: x.props('disabled'),
+ }));
+ const findResponsiveDropdown = () => wrapper.findComponent(GlDropdown);
+ // We can't search by component here since we are full mounting and the attributes are applied to a child of the GlDropdownItem
+ const findResponsiveDropdownItems = () => findResponsiveDropdown().findAll('[role="menuitem"]');
+ const findResponsiveDropdownItemsAsModel = () =>
+ findResponsiveDropdownItems().wrappers.map((x) => ({
+ disabled: x.attributes('disabled'),
+ href: x.attributes('href'),
+ title: x.attributes('title'),
+ text: x.text(),
+ }));
beforeEach(() => {
gon.relative_url_root = '/foo/';
@@ -141,42 +166,108 @@ describe('Snippet header component', () => {
expect(text).toBe('Authored 1 month ago');
});
- it('renders action buttons based on permissions', () => {
- createComponent({
- permissions: {
- adminSnippet: false,
- updateSnippet: false,
+ it('renders a action buttons', () => {
+ createComponent();
+
+ expect(findButtonsAsModel()).toEqual([
+ {
+ category: 'primary',
+ disabled: false,
+ href: `${snippet.webUrl}/edit`,
+ text: 'Edit',
+ variant: 'default',
},
- });
- expect(buttonCount()).toEqual(0);
+ {
+ category: 'secondary',
+ disabled: false,
+ text: 'Delete',
+ variant: 'danger',
+ },
+ {
+ category: 'primary',
+ disabled: false,
+ href: reportAbusePath,
+ text: 'Submit as spam',
+ variant: 'default',
+ },
+ ]);
+ });
- createComponent({
- permissions: {
- adminSnippet: true,
- updateSnippet: false,
+ it('renders responsive dropdown for action buttons', () => {
+ createComponent();
+
+ expect(findResponsiveDropdownItemsAsModel()).toEqual([
+ {
+ href: `${snippet.webUrl}/edit`,
+ text: 'Edit',
},
- });
- expect(buttonCount()).toEqual(1);
+ {
+ text: 'Delete',
+ },
+ {
+ href: reportAbusePath,
+ text: 'Submit as spam',
+ title: 'Submit as spam',
+ },
+ ]);
+ });
+ it.each`
+ permissions | buttons
+ ${{ adminSnippet: false, updateSnippet: false }} | ${['Submit as spam']}
+ ${{ adminSnippet: true, updateSnippet: false }} | ${['Delete', 'Submit as spam']}
+ ${{ adminSnippet: false, updateSnippet: true }} | ${['Edit', 'Submit as spam']}
+ `('with permissions ($permissions), renders buttons ($buttons)', ({ permissions, buttons }) => {
createComponent({
permissions: {
- adminSnippet: true,
- updateSnippet: true,
+ ...permissions,
},
});
- expect(buttonCount()).toEqual(2);
- createComponent({
- permissions: {
- adminSnippet: true,
- updateSnippet: true,
- },
+ expect(findButtonsAsModel().map((x) => x.text)).toEqual(buttons);
+ });
+
+ it('with canCreateSnippet permission, renders create button', async () => {
+ createComponent();
+
+ // TODO: we should avoid `wrapper.setData` since they
+ // are component internals. Let's use the apollo mock helpers
+ // in a follow-up.
+ wrapper.setData({ canCreateSnippet: true });
+ await wrapper.vm.$nextTick();
+
+ expect(findButtonsAsModel()).toEqual(
+ expect.arrayContaining([
+ {
+ category: 'secondary',
+ disabled: false,
+ href: `/foo/-/snippets/new`,
+ text: 'New snippet',
+ variant: 'success',
+ },
+ ]),
+ );
+ });
+
+ describe('with guest user', () => {
+ beforeEach(() => {
+ createComponent({
+ permissions: {
+ adminSnippet: false,
+ updateSnippet: false,
+ },
+ provide: {
+ reportAbusePath: null,
+ },
+ });
});
- wrapper.setData({
- canCreateSnippet: true,
+
+ it('does not show any action buttons', () => {
+ expect(findButtons()).toHaveLength(0);
});
- return wrapper.vm.$nextTick().then(() => {
- expect(buttonCount()).toEqual(3);
+
+ it('does not show responsive action dropdown', () => {
+ expect(findResponsiveDropdown().exists()).toBe(false);
});
});
@@ -200,19 +291,6 @@ describe('Snippet header component', () => {
});
describe('Delete mutation', () => {
- const { location } = window;
-
- beforeEach(() => {
- delete window.location;
- window.location = {
- pathname: '',
- };
- });
-
- afterEach(() => {
- window.location = location;
- });
-
it('dispatches a mutation to delete the snippet with correct variables', () => {
createComponent();
wrapper.vm.deleteSnippet();
@@ -231,6 +309,8 @@ describe('Snippet header component', () => {
});
describe('in case of successful mutation, closes modal and redirects to correct listing', () => {
+ useMockLocationHelper();
+
const createDeleteSnippet = (snippetProps = {}) => {
createComponent({
snippetProps,
diff --git a/spec/frontend/syntax_highlight_spec.js b/spec/frontend/syntax_highlight_spec.js
index 418679e7d18..8ad4f8d5c70 100644
--- a/spec/frontend/syntax_highlight_spec.js
+++ b/spec/frontend/syntax_highlight_spec.js
@@ -10,39 +10,50 @@ describe('Syntax Highlighter', () => {
}
return (window.gon.user_color_scheme = value);
};
- describe('on a js-syntax-highlight element', () => {
- beforeEach(() => {
- setFixtures('<div class="js-syntax-highlight"></div>');
- });
-
- it('applies syntax highlighting', () => {
- stubUserColorScheme('monokai');
- syntaxHighlight($('.js-syntax-highlight'));
- expect($('.js-syntax-highlight')).toHaveClass('monokai');
+ // We have to bind `document.querySelectorAll` to `document` to not mess up the fn's context
+ describe.each`
+ desc | fn
+ ${'jquery'} | ${$}
+ ${'vanilla all'} | ${document.querySelectorAll.bind(document)}
+ ${'vanilla single'} | ${document.querySelector.bind(document)}
+ `('highlight using $desc syntax', ({ fn }) => {
+ describe('on a js-syntax-highlight element', () => {
+ beforeEach(() => {
+ setFixtures('<div class="js-syntax-highlight"></div>');
+ });
+
+ it('applies syntax highlighting', () => {
+ stubUserColorScheme('monokai');
+ syntaxHighlight(fn('.js-syntax-highlight'));
+
+ expect(fn('.js-syntax-highlight')).toHaveClass('monokai');
+ });
});
- });
- describe('on a parent element', () => {
- beforeEach(() => {
- setFixtures(
- '<div class="parent">\n <div class="js-syntax-highlight"></div>\n <div class="foo"></div>\n <div class="js-syntax-highlight"></div>\n</div>',
- );
- });
+ describe('on a parent element', () => {
+ beforeEach(() => {
+ setFixtures(
+ '<div class="parent">\n <div class="js-syntax-highlight"></div>\n <div class="foo"></div>\n <div class="js-syntax-highlight"></div>\n</div>',
+ );
+ });
- it('applies highlighting to all applicable children', () => {
- stubUserColorScheme('monokai');
- syntaxHighlight($('.parent'));
+ it('applies highlighting to all applicable children', () => {
+ stubUserColorScheme('monokai');
+ syntaxHighlight(fn('.parent'));
- expect($('.parent, .foo')).not.toHaveClass('monokai');
- expect($('.monokai').length).toBe(2);
- });
+ expect(fn('.parent')).not.toHaveClass('monokai');
+ expect(fn('.foo')).not.toHaveClass('monokai');
+
+ expect(document.querySelectorAll('.monokai').length).toBe(2);
+ });
- it('prevents an infinite loop when no matches exist', () => {
- setFixtures('<div></div>');
- const highlight = () => syntaxHighlight($('div'));
+ it('prevents an infinite loop when no matches exist', () => {
+ setFixtures('<div></div>');
+ const highlight = () => syntaxHighlight(fn('div'));
- expect(highlight).not.toThrow();
+ expect(highlight).not.toThrow();
+ });
});
});
});
diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js
index c86160e18f3..1637ac2039c 100644
--- a/spec/frontend/terraform/components/empty_state_spec.js
+++ b/spec/frontend/terraform/components/empty_state_spec.js
@@ -1,4 +1,4 @@
-import { GlEmptyState, GlSprintf } from '@gitlab/ui';
+import { GlEmptyState, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EmptyState from '~/terraform/components/empty_state.vue';
@@ -8,19 +8,20 @@ describe('EmptyStateComponent', () => {
const propsData = {
image: '/image/path',
};
+ const docsUrl = '/help/user/infrastructure/terraform_state';
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findLink = () => wrapper.findComponent(GlLink);
beforeEach(() => {
- wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlSprintf } });
- return wrapper.vm.$nextTick();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
+ wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlLink } });
});
it('should render content', () => {
- expect(wrapper.find(GlEmptyState).exists()).toBe(true);
+ expect(findEmptyState().exists()).toBe(true);
expect(wrapper.text()).toContain('Get started with Terraform');
});
+
+ it('should have a link to the GitLab managed Terraform States docs', () => {
+ expect(findLink().attributes('href')).toBe(docsUrl);
+ });
});
diff --git a/spec/frontend/terraform/components/init_command_modal_spec.js b/spec/frontend/terraform/components/init_command_modal_spec.js
new file mode 100644
index 00000000000..dbdff899bac
--- /dev/null
+++ b/spec/frontend/terraform/components/init_command_modal_spec.js
@@ -0,0 +1,79 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import InitCommandModal from '~/terraform/components/init_command_modal.vue';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+
+const accessTokensPath = '/path/to/access-tokens-page';
+const terraformApiUrl = 'https://gitlab.com/api/v4/projects/1';
+const username = 'username';
+const modalId = 'fake-modal-id';
+const stateName = 'production';
+const modalInfoCopyStr = `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN>
+terraform init \\
+ -backend-config="address=${terraformApiUrl}/${stateName}" \\
+ -backend-config="lock_address=${terraformApiUrl}/${stateName}/lock" \\
+ -backend-config="unlock_address=${terraformApiUrl}/${stateName}/lock" \\
+ -backend-config="username=${username}" \\
+ -backend-config="password=$GITLAB_ACCESS_TOKEN" \\
+ -backend-config="lock_method=POST" \\
+ -backend-config="unlock_method=DELETE" \\
+ -backend-config="retry_wait_min=5"
+ `;
+
+describe('InitCommandModal', () => {
+ let wrapper;
+
+ const propsData = {
+ modalId,
+ stateName,
+ };
+ const provideData = {
+ accessTokensPath,
+ terraformApiUrl,
+ username,
+ };
+
+ const findExplanatoryText = () => wrapper.findByTestId('init-command-explanatory-text');
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findInitCommand = () => wrapper.findByTestId('terraform-init-command');
+ const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
+
+ beforeEach(() => {
+ wrapper = shallowMountExtended(InitCommandModal, {
+ propsData,
+ provide: provideData,
+ stubs: {
+ GlSprintf,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('on rendering', () => {
+ it('renders the explanatory text', () => {
+ expect(findExplanatoryText().text()).toContain('personal access token');
+ });
+
+ it('renders the personal access token link', () => {
+ expect(findLink().attributes('href')).toBe(accessTokensPath);
+ });
+
+ it('renders the init command with the username and state name prepopulated', () => {
+ expect(findInitCommand().text()).toContain(username);
+ expect(findInitCommand().text()).toContain(stateName);
+ });
+
+ it('renders the copyToClipboard button', () => {
+ expect(findCopyButton().exists()).toBe(true);
+ });
+ });
+
+ describe('when copy button is clicked', () => {
+ it('copies init command to clipboard', () => {
+ expect(findCopyButton().props('text')).toBe(modalInfoCopyStr);
+ });
+ });
+});
diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js
index 61f6e9f0f7b..9d28e8ce294 100644
--- a/spec/frontend/terraform/components/states_table_actions_spec.js
+++ b/spec/frontend/terraform/components/states_table_actions_spec.js
@@ -3,6 +3,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import InitCommandModal from '~/terraform/components/init_command_modal.vue';
import StateActions from '~/terraform/components/states_table_actions.vue';
import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql';
import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql';
@@ -73,12 +74,14 @@ describe('StatesTableActions', () => {
return wrapper.vm.$nextTick();
};
- const findActionsDropdown = () => wrapper.find(GlDropdown);
+ const findActionsDropdown = () => wrapper.findComponent(GlDropdown);
+ const findCopyBtn = () => wrapper.find('[data-testid="terraform-state-copy-init-command"]');
+ const findCopyModal = () => wrapper.findComponent(InitCommandModal);
const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]');
const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]');
const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]');
const findRemoveBtn = () => wrapper.find('[data-testid="terraform-state-remove"]');
- const findRemoveModal = () => wrapper.find(GlModal);
+ const findRemoveModal = () => wrapper.findComponent(GlModal);
beforeEach(() => {
return createComponent();
@@ -125,6 +128,25 @@ describe('StatesTableActions', () => {
});
});
+ describe('copy command button', () => {
+ it('displays a copy init command button', () => {
+ expect(findCopyBtn().text()).toBe('Copy Terraform init command');
+ });
+
+ describe('when clicking the copy init command button', () => {
+ beforeEach(() => {
+ findCopyBtn().vm.$emit('click');
+
+ return waitForPromises();
+ });
+
+ it('opens the modal', async () => {
+ expect(findCopyModal().exists()).toBe(true);
+ expect(findCopyModal().isVisible()).toBe(true);
+ });
+ });
+ });
+
describe('download button', () => {
it('displays a download button', () => {
expect(findDownloadBtn().text()).toBe('Download JSON');
@@ -253,7 +275,7 @@ describe('StatesTableActions', () => {
it('displays a remove modal', () => {
expect(findRemoveModal().text()).toContain(
- `You are about to remove the State file ${defaultProps.state.name}`,
+ `You are about to remove the state file ${defaultProps.state.name}`,
);
});
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 9b95ed6b816..4d1b0f54e42 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -3,6 +3,8 @@ import * as jqueryMatchers from 'custom-jquery-matchers';
import Vue from 'vue';
import 'jquery';
import { setGlobalDateToFakeDate } from 'helpers/fake_date';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
import Translate from '~/vue_shared/translate';
import { getJSONFixture, loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures';
import { initializeTestTimeout } from './__helpers__/timeout';
@@ -88,8 +90,13 @@ Object.assign(global, {
},
});
-// make sure that each test actually tests something
-// see https://jestjs.io/docs/en/expect#expecthasassertions
beforeEach(() => {
+ // make sure that each test actually tests something
+ // see https://jestjs.io/docs/en/expect#expecthasassertions
expect.hasAssertions();
+
+ // Reset the mocked window.location. This ensures tests don't interfere with
+ // each other, and removes the need to tidy up if it was changed for a given
+ // test.
+ setWindowLocation(TEST_HOST);
});
diff --git a/spec/frontend/token_access/token_access_spec.js b/spec/frontend/token_access/token_access_spec.js
index c7323eb19fe..c4e29a52f1c 100644
--- a/spec/frontend/token_access/token_access_spec.js
+++ b/spec/frontend/token_access/token_access_spec.js
@@ -1,7 +1,8 @@
import { GlToggle, GlLoadingIcon } from '@gitlab/ui';
-import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
+import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import TokenAccess from '~/token_access/components/token_access.vue';
@@ -41,15 +42,15 @@ describe('TokenAccess component', () => {
const findToggle = () => wrapper.findComponent(GlToggle);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findAddProjectBtn = () => wrapper.find('[data-testid="add-project-button"]');
- const findRemoveProjectBtn = () => wrapper.find('[data-testid="remove-project-button"]');
+ const findAddProjectBtn = () => wrapper.findByRole('button', { name: 'Add project' });
+ const findRemoveProjectBtn = () => wrapper.findByRole('button', { name: 'Remove access' });
const findTokenSection = () => wrapper.find('[data-testid="token-section"]');
const createMockApolloProvider = (requestHandlers) => {
return createMockApollo(requestHandlers);
};
- const createComponent = (requestHandlers, mountFn = shallowMount) => {
+ const createComponent = (requestHandlers, mountFn = shallowMountExtended) => {
wrapper = mountFn(TokenAccess, {
localVue,
provide: {
@@ -138,7 +139,7 @@ describe('TokenAccess component', () => {
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
[addProjectCIJobTokenScopeMutation, addProjectSuccessHandler],
],
- mount,
+ mountExtended,
);
await waitForPromises();
@@ -160,7 +161,7 @@ describe('TokenAccess component', () => {
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
[addProjectCIJobTokenScopeMutation, addProjectFailureHandler],
],
- mount,
+ mountExtended,
);
await waitForPromises();
@@ -181,7 +182,7 @@ describe('TokenAccess component', () => {
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
[removeProjectCIJobTokenScopeMutation, removeProjectSuccessHandler],
],
- mount,
+ mountExtended,
);
await waitForPromises();
@@ -203,7 +204,7 @@ describe('TokenAccess component', () => {
[getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope],
[removeProjectCIJobTokenScopeMutation, removeProjectFailureHandler],
],
- mount,
+ mountExtended,
);
await waitForPromises();
diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js
index c44918ceaf3..9b703b74a1a 100644
--- a/spec/frontend/tooltips/components/tooltips_spec.js
+++ b/spec/frontend/tooltips/components/tooltips_spec.js
@@ -4,7 +4,7 @@ import { useMockMutationObserver } from 'helpers/mock_dom_observer';
import Tooltips from '~/tooltips/components/tooltips.vue';
describe('tooltips/components/tooltips.vue', () => {
- const { trigger: triggerMutate, observersCount } = useMockMutationObserver();
+ const { trigger: triggerMutate } = useMockMutationObserver();
let wrapper;
const buildWrapper = () => {
@@ -211,11 +211,14 @@ describe('tooltips/components/tooltips.vue', () => {
it('disconnects mutation observer on beforeDestroy', () => {
buildWrapper();
wrapper.vm.addTooltips([createTooltipTarget()]);
+ const { observer } = wrapper.vm;
+ jest.spyOn(observer, 'disconnect');
- expect(observersCount()).toBe(1);
+ expect(observer.disconnect).toHaveBeenCalledTimes(0);
wrapper.destroy();
- expect(observersCount()).toBe(0);
+
+ expect(observer.disconnect).toHaveBeenCalledTimes(1);
});
it('exposes hidden event', async () => {
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
index 13498cfb823..a17efdd61a9 100644
--- a/spec/frontend/tracking_spec.js
+++ b/spec/frontend/tracking_spec.js
@@ -387,11 +387,13 @@ describe('Tracking', () => {
beforeEach(() => {
eventSpy = jest.spyOn(Tracking, 'event');
setHTMLFixture(`
- <input data-track-${term}="render" data-track-label="label1" value=1 data-track-property="_property_"/>
- <span data-track-${term}="render" data-track-label="label2" data-track-value=1>
- Something
- </span>
- <input data-track-${term}="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/>
+ <div data-track-${term}="click_link" data-track-label="all_nested_links">
+ <input data-track-${term}="render" data-track-label="label1" value=1 data-track-property="_property_"/>
+ <span data-track-${term}="render" data-track-label="label2" data-track-value=1>
+ <a href="#" id="link">Something</a>
+ </span>
+ <input data-track-${term}="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/>
+ </div>
`);
Tracking.trackLoadEvents('_category_'); // only happens once
});
@@ -417,6 +419,35 @@ describe('Tracking', () => {
],
]);
});
+
+ describe.each`
+ event | actionSuffix
+ ${'click'} | ${''}
+ ${'show.bs.dropdown'} | ${'_show'}
+ ${'hide.bs.dropdown'} | ${'_hide'}
+ `(`auto-tracking $event events on nested elements`, ({ event, actionSuffix }) => {
+ let link;
+
+ beforeEach(() => {
+ link = document.querySelector('#link');
+ eventSpy.mockClear();
+ });
+
+ it(`avoids using ancestor [data-track-${term}="render"] tracking configurations`, () => {
+ link.dispatchEvent(new Event(event, { bubbles: true }));
+
+ expect(eventSpy).not.toHaveBeenCalledWith(
+ '_category_',
+ `render${actionSuffix}`,
+ expect.any(Object),
+ );
+ expect(eventSpy).toHaveBeenCalledWith(
+ '_category_',
+ `click_link${actionSuffix}`,
+ expect.objectContaining({ label: 'all_nested_links' }),
+ );
+ });
+ });
});
describe('tracking mixin', () => {
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 f44f0b98207..a09269e869c 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
@@ -1,6 +1,7 @@
import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Header from '~/vue_merge_request_widget/components/mr_widget_header.vue';
+import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
describe('MRWidgetHeader', () => {
let wrapper;
@@ -35,6 +36,8 @@ describe('MRWidgetHeader', () => {
statusPath: 'abc',
};
+ const findWebIdeButton = () => wrapper.findComponent(WebIdeLink);
+
describe('computed', () => {
describe('shouldShowCommitsBehindText', () => {
it('return true when there are divergedCommitsCount', () => {
@@ -147,73 +150,81 @@ describe('MRWidgetHeader', () => {
statusPath: 'abc',
sourceProjectFullPath: 'root/gitlab-ce',
targetProjectFullPath: 'gitlab-org/gitlab-ce',
+ gitpodEnabled: true,
+ showGitpodButton: true,
+ gitpodUrl: 'http://gitpod.localhost',
};
- beforeEach(() => {
+ it('renders checkout branch button with modal trigger', () => {
createComponent({
mr: { ...mrDefaultOptions },
});
- });
- it('renders checkout branch button with modal trigger', () => {
const button = wrapper.find('.js-check-out-branch');
expect(button.text().trim()).toBe('Check out branch');
});
- it('renders web ide button', async () => {
- const button = wrapper.find('.js-web-ide');
-
- await nextTick();
-
- expect(button.text().trim()).toBe('Open in Web IDE');
- expect(button.classes('disabled')).toBe(false);
- expect(button.attributes('href')).toBe(
- '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=gitlab-org%2Fgitlab-ce',
- );
- });
-
- it('renders web ide button in disabled state with no href', async () => {
- const mr = { ...mrDefaultOptions, canPushToSourceBranch: false };
- createComponent({ mr });
-
- await nextTick();
-
- const link = wrapper.find('.js-web-ide');
-
- expect(link.attributes('disabled')).toBe('true');
- expect(link.attributes('href')).toBeUndefined();
- });
-
- it('renders web ide button with blank query string if target & source project branch', async () => {
- createComponent({ mr: { ...mrDefaultOptions, targetProjectFullPath: 'root/gitlab-ce' } });
+ it.each([
+ [
+ 'renders web ide button',
+ {
+ mrProps: {},
+ relativeUrl: '',
+ webIdeUrl:
+ '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=gitlab-org%2Fgitlab-ce',
+ },
+ ],
+ [
+ 'renders web ide button with blank target_project, when mr has same target project',
+ {
+ mrProps: { targetProjectFullPath: 'root/gitlab-ce' },
+ relativeUrl: '',
+ webIdeUrl: '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=',
+ },
+ ],
+ [
+ 'renders web ide button with relative url',
+ {
+ mrProps: { iid: 2 },
+ relativeUrl: '/gitlab',
+ webIdeUrl:
+ '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce',
+ },
+ ],
+ ])('%s', async (_, { mrProps, relativeUrl, webIdeUrl }) => {
+ gon.relative_url_root = relativeUrl;
+ createComponent({
+ mr: { ...mrDefaultOptions, ...mrProps },
+ });
await nextTick();
- const button = wrapper.find('.js-web-ide');
-
- expect(button.text().trim()).toBe('Open in Web IDE');
- expect(button.attributes('href')).toBe(
- '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=',
- );
+ expect(findWebIdeButton().props()).toMatchObject({
+ showEditButton: false,
+ showWebIdeButton: true,
+ webIdeText: 'Open in Web IDE',
+ gitpodText: 'Open in Gitpod',
+ gitpodEnabled: true,
+ showGitpodButton: true,
+ gitpodUrl: 'http://gitpod.localhost',
+ webIdeUrl,
+ });
});
- it('renders web ide button with relative URL', async () => {
- gon.relative_url_root = '/gitlab';
-
- createComponent({ mr: { ...mrDefaultOptions, iid: 2 } });
+ it('does not render web ide button if source branch is removed', async () => {
+ createComponent({ mr: { ...mrDefaultOptions, sourceBranchRemoved: true } });
await nextTick();
- const button = wrapper.find('.js-web-ide');
-
- expect(button.text().trim()).toBe('Open in Web IDE');
- expect(button.attributes('href')).toBe(
- '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce',
- );
+ expect(findWebIdeButton().exists()).toBe(false);
});
it('renders download dropdown with links', () => {
+ createComponent({
+ mr: { ...mrDefaultOptions },
+ });
+
expectDownloadDropdownItems();
});
});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
index a879b06e858..6ea8ca10c02 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
@@ -17,7 +17,7 @@ describe('MRWidgetRelatedLinks', () => {
it('returns Closes text for open merge request', () => {
createComponent({ state: 'open', relatedLinks: {} });
- expect(wrapper.vm.closesText).toBe('Closes');
+ expect(wrapper.vm.closesText).toBe('Closes issues');
});
it('returns correct text for closed merge request', () => {
@@ -38,6 +38,7 @@ describe('MRWidgetRelatedLinks', () => {
createComponent({
relatedLinks: {
closing: '<a href="#">#23</a> and <a>#42</a>',
+ closingCount: 2,
},
});
const content = wrapper
@@ -45,7 +46,7 @@ describe('MRWidgetRelatedLinks', () => {
.replace(/\n(\s)+/g, ' ')
.trim();
- expect(content).toContain('Closes #23 and #42');
+ expect(content).toContain('Closes issues #23 and #42');
expect(content).not.toContain('Mentions');
});
@@ -53,11 +54,17 @@ describe('MRWidgetRelatedLinks', () => {
createComponent({
relatedLinks: {
mentioned: '<a href="#">#7</a>',
+ mentionedCount: 1,
},
});
- expect(wrapper.text().trim()).toContain('Mentions #7');
- expect(wrapper.text().trim()).not.toContain('Closes');
+ const content = wrapper
+ .text()
+ .replace(/\n(\s)+/g, ' ')
+ .trim();
+
+ expect(content).toContain('Mentions issue #7');
+ expect(content).not.toContain('Closes issues');
});
it('should have closing and mentioned issues at the same time', () => {
@@ -65,6 +72,8 @@ describe('MRWidgetRelatedLinks', () => {
relatedLinks: {
closing: '<a href="#">#7</a>',
mentioned: '<a href="#">#23</a> and <a>#42</a>',
+ closingCount: 1,
+ mentionedCount: 2,
},
});
const content = wrapper
@@ -72,8 +81,8 @@ describe('MRWidgetRelatedLinks', () => {
.replace(/\n(\s)+/g, ' ')
.trim();
- expect(content).toContain('Closes #7');
- expect(content).toContain('Mentions #23 and #42');
+ expect(content).toContain('Closes issue #7');
+ expect(content).toContain('Mentions issues #23 and #42');
});
it('should have assing issues link', () => {
diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap
index ac20487c55f..5981d2d7849 100644
--- a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap
+++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap
@@ -4,8 +4,10 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have
<div
class="mr-widget-body media"
>
- <status-icon-stub
- status="success"
+ <gl-icon-stub
+ class="gl-text-blue-500 gl-mr-3 gl-mt-1"
+ name="status_scheduled"
+ size="24"
/>
<div
@@ -17,55 +19,31 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have
<span
class="gl-mr-3"
>
- <span
- class="js-status-text-before-author"
- data-testid="beforeStatusText"
- >
- Set by
- </span>
-
- <mr-widget-author-stub
- author="[object Object]"
- showauthorname="true"
+ <gl-sprintf-stub
+ data-testid="statusText"
+ message="Set by %{merge_author} to be merged automatically when the pipeline succeeds"
/>
-
- <span
- class="js-status-text-after-author"
- data-testid="afterStatusText"
- >
- to be merged automatically when the pipeline succeeds
- </span>
</span>
- <a
- class="btn btn-sm btn-default js-cancel-auto-merge"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="js-cancel-auto-merge"
data-qa-selector="cancel_auto_merge_button"
data-testid="cancelAutomaticMergeButton"
- href="#"
- role="button"
+ icon=""
+ size="small"
+ variant="default"
>
- <!---->
- Cancel
+ Cancel auto-merge
- </a>
+ </gl-button-stub>
</h4>
<section
class="mr-info-list"
>
- <p>
-
- The changes will be merged into
-
- <a
- class="label-branch"
- href="/foo/bar"
- >
- foo
- </a>
- </p>
-
<p
class="gl-display-flex"
>
@@ -75,17 +53,19 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have
The source branch will not be deleted
</span>
- <a
- class="btn btn-sm btn-default js-remove-source-branch"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="js-remove-source-branch"
data-testid="removeSourceBranchButton"
- href="#"
- role="button"
+ icon=""
+ size="small"
+ variant="default"
>
- <!---->
Delete source branch
- </a>
+ </gl-button-stub>
</p>
</section>
</div>
@@ -96,8 +76,10 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c
<div
class="mr-widget-body media"
>
- <status-icon-stub
- status="success"
+ <gl-icon-stub
+ class="gl-text-blue-500 gl-mr-3 gl-mt-1"
+ name="status_scheduled"
+ size="24"
/>
<div
@@ -109,55 +91,31 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c
<span
class="gl-mr-3"
>
- <span
- class="js-status-text-before-author"
- data-testid="beforeStatusText"
- >
- Set by
- </span>
-
- <mr-widget-author-stub
- author="[object Object]"
- showauthorname="true"
+ <gl-sprintf-stub
+ data-testid="statusText"
+ message="Set by %{merge_author} to be merged automatically when the pipeline succeeds"
/>
-
- <span
- class="js-status-text-after-author"
- data-testid="afterStatusText"
- >
- to be merged automatically when the pipeline succeeds
- </span>
</span>
- <a
- class="btn btn-sm btn-default js-cancel-auto-merge"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="js-cancel-auto-merge"
data-qa-selector="cancel_auto_merge_button"
data-testid="cancelAutomaticMergeButton"
- href="#"
- role="button"
+ icon=""
+ size="small"
+ variant="default"
>
- <!---->
- Cancel
+ Cancel auto-merge
- </a>
+ </gl-button-stub>
</h4>
<section
class="mr-info-list"
>
- <p>
-
- The changes will be merged into
-
- <a
- class="label-branch"
- href="/foo/bar"
- >
- foo
- </a>
- </p>
-
<p
class="gl-display-flex"
>
@@ -167,17 +125,19 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c
The source branch will not be deleted
</span>
- <a
- class="btn btn-sm btn-default js-remove-source-branch"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="js-remove-source-branch"
data-testid="removeSourceBranchButton"
- href="#"
- role="button"
+ icon=""
+ size="small"
+ variant="default"
>
- <!---->
Delete source branch
- </a>
+ </gl-button-stub>
</p>
</section>
</div>
diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap
deleted file mode 100644
index cef1dff3335..00000000000
--- a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_ready_to_merge_spec.js.snap
+++ /dev/null
@@ -1,3 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`ReadyToMerge with a mismatched SHA warns the user to refresh to review 1`] = `"<gl-sprintf-stub message=\\"New changes were added. %{linkStart}Reload the page to review them%{linkEnd}\\"></gl-sprintf-stub>"`;
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js
index 0110a76e722..4c1534574f5 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js
@@ -72,6 +72,8 @@ const defaultMrProps = () => ({
autoMergeStrategy: MWPS_MERGE_STRATEGY,
});
+const getStatusText = () => wrapper.findByTestId('statusText').attributes('message');
+
describe('MRWidgetAutoMergeEnabled', () => {
let oldWindowGl;
@@ -167,30 +169,6 @@ describe('MRWidgetAutoMergeEnabled', () => {
});
});
- describe('statusTextBeforeAuthor', () => {
- it('should return "Set by" if the MWPS is selected', () => {
- factory({
- ...defaultMrProps(),
- autoMergeStrategy: MWPS_MERGE_STRATEGY,
- });
-
- expect(wrapper.findByTestId('beforeStatusText').text()).toBe('Set by');
- });
- });
-
- describe('statusTextAfterAuthor', () => {
- it('should return "to be merged automatically..." if MWPS is selected', () => {
- factory({
- ...defaultMrProps(),
- autoMergeStrategy: MWPS_MERGE_STRATEGY,
- });
-
- expect(wrapper.findByTestId('afterStatusText').text()).toBe(
- 'to be merged automatically when the pipeline succeeds',
- );
- });
- });
-
describe('cancelButtonText', () => {
it('should return "Cancel" if MWPS is selected', () => {
factory({
@@ -198,7 +176,9 @@ describe('MRWidgetAutoMergeEnabled', () => {
autoMergeStrategy: MWPS_MERGE_STRATEGY,
});
- expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe('Cancel');
+ expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe(
+ 'Cancel auto-merge',
+ );
});
});
});
@@ -279,7 +259,7 @@ describe('MRWidgetAutoMergeEnabled', () => {
await nextTick();
- expect(wrapper.find('.js-cancel-auto-merge').attributes('disabled')).toBe('disabled');
+ expect(wrapper.find('.js-cancel-auto-merge').props('loading')).toBe(true);
});
it('should show source branch will be deleted text when it source branch set to remove', () => {
@@ -313,7 +293,7 @@ describe('MRWidgetAutoMergeEnabled', () => {
await nextTick();
- expect(wrapper.find('.js-remove-source-branch').attributes('disabled')).toBe('disabled');
+ expect(wrapper.find('.js-remove-source-branch').props('loading')).toBe(true);
});
it('should render the status text as "...to merged automatically" if MWPS is selected', () => {
@@ -322,9 +302,9 @@ describe('MRWidgetAutoMergeEnabled', () => {
autoMergeStrategy: MWPS_MERGE_STRATEGY,
});
- const statusText = trimText(wrapper.find('.js-status-text-after-author').text());
-
- expect(statusText).toBe('to be merged automatically when the pipeline succeeds');
+ expect(getStatusText()).toBe(
+ 'Set by %{merge_author} to be merged automatically when the pipeline succeeds',
+ );
});
it('should render the cancel button as "Cancel" if MWPS is selected', () => {
@@ -335,7 +315,7 @@ describe('MRWidgetAutoMergeEnabled', () => {
const cancelButtonText = trimText(wrapper.find('.js-cancel-auto-merge').text());
- expect(cancelButtonText).toBe('Cancel');
+ expect(cancelButtonText).toBe('Cancel auto-merge');
});
});
});
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 cd77d442cbf..e41fb815c8d 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -1,4 +1,3 @@
-import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import simplePoll from '~/lib/utils/simple_poll';
@@ -782,26 +781,4 @@ describe('ReadyToMerge', () => {
});
});
});
-
- describe('with a mismatched SHA', () => {
- const findMismatchShaBlock = () => wrapper.find('.js-sha-mismatch');
- const findMismatchShaTextBlock = () => findMismatchShaBlock().find(GlSprintf);
-
- beforeEach(() => {
- createComponent({
- mr: {
- isSHAMismatch: true,
- mergeRequestDiffsPath: '/merge_requests/1/diffs',
- },
- });
- });
-
- it('displays a warning message', () => {
- expect(findMismatchShaBlock().exists()).toBe(true);
- });
-
- it('warns the user to refresh to review', () => {
- expect(findMismatchShaTextBlock().element.outerHTML).toMatchSnapshot();
- });
- });
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js
index ef6a9b1e8fc..2a343997cf5 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js
@@ -1,25 +1,42 @@
-import Vue from 'vue';
-import { removeBreakLine } from 'helpers/text_helper';
-import mountComponent from 'helpers/vue_mount_component_helper';
+import { mount } from '@vue/test-utils';
import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue';
+import { I18N_SHA_MISMATCH } from '~/vue_merge_request_widget/i18n';
+
+function createComponent({ path = '' } = {}) {
+ return mount(ShaMismatch, {
+ propsData: {
+ mr: {
+ mergeRequestDiffsPath: path,
+ },
+ },
+ });
+}
describe('ShaMismatch', () => {
- let vm;
+ let wrapper;
+ const findActionButton = () => wrapper.find('[data-testid="action-button"]');
beforeEach(() => {
- const Component = Vue.extend(ShaMismatch);
- vm = mountComponent(Component);
+ wrapper = createComponent();
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ });
+
+ it('should render warning message', () => {
+ expect(wrapper.element.innerText).toContain(I18N_SHA_MISMATCH.warningMessage);
});
- it('should render information message', () => {
- expect(vm.$el.querySelector('button').disabled).toEqual(true);
+ it('action button should have correct label', () => {
+ expect(findActionButton().text()).toBe(I18N_SHA_MISMATCH.actionButtonLabel);
+ });
+
+ it('action button should link to the diff path', () => {
+ const DIFF_PATH = '/gitlab-org/gitlab-test/-/merge_requests/6/diffs';
+
+ wrapper = createComponent({ path: DIFF_PATH });
- expect(removeBreakLine(vm.$el.textContent).trim()).toContain(
- 'The source branch HEAD has recently changed. Please reload the page and review the changes before merging',
- );
+ expect(findActionButton().attributes('href')).toBe(DIFF_PATH);
});
});
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 49783560bf2..31ade17e50a 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
@@ -45,7 +45,6 @@ describe('DeploymentAction component', () => {
propsData: {
computedDeploymentStatus: CREATED,
deployment: deploymentMockData,
- showVisualReviewApp: false,
},
});
});
@@ -64,7 +63,6 @@ describe('DeploymentAction component', () => {
...deploymentMockData,
stop_url: null,
},
- showVisualReviewApp: false,
},
});
});
@@ -115,7 +113,6 @@ describe('DeploymentAction component', () => {
...deploymentMockData,
details: displayConditionChanges,
},
- showVisualReviewApp: false,
},
});
});
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js
index dd0c483b28a..948d7ebab5e 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js
@@ -7,7 +7,6 @@ import MrCollapsibleExtension from '~/vue_merge_request_widget/components/mr_col
import { mockStore } from '../mock_data';
const DEFAULT_PROPS = {
- showVisualReviewAppLink: false,
hasDeploymentMetrics: false,
deploymentClass: 'js-pre-deployment',
};
@@ -46,7 +45,6 @@ describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue',
([deploymentWrapper, deployment]) => {
expect(deploymentWrapper.props('deployment')).toEqual(deployment);
expect(deploymentWrapper.props()).toMatchObject({
- showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink,
showMetrics: DEFAULT_PROPS.hasDeploymentMetrics,
});
expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true);
@@ -87,10 +85,6 @@ describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue',
zip(deploymentWrappers.wrappers, propsData.deployments).forEach(
([deploymentWrapper, deployment]) => {
expect(deploymentWrapper.props('deployment')).toEqual(deployment);
- expect(deploymentWrapper.props()).toMatchObject({
- showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink,
- showMetrics: DEFAULT_PROPS.hasDeploymentMetrics,
- });
expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true);
expect(deploymentWrapper.text()).toEqual(expect.any(String));
expect(deploymentWrapper.text()).not.toBe('');
diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js
index e6f1e15d718..f356f6fb5bf 100644
--- a/spec/frontend/vue_mr_widget/mock_data.js
+++ b/spec/frontend/vue_mr_widget/mock_data.js
@@ -234,14 +234,11 @@ export default {
can_revert_on_current_merge_request: true,
can_cherry_pick_on_current_merge_request: true,
},
- codeclimate: {
- head_path: 'head.json',
- base_path: 'base.json',
- },
blob_path: {
base_path: 'blob_path',
head_path: 'blob_path',
},
+ codequality_reports_path: 'codequality_reports.json',
codequality_help_path: 'code_quality.html',
target_branch_path: '/root/acets-app/branches/main',
source_branch_path: '/root/acets-app/branches/daaaa',
@@ -284,6 +281,9 @@ export default {
security_reports_docs_path: 'security-reports-docs-path',
sast_comparison_path: '/sast_comparison_path',
secret_scanning_comparison_path: '/secret_scanning_comparison_path',
+ gitpod_enabled: true,
+ show_gitpod_button: true,
+ gitpod_url: 'http://gitpod.localhost',
};
export const mockStore = {
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 9da370747fc..c50cf7cb076 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -12,7 +12,7 @@ import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/consta
import eventHub from '~/vue_merge_request_widget/event_hub';
import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
-import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql';
+import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
import mockData from './mock_data';
@@ -80,14 +80,15 @@ describe('MrWidgetOptions', () => {
describe('computed', () => {
describe('componentName', () => {
- it('should return merged component', () => {
- expect(wrapper.vm.componentName).toEqual('mr-widget-merged');
- });
-
- it('should return conflicts component', () => {
- wrapper.vm.mr.state = 'conflicts';
-
- expect(wrapper.vm.componentName).toEqual('mr-widget-conflicts');
+ it.each`
+ state | componentName
+ ${'merged'} | ${'mr-widget-merged'}
+ ${'conflicts'} | ${'mr-widget-conflicts'}
+ ${'shaMismatch'} | ${'sha-mismatch'}
+ `('should translate $state into $componentName', ({ state, componentName }) => {
+ wrapper.vm.mr.state = state;
+
+ expect(wrapper.vm.componentName).toEqual(componentName);
});
});
diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
index cfc846075ea..bf0179aa425 100644
--- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
+++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js
@@ -10,6 +10,14 @@ describe('MergeRequestStore', () => {
store = new MergeRequestStore(mockData);
});
+ it('should initialize gitpod attributes', () => {
+ expect(store).toMatchObject({
+ gitpodEnabled: mockData.gitpod_enabled,
+ showGitpodButton: mockData.show_gitpod_button,
+ gitpodUrl: mockData.gitpod_url,
+ });
+ });
+
describe('setData', () => {
it('should set isSHAMismatch when the diff SHA changes', () => {
store.setData({ ...mockData, diff_head_sha: 'a-different-string' });
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js
new file mode 100644
index 00000000000..016fe1f131e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js
@@ -0,0 +1,97 @@
+import { GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue';
+
+describe('DropdownWidget component', () => {
+ let wrapper;
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findSearch = () => wrapper.findComponent(GlSearchBoxByType);
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMount(DropdownWidget, {
+ propsData: {
+ options: [
+ {
+ id: '1',
+ title: 'Option 1',
+ },
+ {
+ id: '2',
+ title: 'Option 2',
+ },
+ ],
+ ...props,
+ },
+ stubs: {
+ GlDropdown,
+ },
+ });
+
+ // We need to mock out `showDropdown` which
+ // invokes `show` method of BDropdown used inside GlDropdown.
+ // Context: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54895#note_524281679
+ jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('passes default selectText prop to dropdown', () => {
+ expect(findDropdown().props('text')).toBe('Select');
+ });
+
+ describe('when dropdown is open', () => {
+ beforeEach(async () => {
+ findDropdown().vm.$emit('show');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('emits search event when typing in search box', () => {
+ const searchTerm = 'searchTerm';
+ findSearch().vm.$emit('input', searchTerm);
+
+ expect(wrapper.emitted('set-search')).toEqual([[searchTerm]]);
+ });
+
+ it('renders one selectable item per passed option', async () => {
+ expect(findDropdownItems()).toHaveLength(2);
+ });
+
+ it('emits set-option event when clicking on an option', async () => {
+ wrapper
+ .findAll('[data-testid="unselected-option"]')
+ .at(1)
+ .vm.$emit('click', new Event('click'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('set-option')).toEqual([[wrapper.props().options[1]]]);
+ });
+ });
+
+ describe('when options are users', () => {
+ const mockUser = {
+ id: 1,
+ name: 'User name',
+ username: 'username',
+ avatarUrl: 'foo/bar',
+ };
+
+ beforeEach(() => {
+ createComponent({ props: { options: [mockUser] } });
+ });
+
+ it('passes user related props to dropdown item', () => {
+ expect(findDropdownItems().at(0).props('avatarUrl')).toBe(mockUser.avatarUrl);
+ expect(findDropdownItems().at(0).props('secondaryText')).toBe(mockUser.username);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
index 9fa9d35e3e2..8e931aebfe0 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
@@ -32,6 +32,9 @@ jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', (
stripQuotes: jest.requireActual(
'~/vue_shared/components/filtered_search_bar/filtered_search_utils',
).stripQuotes,
+ filterEmptySearchTerm: jest.requireActual(
+ '~/vue_shared/components/filtered_search_bar/filtered_search_utils',
+ ).filterEmptySearchTerm,
}));
const createComponent = ({
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index 74f579e77ed..d3e1bfef561 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -86,7 +86,7 @@ describe('AuthorToken', () => {
});
describe('methods', () => {
- describe('fetchAuthorBySearchTerm', () => {
+ describe('fetchAuthors', () => {
beforeEach(() => {
wrapper = createComponent();
});
@@ -155,7 +155,7 @@ describe('AuthorToken', () => {
expect(baseTokenEl.exists()).toBe(true);
expect(baseTokenEl.props()).toMatchObject({
suggestions: mockAuthors,
- fnActiveTokenValue: wrapper.vm.getActiveAuthor,
+ getActiveTokenValue: wrapper.vm.getActiveAuthor,
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index cd6ffd679d0..eb1dbed52cc 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -5,7 +5,7 @@ import {
mockLabels,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
-import { DEFAULT_LABELS } from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
import {
getRecentlyUsedSuggestions,
setTokenValueToRecentlyUsed,
@@ -51,9 +51,8 @@ const mockProps = {
active: false,
suggestions: [],
suggestionsLoading: false,
- defaultSuggestions: DEFAULT_LABELS,
+ defaultSuggestions: DEFAULT_NONE_ANY,
recentSuggestionsStorageKey: mockStorageKey,
- fnCurrentTokenValue: jest.fn(),
};
function createComponent({
@@ -99,31 +98,20 @@ describe('BaseToken', () => {
});
describe('computed', () => {
- describe('currentTokenValue', () => {
- it('calls `fnCurrentTokenValue` when it is provided', () => {
- // We're disabling lint to trigger computed prop execution for this test.
- // eslint-disable-next-line no-unused-vars
- const { currentTokenValue } = wrapper.vm;
-
- expect(wrapper.vm.fnCurrentTokenValue).toHaveBeenCalledWith(`"${mockRegularLabel.title}"`);
- });
- });
-
describe('activeTokenValue', () => {
- it('calls `fnActiveTokenValue` when it is provided', async () => {
- const mockFnActiveTokenValue = jest.fn();
+ it('calls `getActiveTokenValue` when it is provided', async () => {
+ const mockGetActiveTokenValue = jest.fn();
wrapper.setProps({
- fnActiveTokenValue: mockFnActiveTokenValue,
- fnCurrentTokenValue: undefined,
+ getActiveTokenValue: mockGetActiveTokenValue,
});
await wrapper.vm.$nextTick();
- expect(mockFnActiveTokenValue).toHaveBeenCalledTimes(1);
- expect(mockFnActiveTokenValue).toHaveBeenCalledWith(
+ expect(mockGetActiveTokenValue).toHaveBeenCalledTimes(1);
+ expect(mockGetActiveTokenValue).toHaveBeenCalledWith(
mockLabels,
- `"${mockRegularLabel.title.toLowerCase()}"`,
+ `"${mockRegularLabel.title}"`,
);
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
index 331c9c2c14d..09eac636cae 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
@@ -61,40 +61,16 @@ describe('BranchToken', () => {
wrapper.destroy();
});
- describe('computed', () => {
- beforeEach(async () => {
- wrapper = createComponent({ value: { data: mockBranches[0].name } });
-
- wrapper.setData({
- branches: mockBranches,
- });
-
- await wrapper.vm.$nextTick();
- });
-
- describe('currentValue', () => {
- it('returns lowercase string for `value.data`', () => {
- expect(wrapper.vm.currentValue).toBe('main');
- });
- });
-
- describe('activeBranch', () => {
- it('returns object for currently present `value.data`', () => {
- expect(wrapper.vm.activeBranch).toEqual(mockBranches[0]);
- });
- });
- });
-
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
- describe('fetchBranchBySearchTerm', () => {
+ describe('fetchBranches', () => {
it('calls `config.fetchBranches` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches');
- wrapper.vm.fetchBranchBySearchTerm('foo');
+ wrapper.vm.fetchBranches('foo');
expect(wrapper.vm.config.fetchBranches).toHaveBeenCalledWith('foo');
});
@@ -102,7 +78,7 @@ describe('BranchToken', () => {
it('sets response to `branches` when request is succesful', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockResolvedValue({ data: mockBranches });
- wrapper.vm.fetchBranchBySearchTerm('foo');
+ wrapper.vm.fetchBranches('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.branches).toEqual(mockBranches);
@@ -112,7 +88,7 @@ describe('BranchToken', () => {
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
- wrapper.vm.fetchBranchBySearchTerm('foo');
+ wrapper.vm.fetchBranches('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
@@ -124,7 +100,7 @@ describe('BranchToken', () => {
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
- wrapper.vm.fetchBranchBySearchTerm('foo');
+ wrapper.vm.fetchBranches('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
index 778a214f97e..c2d61fd9f05 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
@@ -67,40 +67,16 @@ describe('EmojiToken', () => {
wrapper.destroy();
});
- describe('computed', () => {
- beforeEach(async () => {
- wrapper = createComponent({ value: { data: mockEmojis[0].name } });
-
- wrapper.setData({
- emojis: mockEmojis,
- });
-
- await wrapper.vm.$nextTick();
- });
-
- describe('currentValue', () => {
- it('returns lowercase string for `value.data`', () => {
- expect(wrapper.vm.currentValue).toBe(mockEmojis[0].name);
- });
- });
-
- describe('activeEmoji', () => {
- it('returns object for currently present `value.data`', () => {
- expect(wrapper.vm.activeEmoji).toEqual(mockEmojis[0]);
- });
- });
- });
-
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
- describe('fetchEmojiBySearchTerm', () => {
+ describe('fetchEmojis', () => {
it('calls `config.fetchEmojis` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis');
- wrapper.vm.fetchEmojiBySearchTerm('foo');
+ wrapper.vm.fetchEmojis('foo');
expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo');
});
@@ -108,7 +84,7 @@ describe('EmojiToken', () => {
it('sets response to `emojis` when request is successful', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis);
- wrapper.vm.fetchEmojiBySearchTerm('foo');
+ wrapper.vm.fetchEmojis('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.emojis).toEqual(mockEmojis);
@@ -118,7 +94,7 @@ describe('EmojiToken', () => {
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
- wrapper.vm.fetchEmojiBySearchTerm('foo');
+ wrapper.vm.fetchEmojis('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
@@ -130,7 +106,7 @@ describe('EmojiToken', () => {
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
- wrapper.vm.fetchEmojiBySearchTerm('foo');
+ wrapper.vm.fetchEmojis('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js
index bd654c5a9cb..a609aaa1c4e 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js
@@ -1,5 +1,6 @@
import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
import { mockIterationToken } from '../mock_data';
@@ -13,6 +14,7 @@ describe('IterationToken', () => {
const createComponent = ({ config = mockIterationToken, value = { data: '' } } = {}) =>
mount(IterationToken, {
propsData: {
+ active: false,
config,
value,
},
@@ -69,7 +71,7 @@ describe('IterationToken', () => {
config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
});
- await wrapper.vm.$nextTick();
+ await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'There was a problem fetching iterations.',
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index ec9458f64d2..a348344b9dd 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -13,10 +13,7 @@ import {
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import {
- DEFAULT_LABELS,
- DEFAULT_NONE_ANY,
-} from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
@@ -98,11 +95,11 @@ describe('LabelToken', () => {
});
});
- describe('fetchLabelBySearchTerm', () => {
+ describe('fetchLabels', () => {
it('calls `config.fetchLabels` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels');
- wrapper.vm.fetchLabelBySearchTerm('foo');
+ wrapper.vm.fetchLabels('foo');
expect(wrapper.vm.config.fetchLabels).toHaveBeenCalledWith('foo');
});
@@ -110,7 +107,7 @@ describe('LabelToken', () => {
it('sets response to `labels` when request is succesful', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockResolvedValue(mockLabels);
- wrapper.vm.fetchLabelBySearchTerm('foo');
+ wrapper.vm.fetchLabels('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.labels).toEqual(mockLabels);
@@ -120,7 +117,7 @@ describe('LabelToken', () => {
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({});
- wrapper.vm.fetchLabelBySearchTerm('foo');
+ wrapper.vm.fetchLabels('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
@@ -132,7 +129,7 @@ describe('LabelToken', () => {
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({});
- wrapper.vm.fetchLabelBySearchTerm('foo');
+ wrapper.vm.fetchLabels('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
@@ -160,7 +157,7 @@ describe('LabelToken', () => {
expect(baseTokenEl.exists()).toBe(true);
expect(baseTokenEl.props()).toMatchObject({
suggestions: mockLabels,
- fnActiveTokenValue: wrapper.vm.getActiveLabel,
+ getActiveTokenValue: wrapper.vm.getActiveLabel,
});
});
@@ -208,7 +205,7 @@ describe('LabelToken', () => {
expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
});
- it('renders `DEFAULT_LABELS` as default suggestions', () => {
+ it('renders `DEFAULT_NONE_ANY` as default suggestions', () => {
wrapper = createComponent({
active: true,
config: { ...mockLabelToken },
@@ -220,8 +217,8 @@ describe('LabelToken', () => {
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
- expect(suggestions).toHaveLength(DEFAULT_LABELS.length);
- DEFAULT_LABELS.forEach((label, index) => {
+ expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length);
+ DEFAULT_NONE_ANY.forEach((label, index) => {
expect(suggestions.at(index).text()).toBe(label.text);
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
index 74ceb03bb96..529844817d3 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
@@ -14,12 +14,7 @@ import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
-import {
- mockMilestoneToken,
- mockMilestones,
- mockRegularMilestone,
- mockEscapedMilestone,
-} from '../mock_data';
+import { mockMilestoneToken, mockMilestones, mockRegularMilestone } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/milestones/milestone_utils');
@@ -70,37 +65,12 @@ describe('MilestoneToken', () => {
wrapper.destroy();
});
- describe('computed', () => {
- beforeEach(async () => {
- // Milestone title with spaces is always enclosed in quotations by component.
- wrapper = createComponent({ value: { data: `"${mockEscapedMilestone.title}"` } });
-
- wrapper.setData({
- milestones: mockMilestones,
- });
-
- await wrapper.vm.$nextTick();
- });
-
- describe('currentValue', () => {
- it('returns lowercase string for `value.data`', () => {
- expect(wrapper.vm.currentValue).toBe('"5.0 rc1"');
- });
- });
-
- describe('activeMilestone', () => {
- it('returns object for currently present `value.data`', () => {
- expect(wrapper.vm.activeMilestone).toEqual(mockEscapedMilestone);
- });
- });
- });
-
describe('methods', () => {
- describe('fetchMilestoneBySearchTerm', () => {
+ describe('fetchMilestones', () => {
it('calls `config.fetchMilestones` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones');
- wrapper.vm.fetchMilestoneBySearchTerm('foo');
+ wrapper.vm.fetchMilestones('foo');
expect(wrapper.vm.config.fetchMilestones).toHaveBeenCalledWith('foo');
});
@@ -110,7 +80,7 @@ describe('MilestoneToken', () => {
data: mockMilestones,
});
- wrapper.vm.fetchMilestoneBySearchTerm();
+ wrapper.vm.fetchMilestones();
return waitForPromises().then(() => {
expect(wrapper.vm.milestones).toEqual(mockMilestones);
@@ -121,7 +91,7 @@ describe('MilestoneToken', () => {
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({});
- wrapper.vm.fetchMilestoneBySearchTerm('foo');
+ wrapper.vm.fetchMilestones('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
@@ -133,7 +103,7 @@ describe('MilestoneToken', () => {
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({});
- wrapper.vm.fetchMilestoneBySearchTerm('foo');
+ wrapper.vm.fetchMilestones('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js
index 9a72be636cd..e788c742736 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js
@@ -12,6 +12,7 @@ describe('WeightToken', () => {
const createComponent = ({ config = mockWeightToken, value = { data: '' } } = {}) =>
mount(WeightToken, {
propsData: {
+ active: false,
config,
value,
},
diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
index 8738924f717..6ab828efebe 100644
--- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
+++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
@@ -144,23 +144,6 @@ describe('RelatedIssuableItem', () => {
expect(wrapper.find(IssueDueDate).props('closed')).toBe(true);
});
-
- it('should not contain the `.text-danger` css class for overdue issue that is closed', async () => {
- mountComponent({
- props: {
- ...props,
- closedAt: '2018-12-01T00:00:00.00Z',
- },
- });
- await wrapper.vm.$nextTick();
-
- expect(wrapper.find(IssueDueDate).find('.board-card-info-icon').classes('text-danger')).toBe(
- false,
- );
- expect(wrapper.find(IssueDueDate).find('.board-card-info-text').classes('text-danger')).toBe(
- false,
- );
- });
});
describe('token assignees', () => {
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
index 786dfabb990..19e4f2d8c92 100644
--- a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
@@ -1,3 +1,4 @@
+import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue';
@@ -25,7 +26,7 @@ describe('toolbar_button', () => {
});
const getButtonShortcutsAttr = () => {
- return wrapper.find('button').attributes('data-md-shortcuts');
+ return wrapper.find(GlButton).attributes('data-md-shortcuts');
};
describe('keyboard shortcuts', () => {
diff --git a/spec/frontend/vue_shared/components/papa_parse_alert_spec.js b/spec/frontend/vue_shared/components/papa_parse_alert_spec.js
new file mode 100644
index 00000000000..9be2de17d01
--- /dev/null
+++ b/spec/frontend/vue_shared/components/papa_parse_alert_spec.js
@@ -0,0 +1,44 @@
+import { GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue';
+
+describe('app/assets/javascripts/vue_shared/components/papa_parse_alert.vue', () => {
+ let wrapper;
+
+ const createComponent = ({ errorMessages } = {}) => {
+ wrapper = shallowMount(PapaParseAlert, {
+ propsData: {
+ papaParseErrors: errorMessages,
+ },
+ });
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render alert with correct props', async () => {
+ createComponent({ errorMessages: [{ code: 'MissingQuotes' }] });
+ await nextTick;
+
+ expect(findAlert().props()).toMatchObject({
+ variant: 'danger',
+ });
+ expect(findAlert().text()).toContain(
+ 'Failed to render the CSV file for the following reasons:',
+ );
+ expect(findAlert().text()).toContain('Quoted field unterminated');
+ });
+
+ it('should render original message if no translation available', async () => {
+ createComponent({
+ errorMessages: [{ code: 'NotDefined', message: 'Error code is undefined' }],
+ });
+ await nextTick;
+
+ expect(findAlert().text()).toContain('Error code is undefined');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js
index 395c74dcba6..71ebe561def 100644
--- a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js
+++ b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js
@@ -13,7 +13,7 @@ import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
-import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql';
+import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
jest.mock('~/flash');
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 06ea88c09a0..a1942e59571 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
@@ -116,6 +116,8 @@ describe('DropdownContentsLabelsView', () => {
});
describe('methods', () => {
+ const fakePreventDefault = jest.fn();
+
describe('isLabelSelected', () => {
it('returns true when provided `label` param is one of the selected labels', () => {
expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
@@ -191,9 +193,11 @@ describe('DropdownContentsLabelsView', () => {
wrapper.vm.handleKeyDown({
keyCode: ENTER_KEY_CODE,
+ preventDefault: fakePreventDefault,
});
expect(wrapper.vm.searchKey).toBe('');
+ expect(fakePreventDefault).toHaveBeenCalled();
});
it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
@@ -204,6 +208,7 @@ describe('DropdownContentsLabelsView', () => {
wrapper.vm.handleKeyDown({
keyCode: ENTER_KEY_CODE,
+ preventDefault: fakePreventDefault,
});
expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
index be849789667..bc1ec8b812b 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
@@ -238,4 +238,14 @@ describe('LabelsSelectRoot', () => {
expect(store.dispatch).not.toHaveBeenCalled();
});
+
+ it('calls updateLabelsSetState after selected labels were updated', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ selectedLabels: [] });
+ jest.advanceTimersByTime(100);
+
+ expect(store.dispatch).toHaveBeenCalledWith('updateLabelsSetState');
+ });
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
index 46ade5d5857..2e4c056df61 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
@@ -214,7 +214,7 @@ describe('LabelsSelect Actions', () => {
});
describe('on success', () => {
- it('dispatches `requestCreateLabel`, `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', (done) => {
+ it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', (done) => {
const label = { id: 1 };
mock.onPost(/labels.json/).replyOnce(200, label);
@@ -225,6 +225,7 @@ describe('LabelsSelect Actions', () => {
[],
[
{ type: 'requestCreateLabel' },
+ { payload: { refetch: true }, type: 'fetchLabels' },
{ type: 'receiveCreateLabelSuccess' },
{ type: 'toggleDropdownContentsCreateView' },
],
@@ -263,4 +264,16 @@ describe('LabelsSelect Actions', () => {
);
});
});
+
+ describe('updateLabelsSetState', () => {
+ it('updates labels `set` state to match `selectedLabels`', () => {
+ testAction(
+ actions.updateLabelsSetState,
+ {},
+ state,
+ [{ type: types.UPDATE_LABELS_SET_STATE }],
+ [],
+ );
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
index 1d2a9c34599..14e0c8a2278 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
@@ -197,4 +197,26 @@ describe('LabelsSelect Mutations', () => {
});
});
});
+
+ describe(`${types.UPDATE_LABELS_SET_STATE}`, () => {
+ it('updates labels `set` state to match selected labels', () => {
+ const state = {
+ labels: [
+ { id: 1, title: 'scoped::test', set: false },
+ { id: 2, set: true, title: 'scoped::one', touched: true },
+ { id: 3, title: '' },
+ { id: 4, title: '' },
+ ],
+ selectedLabels: [{ id: 1 }, { id: 3 }],
+ };
+ mutations[types.UPDATE_LABELS_SET_STATE](state);
+
+ expect(state.labels).toEqual([
+ { id: 1, title: 'scoped::test', set: true },
+ { id: 2, set: false, title: 'scoped::one', touched: true },
+ { id: 3, title: '', set: true },
+ { id: 4, title: '', set: false },
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
index 46a11bc28d8..90bc1980ac3 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -1,6 +1,6 @@
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -14,7 +14,7 @@ jest.mock('~/flash');
const colors = Object.keys(mockSuggestedColors);
const localVue = createLocalVue();
-Vue.use(VueApollo);
+localVue.use(VueApollo);
const userRecoverableError = {
...createLabelSuccessfulResponse,
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
index 51301387c99..8bd944a3d54 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
@@ -1,357 +1,213 @@
-import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
+import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+import { mockConfig, labelsQueryResponse } from './mock_data';
-import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
-import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
-import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
-import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
-
-import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
+jest.mock('~/flash');
const localVue = createLocalVue();
-localVue.use(Vuex);
+localVue.use(VueApollo);
+
+const selectedLabels = [
+ {
+ id: 28,
+ title: 'Bug',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+];
describe('DropdownContentsLabelsView', () => {
let wrapper;
- const createComponent = (initialState = mockConfig) => {
- const store = new Vuex.Store({
- getters,
- mutations,
- state: {
- ...defaultState(),
- footerCreateLabelTitle: 'Create label',
- footerManageLabelTitle: 'Manage labels',
- },
- actions: {
- ...actions,
- fetchLabels: jest.fn(),
- },
- });
+ const successfulQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse);
- store.dispatch('setInitialState', initialState);
- store.dispatch('receiveLabelsSuccess', mockLabels);
+ const createComponent = ({
+ initialState = mockConfig,
+ queryHandler = successfulQueryHandler,
+ injected = {},
+ } = {}) => {
+ const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]);
wrapper = shallowMount(DropdownContentsLabelsView, {
localVue,
- store,
+ apolloProvider: mockApollo,
+ provide: {
+ projectPath: 'test',
+ iid: 1,
+ allowLabelCreate: true,
+ labelsManagePath: '/gitlab-org/my-project/-/labels',
+ variant: DropdownVariant.Sidebar,
+ ...injected,
+ },
+ propsData: {
+ ...initialState,
+ selectedLabels,
+ },
+ stubs: {
+ GlSearchBoxByType,
+ },
});
};
- beforeEach(() => {
- createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
- const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
+ const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
+ const findLabels = () => wrapper.findAllComponents(LabelItem);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const findLabelsList = () => wrapper.find('[data-testid="labels-list"]');
+ const findDropdownWrapper = () => wrapper.find('[data-testid="dropdown-wrapper"]');
const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
- const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
-
- describe('computed', () => {
- describe('visibleLabels', () => {
- it('returns matching labels filtered with `searchKey`', () => {
- wrapper.setData({
- searchKey: 'bug',
- });
-
- expect(wrapper.vm.visibleLabels.length).toBe(1);
- 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: '',
- });
-
- expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
- });
- });
+ const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]');
+ const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
- describe('showNoMatchingResultsMessage', () => {
- it.each`
- searchKey | labels | labelsDescription | returnValue
- ${''} | ${[]} | ${'empty'} | ${false}
- ${'bug'} | ${[]} | ${'empty'} | ${true}
- ${''} | ${mockLabels} | ${'not empty'} | ${false}
- ${'bug'} | ${mockLabels} | ${'not empty'} | ${false}
- `(
- 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription',
- async ({ searchKey, labels, returnValue }) => {
- wrapper.setData({
- searchKey,
- });
-
- wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels);
-
- await wrapper.vm.$nextTick();
-
- expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue);
- },
- );
+ describe('when loading labels', () => {
+ it('renders disabled search input field', async () => {
+ createComponent();
+ expect(findSearchInput().props('disabled')).toBe(true);
});
- });
-
- describe('methods', () => {
- describe('isLabelSelected', () => {
- it('returns true when provided `label` param is one of the selected labels', () => {
- expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
- });
- it('returns false when provided `label` param is not one of the selected labels', () => {
- expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false);
- });
+ it('renders loading icon', async () => {
+ createComponent();
+ expect(findLoadingIcon().exists()).toBe(true);
});
- describe('handleComponentAppear', () => {
- it('calls `focusInput` on searchInput field', async () => {
- wrapper.vm.$refs.searchInput.focusInput = jest.fn();
-
- await wrapper.vm.handleComponentAppear();
-
- expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
- });
+ it('does not render labels list', async () => {
+ createComponent();
+ expect(findLabelsList().exists()).toBe(false);
});
+ });
- describe('handleComponentDisappear', () => {
- it('calls action `receiveLabelsSuccess` with empty array', () => {
- jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
-
- wrapper.vm.handleComponentDisappear();
-
- expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
- });
+ describe('when labels are loaded', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
});
- describe('handleCreateLabelClick', () => {
- it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => {
- jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
- jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView');
-
- wrapper.vm.handleCreateLabelClick();
-
- expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
- expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled();
- });
+ it('renders enabled search input field', async () => {
+ expect(findSearchInput().props('disabled')).toBe(false);
});
- describe('handleKeyDown', () => {
- it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
- wrapper.setData({
- currentHighlightItem: 1,
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: UP_KEY_CODE,
- });
-
- expect(wrapper.vm.currentHighlightItem).toBe(0);
- });
-
- it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => {
- wrapper.setData({
- currentHighlightItem: 1,
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: DOWN_KEY_CODE,
- });
-
- 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({
- currentHighlightItem: 1,
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: ENTER_KEY_CODE,
- });
-
- expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
- {
- ...mockLabels[1],
- set: true,
- },
- ]);
- });
-
- it('calls action `toggleDropdownContents` when Esc key is pressed', () => {
- jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation();
- wrapper.setData({
- currentHighlightItem: 1,
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: ESC_KEY_CODE,
- });
-
- expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
- });
-
- it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => {
- jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation();
- wrapper.setData({
- currentHighlightItem: 1,
- });
-
- wrapper.vm.handleKeyDown({
- keyCode: DOWN_KEY_CODE,
- });
-
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled();
- });
- });
+ it('does not render loading icon', async () => {
+ expect(findLoadingIcon().exists()).toBe(false);
});
- describe('handleLabelClick', () => {
- beforeEach(() => {
- jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
- });
-
- it('calls action `updateSelectedLabels` with provided `label` param', () => {
- wrapper.vm.handleLabelClick(mockRegularLabel);
-
- expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
- });
+ it('renders labels list', async () => {
+ expect(findLabelsList().exists()).toBe(true);
+ expect(findLabels()).toHaveLength(2);
+ });
- it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
- jest.spyOn(wrapper.vm, 'toggleDropdownContents');
- wrapper.vm.$store.state.allowMultiselect = false;
+ it('changes highlighted label correctly on pressing down button', async () => {
+ expect(findLabels().at(0).attributes('highlight')).toBeUndefined();
- wrapper.vm.handleLabelClick(mockRegularLabel);
+ await findDropdownWrapper().trigger('keydown.down');
+ expect(findLabels().at(0).attributes('highlight')).toBe('true');
- expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
- });
+ await findDropdownWrapper().trigger('keydown.down');
+ expect(findLabels().at(1).attributes('highlight')).toBe('true');
+ expect(findLabels().at(0).attributes('highlight')).toBeUndefined();
});
- });
- describe('template', () => {
- it('renders gl-intersection-observer as component root', () => {
- expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
- });
+ it('changes highlighted label correctly on pressing up button', async () => {
+ await findDropdownWrapper().trigger('keydown.down');
+ await findDropdownWrapper().trigger('keydown.down');
+ expect(findLabels().at(1).attributes('highlight')).toBe('true');
- it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
- wrapper.vm.$store.dispatch('requestLabels');
+ await findDropdownWrapper().trigger('keydown.up');
+ expect(findLabels().at(0).attributes('highlight')).toBe('true');
+ });
- return wrapper.vm.$nextTick(() => {
- const loadingIconEl = findLoadingIcon();
+ it('changes label selected state when Enter is pressed', async () => {
+ expect(findLabels().at(0).attributes('islabelset')).toBeUndefined();
+ await findDropdownWrapper().trigger('keydown.down');
+ await findDropdownWrapper().trigger('keydown.enter');
- expect(loadingIconEl.exists()).toBe(true);
- expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
- });
+ expect(findLabels().at(0).attributes('islabelset')).toBe('true');
});
- it('renders label search input element', () => {
- const searchInputEl = wrapper.find(GlSearchBoxByType);
+ it('emits `closeDropdown event` when Esc button is pressed', () => {
+ findDropdownWrapper().trigger('keydown.esc');
- expect(searchInputEl.exists()).toBe(true);
+ expect(wrapper.emitted('closeDropdown')).toEqual([[selectedLabels]]);
});
+ });
- it('renders label elements for all labels', () => {
- expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
+ it('when search returns 0 results', async () => {
+ createComponent({
+ queryHandler: jest.fn().mockResolvedValue({
+ data: {
+ workspace: {
+ labels: {
+ nodes: [],
+ },
+ },
+ },
+ }),
});
+ findSearchInput().vm.$emit('input', '123');
+ await waitForPromises();
+ await nextTick();
- it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => {
- wrapper.setData({
- currentHighlightItem: 0,
- });
+ expect(findNoResultsMessage().isVisible()).toBe(true);
+ });
- return wrapper.vm.$nextTick(() => {
- const labelItemEl = findDropdownContent().find(LabelItem);
+ it('calls `createFlash` when fetching labels failed', async () => {
+ createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') });
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await waitForPromises();
+ expect(createFlash).toHaveBeenCalled();
+ });
- expect(labelItemEl.attributes('highlight')).toBe('true');
- });
- });
+ it('does not render footer on standalone dropdown', () => {
+ createComponent({ injected: { variant: DropdownVariant.Standalone } });
- it('renders element containing "No matching results" when `searchKey` does not match with any label', () => {
- wrapper.setData({
- searchKey: 'abc',
- });
+ expect(findDropdownFooter().exists()).toBe(false);
+ });
- return wrapper.vm.$nextTick(() => {
- const noMatchEl = findDropdownContent().find('li');
+ it('renders footer on sidebar dropdown', () => {
+ createComponent();
- expect(noMatchEl.isVisible()).toBe(true);
- expect(noMatchEl.text()).toContain('No matching results');
- });
- });
+ expect(findDropdownFooter().exists()).toBe(true);
+ });
- it('renders empty content while loading', () => {
- wrapper.vm.$store.state.labelsFetchInProgress = true;
+ it('renders footer on embedded dropdown', () => {
+ createComponent({ injected: { variant: DropdownVariant.Embedded } });
- return wrapper.vm.$nextTick(() => {
- const dropdownContent = findDropdownContent();
- const loadingIcon = findLoadingIcon();
+ expect(findDropdownFooter().exists()).toBe(true);
+ });
- expect(dropdownContent.exists()).toBe(true);
- expect(dropdownContent.isVisible()).toBe(true);
- expect(loadingIcon.exists()).toBe(true);
- expect(loadingIcon.isVisible()).toBe(true);
- });
- });
+ it('does not render create label button if `allowLabelCreate` is false', () => {
+ createComponent({ injected: { allowLabelCreate: false } });
- it('renders footer list items', () => {
- const footerLinks = findDropdownFooter().findAll(GlLink);
- const createLabelLink = footerLinks.at(0);
- const manageLabelsLink = footerLinks.at(1);
+ expect(findCreateLabelButton().exists()).toBe(false);
+ });
- expect(createLabelLink.exists()).toBe(true);
- expect(createLabelLink.text()).toBe('Create label');
- expect(manageLabelsLink.exists()).toBe(true);
- expect(manageLabelsLink.text()).toBe('Manage labels');
+ describe('when `allowLabelCreate` is true', () => {
+ beforeEach(() => {
+ createComponent();
});
- it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => {
- wrapper.vm.$store.state.allowLabelCreate = false;
-
- return wrapper.vm.$nextTick(() => {
- const createLabelLink = findDropdownFooter().findAll(GlLink).at(0);
-
- expect(createLabelLink.text()).not.toBe('Create label');
- });
+ it('renders create label button', () => {
+ expect(findCreateLabelButton().exists()).toBe(true);
});
- it('does not render footer list items when `state.variant` is "standalone"', () => {
- createComponent({ ...mockConfig, variant: 'standalone' });
- expect(findDropdownFooter().exists()).toBe(false);
- });
+ it('emits `toggleDropdownContentsCreateView` event on create label button click', () => {
+ findCreateLabelButton().vm.$emit('click');
- it('renders footer list items when `state.variant` is "embedded"', () => {
- expect(findDropdownFooter().exists()).toBe(true);
+ expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]);
});
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
index 8273bbdf7a7..3c2fd0c5acc 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
@@ -5,7 +5,7 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
-import { mockConfig } from './mock_data';
+import { mockConfig, mockLabels } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -19,6 +19,11 @@ const createComponent = (initialState = mockConfig, defaultProps = {}) => {
propsData: {
...defaultProps,
labelsCreateTitle: 'test',
+ selectedLabels: mockLabels,
+ allowMultiselect: true,
+ labelsListTitle: 'Assign labels',
+ footerCreateLabelTitle: 'create',
+ footerManageLabelTitle: 'manage',
},
localVue,
store,
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
index 66971446f47..e17dfd93efc 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
@@ -50,58 +50,6 @@ describe('LabelsSelectRoot', () => {
});
describe('methods', () => {
- describe('handleVuexActionDispatch', () => {
- it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => {
- createComponent();
- jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
-
- wrapper.vm.handleVuexActionDispatch(
- { type: 'toggleDropdownContents' },
- {
- showDropdownButton: false,
- showDropdownContents: false,
- labels: [{ id: 1 }, { id: 2, touched: true }],
- },
- );
-
- expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
- expect.arrayContaining([
- {
- id: 2,
- touched: true,
- },
- ]),
- );
- });
-
- it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => {
- createComponent({
- ...mockConfig,
- variant: 'embedded',
- });
-
- jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
-
- wrapper.vm.handleVuexActionDispatch(
- { type: 'toggleDropdownContents' },
- {
- showDropdownButton: false,
- showDropdownContents: false,
- labels: [{ id: 1 }, { id: 2, set: true }],
- },
- );
-
- expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
- expect.arrayContaining([
- {
- id: 2,
- set: true,
- },
- ]),
- );
- });
- });
-
describe('handleDropdownClose', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
index 9e29030fb56..5dd8fc1b8b2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
@@ -48,6 +48,8 @@ export const mockConfig = {
labelsManagePath: '/gitlab-org/my-project/-/labels',
labelsFilterBasePath: '/gitlab-org/my-project/issues',
labelsFilterParam: 'label_name',
+ footerCreateLabelTitle: 'create',
+ footerManageLabelTitle: 'manage',
};
export const mockSuggestedColors = {
@@ -91,3 +93,26 @@ export const createLabelSuccessfulResponse = {
},
},
};
+
+export const labelsQueryResponse = {
+ data: {
+ workspace: {
+ labels: {
+ nodes: [
+ {
+ color: '#330066',
+ description: null,
+ id: 'gid://gitlab/ProjectLabel/1',
+ title: 'Label1',
+ },
+ {
+ color: '#2f7b2e',
+ description: null,
+ id: 'gid://gitlab/ProjectLabel/2',
+ title: 'Label2',
+ },
+ ],
+ },
+ },
+ },
+};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
index 27de7de2411..ee905410ffa 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
@@ -1,8 +1,4 @@
-import MockAdapter from 'axios-mock-adapter';
-
import testAction from 'helpers/vuex_action_helper';
-import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
@@ -72,90 +68,6 @@ describe('LabelsSelect Actions', () => {
});
});
- describe('requestLabels', () => {
- it('sets value of `state.labelsFetchInProgress` to `true`', (done) => {
- testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done);
- });
- });
-
- describe('receiveLabelsSuccess', () => {
- it('sets provided labels to `state.labels`', (done) => {
- const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
-
- testAction(
- actions.receiveLabelsSuccess,
- labels,
- state,
- [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }],
- [],
- done,
- );
- });
- });
-
- describe('receiveLabelsFailure', () => {
- it('sets value `state.labelsFetchInProgress` to `false`', (done) => {
- testAction(
- actions.receiveLabelsFailure,
- {},
- state,
- [{ type: types.RECEIVE_SET_LABELS_FAILURE }],
- [],
- done,
- );
- });
-
- it('shows flash error', () => {
- actions.receiveLabelsFailure({ commit: () => {} });
-
- expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' });
- });
- });
-
- describe('fetchLabels', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- state.labelsFetchPath = 'labels.json';
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('on success', () => {
- it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => {
- const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
- mock.onGet(/labels.json/).replyOnce(200, labels);
-
- testAction(
- actions.fetchLabels,
- {},
- state,
- [],
- [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }],
- done,
- );
- });
- });
-
- describe('on failure', () => {
- it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => {
- mock.onGet(/labels.json/).replyOnce(500, {});
-
- testAction(
- actions.fetchLabels,
- {},
- state,
- [],
- [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }],
- done,
- );
- });
- });
- });
-
describe('updateSelectedLabels', () => {
it('updates `state.labels` based on provided `labels` param', (done) => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
index 9e965cb33e8..1f0e0eee420 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
@@ -67,58 +67,6 @@ describe('LabelsSelect Mutations', () => {
});
});
- describe(`${types.REQUEST_LABELS}`, () => {
- it('sets value of `state.labelsFetchInProgress` to true', () => {
- const state = {
- labelsFetchInProgress: false,
- };
- mutations[types.REQUEST_LABELS](state);
-
- expect(state.labelsFetchInProgress).toBe(true);
- });
- });
-
- describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => {
- const selectedLabels = [{ id: 2 }, { id: 4 }];
- const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
-
- it('sets value of `state.labelsFetchInProgress` to false', () => {
- const state = {
- selectedLabels,
- labelsFetchInProgress: true,
- };
- mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
-
- expect(state.labelsFetchInProgress).toBe(false);
- });
-
- it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => {
- const selectedLabelIds = selectedLabels.map((label) => label.id);
- const state = {
- selectedLabels,
- labelsFetchInProgress: true,
- };
- mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
-
- state.labels.forEach((label) => {
- if (selectedLabelIds.includes(label.id)) {
- expect(label.set).toBe(true);
- }
- });
- });
- });
-
- describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => {
- it('sets value of `state.labelsFetchInProgress` to false', () => {
- const state = {
- labelsFetchInProgress: true,
- };
- mutations[types.RECEIVE_SET_LABELS_FAILURE](state);
-
- expect(state.labelsFetchInProgress).toBe(false);
- });
- });
-
describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
let labels;
diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js
index 86bbc146c5f..aefe6a5c3e8 100644
--- a/spec/frontend/vue_shared/components/url_sync_spec.js
+++ b/spec/frontend/vue_shared/components/url_sync_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import setWindowLocation from 'helpers/set_window_location_helper';
import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import UrlSyncComponent from '~/vue_shared/components/url_sync.vue';
@@ -15,9 +14,6 @@ jest.mock('~/lib/utils/common_utils', () => ({
describe('url sync component', () => {
let wrapper;
const mockQuery = { group_id: '5014437163714', project_ids: ['5014437608314'] };
- const TEST_HOST = 'http://testhost/';
-
- setWindowLocation(TEST_HOST);
const findButton = () => wrapper.find('button');
@@ -35,7 +31,9 @@ describe('url sync component', () => {
const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => {
expect(mergeUrlParams).toHaveBeenCalledTimes(times);
- expect(mergeUrlParams).toHaveBeenCalledWith(query, TEST_HOST, { spreadArrays: true });
+ expect(mergeUrlParams).toHaveBeenCalledWith(query, window.location.href, {
+ spreadArrays: true,
+ });
expect(historyPushState).toHaveBeenCalledTimes(times);
expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue);
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
index d62c4a98b10..d3fec680b54 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js
@@ -104,4 +104,15 @@ describe('User Avatar Link Component', () => {
);
});
});
+
+ describe('lazy', () => {
+ it('passes lazy prop to avatar image', () => {
+ createWrapper({
+ username: '',
+ lazy: true,
+ });
+
+ expect(wrapper.find(UserAvatarImage).props('lazy')).toBe(true);
+ });
+ });
});
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 0fd4d0dab87..5fe4eeb6061 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -85,6 +85,10 @@ describe('Web IDE link component', () => {
expectedActions: [ACTION_WEB_IDE, ACTION_EDIT],
},
{
+ props: { webIdeText: 'Test Web IDE' },
+ expectedActions: [{ ...ACTION_WEB_IDE_EDIT_FORK, text: 'Test Web IDE' }, ACTION_EDIT],
+ },
+ {
props: { isFork: true },
expectedActions: [ACTION_WEB_IDE_EDIT_FORK, ACTION_EDIT],
},
@@ -105,6 +109,10 @@ describe('Web IDE link component', () => {
expectedActions: [ACTION_WEB_IDE, ACTION_EDIT, ACTION_GITPOD_ENABLE],
},
{
+ props: { showEditButton: false, showGitpodButton: true, gitpodText: 'Test Gitpod' },
+ expectedActions: [ACTION_WEB_IDE, { ...ACTION_GITPOD_ENABLE, text: 'Test Gitpod' }],
+ },
+ {
props: { showEditButton: false },
expectedActions: [ACTION_WEB_IDE],
},
diff --git a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js
index 1c9e89f99e9..59ce9f086c3 100644
--- a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js
+++ b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js
@@ -1,4 +1,3 @@
-import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
/**
@@ -7,8 +6,6 @@ import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
* on underlying DOM methods.
*/
describe('AutofocusOnShow directive', () => {
- useMockIntersectionObserver();
-
describe('with input invisible on component render', () => {
let el;
diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
index bef538e1ff1..4d579fa61df 100644
--- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
+++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
@@ -22,7 +22,7 @@ import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
-import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql';
+import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
jest.mock('~/flash');